BITHUMB ALL-IN-ONE SOURCE REVIEW v1.9.33 (versionCode 52)
Generated: 2026-09-02T06:47:54Z
Git: 196c3acfc4350e01fa78a482d33a2148bb702f13
APK SHA-256: a26826751441683baab7b4451d9a8f2eddcb84258d49ec5610312a25a11d2d25
Scope: Android + Server AI Brain (Bithumb/Upbit) + Trading/Risk/AI/Execution + Gradle/config + CHECKPOINT
Secrets policy: .env / keystore / live credentials EXCLUDED; token-like literals REDACTED
================================================================================


===== FILE: CHECKPOINT.md =====
## Fixed Delivery Principle — Auto Source Review On Every Completed Job

Every completed Cursor job on this project must automatically:
1. Generate the latest full Source Review
2. Confirm the current work is included
3. Run Secret Scan
4. Compute SHA-256
5. Update the existing website Source URL (`https://riderapp.duckdns.org/bithumb-all-in-one-source-review.txt`)
6. Public Verify
7. Provide the latest Source Review file to the user immediately (do not wait for a separate request)

Final reports must include:
SOURCE_REVIEW_GENERATED / SOURCE_REVIEW_PATH / SOURCE_REVIEW_SHA256 /
SOURCE_PUBLIC_URL / SOURCE_PUBLIC_VERIFY / SOURCE_FILE_PROVIDED_TO_USER

Do not end with "job complete" alone without providing the latest Source Review.


# 빗썸 자동매매 프로젝트 체크포인트

이 문서는 세션이 바뀌어도 방향이 흔들리지 않도록 현재까지의 진행 상황을 기록한 것입니다.
새 작업을 시작하기 전에 이 문서를 먼저 확인하세요.

## 고정 원칙 (매 세션 반드시 유지)

- **경로 고정**: `/workspace/bithumb-all-in-one` 프로젝트만 수정한다. 새 프로젝트를 만들지 않는다.
- **삭제·교체 금지**: 기존 기능/파일을 지우거나 통째로 새로 쓰지 않는다. 항상 누적식으로 이어 붙인다.
- **배민 무관**: `rider-app` 등 배민 서버/앱 소스는 이 작업과 완전히 분리되어 있으며 절대 건드리지 않는다.
- **브랜치/PR**: 브랜치 `cursor/create-bithumb-android-app-e74b`, PR [#2](https://github.com/ckdgus20-arch/beamin-ops/pull/2) (Draft) 하나로 계속 이어간다.
- **작업 완료 시 Source Review 자동 제공**: 매 Cursor 작업 완료 시 최신 전체 Source Review 생성 → Secret Scan → SHA-256 → 기존 웹사이트 업로드 → Public Verify → 사용자에게 즉시 제공. 별도 요청을 기다리지 않는다.
- **검증 절차**: 기능 추가 후 항상 `./gradlew clean testDebugUnitTest assembleDebug` 성공을 확인한 뒤 커밋한다.

## 안전 경계 (절대 원칙)

- 기본/사실상 유일 동작 모드는 **PAPER(모의매매)**. `BithumbExecutionEngine`(실거래 주문 클래스)은 코드상 존재하지만 매매 루프(`scanOnce`)에 연결되어 있지 않아 **LIVE 자동매매는 실제로 실행되지 않는다.**
- AI는 어떤 형태로든 **"추천"만** 하고 전략을 직접 바꾸지 않는다. 새 전략 후보는 백테스트 → Paper 검증을 통과해도 **소액 Live 검증 단계에서 자동 승격되지 않고 항상 멈춘다** (Live 미연결이므로 사람의 수동 검토가 필요).

## 구현 완료 기능

### 코어 매매 엔진
- `StrategyEngine`: EMA/RSI/MACD/거래대금/모멘텀/스프레드 기반 0~100점 스코어링
- `RiskManager`: 매수 검증(모드/킬스위치/연속손실/일일손실/API상태/시세지연/중복보유/중복주문/보유종목수/점수/스프레드/한도/잔액), 매도 조건(손절/익절/트레일링스탑/점수급락)
- `PaperExecutionEngine`: 실제 시세 기반 체결가·슬리피지·수수료 반영 Paper 매수/매도, Room 기록
- `BithumbExecutionEngine`: 실거래 주문 클래스(존재하나 매매 루프 미연결, `mode="LIVE"` 태깅 버그는 수정 완료)
- 전체 KRW 마켓 배치 순환 스캔(원형 커서), 후보 TOP10 유지
- `TradingForegroundService`: 30초 주기 지속 스캔, wake lock, 알림, 오류 시 재시도 — "실거래처럼 지속 동작" 요구사항은 이미 이 구조로 충족됨

### AI 반자동 학습 (전략을 직접 바꾸지 않음)
- 온디바이스 로지스틱 회귀 AI 모델(자산 번들 + OTA)
- Paper 매수마다 feature/score 기록 → 매도 시 성공/실패 라벨링 (`ai_training_samples`)
- 라벨 10건 누적마다 기기 내 재학습 시도, **검증 정확도가 기존보다 실제로 나을 때만 채택** (`AiRetrainPolicy`)
- 모델 버전 이력(`strategy_model_versions`) 감사 로그, 대시보드에 세대/출처/이력 표시

### 리스크 엔진
- 일일 손실 한도, 종목당 투자금 한도, 최대 동시 보유 (기존부터 구현되어 있던 것)
- 연속 손실 잠금 (신규: 설정 가능한 횟수 도달 시 신규 매수 차단, 수동 해제 필요)
- 킬 스위치 (신규: 발동 시 신규 매수 차단 + 보유 포지션 즉시 전량 강제청산, 재시작해도 유지, 수동 해제 필요)

### 전략 추천 파이프라인 (전략을 직접 바꾸지 않음)
- `StrategyPerformanceEngine`: 승률/Profit Factor/MDD/거래당 기대수익 계산 (PAPER/LIVE 동일 스키마로 재사용 가능)
- `StrategyRecommendationEngine`: 규칙 기반, 표본 30건 이상일 때만 설명 가능한 소폭 파라미터 조정을 제안
- 승격 파이프라인: 제안 → 백테스트(과거 Paper 매수 신호 재적용) → Paper 실거래 트라이얼(기준 대비 상대평가) → `AWAITING_LIVE_VALIDATION`에서 항상 정지(자동 OTA 승격 없음). 모든 시도(채택/기각)는 `strategy_recommendations` 테이블에 감사 로그로 저장

### OTA
- 전략 설정 OTA: 앱 시작 + 10분마다 확인, 버전 검증, 실패 시 롤백
- AI 모델 OTA: 동일 주기, 버전 검증, 실패 시 롤백

### 실시간 시세·재시작·데이터 보존 신뢰성 (완료)
- `BithumbTickerWebSocket`: 공식 Public WebSocket(`wss://ws-api.bithumb.com/websocket/v1`)으로 전체 KRW 티커 실시간 구독, 연결 실패 시 지수 백오프(최대 60초) 자동 재연결. 실시간 값이 없거나 오래된 종목만 기존 REST ticker로 폴백하며, orderbook/candle REST 흐름은 그대로 유지
- 외부 WebSocket 실연결 검증 완료: KRW-BTC DEFAULT ticker 메시지 수신 확인. Dashboard에 연결 상태와 마지막 WebSocket 수신시각 표시
- `TradingSettingsStore`: Paper 초기자금·리스크 한도·테스트모드·동적비중·쿨다운·부팅재개 설정을 검증 후 영구 저장하고 앱 재시작 시 복원
- `BootReceiver`: 사용자가 Settings에서 "재부팅 후 Paper 자동매매 재개"를 켠 경우에만 foreground service 시작(기본 OFF/fail-closed)
- Room 3→4→5→6→7 명시적 마이그레이션 추가, 기존 `fallbackToDestructiveMigration()` 제거. 앱 업데이트 시 거래/AI 학습/전략 추천/국면/Crash 데이터 보존
- 서비스 중지 시 WebSocket도 종료하고 엔진 상태를 STOPPED로 동기화. 반복 루프 예외 시 Dashboard 엔진/API 상태를 ERROR로 반영
- 앱 버전: versionCode 7 / versionName 0.7.0
- **보유 포지션 보호 보완**: 분석 배치 10종목과 별개로 모든 현재 보유 종목을 매 30초 스캔의 필수 티커 목록에 합쳐, 배치 밖 포지션도 손절/익절/트레일링스탑을 매 사이클 평가
- 일일 손실 차단과 Dashboard 오늘 손익 기준을 최초 Paper 원금이 아닌 영속 저장된 **당일 시작 평가금액**으로 수정. Paper 계좌 초기화 시 당일 기준값도 함께 원자적으로 재설정
- Public REST API는 성공 상태/본문을 검증하고 네트워크 오류·429·5xx에 최대 3회 지수 백오프 재시도. 재시도 불가능한 4xx는 즉시 실패해 엔진/API 오류 상태에 반영
- 기존에 설정만 있고 적용되지 않던 `min24hTradePrice`를 신규 후보 검증에 연결(전략 점수식은 변경하지 않음)
- **Paper 원장 복구**: `PaperCashLedger`가 Paper BUY/SELL 체결 원장에서 현금 잔액을 재계산해 앱 비정상 종료로 `paper_cash` 저장이 늦어져도 다음 시작/스캔에서 자동 보정. 음수 현금은 0으로 제한
- 현재 스캔의 시장 건강도/Crash 보호를 Paper 매수보다 먼저 평가해, 같은 스캔에서 새로 감지된 급락이 첫 매수에 한 사이클 늦게 적용되지 않도록 보완
- 기존 `minTradeVolume` 설정도 후보 검증에 연결

### 성과 리포트 (완성)
- `daily_performance` / `balance_snapshots` 테이블은 프로젝트 초기부터 정의만 되어 있고 실제로 기록되지 않던 상태 → 실제로 채워지도록 연결
- "통계" 탭: 전체 누적 승률/Profit Factor/MDD/기대수익 + 일별 성과표 (Placeholder였던 화면을 완성)
- "거래내역" 탭: 실제 체결 내역을 Room Flow로 실시간 표시 (Placeholder였던 화면을 완성)

### AI 전략 연구소 1단계 (신규)
- `MarketRegimeClassifier`: 이미 매 스캔마다 받아오던 티커의 24시간 등락률(signedChangeRate)을 롤링 윈도우(최대 300개 표본)로 모아 상승장/횡보장/하락장을 판정. 새 외부 데이터 소스나 API 호출 추가 없음. `market_regime_history` 테이블에 5분 간격으로 스냅샷 저장
- `ai_training_samples`에 매수 시점의 국면(`marketRegime`)을 태깅 → 통계 탭에 "시장국면별 성과"(국면별 승률/PF/기대수익) 표시. 전략 추천 사유에도 최근 국면을 참고 정보로 덧붙임(추천 로직 자체는 변경하지 않음)
- `PortfolioAllocationEngine`: 후보의 전략 Score/AI Score 품질에 따라 투자금을 기존 한도의 50~100% 범위에서만 동적으로 축소(설정에서 끌 수 있음, 기본 ON). 기존 `maxOrderPercent`/`maxAssetPercentPerCoin` 상한은 그대로 유지되며 더 위험해지는 방향으로는 절대 작동하지 않음
- `AnomalyDetector`: 이미 쌓이던 `app_events` 로그 위에서 반복 주문 실패, 반복 OTA 실패, API 오류 지속, 시세 지연을 감시해 대시보드에 빨간 배너로 표시하고 새로 감지된 항목만 로그에 남김

### AI 전략 연구소 2단계 — 고도화 4종 (이번 작업)
- `OpportunityCostEngine`: 보유 포지션 KEEP / 신규 후보 ROTATE / DO_NOTHING을 점수·손익·실제 신호 모멘텀·거래량변화·변동성·스프레드·Market Health·보유시간·수수료·슬리피지로 비교. `RiskManager.canBuy`를 먼저 확인하며 실제 교체매매는 하지 않고 `opportunity_decisions`에 Shadow 추천만 저장
- `PostEntryQualityEngine`: Paper 매수의 실제 체결가를 기준으로 +1/+3/+5/+15/+30/+60분에 기존 ticker snapshot을 재사용해 변화율·MFE·MAE·진입 당시 점수/Health/국면/전략버전을 `post_entry_snapshots`에 저장. 통계/대시보드에 평균 성과·즉시 상승확률 표시
- `ShadowSimulationEngine`: CURRENT/AGGRESSIVE/CONSERVATIVE/AI_RECOMMENDED 4개 독립 가상 계좌를 실제 Paper 잔액·포지션과 분리해 같은 후보 snapshot으로 병렬 시뮬레이션. 가상 거래/잔액/승률/PF/기대값/MDD/연속손실/보유시간 저장. 최소 표본 미달 전략은 Risk Adjusted Score 우승 후보에서 제외
- `MissedOpportunityEngine`: 실제 매수되지 않은 REJECTED/BUY_READY_NOT_SELECTED 후보를 +5/+15/+30/+60/+180/+360분 기존 ticker로 추적해 GOOD_REJECTION/MISSED_WIN/NEUTRAL과 필터별 집계를 저장. Risk Rule을 삭제하거나 완화하지 않음
- `AiResearchDatasetBuilder`: 네 연구 데이터(Post Entry/Missed/Shadow/Opportunity)를 기존 AI 재학습 파이프라인이 읽을 수 있는 집계 진입점으로 연결. 기존 8개 feature 모델의 차원을 임의로 바꾸지 않아 기존 전략/모델과 호환 유지
- Dashboard와 통계 탭에 기회비용, 진입품질, Shadow 전략경쟁, 놓친기회 결과를 카드로 누적 추가. 기존 코어 카드/전략/리스크/OTA/AI 학습 화면은 유지
- 신규 Room 테이블: `opportunity_decisions`, `post_entry_trackers`, `post_entry_snapshots`, `shadow_portfolios`, `shadow_trades`, `missed_opportunities`, `missed_opportunity_snapshots`; DB 7→8 명시적 마이그레이션 추가

## 대시보드 구성 현황
- Dashboard: 매수 후보 TOP10, AI 반자동 학습 패널, 전략·리스크·AI 추천 패널, 지표 그리드, 시작/중지/긴급중지/즉시스캔
- Settings: API 키, 테스트 모드, Paper 초기자금/리셋, 매수 Score 기준, 연속 손실 한도, 전략 OTA 수동 확인
- 거래내역 / 통계 / 로그 탭

### Market Regime Adaptive Strategy Engine (이번 작업)
- 기존 `MarketRegimeClassifier`를 확장해 `STRONG_BULL`, `BULL`, `SIDEWAYS`, `HIGH_VOLATILITY`, `WEAK_BEAR`, `BEAR`, `STRONG_BEAR`, `CRASH`, `RECOVERY`, `UNKNOWN`을 지원
- 기존 ticker 등락률 롤링 데이터만 재사용해 SHORT/MID/LONG 윈도우를 계산하고 Confidence, Trend Strength, Volatility Level을 산출. 새 API 호출 없음
- `RegimeHysteresis`: 일반 국면은 Confidence 75% 이상 연속 3회 확인 후 전환, CRASH는 즉시 전환. 안정 국면/지속시간을 settings와 `market_regime_history`에 보존
- `RegimeStrategySelector`: 기존 CURRENT/AGGRESSIVE/CONSERVATIVE/AI_RECOMMENDED Shadow 성과와 Paper 성과를 국면별로 비교해 가중치 및 NO_TRADE를 추천. 실제 Paper 전략 자동교체 없음
- `ChampionChallengerEngine`: CURRENT를 Champion, Shadow 우수 후보를 Challenger로 비교하고 최소 표본/리스크 조정 점수 통과 시 `PROMOTION_CANDIDATE`만 기록. 자동 Live/OTA 적용 없음
- `WalkForwardEvaluator`: 시간순 TRAIN → VALIDATION → TEST 분할을 사용해 동일 데이터 재평가 및 look-ahead를 방지
- `RegimeTransitionEntity`에 확정 국면 전환과 Confidence를 저장하고, `ConfidenceCalibrationEngine`이 Confidence 구간별 다중 타임프레임 합의 적중률을 집계. 실제 미래 정답 기반 보정은 전환 후 관측 데이터가 누적될수록 정밀해짐
- 대시보드에 현재 국면/Confidence/Trend/Volatility/지속시간, 국면별 추천 가중치, Champion vs Challenger, 최근 전환/Calibration 표시
- 신규 Room 테이블: `regime_strategy_performance`, `regime_transitions`, `strategy_promotions`, `confidence_calibration`; `market_regime_history` 및 `shadow_trades` 확장 컬럼과 DB 8→9 마이그레이션 추가

### News Intelligence + Continuous Learning (이번 작업)
- `NewsDataProvider`와 `RssNewsDataProvider` 추가. 무분별한 스크래핑 대신 공개 RSS 2개(Coindesk/The Block, 보수적으로 TIER_B)만 사용하며 앱 시작/Foreground Service에서 10분 주기로 별도 수집. 수집 실패는 `OFFLINE`/`DEGRADED`로 기록하고 가격 기반 Paper 엔진으로 전파하지 않음
- `NewsSourceTier`(TIER_A~D), fingerprint/제목 유사도 기반 중복 제거, NEW/CONFIRMED/DUPLICATE/STALE/UNVERIFIED 상태, 24시간 기준 stale 보호
- `NewsCoinMappingEngine`: BTC/ETH/XRP/SOL/DOGE/ADA/DOT/AVAX/LINK의 프로젝트·티커·한국어 alias와 MARKET_WIDE 분류 지원. 빗썸에서 이미 로드된 시장 목록과 교집합 처리
- `NewsEventClassifier`: 상장/상장폐지/해킹/보안사고/거래소 공지/규제/매크로/네트워크/언락/ETF/파트너십/루머 등 확장 가능한 Event Type 분류. 단순 감성보다 사건 의미를 우선하고 Sentiment/Confidence/Impact/Urgency/Scope/Horizon 산출
- `NewsImpactEngine`와 `NewsRiskEngine`: Source Trust/Freshness/Impact를 합산하고, 확인된 중대 악재만 `NEWS_RISK`로 신규 진입 차단. RiskManager 최종 승인권·Kill Switch·Crash Protection 우선순위 유지
- `NewsReactionEngine`: 기존 ticker snapshot을 재사용해 뉴스 이후 1/3/5/15/30/60/180/360/1440분 반응(가격/거래량·스프레드 필드/국면/Health/MFE/MAE)을 기록하고 Event Type/Source/Coin/Horizon 정확도를 집계
- 기존 AI 재학습 경로에 뉴스 반응·예측오차·Paper 성과 집계를 연결. `ConceptDriftDetector`, `ResearchHypothesisEngine`, `news_model_registry`, `news_drift_events`, `research_hypotheses`로 COLLECT→분석→가설 TESTING 상태를 보존. AI는 분석/비교/추천만 하며 모델·Risk Rule·OTA를 자동 승인하지 않음
- Dashboard에 NEWS 상태/최근 중요 이벤트/NEWS RISK/반응 통계/Model Registry/Drift/연구 가설 카드를 누적 추가
- Room DB 9→10 명시적 마이그레이션: `news_events`, `news_reactions`, `news_predictions`, `news_model_registry`, `news_drift_events`, `research_hypotheses`

### Liquidity Filter Diagnostics (이번 작업)
- 실제 Bithumb ticker 필드는 `acc_trade_price_24h`이며 JSON 모델 `TickerDto.accTradePrice24h: Double?` → `TickerModel.accTradePrice24h: Double`로 전달. 빗썸 KRW ticker의 24시간 누적 거래대금(KRW quote notional)이고 만원/억원 재변환 없음. null은 기존 안전 기본값 0.0
- 기존 설정명 `min24hTradePrice`는 호환성을 위해 유지하되 canonical 의미를 `min24hTradeValueKrw` extension으로 명시. 현재 서버 OTA 기준은 `500,000,000 KRW`이며, live snapshot 기준 461개 중 114 PASS / 347 LOW_24H_TRADE_VALUE(약 75.3%), P10 2,521만원 / P25 5,284만원 / P50 1억4,756만원 / P75 4억8,443만원 / P90 21억8,241만원
- `LiquidityFilterEngine`: KRW 값 그대로 percentile(P10/P25/P50/P75/P90), absolute floor + relative percentile threshold, CRASH/Health 위험 시 P75 강화, rank/percentile/required/ratio 계산. 기본 절대 기준은 임의로 낮추지 않으며 현재 분포상 5억원은 대략 P75 수준임을 표시
- 후보 REJECT 로그/상세에 `actual`, `required`, `ratio`, `rank/total`, `percentile`, `score`, `aiScore`, 가격/파이프라인을 표시. AI Score가 높아도 Liquidity 필터를 우회하지 않음
- `LiquidityOverblockingTracker`: 최근 10회·후보 20건 이상·LOW 유동성 차단 비율 80% 이상·BUY_READY 0건이면 `LIQUIDITY_FILTER_OVERBLOCKING_WARNING` 표시/로그. 자동으로 필터를 제거하거나 Risk Rule을 완화하지 않음
- `LiquidityScanEntity`로 매 스캔 전체/통과/차단 및 분포/required를 저장하고, 기존 ticker snapshot을 재사용해 추가 API 호출 없음. Missed Opportunity 사유는 `LOW_24H_TRADE_VALUE` 코드로 정규화해 필터별 성과 집계 가능
- 설정/OTA: `liquidityPercentileThreshold`, `dynamicLiquidityEnabled`, `overblockingWarningThreshold` 범위 검증 및 기존 값 유지. 동적 필터는 기본 ON이지만 절대 기준과 상대 기준 중 더 엄격한 값을 사용

### High Speed Analysis Pipeline (이번 작업)
- `CachedMarketDataProvider` + `MarketDataSharedCache`: Ticker(2초), Orderbook(1초), Candle(55초), Market List(10분) TTL로 기존 Provider를 감싸 동일 snapshot을 Strategy/AI/Regime/News/Opportunity/Shadow가 공유. 캐시 미스만 기존 REST 호출
- `CentralApiRateLimiter`: endpoint별 최소 간격과 호출/대기량을 중앙 집계. 429/5xx 기존 재시도와 결합되며 무제한 async 요청 없음
- `FastScanEngine`: 전체 KRW ticker의 현재가/등락률/거래량/거래대금으로 전체 시장을 먼저 가변 순위화(시장 수의 25%, 20~50개 범위). 기존 순환 10개 batch를 우선 후보에 포함해 저순위 종목도 계속 순환
- Deep Scan은 Fast 후보 상위 10~15개에만 기존 Candle/StrategyEngine/AI를 제한 병렬(concurrency 4)로 실행. StrategyEngine 점수 계산식은 변경하지 않음. Final은 기존 RiskManager/Health/News/Opportunity 검증 유지
- Position Fast Lane: 전체 시세 확보 직후, Deep orderbook/candle 작업 전에 기존 `RiskManager.shouldSell` 경로로 보유 포지션 가격/손절/익절/트레일링/Crash 설정을 먼저 검사. 이후 정밀 신호 단계에서는 동일 helper를 재사용해 중복 청산 엔진을 만들지 않음
- `ScanPerformanceProfiler`: FAST/DEEP/FINAL/시장데이터/Position Fast Lane 시간, API 호출, Cache hit, Rate wait, WebSocket update, **FAST 후보 발견 시각부터 BUY_READY까지의 Candidate Detection Latency**(AVG/P50/P95)를 Dashboard에 표시
- News 수집은 기존 별도 10분 Coroutine을 유지해 가격 루프를 block하지 않으며, AI/뉴스 실패가 Paper 엔진 전체를 멈추지 않음

### Continuous Profit Trading + Profit Protection (이번 작업)
- 기존 코드에서 `dailyProfitTarget`, `profitLock`, `dailyTargetReached`, `stopTradingAfterProfit`, `+3% STOP` 로직은 발견되지 않음. 따라서 +3% 도달만으로 거래를 중단하는 기존 기능을 삭제/교체하지 않았고, Paper 자동매매는 계속 기회를 탐색함
- `ProfitProtectionEngine`: 영속 저장된 `dayStartValue`/`dayPeakValue`/현재 Equity로 Daily Return, Peak Return, Peak 대비 Drawdown, Giveback을 계산. `NORMAL`/`PROFIT_RUNNING`/`PROFIT_CAUTION`/`PROFIT_DEFENSE`/`PROFIT_LOCKED` 상태를 설정값으로 판정
- High Water Mark는 기존 `recordPerformanceSnapshots()`의 KST Trading Day 기준 dayPeak 추적을 재사용하며 앱 재시작 후 복원. 일일 손실 잠금과 별개로 동작하고, 일일 손실 잠금이 항상 우선
- Profit Caution/Defense는 신규 진입 Position Size Multiplier만 0.7/0.4로 축소하고 기존 포지션을 강제청산하지 않음. Locked만 신규 진입을 잠그며, 고점 수익 반납이 회복되면 자동으로 Caution/Running으로 복귀
- `ProfitVelocityEngine`: 기존 `balance_snapshots` Equity Curve를 재사용해 30분 수익 변화와 ACCELERATING/DECELERATING/FLAT을 계산. 속도 하나만으로 매매하지 않음
- Winning Streak 후 주문금액 배수 증가/Martingale 없음. Losing Streak은 기존 연속손실 잠금과 함께 수익보호 배수를 더 낮추는 방향만 허용
- `ProfitCounterfactualEngine`으로 CONTINUE/CAUTION/DEFENSE/FIXED_3_PERCENT_STOP 시나리오 비교 기반을 추가. 실제 대안 성과는 각 시나리오 데이터를 Paper/Shadow로 충분히 축적한 뒤 비교하며, 자동 전략 변경은 하지 않음
- Profit Protection 설정(레벨1~4, Giveback, 배수, Locked 허용 여부)은 `TradingSettingsStore`와 Strategy OTA에 연결하고 범위/순서 검증. AI는 이를 수정하거나 잠금/Crash/Kill Switch를 해제할 권한 없음
- Dashboard에 Start/Current/Peak/현재수익/Peak수익/Giveback/보호상태/Multiplier/New Entry/Profit Velocity/판정이유 표시

### Re-entry Cooldown + Duplicate Buy Prevention (이번 긴급 수정)
- **원인 분석**:
  1. 손절/Trailing 손절 발생 후에도 `signalCache`에 이전 사이클의 높은 BUY_READY 신호가 남아있어 바로 다음 스캔에서 잔존 신호로 즉시 재매수되던 문제
  2. `PaperExecutionEngine.buy()`가 내부적으로 현재 보유 여부(`currentPositions("PAPER")`)를 재검사하지 않고 호출 순서대로 무조건 포지션을 덮어쓰던 문제
- `ReentryGuardPolicy` + `MarketReentryCoordinator`:
  - 손절(`STOP LOSS`) 기본 15분, Trailing 손절(`TRAILING STOP`) 기본 10분, CRASH 30분 쿨다운을 종목별로 영속 저장
  - 쿨다운 종료 후에도 이전 손절 시점 신호의 잔류를 방지하기 위해 `WAITING_FOR_NEW_SIGNAL` 상태를 유지하고, 점수가 충분히 하락했다가 다시 형성되거나 리셋되어야만 재진입 허용
  - 종목별 손실 연속(lossStreak) 추적: 2연속 손실 시 extended cooldown, 3연속 손실 시 `TEMP_BLOCKED`로 자동 격상. 타 종목 거래는 정상 허용되며 Martingale/물타기 없음
- **동일 종목 중복 BUY 원자적 차단**:
  - `enrichSignal` 1차 검증 외에 실제 `paperExecution.buy()` 직전 per-market Mutex(`reentryCoordinator.lockFor`)를 획득하고 DB/메모리 보유 여부와 주문 in-flight를 최종 재검사
  - `PaperExecutionEngine.buy()` 내부에서도 이미 보유 중인 포지션이 있으면 즉시 `OrderState.FAILED`로 거절
  - SELL 체결 즉시 해당 종목의 `signalCache`를 즉시 삭제하고 guard를 저장해 다음 스캔에서 잔존 신호로 재매수되는 현상을 원천 차단
- AI Score 99 또는 전략 점수 100이어도 `ALREADY_HOLDING`, `COOLDOWN`, `TEMP_BLOCKED`, `WAITING_FOR_NEW_SIGNAL`, RiskManager 거절을 절대 우회할 수 없도록 우선순위 고정
- Room DB 버전 11→12 마이그레이션: `market_reentry_guards` 테이블 추가 및 앱 재시작 시 쿨다운/손실 연속 횟수 복원
- Settings/OTA: `stopLossCooldownMinutes`, `trailingStopCooldownMinutes` 설정 지원

### Paper Risk Separation (이번 추가 수정)
- **PAPER 모드 연속손실/일일손실 거래 완전 중단 금지**: 모의매매는 검증·학습 목적이므로 연속손실 5회나 Daily Loss Limit에 도달해도 하루 종일 완전 정지하지 않고 `PaperRiskEngine`을 통해 위험 축소 상태로 전환
  - 0~2 연속손실: `NORMAL` (배수 1.0)
  - 3~4 연속손실: `PAPER_CAUTION` (배수 0.7, threshold +2)
  - 5~7 연속손실: `PAPER_DEFENSE` (배수 0.3, threshold +5)
  - 8회 이상 연속손실: `PAPER_SHADOW_MODE` (배수 0.1, threshold +10, shadow 중심 데이터 수집 지속)
  - 성과(WinRate/Expectancy)·Market Health·Regime 회복 시 `PAPER_SHADOW_MODE` → `DEFENSE` → `CAUTION` → `NORMAL`로 자동 복구
- **LIVE 안전장치 절대 유지**: LIVE 모드에서는 기존 Daily Loss Lock 및 Consecutive Loss Lock에 도달 시 실제 거래가 엄격히 차단됨
- **PAPER_DAILY_LOSS_WARNING**: PAPER에서는 일일 손실 한도 초과 시 거래/학습/통계 수집을 유지하면서 리스크를 축소하고 Dashboard에 경고 표시
- Dashboard 및 리스크 상태 카드에 `PAPER STATE`(NORMAL/CAUTION/DEFENSE/SHADOW), Multiplier, Trading Active 상태를 명확히 표시

### Institutional Safety & Edge Layer (이번 작업)
- **Orderbook Depth Walk 체결 현실화**: 호가 레벨(`OrderbookDepthEngine.walkBuy / walkSell`)을 순차 소비하여 단순 1호가 고정 슬리피지 대신 가중평균 체결가, 시장 충격, 실제 소비 깊이 비율, `INSUFFICIENT_DEPTH`를 산출하고 Paper 체결 시뮬레이션에 반영. 호가 깊이 부족 시 신규 진입 차단
- **Net Edge Gate**: 진입 직전 `NetEdge = GrossExpected - ExpectedExecutionCost(수수료+스프레드+슬리피지+시장충격) - RiskPenalty`를 계산하여 순기대수익이 최소 마진(`minNetEdgeMarginPercent`, 기본 0.35%) 미만이거나 음수이면 `HIGH_SCORE_BUT_NEGATIVE_NET_EDGE` 또는 `REJECTED_NET_EDGE`로 거절
- **Time-of-Day & Session Edge**: KST 기준 시간대(0~3, 3~6, 9~11, 12~17, 18~21, 21~24) 및 요일별 세션 품질(EXCELLENT/GOOD/NORMAL/WEAK) 계산
- **Portfolio Heat & Correlation Cluster Risk**: 전체 보유 포지션 평가액 대비 총 개방 위험(`totalOpenRisk`), 포트폴리오 노출도(`portfolioExposure`), 주요 BTC 상관 클러스터(BTC/ETH/SOL/XRP/ADA/AVAX) 비중을 종합하여 LOW/MEDIUM/HIGH/CRITICAL 단계별 신규 주문 비중 축소 및 차단
- **Tail Risk & Expected Shortfall Guard**: 최근 거래 수익률 분포 기반 95% VaR, 95% Expected Shortfall(ES), Market Health, Regime을 합성한 Tail Risk Score(LOW/MEDIUM/HIGH) 산출
- **Data Integrity Guardian**: 티커/호가 누락, 가격 역전(bid > ask), 비정상 캔들, REST/WS 15% 이상 가격 불일치를 감지해 `DataQualityStatus`(GOOD/DEGRADED/BAD/QUARANTINED) 부여 및 불량 데이터 매수/학습 격리
- **Portfolio Drawdown Recovery Scaling**: 일일 고점 대비 낙폭(0~2%, 2~4%, 4~6%, 6% 이상)에 따른 1.0 -> 0.75 -> 0.50 -> 0.25 노출 축소 및 안정 회복
- **Decision Pipeline**: `DATA QUALITY -> MARKET SAFETY -> LIQUIDITY -> PORTFOLIO HEAT -> STRATEGY SCORE -> AI RECOMMENDATION -> EXECUTION REALITY -> NET EDGE -> RISK MANAGER -> ATOMIC ORDER CHECK -> BUY` 순서 확립 및 화면/로그 상세화
- Room DB 12->13 마이그레이션: `execution_quality_snapshots`, `net_edge_decisions`, `portfolio_risk_snapshots`, `data_quality_events` 추가
- 앱 버전: versionCode 9 / versionName 0.9.0

### Autonomous Capital Growth Engine (이번 작업)
- **Capital State 6단계 상태머신**: `GROWTH`(호황/검증 +1.2배), `NORMAL`(1.0배), `CAUTION`(낙폭 3% 초과 또는 손실연속 3회, 0.7배), `DEFENSE`(낙폭 6% 초과 또는 손실연속 6회, 0.35배), `RECOVERY`(회복 확인 중, 0.85배), `PRESERVATION`(급락/파산위험 시 0배 자본보존)을 순수 엔진으로 판정
- **Drawdown Adaptive Compounding & Protected Reserve**: 자산 성장 시 `BaseRiskBudget = TradingCapital * BaseRiskPercent`로 복리 운용하되, 수익 20% 도달 시 이익의 25%를 `ProtectedReserve`로 격리 보호. 낙폭 발생 시 `DrawdownRecoveryController`로 노출 축소(절대 물타기/Martingale/손실 후 배수 증가 금지)
- **Risk of Ruin Estimator & Hard Gate**: 최근 승률, Profit Factor, 표본 수, 손실 연속, Tail Risk Score를 종합하여 `LOW/MEDIUM/HIGH/CRITICAL` 파산 위험을 평가. `CRITICAL` 시 신규 자본 확대 전면 차단
- **Strategy Capital Competition & Allocation Recommendation**: 시장 국면(BULL/SIDEWAYS/HIGH_VOL/BEAR/CRASH) 및 자본 상태에 맞춘 전략별 목표 자본 배분 가이드(Momentum, Breakout, MeanReversion, Defensive, DynamicCash) 산출 (검증/연구 추천용, Live 강제 전환 없음)
- **Counterfactual Capital Lab Engine**: 동일 체결 손익 데이터로 고정 자본, 100% 복리, 적응형 복리, 보수적 복리, 자율 성장 전략 5종의 최종 Equity와 우승 전략을 독립 시뮬레이션
- **Dashboard 연동**: `AUTONOMOUS CAPITAL GROWTH` 카드를 추가해 Total Equity, Peak, Protected Reserve, Trading Capital, Capital State, Growth Confidence, Risk Budget, Position Multiplier, Reinvestment %, Ruin Risk, 전략별 추천 배분율을 한눈에 표시
- Room DB 버전 `14` (`13→14` 명시적 마이그레이션): `capital_growth_snapshots` 테이블 추가
- 앱 버전: versionCode 10 / versionName 1.0.0

### UI 한국어 전면 현지화 (이번 작업)
- 대시보드 및 모든 카드 패널의 영어 표기(AUTONOMOUS CAPITAL GROWTH, DAILY PROFIT, INSTITUTIONAL SAFETY, ANALYSIS SPEED, LIQUIDITY DIAGNOSTICS, NEWS INTELLIGENCE, RESEARCH, State/Status/Rank/Pnl/DD/Level/Multiplier 등)를 한국어로 명확하게 통일
- 앱 버전: versionCode 10 / versionName 1.0.0

### Loss Root Cause & Exit Optimization Engine (이번 긴급 수정)
- **손실 원인 자동 분류 (`LossRootCauseEngine`)**: 모든 종료 거래를 `BAD_ENTRY`, `EARLY_STOP`, `EARLY_TRAILING`, `BAD_EXIT`, `REAL_BAD_TRADE`, `EXECUTION_COST_LOSS`, `FALSE_BREAKOUT`, `REGIME_CHANGE`, `LIQUIDITY_FAILURE`, `NEWS_EVENT`, `CRASH_EVENT`, `REENTRY_FAILURE`, `UNKNOWN`으로 정밀 진단.
- **매도 이후 사후 가격 추적 (`PostExitTracker` & `PostExitSnapshot`)**: 매도 체결 시점부터 +1/+3/+5/+10/+15/+30/+60분 동안 가격을 계속 가상 추적하여 손절 직후 가격 회복 여부(Early Stop / Early Trailing / Good Stop)를 객관적으로 측정 (Look-ahead bias 방지: 사후 분석 전용).
- **MFE 포착률 & 수익 반납 분석 (`StopQualityEngine`)**: `MFE_CAPTURE_RATIO` 및 `PROFIT_GIVEBACK` 지표를 산출하여 조기 매도/지연 매도 효율을 정량화.
- **추격 매수 & 노이즈 손절 감지 (`ChaseEntryDetector` / `NoiseStopDetector`)**: 급등 직후 고점 매수 여부(Chase Entry)와 정상 변동성 내 노이즈 손절(Normal Noise) 여부를 분석.
- **가상 매도 비교 연구실 (`CounterfactualExitSimulator`)**: 실제 진입에 대해 `CURRENT_EXIT`, `STOP_-2.5`, `STOP_-3.0`, `STOP_-4.0`, `ATR_STOP`, `VOL_ADAPTIVE_STOP`, `CURRENT_TRAILING`, `WIDER_TRAILING`, `ATR_TRAILING`, `SCORE_DROP_EXIT`, `TIME_30M`, `TIME_60M`, `REGIME_EXIT`, `NO_EARLY_STOP_SHADOW` 14가지 Exit 대안을 실거래 영향 없이 병렬 시뮬레이션.
- **Exit Champion vs Challenger & 표본 보호**: 표본 부족 시(30건 미만) `INSUFFICIENT_SAMPLE`을 유지하며 손절 파라미터를 임의로 확대하지 않음.
- **통계/대시보드 확장**: 손실 원인 비율, 매도 품질(조기손절/유효손절/MFE포착률/사후 30m·60m 수익), 가상 매도 비교 표를 한글로 추가 표시.
- Room DB 14→15 마이그레이션: `post_exit_trackers`, `post_exit_snapshots`, `loss_root_cause_records`, `counterfactual_exit_snapshots` 테이블 추가.
- 앱 버전: versionCode 11 / versionName 1.1.0

### 다운로드 페이지 (완료)
- `/download/index.html`을 기존 프로젝트에 추가하고 기존 Caddy 사이트 루트(`https://riderapp.duckdns.org/`)에 연결
- APK 바로 다운로드, 소스 ZIP/TXT, 전략 OTA JSON, AI 모델 JSON 링크와 현재 버전/테스트/PAPER 안전 안내를 한 화면에 표시
- HTML 페이지 및 5개 리소스 링크 HTTP 200 확인. 페이지 추가는 정적 파일 작업이라 APK 빌드/설치가 필요하지 않음

### Chase Entry 방지 + 진입 타이밍 교정 (이번 긴급 수정)
- 기존 `StrategyEngine`의 0~100 Strategy Score 계산식은 변경하지 않고, `EntryTimingEngine`의 별도 0~100 Entry Timing Score/Chase Score를 BUY 직전에 평가한다.
- 기존 5분 Strategy candle과 신규 1분 candle(기존 Bithumb minute candle API 재사용)을 함께 사용해 1/3/5/10/15분 선행수익, EMA/VWAP 거리, ATR 정규화 확장, RSI/기울기, MACD 확장, 거래량 spike/가속, candle body/wick, spread/orderbook imbalance, 국면/Health를 계산한다.
- `SAFE_ENTRY/NORMAL/EXTENDED/CHASE/EXTREME_CHASE`, `PARABOLIC_MOVE`, `MOMENTUM_EXHAUSTION`, `VOLUME_CLIMAX`, Pullback/Re-acceleration, Breakout Retest 상태를 판정한다.
- Extreme Chase는 `REJECTED_CHASE_ENTRY`, 일반 Chase/확장은 `WAIT_PULLBACK/WAIT_RETEST/WAIT_RECONFIRMATION/WAIT_MOMENTUM`으로 남겨 추적한다. 건강한 추세 초입은 기존 RiskManager/Net Edge를 거쳐 `BUY_READY`가 될 수 있다. 모든 후보를 차단하지 않는다.
- score velocity와 가격 상승 시작→score threshold 통과 signal lag를 기록하고, 오래된 `signalCache` BUY_READY는 `entrySignalMaxAgeMillis`를 넘으면 사용하지 않는다. Paper 주문은 해당 사이클 최신 ticker 가격을 사용한다.
- Room DB `15→16` 명시적 migration 및 `entry_diagnostics` 테이블을 추가해 전략점수/AI점수와 진입점수/Chase/ATR 확장/패턴/선행수익/첫 5분 하락/MFE/MAE/종료 결과를 통합 저장한다.
- Dashboard에 Good/Chase 비율, Score 90+ 실패율, Immediate Drawdown Rate(첫 5분 -2% 이하), Signal Lag 평균/P50/P95, 평균 Chase/Timing 점수와 경고를 추가했다.
- 연구 전용 `CURRENT_ENTRY/CHASE_FILTERED/WAIT_1M/WAIT_3M/PULLBACK/RETEST` 과거 candle 비교를 추가했다. 미래 가격은 BUY 의사결정에 사용하지 않으며, Shadow 결과가 실제 전략/Risk/LIVE를 자동 변경하지 않는다.
- 실제 저장소에는 최근 PAPER DB/export/log가 없으므로 ZORA/LM/TAVA 수치를 추측하지 않았다. 신규 Paper 거래부터 필요한 진입 진단을 자동 축적한다.
- 앱 버전: versionCode 12 / versionName 1.2.0

### Learning Scalping Execution AI (이번 작업)
- 기존 Strategy AI는 "무슨 코인을 볼지", 기존 Entry Timing/Chase는 "늦었는지"를 담당하고, `ScalpingExecutionEngine`은 최종 초단기 진입 타이밍 Challenger/추천 계층으로 분리했다. 기존 Strategy Score 계산식과 RiskManager 권한은 변경하지 않았다.
- 신규 REST polling을 만들지 않고 기존 WebSocket ticker 수신 이력을 `MicroMarketSample`로 10초/30초/1분/3분/5분 분석에 재사용한다. WebSocket 표본이 없을 때는 기존 1분 candle/Cache를 보수적 fallback으로 사용한다.
- `ENTER_NOW/WAIT/WAIT_PULLBACK/WAIT_RETEST/WAIT_REACCELERATION/TOO_LATE/CHASE_RISK/NO_EDGE/AVOID`, Micro Momentum, Micro Volatility, SCALP market state, reason code, execution confidence를 산출한다.
- 초단기 expected move(30초/1분/3분/5분)에서 수수료·spread·slippage·market impact와 safety margin을 차감한 `SHORT_HORIZON_NET_EDGE`를 계산한다. 비용 이하이면 `SCALP_EDGE_TOO_SMALL`로 진입을 차단한다.
- orderbook imbalance/pressure/depth 변화와 bid depth 급감·spread 확장을 감지한다. Data Quality/Market Health/Chase/Entry Timing/Short Edge 조건이 악화되면 보수적으로 WAIT/REJECT한다.
- current BUY는 `DATA → HEALTH → STRATEGY → CHASE → TIMING → SCALP AI → SHORT EDGE → LIQUIDITY → HEAT → NET EDGE → RISK → REENTRY → DUPLICATE → ORDER` 순서를 유지한다. 기존 RiskManager·Crash Protection·Kill Switch·중복매수·재진입 cooldown은 우회하지 않는다.
- signal TTL은 기존 Entry TTL과 Scalping TTL 중 더 짧은 값을 사용하며, 현재 cycle deep 후보만 실제 Paper 주문에 사용한다. 신호 가격 대비 ATR 기반 `PRICE_MOVED_AWAY` 및 `ENTRY_WINDOW_CLOSED`를 최종 확인한다.
- 후보/거래별 Strategy/AI/Execution 점수·Confidence·초단기 수익률·volume/momentum/orderbook/spread/short edge/state/decision을 `scalping_execution_diagnostics`에 저장하고, 5분/15분 결과·MFE/MAE·FALSE_ENTRY/GOOD_REJECT/FALSE_REJECT를 사후 기록한다.
- 별도 `SCALPING_EXECUTION` Shadow Account와 `SCALP_EXEC_v1` 모델 registry를 추가했다. 표본 부족 시 SHADOW를 유지하고, 검증 통과 시에도 CHALLENGER만 기록하며 자동 Champion/LIVE/OTA 승격하지 않는다. 승률만 높고 기대값이 음수인 모델은 기각한다.
- Dashboard에 Execution Score/Confidence/State, 초단기 momentum/edge/orderbook, 진입·거절·대기·False Entry/Reject, Shadow PF/기대값/MDD/비용, Calibration을 추가했다.
- Room DB `17→18` migration: 초단기 진단 확장 컬럼, `scalping_execution_models`, `scalping_shadow_accounts` 추가.
- 실제 저장소에 PAPER DB/export/log는 없으므로 ZORA/LM/TAVA를 재분석하지 않았다. 신규 Paper 데이터 축적 후에만 통계가 채워진다.
- 앱 버전: versionCode 13 / versionName 1.3.0

### Global Derivatives Intelligence AI (이번 작업)
- 기존 Bithumb 현물 `StrategyEngine`은 WHAT TO BUY, 기존 `ScalpingExecutionEngine`은 WHEN TO BUY, 신규 `GlobalDerivativesIntelligenceEngine`은 글로벌 파생 POSITIONING/RISK 보조 정보로 분리했다. 파생 데이터 하나만으로 BUY하지 않으며 Bithumb 현물/호가/리스크가 주 데이터다.
- 공식 Bybit V5 Public API 문서를 확인하고 `linear` USDT perpetual의 `market/instruments-info`, `market/tickers`, `market/open-interest`, `market/funding/history`, `market/account-ratio`만 사용한다. API Key가 필요한 private endpoint는 사용하지 않는다.
- `DerivativeSymbolMapper`가 Bithumb KRW 마켓을 Bybit USDT symbol로 매핑하고, 실제 instruments 목록에 없으면 `UNSUPPORTED`, Provider 장애/실패는 `TEMP_UNAVAILABLE`로 유지한다.
- 공식 ticker에서 mark/index/last price, OI, funding/next funding, turnover, price change, best bid/ask size를 취득한다. OI 5분/15분/1시간 변화와 funding percentile/state, long/short ratio를 계산하며 API가 제공하지 않는 1분 OI·basis는 가짜 값으로 채우지 않는다.
- 공식 Bybit Public Linear WebSocket `allLiquidation.{symbol}`을 별도 스트림으로 사용해 수신된 청산만 집계한다. 수신되지 않으면 `UNAVAILABLE`; liquidation 값을 추정하지 않는다.
- Price+OI matrix, `CROWDED_LONG/CROWDED_SHORT`, Short Squeeze Score, Long Squeeze Risk, `SPOT_FUTURES_DIVERGENCE`, `GLOBAL_MOVE_CONFIRMED`, 관측 timestamp/age/freshness, lead/lag를 구현했다. Funding/positioning/squeeze는 방향성·위험 보조 feature이지 확정적 사실로 취급하지 않는다.
- 파생 장애/STALE/UNSUPPORTED 시 기존 Bithumb Paper 분석은 계속되고, 파생 confirmation이 필요한 후보의 신뢰도만 낮아진다. 고점 Chase + Crowded Long/Long Squeeze Risk는 기존 Chase/Entry Timing과 Scalping 게이트에서 추가 감점/거절한다.
- `ScalpingExecutionInput`에 파생 sentiment/risk/OI/funding/positioning/squeeze/divergence/lead 정보를 추가했으며, RiskManager/Crash Protection/Kill Switch/Net Edge/duplicate/re-entry를 우회하지 않는다.
- 파생 원시 스냅샷은 `derivatives_snapshots`, 후보/거래 결합 진단은 기존 `scalping_execution_diagnostics`에 저장한다. 기존 AI Research 집계와 별도 Shadow/모델 registry 흐름을 유지하며 자동 Champion/LIVE/OTA 승격은 없다.
- Dashboard에 지원/미지원/일시불가 수, Funding/OI/Long-Short/Liquidation/Sentiment/Risk/Squeeze/Lead/Divergence를 추가 표시했다.
- Room DB `18→19` migration: `derivatives_snapshots` 추가. 앱 버전: versionCode 14 / versionName 1.4.0
- 공식 문서 확인 결과, 파생 데이터는 시장 데이터 API에서 신뢰 가능한 범위만 사용하며 실제 PAPER 거래 원장/export가 없어 과거 거래 성과는 재분석하지 않았다.

### 핵심 대시보드 상단 고정 UI (이번 작업)
- 기존 Dashboard의 실제 계좌/매매 수치는 `CoreAccountSummary`로 첫 번째 Section에 고정했다. PAPER/LIVE, 엔진 상태, 초기자산, 평가금액, KRW/코인 평가액, 실현·미실현·총/오늘 손익, 보유 수/최대 수, 후보 기준 매수 가능 예상액, Risk/Kill/Crash/Cooldown 상태를 기존 `DashboardState` 값으로 표시한다.
- `CurrentPositionsPanel`을 계좌 현황 바로 아래에 추가했다. 기존 Room `positions()` Flow와 `topSignals`를 재사용해 종목, HELD 상태, 매수가, 현재가, 수량, 매수/평가금액, 손익/손익률, 최고가, Stop/Take Profit, Trailing, 보유시간, Entry/AI/Execution 점수를 표시한다.
- `BuyCandidatePanel`은 세 번째 Section으로 고정했다. 이후 Market/Execution 상태, Scalping, Global Derivatives, Regime/Health, News/Risk, Research/Shadow/Opportunity 순서를 유지한다.
- `DASHBOARD_SECTION_ORDER`로 핵심 Section 순서를 명시하고, 새 카드가 핵심 1~3순위 위로 삽입되지 않도록 구조/주석을 추가했다. 기존 카드 자체는 삭제하지 않고 위치만 이동했다.
- `MetricGrid`에서 상단에 중복되는 계좌/손익 항목을 제거하고 기술 상태만 하단에 남겨 핵심 데이터 중복을 줄였다. 매매·리스크·가격·계산 로직은 변경하지 않았다.
- 별도 Sticky Summary Bar는 추가하지 않았다. 요구사항상 선택 사항이며, 현재 ModeBanner와 고정된 첫 Section으로 목적을 충족하고 화면을 가리지 않는다.
- 기존 `positions` Flow를 `MainViewModel`에 노출한 것은 표시용 연결만을 위한 변경이다. 실시간 StateFlow 갱신 시 Section 순서가 변하지 않는다.
- 앱 버전: versionCode 15 / versionName 1.5.0

### Smart Re-Entry Engine (이번 긴급 수정)
- 기존 `MarketReentryCoordinator`/손실 cooldown/`WAITING_FOR_NEW_SIGNAL`/중복 BUY를 유지하면서 익절 전용 `SmartReentryCoordinator`와 `SmartReentryEngine`을 추가했다.
- 익절 SELL 시 `ProfitExitAnchor`(매도가·시각·진입가·고점·실현손익·종료 이유·Strategy/AI·Regime/Health·소비된 Signal ID)를 저장하고 `PROFIT_EXIT_COOLDOWN`과 `ENTRY_SIGNAL_CONSUMED`를 먼저 적용한다.
- 동일 상승파동은 `REENTRY_ABOVE_EXIT_CHASE`/`SAME_WAVE`로 차단한다. 단순 가격 하락만으로 재진입하지 않고 `PULLBACK → STABILIZATION → REACCELERATION → NEW_SIGNAL → REENTRY_READY` 조건을 모두 요구한다.
- `ReEntryQualityScore`, pullback depth/duration, ATR, Entry/Chase/Scalping, spread/orderbook, Net Edge, Regime/Health, previous profit 대비 reentry risk를 사용한다. 품질 미달·Chase·수익반납 위험은 `WAIT_*` 또는 reject 처리한다.
- 새 signal ID와 exit 이후 timestamp를 검증하며, 기존 signal을 재사용할 수 없다. 실제 최종 Paper BUY mutex 내부에서도 Smart Re-Entry를 재평가한다.
- 익절 후 재진입 손실 chain과 `PROFIT_REENTRY_GIVEBACK`, 동일 종목 churn, 수수료 비용 경고를 집계한다. 다른 종목 Paper 거래는 계속 허용한다.
- `IMMEDIATE_REENTRY/TIME_COOLDOWN_ONLY/WAIT_PULLBACK/WAIT_REACCELERATION/NEW_WAVE_ONLY/NO_REENTRY` Shadow 비교 엔진과 `smart_reentry_attempts` 학습 기록을 추가했다. 결과가 충분히 쌓이기 전 자동 정책 변경은 없다.
- Room DB `19→20` migration: `smart_reentry_states`, `smart_reentry_attempts` 추가. `MainViewModel`/Dashboard에 현재 보호 상태·이전 매도가·ReEntry Quality·반납/churn 통계를 표시한다.
- Paper 계좌 초기화 시 기존 손실 guard와 Smart Re-Entry 상태/시도를 함께 초기화한다. repository scan mutex로 겹치는 scan이 같은 포지션을 재진입시키지 않도록 했다.
- 실제 PAPER DB/export/log가 없으므로 PROFIT→REENTRY→LOSS 수치는 `DATA_INSUFFICIENT`로 유지한다.
- 앱 버전: versionCode 16 / versionName 1.6.0

### Continuous Self-Learning / Deep Learning Evolution Loop (이번 작업)
- 기존 `AiTrainingPolicy`/Paper 라벨·`StrategyPerformanceEngine`/`ShadowSimulationEngine`/`ChampionChallengerEngine`/`WalkForwardEvaluator`/`MarketRegime`/Entry·Exit·Re-entry·Scalping·Derivatives 연구 구조를 새로 복제하지 않고 연결했다.
- `HistoricalBootstrapEngine`이 실제 Bithumb 1분 OHLCV candle에서 과거 시점 T 이전 feature만 재구성하고, +1/+3/+5/+15/+30/+60분 futureReturn·MFE/MAE·stop/take-profit·cost 차감 net label을 `source=HISTORICAL`로 저장한다. 실시간 orderbook/news/derivatives가 없는 과거 시점에는 값을 발명하지 않고 UNKNOWN/기본 보수 처리한다.
- 기존 `ai_training_samples`에 source(HISTORICAL/PAPER/LIVE), 다중 horizon outcome, cost-adjusted result, hardExample, lookaheadSafe 필드를 확장했다. 기존 Strategy AI 재학습은 `source=PAPER`만 사용해 Historical/Paper를 섞지 않는다.
- `LearningReplayBuffer`가 최근 데이터·Historical·hard example·regime bucket을 균형 있게 선택하며, invalid/stale/lookahead-unsafe sample을 제외한다.
- `DeepLearningCandidateModel`은 기존 Strategy/Risk를 대체하지 않는 소형 on-device hidden-layer 다중 head 후보 모델이다. 5m/15m positive, stop/rebound/chase failure 확률과 short return을 동시에 산출하고, 모델 실패 시 기존 Paper/Strategy 경로를 유지한다.
- `ContinuousLearningEngine`이 TRAIN → VALIDATE → OOS metrics(Accuracy/Brier/MAE/RMSE/PF/Expectancy/MDD/Net Return)를 계산하고, OOS·표본·MDD·PF·기대값 기준을 통과한 후보만 `PROMOTION_CANDIDATE`/SHADOW Challenger로 보존한다. Champion/Production/LIVE 자동 승격은 없다.
- `PredictionJournalEntity`로 predictionId/modelVersion/features/prediction/confidence/decision/reason을 저장하고 Paper 거래·종료 후 실제 return/prediction error를 연결한다.
- Paper 거래/후보 데이터가 쌓일 때 `TrainingBuffer` 역할의 source-tagged sample과 journal을 축적하며, 설정 가능한 sample trigger/interval/replay size로 batch 학습을 scheduling한다. 매 30초 전체 재학습을 하지 않고 Trading 경로와 별도 coroutine에서 수행한다.
- Historical/Paper 분포 drift, confidence calibration, hard example(HIGH confidence failure/Chase/즉시하락 등), training stalled/insufficient sample 상태를 Dashboard에서 확인할 수 있게 했다.
- `continuousLearning` Dashboard에 Champion/Challenger, Historical/Paper/LIVE sample, Hard/Replay, training/OOS metrics, Drift/status를 추가했다. 실제 PAPER 원장/export가 없으므로 empirical 학습 성과는 아직 `INSUFFICIENT_SAMPLE`이다.
- Room DB `20→21` migration: 기존 `ai_training_samples` 확장 컬럼 및 `prediction_journal` 추가. 앱 버전: versionCode 17 / versionName 1.7.0

### APK 업데이트 학습 자산 보존/무결성 (이번 긴급 점검)
- `bithumb_trader.db` 이름과 `applicationId`를 유지하고 Room 1→21 명시적 migration, destructive fallback 미사용을 재확인했다. 모든 migration은 CREATE/ALTER 기반이며 학습 행을 DROP/DELETE하지 않는다.
- 실제 원인은 DB 손실이 아니라 persisted AI/continuous model·sample count·Shadow/Champion UI 복원이 첫 scan까지 지연되어 업데이트 직후 빈 상태처럼 보이는 점, 그리고 새 bundled/OTA integer version이 검증된 `LOCAL_RETRAIN`보다 우선될 수 있던 점이었다.
- `TraderApp.onCreate → initializeLearningContinuity → restorePersistedAiModel → ensureAiRetrainStateLoaded → ensureContinuousLearningStateLoaded → manifest 비교 → Strategy/AI OTA` 순서로 eager restore를 추가했다. 수동 첫 scan에서도 persisted AI를 먼저 복원한다.
- 검증된 `LOCAL_RETRAIN`은 새 bundled modelVersion보다 우선 복원하며, OTA가 integer version만 높다는 이유로 자동 덮어쓰지 않고 수동 검증 대상으로 보류한다. Continuous model JSON 복원 실패는 로그를 남기고 model registry의 최근 Promotion Candidate JSON으로 fallback한다.
- `LearningAssetManifest`를 기존 Room `settings`에 저장한다. schema/app version, source별 AI sample, resolved/prediction/hard example, production/continuous model checksum/version/source, last training, Champion/Challenger, Shadow, Research, Smart Re-entry, Scalping diagnostics, 생성/갱신 시각을 집계한다.
- 최초 설치에서 생성한 비식별 `learningLineageId`를 settings에 영속화한다. 업데이트 후 count 감소, checksum 불일치, model/challenger 소실, lineage 변경을 `LEARNING_ASSET_LOSS_DETECTED`로 경고하며 정상 비교는 `LEARNING_ASSETS_PRESERVED`로 기록한다.
- 앱 시작 로그에 APP version, DB schema, sample/journal before→after, model restore/checksum, lineage 상태를 남기고 Continuous Learning 카드에 보존 상태를 표시한다. 앱 삭제/Android 데이터 삭제는 정상 APK 덮어쓰기와 달리 OS 데이터가 삭제되어 별도 backup 없이는 복구 불가함을 명시한다.
- Paper 계좌 초기화는 Cash/Position/Trade/활성 re-entry guard만 초기화한다. AI samples, Prediction Journal, models, Shadow/Research, Historical knowledge, Smart Re-entry 과거 attempt는 유지한다. 별도 AI 초기화 기능은 backup/export 없이 실수로 자산을 지울 위험이 있어 제공하지 않는다.
- 실제 SQLite JDBC migration test가 v20 fixture의 AI sample 600, Prediction Journal 600, model 1, promotion 1, shadow 10, research 3, Smart Re-entry 2, Scalping 4를 동일 `Migration20To21Sql`로 v21로 올린 뒤 전부 동일함을 검증한다. 별도 simulated APK update acceptance는 Historical 500/Paper 100/Journal 600/Model DL_TEST_7/LastCount 600/Lineage ABC의 완전 동일성을 검증한다.
- 앱 버전: versionCode 18 / versionName 1.8.0. Room DB는 schema 변경 없이 21 유지.

### APK 학습 자산 보존 재감사 (v1.9.0 / Room 22)
- 실제 원인 재확인: Room destructive migration이 아니라 (1) restore가 첫 scan까지 지연되던 문제(이미 eager restore로 수정됨), (2) **v1.9.0에서 Room schema=22인데 `TraderApp`/`TradingRepository`가 schemaVersion=21을 하드코딩**해 Manifest/로그가 어긋나던 회귀.
- 최소 수정: `AppDatabase.SCHEMA_VERSION=22` 단일 상수로 TraderApp·Repository·@Database version을 통일. 새 AI 학습 시스템/새 DB를 만들지 않음.
- Migration 감사: 1→22 전부 CREATE/ALTER만 사용, learning 테이블 DROP/DELETE 없음, `fallbackToDestructiveMigration()` 없음, DB명 `bithumb_trader.db` 유지.
- 테스트 확장: 실제 SQLite `20→21`, `21→22`, `20→21→22` multi-step 보존 + Acceptance Manifest(Paper100/Historical500/Journal600/Model DL_TEST_7/Last600/Lineage ABC) + LOCAL_RETRAIN precedence.
- Paper Reset은 Cash/Position/Trade/활성 Guard·Smart Re-entry **상태**만 지움. AI Sample/Journal/Model/Shadow/Research/Smart Re-entry **attempt**는 유지.
- 별도 `RESET AI LEARNING` 확인 다이얼로그만 Sample/Journal/모델 설정을 지움(Paper Reset과 완전 분리).

### Hetzner AI Brain Migration (이전 작업)
- **Phase**: 1 (Shadow) — 서버 health/decision/market collector 추가, Android 실제 BUY는 기존 Local AI 유지.
- **Server URL**: `https://riderapp.duckdns.org/api/trading/v1/*` (기존 Caddy/OTA 경로 유지)
- **Android provider**: `LocalAiDecisionProvider` + `HetznerAiDecisionProvider` + `CompositeAiDecisionProvider`
- **Flags**: `remoteAiEnabled=true`, `remoteAiPrimary=false`(fail-safe), `localAiShadowEnabled=true`, LIVE 관련 항상 false
- **Fallback policy**: SERVER OFFLINE + primary면 신규 BUY FAIL CLOSED. Phase1 primary OFF라 Local 실행 유지. Local AI 삭제안함(Shadow/비상).
- **Position exit**: 서버 dependency 0 (RiskManager SELL 경로 유지)
- **Health**: ONLINE + Bithumb WS CONNECTED 확인. Bybit server collector는 Phase1 NOT_STARTED.
- **Security**: API token (`/etc/bithumb-ai-brain.env`), HTTPS via Caddy, 서버에 거래소 private key 없음
- **Tests**: Android 271 pass / Server pytest 6 pass
- **APK**: v1.9.5 / versionCode 24

### PHASE 3 PAPER Server Primary — Android Viewer + Final Safety (이전 작업)
- **목표**: HETZNER=BRAIN / ANDROID=VIEWER+SAFETY+EXECUTION. Server Primary ON이면 Android 전체시장 분석 중복 중지.
- **Server**: `GET /api/trading/v1/dashboard` + 백그라운드 `_analysis_loop`(5s FAST/DEEP/decision). `state.mode=SERVER_BRAIN_ANDROID_VIEWER`.
- **Android path**: `remoteAiPrimary=true` + healthy → `scanOnceServerPrimaryInternal` (dashboard 수신 → 후보 표시 → BUY만 Risk/TTL/PRICE_MOVED_AWAY/duplicate/session-stale 검사 → PaperExecution). Strategy/AI/Scalp/ShortEdge **재계산 없음**.
- **Primary ON + unhealthy**: `POSITION_SAFETY_ONLY` — NEW BUY BLOCK, SL/TP/Trailing/Crash 로컬 유지.
- **학습**: `remoteAiPrimary || remoteLearningEnabled`이면 Android Historical Bootstrap / Continuous Learning 자동 실행 OFF (모델·DB 삭제 금지).
- **Local Shadow throttle**: `localShadowIntervalMinutes`(기본 10분). Primary 경로에서는 decide() 자체가 스킵됨.
- **앱 재시작**: `engineSessionStartedAtMs` 이전 서버 BUY → `SERVER_STALE_ON_APP_RESTART` (과거 BUY 자동실행 금지).
- **WS 경량화**: Primary에서 held + server candidates만 ticker subscribe.
- **Flags 기본값 유지**: `remoteAiPrimary=false` (Phase1/2 검증 전 Local Primary 유지). Settings에 SERVER PRIMARY 토글 추가.
- **폰 OFF 검증**: 서버 단독으로 WS CONNECTED, messageCount↑, microBufferReady↑, fast=30/deep=15, dashboard 갱신 확인.
- **Tests**: Android 274 pass / Server pytest 7 pass
- **APK**: v1.9.6 / versionCode 25

### Server Primary Runtime Verify + Flow Logs + Rebuild (이전 작업)
- FLOW 한 줄 추적: `SERVER_ANALYSIS -> SNAPSHOT_CREATED -> ANDROID_RECEIVED -> BUY_CHECK -> PAPER_ORDER`
- BUY 차단 시 `BLOCK_REASON=` 고정 코드 출력 (SCORE_LOW / SIGNAL_NOT_BUY / SERVER_STALE / SERVER_OFF / DUPLICATE_SIGNAL / RISK_BLOCK / COOLDOWN 등)
- 서버 `_analysis_loop`에 `FLOW SERVER_ANALYSIS -> SNAPSHOT_CREATED` 로그
- 런타임 검증: health ONLINE+WS CONNECTED, dashboard fast=30/deep=15, symbol/score/signal/reason/timestamp 전달 확인
- Unit: paper E2E flow + blockReason + server-off fail-closed (Local fallback 금지)
- **APK**: v1.9.7 / versionCode 26
- **Tests**: Android 277 pass

### PHASE 4 — Server-resident PAPER Engine (이번 작업)
- **핵심**: PAPER 모의매매 엔진을 Hetzner에 상주. Android는 Remote Control + 모니터.
- Server: `PaperTradingEngine` + SQLite persistence (`paperAuto/cash/positions/trades/used_decisions`)
- APIs: `POST /paper/auto`, `GET /paper/state|positions|trades`, dashboard에 `paper` 포함
- Analysis loop마다 `FLOW PAPER_TICK` (Android 연결 무관)
- Android Primary: 로컬 PAPER buy/sell 중지. 시작/중지 버튼 → 서버 PAPER AUTO ON/OFF
- 검증: 앱 없이 PAPER_TICK 지속, verify-buy→FILLED, restart persistence, ALREADY_HOLDING, STOP LOSS SELL, AUTO OFF persist
- **APK**: v1.9.8 / versionCode 27 · https://riderapp.duckdns.org/bithumb-app-debug.apk
### PHASE 4 hotfix — paper_positions UNIQUE after SELL
- Bug: SELL left `quantity=0` row → next BUY `INSERT` hit `UNIQUE(market)` and aborted analysis tick
- Fix: `DELETE FROM paper_positions` on SELL; purge `quantity<=0` on init; pre-INSERT cleanup
- Verified on Hetzner: BUY→STOP LOSS SELL→reBUY OK; service restart with open position: no duplicate order



### PHASE 5 — UI SoT + App Reopen Restore (이번 작업)
- FAIL 2건만 최소 수정: UI SERVER DATA MATCH / APP REOPEN SERVER STATE RESTORE
- `ServerPrimaryCoordinator.applyServerPaperMirror` : SERVER → UI 단방향 (cash/positions/PnL/auto)
- `restoreServerPaperOnAppStart`: APP_START → HEALTH → PAPER STATE → UI_RESTORE (주문 없음)
- Primary UI: CoreAccount + Positions는 서버 값 표시 (로컬 Room 포지션/재계산 금지)
- Unit: phase5 mirror/reopen tests PASS · total **279** unit tests
- **APK**: v1.9.9 / versionCode 28 · https://riderapp.duckdns.org/bithumb-app-debug.apk
- 실기기 UI eyeball은 cloud에 디바이스 없어 **UNVERIFIED** (로직/유닛/미러 시뮬레이션은 PASS)


### PHASE 6 — Device verify diagnostic upload (이번 작업)
- 문제: UI_SERVER_DATA_MATCH / APP_REOPEN_SERVER_STATE_RESTORE 가 Logcat에만 존재
- 서버: `POST/GET /api/trading/v1/diagnostics/device-verify` + `FLOW DEVICE_VERIFY ...` (trading-neutral)
- Android: 실제 paper SoT vs UI mirror 비교 후 결과만 업로드 (실패해도 매매 무영향, PASS throttle 45s)
- **APK**: v1.9.10 / versionCode 29 · https://riderapp.duckdns.org/bithumb-app-debug.apk
- 사용법: PRIMARY ON → Start → 강제종료 → 재실행 → Cursor가 Hetzner `FLOW DEVICE_VERIFY` / diagnostics API로 판정


### PHASE 6 HOTFIX — AUTH_TOKEN_MISSING / authenticated API never called
- ROOT CAUSE: `/health`는 무인증, `/paper/state`·`/dashboard`·`device-verify`는 토큰 필요. Settings에 Trading API Token 입력 UI가 없어 실기기는 health만 호출.
- FIX: Token 암호화 저장 UI + `AUTH_TOKEN_MISSING`/`AUTH_CLIENT_READY`/`HTTP_401` 명시 로그 + Primary 경로 인증 게이트. 빌드 시 `TRADING_AI_TOKEN` env로만 seed(소스 하드코딩 없음).
- **APK**: v1.9.11 / versionCode 30 · https://riderapp.duckdns.org/bithumb-app-debug.apk

### PHASE 6 FINAL — 실기기 테스트 완료 재판정 (2026-09-01T06:10Z, Hetzner 증거만)
- Phone IP `118.235.6.95` since 06:00 UTC: `POST /decision`×90 + `GET /health`×12 **only**. `/dashboard` `/paper/state` `/paper/auto` `/diagnostics/device-verify` = **0**.
- `PAPER_AUTO_CMD source=ANDROID` today = **0**. Real `DEVICE_VERIFY` (non-smoke) = **0** (only agent smoke-session / hotfix-smoke).
- Server PAPER: `paperAuto=true` `androidIndependent=YES` positions=3 tick≈265 → SERVER_ANALYSIS / APP_CLOSED / PHONE_OFF trading **PASS**.
- Auth: `/decision` HTTP 200 → ANDROID_AUTH_CLIENT **PASS**.
- SERVER_PRIMARY / UI_SERVER_MATCH / APP_REOPEN_RESTORE / DEVICE_VERIFY_UPLOAD → **FAIL or UNVERIFIED**.
- **ALL_PASS = NO**. DIFF: token works but device stayed on LOCAL scan + remote decision, not SERVER PRIMARY viewer path.
- Next device steps: v1.9.11 + Token 저장됨 + **SERVER PRIMARY ON** + Start → force-stop → reopen → expect dashboard/paper/state + real DEVICE_VERIFY.

### PHASE 6 RETEST “했어” (2026-09-01T06:16Z) — still FAIL + UX fix 1.9.12
- Re-check after user “했어”: phone still only `/decision`+`/health` (135+9 since 06:10). No `/dashboard`/`/paper/state`/`device-verify` from phone. Real DEVICE_VERIFY=0. **ALL_PASS=NO**.
- ROOT: Primary remained OFF (LOCAL remote-decision path). Settings-only toggle insufficient for device tests.
- FIX v1.9.12: Dashboard **SERVER PRIMARY** panel; Start auto-ON Primary when Trading token ready (sync before service); `ANDROID_MODE_REPORT` diagnostic upload; server accepts that event.
- **APK**: v1.9.12 / versionCode 31 · https://riderapp.duckdns.org/bithumb-app-debug.apk
- Retest: install 1.9.12 → (token already saved) → 자동매매 시작 (Primary auto-ON) → wait → force-stop → reopen. Expect `/dashboard` `/paper/state` `PAPER_AUTO_CMD source=ANDROID` `ANDROID_MODE_REPORT decision=PASS` + UI match verify.

### PHASE 6 RETEST “했어” #2 (2026-09-01T06:25Z) — still FAIL + force Primary 1.9.13
- After 1.9.12 deploy: phone still `/decision`×148 + `/health`×7 only. **No** `ANDROID_MODE_REPORT` from phone (only agent mode-smoke) → old APK still running and/or Primary never applied.
- FIX v1.9.13: default `remoteAiPrimary=true`; app-start + service-loop **force Primary ON** when token ready; dashboard shows `SERVER PRIMARY v1.9.13`.
- **APK**: v1.9.13 / versionCode 32 · https://riderapp.duckdns.org/bithumb-app-debug.apk
- Retest: **이전 앱 삭제 후** 1.9.13 설치 → 대시보드에 `v1.9.13` 확인 → 시작 → 강제종료 → 재실행.

### PHASE 6 ROOT CAUSE FIX v1.9.14 (한번에 해결)
- **ROOT CAUSE**: 저장된 `remoteAiPrimary=false`(Phase1/2)면 Android가 LOCAL FAST/DEEP를 돌리고, DEEP 후보마다 `HetznerAiDecisionProvider.decide()` → `POST /decision`. Primary 토글/자동ON만으로는 기기에서 계속 LOCAL 폭주.
- **HARD FIX**:
  1. `scanOnceInternal`: Trading token + Remote AI + dashboard ⇒ **항상** Server Primary 경로 (Primary 플래그 무시)
  2. `TradingSettingsStore.load`: Primary OFF 저장값 → ON 마이그레이션
  3. `HetznerAiBrainClient.decision()` HTTP **완전 차단**; `decide()`도 `/decision` 미호출
  4. 상단 배너에 `app v1.9.14` 표시 (설치 버전 확인)
- **APK**: v1.9.14 / versionCode 33 · https://riderapp.duckdns.org/bithumb-app-debug.apk
- Expect: phone `POST /decision` = 0, `GET /dashboard` + `GET /paper/state` + `ANDROID_MODE_REPORT`

### PHASE 6 device evidence + UI false alarm fix v1.9.15
- Real device **1.9.13**: `SERVER_PRIMARY_VIEWER`, `ANDROID_MODE_REPORT PASS`, `UI_SERVER_DATA_MATCH PASS`, `APP_REOPEN_SERVER_STATE_RESTORE PASS`, `/dashboard` every ~30s. `/decision` only before Primary (06:30–06:31), then **0**.
- Screenshot "API 연결 끊김" = **false alarm**: Primary path never set `apiStatus=CONNECTED`; `마지막 시세 00:37:54` = stale trade timestamp while WS was live.
- FIX v1.9.15: Primary sets apiStatus CONNECTED; MetricGrid shows Hetzner ONLINE; last price prefers WS time.
- **APK**: v1.9.15 / versionCode 34 · https://riderapp.duckdns.org/bithumb-app-debug.apk

### PHASE 6 FINAL — 1.9.15 device verify ALL_PASS (2026-09-01T06:55Z)
- Phone after install: `appVersion=1.9.15`, `APP_REOPEN_SERVER_STATE_RESTORE PASS`, `ANDROID_MODE_REPORT PASS path=SERVER_PRIMARY`, `UI_SERVER_DATA_MATCH PASS`.
- Last 20m phone APIs: `device-verify`49 · `dashboard`32 · `health`33 · `paper/state`1 · **`decision` 0**.
- Server PAPER: `paperAuto=true` `androidIndependent=YES` ticks continue → APP_CLOSED / PHONE_OFF trading PASS.
- **ALL_PASS = YES**.

### Execution Data Pipeline / SCALP State Mapping Bug Fix (이전 작업)
- **BUG**: `reasons.isEmpty()`이면 무조건 `EXECUTION_DATA_INSUFFICIENT`를 붙여, 실제 데이터 부족이 아닌데 reason이 그렇게 보임.
- **BUG**: `CHASE_RISK` 상태인데 chase reason code를 안 넣음 → 빈 reasons → EXECUTION_DATA_INSUFFICIENT로 위장.
- **BUG**: `enrichSignal`이 candles=[]로 EntryTiming을 재평가해 Chase=0 / Timing=50 / SLIGHTLY_LATE로 UI를 덮어씀(Deep Scan 값과 모순).
- **BUG**: 1m candle을 micro sample proxy로 써서 Short Edge가 과대평가될 수 있음 → proxy 제거 + `shortEdgeReliable` 플래그.
- 수정: `DATA_INSUFFICIENT`/`WARMING_UP` 상태 분리, gap reason 세분화, CHASE_RISK는 실제 chase일 때만, enrichSignal은 Deep timing 재사용, Dashboard에 실행 데이터 상태 표시. Threshold 변경 없음.
- **진단 보강**: `ExecutionDataPipelineDiagnostics` — Scalp 직전 Input Snapshot 로그(null 숨김 금지), 시장별 gap duration/`BUG_CANDIDATE`(≥5분), `WEBSOCKET_ZOMBIE`, REST fallback TRIGGERED/SUCCESS/FAILED/LATENCY 로그, 30m/1h/3h `EXECUTION_DATA_INSUFFICIENT_RATE`(+50%면 PIPELINE_DEGRADED), `TimestampUnits` 초/ms 보정. Dashboard CORE에 WS health·rate 표시.

### 무거래 진단 / Scan Heartbeat / Gate Funnel (이전 작업)
- 목표: 몇 시간 PAPER 거래 0건이 **ENGINE_STALLED**인지 **ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED**인지 10초 안에 구분.
- `NoTradeDiagnostics` + Dashboard `scanHeartbeat` / `gateFunnel` / `noTradeDiagnosis`. CORE에 "자동매매 실시간 상태" 패널 추가(리뉴얼 없음).
- Scan stage(LOADING→…→COMPLETE), sequence, duration, stall(≥90s), Funnel/TOP blocker, Short/Strategy/AI 분포 라벨.
- **BUG FIX**: Paper BUY가 `currentReadySignals ∩ top-10 표시목록` 교집합만 허용해 BUY_READY가 있어도 체결 누락될 수 있던 문제 → 이번 스캔 BUY_READY만 선택.
- **BUG FIX**: Foreground wake lock 20초 → 5분(긴 Deep Scan mid-sleep/stall 완화). Threshold/정책 자동 완화 없음.
- 기기 PAPER DB 미제공 시 비율 필드는 NO_RUNTIME_DATA / INSUFFICIENT_DATA.

### Entry Urgency / Scalping Decision Audit (이전 작업)
- 목표: 거래를 늘리거나 줄이는 것이 아니라, 급진입 / 신호 지연 / Late Chase / Overblocking을 PAPER 증거로 구분. **정책·임계값 자동 완화 없음 (POLICY_CHANGED=NO).**
- BUY 경로 실측: Deep Scan에서 Strategy → EntryTiming/Chase → Scalping(Short Edge) → enrichSignal(Profit Reentry → Net Edge 익절목표 → Liquidity → Risk → Order). Liquidity는 BUY gate에 CONNECTED.
- **Liquidity 0/0 버그**: `lastLiquidityValues`가 선언만 되고 대입되지 않아 `decide(..., emptyList())` → rank/total 0/0. Deep Scan 직전 `tickers` 값으로 채우도록 수정. 미초기화는 `LIQUIDITY_UNINITIALIZED` fail-closed.
- UI: `순위 0/0` / `최소 0 KRW` / `상위 100%`를 실제처럼 표시하지 않음 → "순위 계산 전" / "최소 거래대금 계산 전" / "유동성 백분위 계산 전".
- Short Edge vs General Net Edge: Short=초단기(30s~5m micro move − fee/spread/slip/impact×safety). General=`takeProfitPercent` 기준 중기 Net Edge. 동시 +5% / −0.88%는 **horizon 차이로 NORMAL**. UI에 라벨 분리 + cost decomposition + Horizon Conflict + 한 줄 decision summary.
- `EntryUrgencyAudit`: cost breakdown, horizon conflict, urgency class(기존 EntryTiming 재사용), candle CONFIRMED/IN_PROGRESS 태깅, reject/entry 분류 헬퍼, runtime threshold 표. 새 매매 엔진 없음.
- 표본 부족 시 False Reject/Entry·BUY_NOW vs WAIT·진입 속도 결론은 INSUFFICIENT_DATA/NO_DATA (기기 PAPER DB 미제공).

### 국면별 전략 파라미터 세트 자동 전환 (이전 작업)
- 기존 `RegimeStrategySelector`(가중치 추천)와 `RegimeHysteresis`를 유지한 채, 국면마다 별도의 파라미터 세트(`RegimeStrategySetCatalog`)를 둔다. Strategy Score 계산식은 변경하지 않는다.
- 세트는 scoreThreshold / AI min / stop·take·trailing / maxPositions / order size / chase·entry timing 기준을 국면별로 조정한다. STRONG_BULL~CRASH/RECOVERY/UNKNOWN 전 국면을 포함한다.
- PAPER + 설정 ON일 때만 `effectiveSettings()`에 자동 적용한다. LIVE는 추천/표시만 하며 자동 적용하지 않는다. Crash 세트는 신규 진입을 사실상 차단한다.
- 안전 clamp: dailyMaxLoss/연속손실 한도는 절대 변경하지 않고, 손절은 기준선보다 넓어지지 않으며, score 완화는 최대 5점, 주문비중 상한은 기준선의 1.1배로 제한한다.
- `RegimeAccuracyValidator`로 국면 방향 적중률을 사후 집계하고, `RegimeSetShadowEngine`으로 FIXED_BASELINE vs REGIME_ADAPTIVE를 비교한다. 자동 OTA/Champion 승격은 없다.
- Dashboard 적응형 국면 카드에 활성 세트·적용 여부·파라미터·정확도·Shadow 결과를 표시하고, Settings에서 세트/Paper 자동적용 토글을 제공한다.
- Room DB `21→22` migration: `regime_strategy_set_snapshots`, `regime_accuracy_samples` 추가. 앱 버전: versionCode 19 / versionName 1.9.0

## 알려진 한계 / 다음 후보 ("AI 전략 연구소" 로드맵 진행 상황)

1. ~~상승장/횡보장/하락장 국면 분류~~ — 완료 (`MarketRegimeClassifier`, 위 참고).
2. ~~국면별 전략 추천~~ — **완료(국면별 파라미터 세트 자동 전환)**. PAPER 자동적용 + LIVE 추천만 + 정확도/Shadow 검증. Strategy Score 공식·Risk 하드가드는 유지.
3. ~~포트폴리오 자동 비중 조절~~ — 완료 (`PortfolioAllocationEngine`, 위 참고, 기존 상한 내에서만 축소).
4. ~~이상 징후 자체 진단~~ — 완료 (`AnomalyDetector`, 위 참고).
5. ~~성과 리포트 자동화~~ — 완료 (위 참고).

### 다음에 더 다듬을 수 있는 것 (아직 미착수)
- 국면 정확도의 진짜 forward-horizon 라벨(현재는 시장 평균 등락 프록시 + 방향 일치 검증)
- Crash Replay 틱 단위 전체 재생(현재는 발생/종료 스냅샷 + 요약)
- 학습 자산 export/import(앱 삭제·데이터 삭제 대비 백업)

### Crash Detection Engine (완료)
- `MarketHealthEngine`: 이미 계산된 국면(Regime)과 이상 징후(Anomaly) 수, 시세 지연, API 오류만 재사용해 0~100 Market Health Score 산출(HEALTHY/CAUTION/CRASH). **새로운 데이터 수집 없음, 중복 계산 없음.**
- 신규 진입 보호: `crashProtectionEngaged` 플래그로 CRASH 감지 시 신규 매수 자동 차단 (킬 스위치와 별개 — 킬 스위치는 수동+전량 강제청산, 이건 자동+신규매수만 차단)
- 비상 포지션 보호: CRASH 상태에서는 `effectiveSettings()`가 손절/트레일링스탑을 타이트닝해 **기존 RiskManager.shouldSell 경로 그대로** 더 빨리 정리되게 유도(새 청산 로직 추가 없음)
- 쿨다운 모드: CRASH 해소 후 설정 가능한 시간(기본 30분) 동안 자동으로 신규 매수 차단, 시간 경과 시 자동 해제(연속손실 잠금·킬스위치는 수동 해제라 다른 메커니즘)
- 다이내믹 익스포저 컨트롤: `ExposureControlEngine`이 Health Score에 따라 `maxPositions`/주문 비중 상한을 축소(PortfolioAllocationEngine과는 축이 다름 — 이건 계좌 전체, 그건 후보 1건 단위. 곱해져서 함께 적용, 중복 아님)
- Crash Replay: `crash_events` 테이블에 급락 발생/종료 시점의 국면·Health·자산가치 스냅샷 기록, 대시보드에 이력 표시

## 테스트/빌드 이력

- 최신: `./gradlew clean testDebugUnitTest assembleDebug` → BUILD SUCCESSFUL
- Server pytest: 9 passed · Runtime PHASE4 paper engine verified on Hetzner
- APK: v1.9.10 / versionCode 29 · see phase6 sha
- 이전: APK: v1.9.9 / versionCode 28 · SHA-256 `dfad5a5b1ab18df3cc36cd587e41a82f4f4394bb1649af449fac59bb587ecf4e`
- 이전: APK: v1.9.8 / versionCode 27 · SHA-256 `385566056acd6c0f5e6b4e7d82b4ba718dcb70a91a5e1c662c734235373f9449`
- Web: https://riderapp.duckdns.org/
- 이전: APK v1.9.7 / versionCode 26 · SHA-256 `3746e22b2fed24b1500832f766efaac29bfe99fe1c40c3c43b8ddd3519d43570`
- 이전: `./gradlew clean testDebugUnitTest assembleDebug` → BUILD SUCCESSFUL, 271개 유닛 테스트 통과, 0 실패
- Server: `server/ai-brain` pytest 6 passed
- Room DB 버전: 22
- APK: v1.9.5 / versionCode 24 · SHA-256 `d691e38009a2baa36214f667d67ce8ef2d64c5310d65eda9623b6a02725a4fa8`
- Hetzner AI Brain Phase1 배포 + Android remote provider/shadow 포함

- 이전: `./gradlew clean testDebugUnitTest assembleDebug` → BUILD SUCCESSFUL, 266개 유닛 테스트 통과, 0 실패
- Room DB 버전: 22
- APK: v1.9.4 / versionCode 23 · SHA-256 `15b1a2d790381b00b12b52dcac099767299d512705b19561b792e0bf2c264b1b`
- Execution Data Pipeline diagnostics 보강 + SCALP CHASE_RISK mapping bug fix 포함

- 이전: `./gradlew clean testDebugUnitTest assembleDebug` → BUILD SUCCESSFUL, 261개 유닛 테스트 통과, 0 실패
- Room DB 버전: 22
- APK: v1.9.3 / versionCode 22 · SHA-256 `7bc8c8925031e16d07b761a108b406a041c4ac2971857b7615abba0ca93b61d5`
- Execution Data / SCALP CHASE_RISK mapping bug fix 포함

## Net Profit After Cost / Fee Drag Protection (v1.9.16)

### 목표
소액 계좌에서 Gross 소폭 수익이 수수료·스프레드·슬리피지에 잠식되는 과매매를 막고,
**모든 비용 차감 후 Net Profit** 기준으로 BUY / 학습 / 통계를 정렬한다.

### 재사용 (중복 엔진 금지)
- `PaperTradingMath` 체결 수수료·슬리피지 (ratio `0.0025` / `0.001`) 유지
- Short Edge / Net Edge (percent 단위 `0.25` = 0.25%) 유지 — Short Edge는 one-way 레거시 게이트
- 신규 `NetProfitAfterCostEngine`: **왕복** 비용 = buyFee + sellFee + buySlip + sellSlip + spread(1회) + impact × safetyMargin
- PAPER 실현손익은 이미 Net (매수비 평균가 반영 + 매도비 차감). PF/Expectancy/MDD는 Net 기준

### BUY 게이트 순서
DATA → … → SCALP(SHORT EDGE) → NET EDGE → **NET PROFIT AFTER COST** → … → RISK → ORDER

차단 코드: `NET_PROFIT_TOO_SMALL` / `COST_COVERAGE_TOO_LOW` / `COST_TO_PROFIT_TOO_HIGH`

보수적 기본값 (threshold 완화 금지, LIVE 변경 없음):
- `absoluteMinimumNetProfitKrw=30`
- `minimumNetProfitPercentOfOrder=0.05` (% of order)
- `minimumCostCoverageMultiple=1.5`
- cost/gross 상한 67%

### 서버 (Hetzner DecisionEngine)
- 후보마다 expectedGross/RoundTrip/Net/coverage/breakEven 계산
- BUY인데 Net gate 실패 시 `WAIT` + reason
- Android SERVER PRIMARY는 **재계산 금지**, `expectedNetProfitKrw <= 0` 이면 `REMOTE_NET_PROFIT_INVALID` sanity만

### 통계 / UI / AI
- Dashboard: Gross / Fees / Net / 거래비용 비중 / Fee Drag / OVERTRADING_COST_DRAG (1h)
- 후보 카드: 예상 총수익·왕복비용·순이익·비용비중·손익분기
- 거래내역: Buy/Sell Fee + Gross≈ + Net PnL
- AI 라벨: `profitableAfterCost` = Net>0, 강한 성공은 Net≥0.10%만; fee-scrape 승리는 hardExample

### FEE 단위 감사
| 위치 | 값 | 단위 |
|------|-----|------|
| PaperTradingMath / paperFeeRate | 0.0025 | ratio |
| Short Edge / Net Edge / NetProfitAfterCost | 0.25 | percent |
| ROUND_TRIP_FEE | 0.50 | percent (buy+sell) |

DOUBLE_COST_BUG: NO (체결 이중차감 없음; 게이트는 예상치, 체결은 실제 fill)

LIVE_CHANGED: NO · THRESHOLD Short/Net Edge: 변경 없음

- Net Profit After Cost: `./gradlew clean testDebugUnitTest assembleDebug` → BUILD SUCCESSFUL · 291 tests · 0 FAILED · APK v1.9.16 / versionCode 35 · SHA-256 `42851d63187a9d2dfa987ebe6f0f2d6ccabceda700557fb848b14f14cda87695`
- Server pytest: 11 passed

### SERVER PRIMARY 거래내역 표시 수정 (v1.9.17)
- 원인: Primary에서 체결은 Hetzner `/paper/trades`에만 쌓이는데 앱 거래내역 탭은 로컬 Room만 조회 → 항상 0건.
- 수정: `paperTrades` API 연동 → `DashboardState.serverPaperTrades` 미러 → 거래내역 탭이 SERVER SoT 표시. 스캔/앱시작/PAPER AUTO 시 동기화.

## Dynamic Portfolio Capacity (v1.9.18)

- 고정 `maxPositions=3` BUY 차단 제거 → `DynamicPortfolioCapacityEngine`
- 제한 축: Portfolio Heat(open risk) + Cash Reserve + Minimum Viable Order + Net Profit After Cost + Correlation + **Hard Cap 8**
- RiskManager / Hetzner paper_engine / Dashboard capacity 표시 연동
- CASE A–G 유닛 테스트
- LIVE 변경 없음 · minKrwCashPercent / maxAssetPercentPerCoin 유지

## Dual Independent Exchange Architecture (v1.9.19)

기존 BITHUMB 엔진을 유지한 채 UPBIT 엔진을 **완전 독립**으로 추가.

### 구조
```
HETZNER
├── BITHUMB ENGINE  (기존 유지)
└── UPBIT ENGINE    (신규)

ANDROID
├── BITHUMB view / safety / execution
└── UPBIT view / safety / execution
```

### 절대 원칙 준수
- 하나의 포트폴리오/Risk/Decision로 합치지 않음
- 차익거래·자금이동 금지
- LIVE: BITHUMB DISABLED / UPBIT DISABLED
- Private Key 서버 저장 금지
- Android full-market analysis: OFF (SERVER PRIMARY)

### UPBIT PHASE 상태
| Phase | 내용 | 상태 |
|-------|------|------|
| PHASE 1 | Public REST/WS, cache, micro, health, zombie | DONE |
| PHASE 2 | FAST/DEEP, Strategy/AI, Net Profit After Cost, Decision | DONE |
| PHASE 3 | Upbit PAPER 독립 원장 + Android Viewer 전환 | DONE |

### 서버
- `upbit_collector.py` — 공식 `api.upbit.com` / `wss://api.upbit.com/websocket/v1`
- 독립 `MicroBufferStore` / `DecisionStore` / `PaperTradingEngine(paper_upbit.sqlite3)`
- 독립 analysis/orderbook loops + exception isolation
- Fee: `BITHUMB_FEE_CONFIG` ≠ `UPBIT_FEE_CONFIG` (환경변수로 설정 가능)
- APIs: `/api/trading/v1/upbit/health|dashboard|candidates|paper/*`
- Decision/candidate/paper에 `exchange` + `positionKey` (`UPBIT:KRW-BTC`)

### Android
- `ExchangeIsolation` — position/duplicate/reentry/daily-loss 키 분리
- 대시보드 빗썸|업비트 전환 (기존 UI 유지)
- Upbit dashboard/paper API 클라이언트
- 거래내역 reason에 `[BITHUMB]`/`[UPBIT]` 태그
- versionName **1.9.19** / versionCode **38**

### 격리 검증 포인트
- Bithumb holding ≠ Upbit Already Holding
- Paper capital 각 100,000 KRW 독립
- Bithumb offline ≠ Upbit shutdown (반대도 동일)

## SERVER PRIMARY 거래내역 0건 재수정 (v1.9.20)

### 원인
1. 배포 사이트 APK가 **v1.9.15**라 `/paper/trades` UI 동기화 코드가 없음 → Primary에서 Room도 비어 항상 0건.
2. 서버 원장에는 실제 체결 존재(검증: `/paper/trades` 20건+).
3. 동기화 실패 시 이전 내역을 지우고 빈 목록처럼 보이는 UX.

### 수정
- `paper/state`·`dashboard`에 `recentTrades` 포함 (별도 API 실패해도 SoT에 거래 동봉)
- Android: paper mirror 시 embedded trades 적용 + `/paper/trades` 강제 재조회
- `RemotePaperTradesParser` org.json 폴백 (`decisionId: null` 안전)
- 거래내역 탭 `LaunchedEffect` + 수동 새로고침 + 동기화 상태(OK/EMPTY/FAILED) 표시
- 실패 시 기존 non-empty 목록 유지 (0으로 덮어쓰지 않음)

## PAPER 손실 자동해부 + 회계 교정 (v1.9.21)

### 원인 (10만 → ~9만 급락 분해)
- 체결 원장상 **매수 수수료는 cash에서 이미 차감**되는데, legacy `realizedPnl = sellGross − sellFee − qty×avgBuy`는 **매수 수수료를 실현손익에서 제외** → `init + realized + unrealized ≠ equity` (ACCOUNTING_MISMATCH ≈ 누적 buy fees).
- 실제 손실 기여는 STOP LOSS / TRAILING / 수수료·오버트레이딩이 혼합. Slippage/spread는 체결가에 이미 반영되어 PnL 이중차감하지 않음.

### 구현
- `PaperLossAutopsyEngine` / `TradeLossBreakdown`: 왕복별 Gross/Net·fee/slip(통계용)·MFE/MAE·future return·primary/secondary cause, 20/50/100 window, equity curve, KRW 기여도 TOP 원인.
- BAD_SIGNAL vs BAD_TIMING / FEE_DRAG / EARLY_EXIT / CHASE / REENTRY_CHAIN 구분.
- Server `paper_engine.try_sell`: economic realized = sellNet − buyCashSpent(매수 수수료 포함). `state()`에 economicRealized / accountingMismatch, flat 시 meta 정합.
- Android `PaperExecutionEngine.sell` + `PaperTradingMath.economicRealizedPnl` 동일 규칙. AI 라벨은 **Net after cost** (Gross 아님).
- `PaperRiskEngine`: autopsy hint로 CAUTION/DEFENSE 오버레이 (영구 lock 금지). Dynamic Capacity remainingRiskBudget 축소 연동.
- Dashboard `PaperLossAutopsyPanel` + 통계 TOP 원인 카드 (UI 리뉴얼 없음).

### 검증
- `./gradlew testDebugUnitTest assembleDebug` → BUILD SUCCESSFUL · APK **v1.9.21** / versionCode **40**
- LIVE: DISABLED · 손절폭 임의 확대 / 매수 기준 무조건 완화 금지 준수

## LATEST WEB DEPLOY (auto)

BUILD_TIME: 2026-09-02T05:55:23Z
APK_BUILD_STATUS: SUCCESS
APK_VERSION: 1.9.33 / 52
APK_SHA256: a26826751441683baab7b4451d9a8f2eddcb84258d49ec5610312a25a11d2d25
SOURCE_REVIEW_STATUS: SUCCESS
SOURCE_REVIEW_SHA256: d3a87fe5648f5ae4589528a1632881e04004d0efe4ea7b4648da26fe9dc76292
WEB_DEPLOY_STATUS: SUCCESS
PUBLIC_VERIFY_STATUS: SUCCESS
BITHUMB_SERVER_STATUS: HEALTHY
UPBIT_SERVER_STATUS: HEALTHY
APK_PUBLIC_URL: https://riderapp.duckdns.org/bithumb-app-debug.apk
SOURCE_PUBLIC_URL: https://riderapp.duckdns.org/bithumb-all-in-one-source-review.txt


## Critical 50% Paper Drawdown Investigation

### Live reconstruction (BITHUMB PAPER DB, 2026-09-01)
- INITIAL_EQUITY: 100,000 KRW
- CURRENT_EQUITY: ~52,266 KRW (flat, positions=0 at pause verify)
- DRAWDOWN_KRW: ~-47,734
- DRAWDOWN_PERCENT: ~-47.73%
- ACCOUNTING_MISMATCH (economic): False (~0 KRW after buy-fee-aware reconcile)
- META vs economic gap historically ≈ cumulative buy fees (legacy realized formula) — not the primary -50% driver
- UPBIT PAPER DB / API: **DATA_NOT_AVAILABLE** on Hetzner (`paper_upbit.sqlite3` missing, `/api/trading/v1/upbit/*` 404 on prod stack)

### TOP LOSS CAUSES (KRW contribution, overlapping buckets OK)
1. SHORT_HOLD_LOSS (~-56,272 KRW) — many exits <3m
2. REENTRY_CHAIN (~-55,038 KRW) — stop/trail → rebuy same market
3. STOP_LOSS (~-53,423 KRW)
- Also: TRAILING losses large; FEES ~23.6k (~half of drawdown magnitude as cost layer, not sole cause)
- Trades/hour ~18 → overtrading

### COMMON_ROOT_CAUSE
- **MULTIPLE**: OVERTRADING + REENTRY_CHAIN after STOP/TRAILING + large order sizing (maxOrderPercent 20%) amplified by fee round-trips
- ROOT_CAUSE_CONFIDENCE: **HIGH** (ledger-aligned; not primary double-fee accounting bug)

### Emergency controls applied
- `newBuyPaused=true`, `paperBuyResumeMode=PAUSED_DIAGNOSTIC` on live BITHUMB settings_json
- Server `try_buy` blocks `NEW_BUY_PAUSED` while `manage_exits` / analysis / tick continue
- Server same-market STOP/TRAILING reentry cooldown (mirrors Android `stopLossCooldownMinutes=15`)
- Hard gate: CHASE_RISK / DATA_INSUFFICIENT / AVOID / NO_EDGE / stale TTL / PRICE_MOVED_AWAY / duplicate decision
- DEFENSE sizing multiplier 0.3 (PaperRiskEngine) when resume mode=DEFENSE
- Per-exchange pause/resume isolation preserved in engine API
- Dashboard: PAPER EQUITY / DRAWDOWN / STATE + TOP LOSS CAUSE 1–3
- LIVE BITHUMB/UPBIT: still DISABLED
- Resume ladder only: PAUSED → SHADOW → DEFENSE → NORMAL (not auto-promoted)

### FIXES_APPLIED
- paper_engine pause + reentry cooldown + execution gates + defense sizing + topLossCauses in state
- Android RemotePaperState/Dashboard emergency fields
- Unit tests for pause/stale/PMA/chase/defense/isolation/tick-exits
- Prod hot-patched `paper_engine.py` (prod `main.py` remains legacy single-exchange until full stack sync)

### NOT_CHANGED
- Stop-loss width, score thresholds (no arbitrary widen/relax)
- Forced full liquidation of open positions
- LIVE enablement
- Upbit live DB (unavailable)

### PAPER_BUY_STATE
- BITHUMB: PAUSED_DIAGNOSTIC
- UPBIT: DATA_NOT_AVAILABLE (no independent engine on current prod host)


## Critical addendum: COS 12s STOP + HOOK net-negative TRAILING (2026-09-02)

### Case KRW-COS `e96e5c09-678c-43dd-9a00-88d53b7a390b`
- Decision→fill latency **34ms**; signalPrice 0.408 → entry avg 0.408408 (slip only)
- Strategy 76.6 / AI 76.6 / Exec ENTER_NOW / Chase 0 / Timing 70 / netEdge ~2.60 / data GOOD
- Mark at stop ~0.3961 (−3.01% vs entry) within **12s** → STOP LOSS (threshold −2.5%, percent unit OK)
- COS avg "0 KRW" on UI = `won()` integer format only; engine used Double 0.4084 (no Int stop math)

### Case KRW-HOOK `f2143ffe-b6da-4494-b313-70714917ec16`
- Latency **61ms**; signal 7.957 → entry 7.964957
- Strategy 100 / AI 93 / netEdge only **0.47%** / ENTER_NOW
- Exit TRAILING at net **−1.90%**: implied peak only ~**+0.97%** then −2.5% from peak
- Root: trailing armed from entry high without min-profit arm → acts like soft stop while underwater

### Session stats (full PAPER ledger)
- UNDER_30_SEC: 28 / −14,244 (15.7% of loss mass)
- UNDER_60_SEC: 60 / −22,545 (24.8%)
- UNDER_3_MIN: 152 / −40,165 (44.1%)
- STOP_WITHIN_30_SEC: 21 / −14,623 (16.1%)
- TRAILING_EXIT_WHILE_NET_NEGATIVE: **143 / −37,569 (41.3%)**
- RAPID_ENTRY_EXIT_LOSS_LOOP: 21 / −7,666
- Diagnosis: **RAPID_ENTRY_EXIT_LOSS_LOOP** + unarmed trailing

### Fix
- `trailingArmMinProfitPercent=1.0` (Profit Protection L1): trailing fires only after peak unrealized ≥ arm
- UI `priceKrw()` for sub-1 KRW coins; stop/PnL remain Double
- PAPER BUY remains **PAUSED_DIAGNOSTIC**; LIVE DISABLED


## Peak-to-Current Drawdown & Reentry Loop Investigation

### Peak validation
- UI Peak **123,529** → **PEAK_VALID=NO**
- Cause: Android `dayPeakValue` override not reproducible from trade ledger (cost-basis curve max **99,950** @ 2026-09-01 14:02:46 KST; decision-MTM max ~100.4k)
- Display now rejects inflated override; shows ledger peak + ReturnFromInitial vs DrawdownFromPeak separately

### Accounting vs Attribution
- **ACCOUNTING_STATUS=OK** (difference ≈ 0)
- **econΔ ≈ 42,587** = ATTRIBUTION_GAP: recent-window economic realized vs full-account equity — **not** ledger mismatch
- UI: ACCOUNTING OK vs ATTRIBUTION GAP separated; `-0 KRW` display clamped

### Exclusive PRIMARY attribution (full session losers)
1. REENTRY_LOSS **-52,984** (58.2%, 154)
2. SHORT_HOLD_LOSS **-26,143** (28.7%, 51)
3. TRAILING_EXIT_LOSS **-6,034** (6.6%, 20)
- PRIMARY_SUM == ACTUAL_NET_LOSS_OF_LOSERS (−90,992)
- Contributing factors (stop/fee/short-hold tags) remain separate, overlap allowed

### Reentry / rapid loop
- REENTRY_CHAIN_COUNT 53 / NET −78,318
- RAPID_REENTRY_LOOPS 41 / −17,194
- Server: cooldown + lossStreak≥3 `REENTRY_SHADOW_ONLY` + streak≥2 score bump reconfirmation
- Trailing arm ≥1% (prior fix) retained

### Replay estimate (NOT live equity)
- Blocking exclusive REENTRY primaries → rough FINAL ≈ 105,250 / BLOCKED 154
- Label: **REPLAY_ESTIMATE_ONLY** (path-dependent; not a promise)

### State
- BITHUMB PAPER: **PAUSED_DIAGNOSTIC** (BUY=0)
- Analysis/exits continue · LIVE DISABLED
- Ready for DEFENSE: **NO** (Shadow validation required first)


## Autonomous Investment AI Research & Learning System

### CURRENT_LEARNING_LEVEL (code-proven)
- **Before this work (Hetzner)**: `LOGGING_ONLY` — `DecisionEngine` used fixed heuristics (`MODEL_VERSION=shadow-heuristic-1`); outcomes saved to SQLite but **never** trained weights into inference. Android Continuous Learning existed but `SERVER_PRIMARY` skips local train.
- **After this work**: Hetzner `AutonomousResearchEngine` closes the loop: outcome → training sample → candidate weights → replay/OOS → shadow → gated promotion → active champion weights used by `DecisionEngine`.
- Safe demo cycle reached **`ACTIVE_LEARNING`** (candidate promoted only after prediction-change + OOS improvement). Production PAPER BUY remains **PAUSED_DIAGNOSTIC**.

### Research Cycle (orchestrator — no engine duplication)
`OBSERVE → DIAGNOSE → HYPOTHESIS → CANDIDATE → REPLAY → OOS → SHADOW → COMPARE → PROMOTE/REJECT → LEARN`  
Modules: `parameter_registry.py`, `research_store.py`, `weighted_policy.py`, `autonomous_research.py` (per-exchange SQLite; `CROSS_EXCHANGE_LEARNING=OFF`).

### Memory
Structured `memory_events`: TradeOutcome, MissedOpportunity, CorrectRejection, PromotionHistory, RollbackHistory, InvalidSampleExcluded, plus hypotheses / experiments / research_journal / model_lineage.

### Hypothesis
Verifiable hypotheses with evidence, baselines (expectancy/PF/MDD), confidence, proposedDeltas. External chat proposals → `EXTERNAL_HYPOTHESIS` only (same gates; no direct champion write).

### Replay / OOS / Shadow
Time-ordered train/val/OOS (no shuffle). Multi-objective gates (expectancy, PF, MDD, overfit). Challenger registers as SHADOW before champion overwrite.

### Champion / Challenger / Promotion / Rollback
Champion stays until gates pass. `WHY_PROMOTED` stored with numeric before/after. Reject reasons: FAILED_OOS, OVERFIT, HIGH_MDD, LOW_SAMPLE, MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED, etc. Deterministic `rollback_to_parent`.

### Model Lineage & Learning Proof
Each cycle records learningCycleId, sample counts, old/candidate version+hash, weight deltas, train/val/OOS metrics, prediction-change %, promotionDecision, activeModelAfter. Decisions store `modelVersion` / `modelHash` / `learningCycleId`.

### Parameter Registry
LEARNABLE / TUNABLE / FIXED_SAFETY / HUMAN_ONLY with min/max/maxDeltaPerExperiment. Kill switch, LIVE, unpause, stale/dup safety are FIXED_SAFETY.

### Extensions (follow-up)
- Shadow outcomes at 30s/1m/3m/5m/15m/30m/60m + MFE/MAE; WAIT/AVOID → MarketObservation (MISSED_OPPORTUNITY / CORRECT_REJECTION)
- Up to 3 Challenger slots (A/B/C), capped
- Regime-split OOS (`regimeOosBefore/After`)
- Calibration buckets + prediction self-eval (CORRECT_BUY / FALSE_BUY / …)
- Counterfactual alternatives at decision-time only (`lookAheadBias=false`)
- Concept drift detector on feature distributions

### APIs / Dashboard
`GET /api/trading/v1/{exchange}/ai/{status,learning,experiments,models,research,explain/{decisionId}}`  
`POST .../ai/research-cycle`, `.../ai/external-hypothesis`  
Dashboard: `autonomousLearning` + `recentLearning` card fields (Android viewer).

### Safety unchanged
- PAPER new BUY: **PAUSED_DIAGNOSTIC**
- LIVE: DISABLED
- Research loop isolated (failures do not stop market/exit loops)


## Autonomous AI Learning Authenticity & Promotion Audit

### M100/M101 진위
- Reported cycle `LC-00501d30ba` / M100→M101 / OOS PF 0.4→0.75 **PROMOTED**
- **DATA_SOURCE = SYNTHETIC_TEST** (local tempfile `_synthetic_samples` demo)
- Workspace `research_bithumb.sqlite3`: **0 learning_cycles**, active remains **M100 BOOTSTRAP**
- **FOUND_IN_PRODUCTION_DB = NO** → `NO_REAL_PRODUCTION_EVIDENCE`
- **M101_PROMOTION_AUDIT = INVALID_PROMOTION** (test data + relative-only gate)

### Promotion bug (fixed)
- Old gate: relative OOS improve alone could PROMOTE even if PF&lt;1.0
- New: `IMPROVED_BUT_UNPROFITABLE` → **SHADOW_ONLY**; absolute PF≥1.0 + positive expectancy + REAL_DATA + completed shadow samples required for `PROMOTION_ELIGIBLE`
- `RECOVERY_VALIDATION_MODE=True` after -50% drawdown
- Synthetic/demo cycles never overwrite Champion

### Dataset lineage / leak audit
- Train/Val/OOS lineage + dataHash; overlap counts; temporal order; forbidden future features as inputs
- Prediction transition matrix + MORE_CONSERVATIVE/AGGRESSIVE stance

### Learning status (honest)
- Without real paper samples: `LOGGING_ONLY` / proof `NO_REAL_PRODUCTION_EVIDENCE`
- Synthetic research: at most `SHADOW_LEARNING` / `TRAINING_ONLY` — not production `ACTIVE_LEARNING`
- PAPER BUY remains **PAUSED_DIAGNOSTIC**; LIVE DISABLED

### API
- `GET /api/trading/v1/{exchange}/ai/authenticity`

## REAL DATA Autonomous AI Production Verification

### Source vs production parity (2026-09-02)
- Pre-sync PRODUCTION_DRIFT: **YES** — Hetzner `/opt/bithumb-ai-brain/app` was legacy single-exchange (`main.py` ~476 lines, no research/Upbit modules); workspace had dual-engine + AutonomousResearchEngine.
- Sync: backup `/opt/bithumb-ai-brain/backups/app-*`, deploy workspace `server/ai-brain/app/*`, restart `bithumb-ai-brain` only (Caddy/OTA untouched).
- Post-sync `SERVER_COMMIT` = workspace HEAD; `GET /api/trading/v1/build-identity` reports `hasResearchModules=true`, `hasUpbitEngine=true`.
- APK/SOURCE matched deploy target: **1.9.28 / versionCode 47** (same GIT_COMMIT).

### Bithumb status (Hetzner)
- COLLECTOR: WORKING (WS CONNECTED, ~458 markets)
- ANALYSIS: WORKING (FAST/DEEP loops)
- PAPER: WORKING — cash ~52266, positions 0, **PAUSED_DIAGNOSTIC** (new BUY blocked)
- RESEARCH: RUNNING — `WAITING_FOR_REAL_DATA`
- REAL VALID samples: **0** (325 historical SELLs all lack `decision_id` → ingested as PARTIAL only, not trainable)
- PARTIAL real outcomes observed: ~200+
- REAL cycles: **0** — refused `INSUFFICIENT_REAL_DATA` (no synthetic fill)
- ACTIVE MODEL: **M100 BOOTSTRAP**
- REAL SHADOW completed: **0**
- PRODUCTION EVIDENCE: **PARTIAL** (observational paper outcomes) / no VERIFIED learning
- M101 prior claim: **INVALID_PROMOTION / SYNTHETIC_TEST** (unchanged audit)

### Upbit status (Hetzner)
- Pre-sync: paper_upbit missing, `/api/trading/v1/upbit/*` **404**
- Post-sync: COLLECTOR WORKING (WS CONNECTED, ~279 markets), ANALYSIS WORKING, PAPER DB created (`paper_upbit.sqlite3`), PAPER API **200**, RESEARCH DB created, auto=OFF, LIVE DISABLED
- REAL samples/cycles: **0** → `UPBIT_REAL_LEARNING: INSUFFICIENT_DATA`
- PRODUCTION EVIDENCE: **NONE**
- Independent from Bithumb (cross-exchange learning OFF)

### Honesty rules locked
- Synthetic/fixture/demo cannot promote ACTIVE CHAMPION or count as REAL cycle
- Absolute PF≥1 + shadow + REAL_DATA gates kept
- PAPER BUY not auto-resumed on model promotion
- Bug/accounting/lookahead-unsafe trades excluded from VALID training; memory keeps `INVALID_SAMPLE_EXCLUDED`

### Next requirement for VERIFIED
- New decision-linked REAL_PAPER or SHADOW outcomes with entry features (BUY currently paused → no new positions; need either temporary defensive shadow-only samples from live decisions, or resumed buys under separate gate later)


## AI Layer 1 — Market Intelligence Foundation Verification

SOURCE_VERSION: 1.9.29 / 48
SOURCE_COMMIT: (this branch tip at deploy)
SERVER_COMMIT: synced from Layer-1 harden commit
BITHUMB_COLLECTOR: WORKING (WS CONNECTED, marketCount≈458, messages increasing)
UPBIT_COLLECTOR: WORKING (WS CONNECTED, marketCount≈279, messages increasing)
BITHUMB_WS: CONNECTED + zombieReason parity (`BITHUMB_WS_ZOMBIE`)
UPBIT_WS: CONNECTED + `UPBIT_WS_ZOMBIE` (existing)
DATA_INTEGRITY: server `market_integrity.py` — ticker/orderbook/candle/micro temporal + quarantine
TIME_ALIGNMENT: decision `maxComponentAgeMs` / `snapshotSkewMs` / `snapshotQuality`
REPLAYABILITY: PARTIAL — DecisionStore provenance (ticker/orderbook timestamps+source, skew, quality); not full raw tick archive
FAILURE_ISOLATION: independent collectors/loops; existing research isolation retained
LAYER_1_STATUS: PASS
FAILED_ITEMS: none critical
REMAINING_ITEMS: optional fuller snapshot retention policy; candle not in Decision AI input path (validator only)
PRODUCTION_EVIDENCE_TIME: 2026-09-02 (post-sync dual snapshots; Bithumb msgΔ>0, Upbit msgΔ>0; sample markets show WS source + skew)

Minimal harden (no rewrite):
- `market_integrity.py` shared validators
- Micro temporal quality (CLUSTERED ≠ READY)
- Reject future timestamps / ticker regression overwrite
- Quarantine bid>ask orderbooks; quarantine blocks BUY + training
- FAST skips stale tickers; candidate `detectedAt` provenance
- Health `layer1` + `marketDataHealth` separated from process ONLINE

Safety unchanged:
- BITHUMB PAPER: PAUSED_DIAGNOSTIC
- LIVE: DISABLED both
- BITHUMB_REAL_LEARNING: WAITING_FOR_REAL_DATA (VALID=0, PARTIAL outcomes only)
- UPBIT_REAL_LEARNING: INSUFFICIENT_DATA

## AI Layer 2 — Real Experience Learning Verification

Status: **PARTIAL_LEARNING_NOT_IMPROVING** · natural-watch exit **WAITING_FOR_NATURAL_REAL_CYCLE** (CASE A — no new REAL cycle after M106)

SOURCE: **v1.9.33 / 52** · server learning `34c41a8…` · **SERVER_CODE_MATCH=YES** · **CODE_CHANGED=NO**

### Proven chain
REAL EXPERIENCE → MODEL CHANGE → REAL DECISION CHANGE (validation) — **yes**  
OOS ACTION CHANGE → OOS ECONOMIC IMPROVEMENT → REALTIME SHADOW — **not yet** (awaiting natural cycle)

### Natural watch (Hetzner, ~120s T1→T2)
- REAL_SHADOW VALID: Bithumb **566→566** (Δ0) / Upbit **900→900** (Δ0); TOTAL 1600/1600 (Δ0)
- Collectors ONLINE (WS CONNECTED); market_observations done Δ **+200 / +200** — pipeline alive; VALID not yet increased in window
- samplesSinceLastLearning=0 · no force cycle · last REAL still **BITHUMB-RLC-538426db87** / **UPBIT-RLC-7b3b068974** (M106 FAILED_OOS)
- Champion **M100** `d2cd5e05e0aafc9b` · Shadow **NONE** · CHALLENGER_SHADOW_ALLOWED=NO
- Latest OOS_DECISION_CHANGED **0/0** → `OOS_BEHAVIOR_CHANGE_NOT_OBSERVED`
- IS_LEARNING=YES · IS_IMPROVING=UNCERTAIN · PRODUCTION_EVIDENCE=PARTIAL

### Economic note (historical; M101–M106 not rewritten)
- Validation flips (M105/M106): REAL decision change; not OOS improvement
- Earlier OOS flips (M102–M104): action/PnL differed but OOS NET did not improve
- KRW-MOC BUY→WAIT net −11.18 → LOSS_AVOIDED; KRW-SOPH BUY→WAIT net +1.93 → MISSED_PROFIT

### Cost / leaks / safety
Bithumb fee 0.25/0.25 + slip 0.10 · Upbit fee 0.05/0.05 + slip 0.10 · CROSS_EXCHANGE_FEE_LEAK=0  
LOOKAHEAD=0 · BAD_DATA=0 · CROSS_EXCHANGE sample/model/shadow=0 · SYNTHETIC_AS_REAL=0  
PAPER: Bithumb PAUSED_DIAGNOSTIC · Upbit paperAuto=false · LIVE disabled · CROSS_EXCHANGE_LEARNING=OFF

### Next required evidence
Natural REAL cycle with **OOS_DECISION_CHANGED>0** + multi-objective **OOS NET improvement** + absolute PF gate → then existing Challenger Shadow. Do not relax thresholds.


===== END FILE: CHECKPOINT.md =====

===== FILE: README.md =====
# Bithumb All-in-One Android Trader

Standalone Android/Kotlin prototype for Bithumb KRW-market automated trading.

## Build

```bash
ANDROID_HOME=/workspace/android-sdk ./gradlew testDebugUnitTest assembleDebug
```

APK:

```text
app/build/outputs/apk/debug/app-debug.apk
```

## Bithumb API references used

- Open API version: 2.1.5
- REST base URL: `https://api.bithumb.com`
- Public WebSocket: `wss://ws-api.bithumb.com/websocket/v1`
- Private WebSocket: `wss://ws-api.bithumb.com/websocket/v2/private`
- Markets: `GET /v1/market/all?isDetails=true`
- Ticker: `GET /v1/ticker?markets=...`
- Orderbook: `GET /v1/orderbook?markets=...`
- Candles: `GET /v1/candles/minutes/{unit}`
- Order chance: `GET /v1/orders/chance?market=...`
- Orders: `POST /v2/orders`
- JWT: ***REDACTED*** with `access_key`, `nonce`, `timestamp`, and SHA-512 `query_hash`
  for requests with parameters.

## Safety

- Default mode is PAPER.
- LIVE execution is separated behind `BithumbExecutionEngine`.
- API keys are stored with Android Keystore-backed encrypted preferences.
- No API key is committed.
- Tests do not send real orders.

## Current implementation status

This is a compilable first Android project with APK output, not a completed
production trading product. See the final report for implemented and missing
items.

===== END FILE: README.md =====

===== FILE: build.gradle.kts =====
plugins {
    id("com.android.application") version "8.5.2" apply false
    id("org.jetbrains.kotlin.android") version "1.9.24" apply false
    id("org.jetbrains.kotlin.kapt") version "1.9.24" apply false
}

===== END FILE: build.gradle.kts =====

===== FILE: settings.gradle.kts =====
pluginManagement {
    repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
rootProject.name = "BithumbAllInOne"
include(":app")

===== END FILE: settings.gradle.kts =====

===== FILE: gradle.properties =====
android.useAndroidX=true
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=35
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
kotlin.code.style=official

===== END FILE: gradle.properties =====

===== FILE: app/build.gradle.kts =====
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.kapt")
}

android {
    namespace = "com.example.bithumbtrader"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.bithumbtrader"
        minSdk = 26
        targetSdk = 35
        versionCode = 52
        versionName = "1.9.33"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        // Optional seed only — never commit real token. Pass -PTRADING_AI_TOKEN=***REDACTED*** or env TRADING_AI_TOKEN at build.
        val seededToken = ***REDACTED***
            (project.findProperty("TRADING_AI_TOKEN") as String?)
                ?: System.getenv("TRADING_AI_TOKEN")
                ?: ""
            ).trim().replace("\"", "").replace("\\", "")
        buildConfigField("String", "SEEDED_TRADING_AI_TOKEN", "\"$seededToken\"")
        buildConfigField("String", "DEFAULT_REMOTE_AI_BASE_URL", "\"https://riderapp.duckdns.org\"")
    }

    buildFeatures {
        compose = true
        buildConfig = true
    }
    composeOptions { kotlinCompilerExtensionVersion = "1.5.14" }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions { jvmTarget = "17" }
}

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
    implementation(composeBom)
    androidTestImplementation(composeBom)
    implementation("androidx.activity:activity-compose:1.9.3")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
    implementation("androidx.lifecycle:lifecycle-service:2.8.7")
    implementation("androidx.datastore:datastore-preferences:1.1.1")
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
    implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
    testImplementation("org.xerial:sqlite-jdbc:3.53.2.1")
}

===== END FILE: app/build.gradle.kts =====

===== FILE: download/index.html =====
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#08111f">
<title>빗썸 자동매매 · 다운로드</title>
<style>
:root{color-scheme:dark;--bg:#08111f;--panel:#101d30;--panel2:#14243a;--line:#263a56;--text:#f6f8fb;--muted:#9fb0c8;--blue:#4da3ff;--green:#53d18b;--orange:#ffbe5c;--red:#ff6f83}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% 0,#183b63 0,transparent 35%),var(--bg);color:var(--text);font-family:system-ui,-apple-system,BlinkMacSystemFont,"Noto Sans KR",sans-serif;line-height:1.55}.wrap{width:min(920px,calc(100% - 32px));margin:auto}.hero{padding:46px 0 25px}.eyebrow{color:var(--blue);font-weight:800;letter-spacing:.08em;font-size:13px}.hero h1{font-size:clamp(30px,6vw,54px);line-height:1.12;margin:10px 0 14px}.hero p{color:var(--muted);font-size:17px;max-width:680px}.card{background:linear-gradient(145deg,rgba(20,36,58,.96),rgba(12,25,42,.96));border:1px solid var(--line);border-radius:22px;padding:24px;box-shadow:0 14px 45px rgba(0,0,0,.2);margin:16px 0}.download{display:flex;gap:18px;align-items:center;justify-content:space-between}.download h2{margin:0 0 4px}.meta{color:var(--muted);font-size:14px}.button{display:inline-flex;justify-content:center;align-items:center;min-height:50px;padding:0 22px;border-radius:13px;text-decoration:none;font-weight:800;white-space:nowrap;background:var(--blue);color:#061326}.button:hover{filter:brightness(1.1)}.button.secondary{background:var(--panel2);border:1px solid var(--line);color:var(--text);min-height:42px;font-size:14px}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.stat{background:rgba(8,17,31,.55);padding:15px;border-radius:15px;border:1px solid var(--line)}.stat b{display:block;font-size:21px}.stat span{font-size:13px;color:var(--muted)}h2{margin-top:0}ul{padding-left:20px;color:var(--muted)}li{margin:7px 0}li strong{color:var(--text)}.links{display:flex;gap:10px;flex-wrap:wrap}.notice{border-left:4px solid var(--orange);padding:4px 0 4px 14px;color:var(--muted)}.notice strong{color:var(--orange)}footer{color:var(--muted);font-size:13px;padding:14px 0 45px;text-align:center}@media(max-width:650px){.download{display:block}.download .button{width:100%;margin-top:18px}.grid{grid-template-columns:1fr 1fr}.card{padding:19px;border-radius:18px}.hero{padding-top:30px}}@media(max-width:400px){.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<main class="wrap">
<section class="hero">
<div class="eyebrow">BITHUMB ALL-IN-ONE · PAPER FIRST</div>
<h1>빗썸 자동매매<br>앱 다운로드</h1>
<p>휴대폰에서 바로 APK를 받고, Paper 자동매매·AI 분석·전략 연구 기능을 한 곳에서 확인하세요.</p>
</section>
<section class="card download">
<div><h2>Android Debug APK <span style="color:var(--green)">v1.9.33</span></h2><div class="meta">versionCode 52 · 국면 전략 세트 + APK 학습 자산 보존 재감사 · 233개 테스트 통과</div></div>
<a class="button" href="/bithumb-app-debug.apk" download>APK 다운로드</a>
</section>
<section class="grid">
<div class="stat"><b>Paper</b><span>기본 자동매매 모드</span></div><div class="stat"><b>AI 추천</b><span>직접 매매하지 않음</span></div><div class="stat"><b>WebSocket</b><span>실시간 시세 연결</span></div>
</section>
<section class="card"><h2>이번 버전에 포함된 기능</h2><ul><li><strong>국면별 전략 세트</strong> · 상승/횡보/하락/급락 국면마다 Paper 파라미터 자동 전환, LIVE는 추천만</li><li><strong>Paper 자동매매</strong> · 30초 주기 지속 실행, 체결·잔액·거래 데이터 저장</li><li><strong>AI 전략 연구소</strong> · 진입 품질, Shadow 전략 경쟁, 기회비용, 놓친 기회 분석</li><li><strong>시장 보호</strong> · Market Health, Crash Detection, 쿨다운, 동적 익스포저, 킬 스위치</li><li><strong>실시간 시세</strong> · 빗썸 Public WebSocket 및 REST 폴백</li><li><strong>통계/거래내역</strong> · 승률, Profit Factor, MDD, 기대수익 및 일별 성과</li><li><strong>유동성 진단</strong> · 실제 24시간 거래대금·시장 분포·순위·동적 필터·과차단 경고</li><li><strong>수익/리스크 분리</strong> · Paper 연속손실 리스크 축소 지속거래 및 Live 안전 잠금 유지</li><li><strong>기관형 안전/체결</strong> · 호가 깊이 체결 시뮬레이션, Net Edge Gate, Portfolio Heat, Tail Risk, 데이터 무결성 검증</li><li><strong>자율 자본 성장</strong> · 위험조정 복리 예산, Protected Reserve, Ruin Risk 가드, Counterfactual Capital Lab</li><li><strong>손실 원인/매도 최적화</strong> · 손절 후 사후 시세 추적, Early Stop 판정, 가상 매도 비교 연구, MFE 포착률 분석</li><li><strong>학습 자산 보존</strong> · APK 업데이트 시 AI Sample/Model/Journal/Shadow 100% 보존 검증</li></ul></section>
<section class="card"><h2>관련 파일</h2><div class="links"><a class="button secondary" href="/bithumb-all-in-one-review.zip">전체 소스 ZIP</a><a class="button secondary" href="/bithumb-all-in-one-source-review.txt">소스 TXT</a><a class="button secondary" href="/bithumb-strategy.json">전략 OTA JSON</a><a class="button secondary" href="/bithumb-ai-model.json">AI 모델 JSON</a></div></section>
<section class="card"><div class="notice"><strong>안전 안내</strong><br>현재 앱은 PAPER 모드가 기본이며 LIVE 자동매매는 연결되어 있지 않습니다. APK 설치 후 Android 설정에서 알 수 없는 앱 설치를 허용해야 할 수 있습니다. 기존 APK 위에 설치할 때는 같은 앱 서명으로 업데이트하세요.</div></section>
<footer>빗썸 자동매매 프로젝트 · 마지막 빌드 2026-09-02 · <a href="/bithumb-app-debug.apk" style="color:var(--blue)">APK 바로 받기</a></footer>
</main>
</body>
</html>

===== END FILE: download/index.html =====

===== FILE: app/src/main/AndroidManifest.xml =====
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

    <application android:name=".TraderApp" android:theme="@style/AppTheme" android:label="@string/app_name" android:allowBackup="false" android:supportsRtl="true">
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service android:name=".TradingForegroundService" android:exported="false" android:foregroundServiceType="dataSync" />
        <receiver android:name=".BootReceiver" android:enabled="true" android:exported="false">
            <intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter>
        </receiver>
    </application>
</manifest>

===== END FILE: app/src/main/AndroidManifest.xml =====

===== FILE: app/src/main/assets/ai_model.json =====
{
  "modelVersion": 1,
  "featureNames": [
    "ema5_gap",
    "ema20_gap",
    "ema60_gap",
    "rsi",
    "macd_gap",
    "volume_ratio",
    "momentum_3",
    "volatility_20"
  ],
  "means": [
    0.0008372622347743041,
    0.0014132562608624036,
    -0.003126509697670758,
    0.37583526774977144,
    -0.00021627369137966812,
    1.0731912489279514,
    -0.0003763014204037813,
    0.002210201562759641
  ],
  "scales": [
    0.0009001114811292625,
    0.00266789224066058,
    0.0033531343550536894,
    0.11307442263785428,
    0.000866249570327714,
    0.539780352400329,
    0.004229138141636821,
    0.00170514040516603
  ],
  "weights": [
    -0.028626536691672425,
    0.20328358387630127,
    -0.22604021522592582,
    -0.07537339396574826,
    -0.2077402963761921,
    -0.09417330819074263,
    0.08835074240557636,
    0.2620531053808932
  ],
  "bias": -1.5314401398374873,
  "positiveThreshold": 0.55,
  "trainingRows": 680,
  "validationAccuracy": 0.8088235294117647,
  "label": "next_3_candles_return_at_least_0.2_percent"
}

===== END FILE: app/src/main/assets/ai_model.json =====

===== FILE: app/src/main/java/com/example/bithumbtrader/AdaptiveRegime.kt =====
package com.example.bithumbtrader

import kotlin.math.abs
import kotlin.math.max

/** Hysteresis prevents a one-cycle noisy signal from changing the stable regime. */
class RegimeHysteresis(
    private val minConfidence: Double = 0.75,
    private val confirmationsRequired: Int = 3
) {
    private var stable = MarketRegime.UNKNOWN
    private var pending = MarketRegime.UNKNOWN
    private var confirmations = 0
    private var stableSince = 0L

    fun update(candidate: MarketRegimeSnapshot, now: Long = System.currentTimeMillis()): MarketRegimeSnapshot {
        if (stable == MarketRegime.UNKNOWN) {
            stable = candidate.regime
            stableSince = now
            return candidate.copy(durationMinutes = 0L)
        }
        if (candidate.regime == MarketRegime.CRASH) {
            if (stable != MarketRegime.CRASH) stableSince = now
            stable = MarketRegime.CRASH
            pending = MarketRegime.UNKNOWN
            confirmations = 0
            return candidate.copy(durationMinutes = 0L)
        }
        if (candidate.regime == stable) {
            pending = MarketRegime.UNKNOWN
            confirmations = 0
        } else if (candidate.confidence >= minConfidence) {
            if (pending == candidate.regime) confirmations++ else { pending = candidate.regime; confirmations = 1 }
            if (confirmations >= confirmationsRequired) {
                stable = candidate.regime
                stableSince = now
                pending = MarketRegime.UNKNOWN
                confirmations = 0
            }
        }
        return candidate.copy(regime = stable, durationMinutes = (now - stableSince).coerceAtLeast(0L) / 60_000L)
    }

    fun restore(regime: MarketRegime, since: Long = 0L) {
        stable = regime
        stableSince = since
        pending = MarketRegime.UNKNOWN
        confirmations = 0
    }

    fun current(): MarketRegime = stable
    fun pendingConfirmations(): Int = confirmations
}

data class RegimeStrategyWeight(val strategy: String, val weightPercent: Double, val reason: String)
data class RegimeSelectorResult(val regime: MarketRegime, val confidence: Double, val noTrade: Boolean, val weights: List<RegimeStrategyWeight>, val reason: String)
data class ChampionChallengerState(
    val champion: String = "CURRENT",
    val challenger: String = "-",
    val status: String = "INSUFFICIENT_SAMPLE",
    val championScore: Double = 0.0,
    val challengerScore: Double = 0.0,
    val sampleCount: Int = 0,
    val reason: String = ""
)
data class WalkForwardResult(val train: PerformanceStats, val validation: PerformanceStats, val test: PerformanceStats, val passed: Boolean, val reason: String)

data class TimedPnl(val time: Long, val pnlRate: Double)

object RegimeStrategySelector {
    const val MIN_SAMPLE = 10

    fun select(regime: MarketRegimeSnapshot, performances: List<RegimeStrategyPerformanceEntity>, health: MarketHealthScore): RegimeSelectorResult {
        if (regime.regime == MarketRegime.CRASH || health.level == MarketHealthLevel.CRASH || health.score < 40.0) {
            return RegimeSelectorResult(regime.regime, regime.confidence, true, listOf(RegimeStrategyWeight("NO_TRADE", 100.0, "급락/건강도 위험")), "Crash 또는 낮은 Market Health — 신규 진입 금지 추천")
        }
        val usable = performances.filter { it.regime == regime.regime.name && it.sampleCount >= MIN_SAMPLE }
        if (usable.isEmpty()) {
            return RegimeSelectorResult(regime.regime, regime.confidence, false, listOf(RegimeStrategyWeight("CURRENT", 70.0, "표본 부족으로 기존 전략 유지"), RegimeStrategyWeight("CASH", 30.0, "불확실성 완충")), "현재 국면의 전략별 표본 부족 — 기존 전략 유지 추천")
        }
        val ranked = usable.sortedByDescending { it.riskAdjustedReturn.coerceIn(-100.0, 100.0) }
        val top = ranked.first()
        val weights = ranked.take(3).mapIndexed { index, row -> RegimeStrategyWeight(row.strategy, if (index == 0) 45.0 else 25.0, "${row.strategy} 국면 성과: PF ${"%.2f".format(row.profitFactor)}, MDD ${"%.1f".format(row.maxDrawdownPercent)}%") }.toMutableList()
        val used = weights.sumOf { it.weightPercent }
        weights += RegimeStrategyWeight("CASH", (100.0 - used).coerceAtLeast(5.0), "국면 불확실성 및 리스크 완충")
        return RegimeSelectorResult(regime.regime, regime.confidence, false, weights, "${top.strategy}가 ${regime.regime.name}에서 Risk Adjusted Return 우수 — 추천만 생성")
    }
}

object ChampionChallengerEngine {
    const val MIN_SAMPLE = 10
    const val MIN_IMPROVEMENT = 0.05

    fun evaluate(champion: ShadowPortfolioEntity?, challengers: List<ShadowPortfolioEntity>): ChampionChallengerState {
        if (champion == null) return ChampionChallengerState(reason = "Champion 데이터 없음")
        val eligible = challengers.filter { it.strategy != champion.strategy && it.tradeCount >= MIN_SAMPLE }
        val challenger = eligible.maxByOrNull { ShadowSimulationEngine.riskAdjustedScore(it) }
            ?: return ChampionChallengerState(champion = champion.strategy, status = "INSUFFICIENT_SAMPLE", championScore = ShadowSimulationEngine.riskAdjustedScore(champion), reason = "Challenger 최소 표본 부족")
        val championScore = ShadowSimulationEngine.riskAdjustedScore(champion)
        val challengerScore = ShadowSimulationEngine.riskAdjustedScore(challenger)
        val promotion = challengerScore >= championScore + MIN_IMPROVEMENT
        return ChampionChallengerState(champion.strategy, challenger.strategy, if (promotion) "PROMOTION_CANDIDATE" else "TESTING", championScore, challengerScore, challenger.tradeCount, if (promotion) "Challenger가 Risk Adjusted Score 개선 — 수동 승격 검토" else "아직 Champion 대비 의미 있는 개선 없음")
    }
}

object WalkForwardEvaluator {
    fun evaluate(returns: List<TimedPnl>, trainSize: Int, validationSize: Int, testSize: Int): WalkForwardResult {
        val clean = returns.sortedBy { it.time }.filter { it.pnlRate.isFinite() }
        if (trainSize <= 0 || validationSize <= 0 || testSize <= 0 || clean.size < trainSize + validationSize + testSize) {
            return WalkForwardResult(PerformanceStats(), PerformanceStats(), PerformanceStats(), false, "순차 데이터가 TRAIN/VALIDATION/TEST 윈도우보다 부족")
        }
        val train = StrategyPerformanceEngine.fromPnlRates(clean.take(trainSize).map { it.pnlRate })
        val validation = StrategyPerformanceEngine.fromPnlRates(clean.drop(trainSize).take(validationSize).map { it.pnlRate })
        val test = StrategyPerformanceEngine.fromPnlRates(clean.drop(trainSize + validationSize).take(testSize).map { it.pnlRate })
        val passed = validation.expectedReturnPercent > 0.0 && test.expectedReturnPercent > 0.0 && test.maxDrawdownPercent > -20.0
        return WalkForwardResult(train, validation, test, passed, if (passed) "미래 데이터 누출 없이 TEST 구간도 양호" else "검증 또는 테스트 구간 성과 부족")
    }
}

object ConfidenceCalibrationEngine {
    fun band(confidence: Double): String = when { confidence >= 0.9 -> "90-100"; confidence >= 0.8 -> "80-90"; confidence >= 0.7 -> "70-80"; confidence >= 0.6 -> "60-70"; else -> "0-60" }
    fun accuracy(rows: List<ConfidenceCalibrationEntity>): Map<String, Double> = rows.groupBy { it.confidenceBand }.mapValues { (_, values) -> values.count { it.correct }.toDouble() / values.size }
}

fun adaptiveRegimeVolatility(values: List<Double>): Double {
    val clean = values.filter { it.isFinite() }
    if (clean.size < 2) return 0.0
    val mean = clean.average()
    return kotlin.math.sqrt(clean.sumOf { (it - mean) * (it - mean) } / clean.size) * 100.0
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/AdaptiveRegime.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/AdvancedResearch.kt =====
package com.example.bithumbtrader

import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlin.math.abs

/** Research-only action. It never places an order. */
enum class OpportunityAction { KEEP, ROTATE, DO_NOTHING }

data class OpportunityInput(
    val heldMarket: String,
    val heldPnlPercent: Double,
    val heldScore: Double,
    val heldMomentumPercent: Double,
    val heldVolumeChangePercent: Double,
    val heldVolatilityPercent: Double,
    val heldSpreadPercent: Double,
    val heldHealthScore: Double,
    val heldAgeMinutes: Long,
    val candidateMarket: String?,
    val candidateScore: Double,
    val candidateMomentumPercent: Double,
    val candidateVolumeChangePercent: Double,
    val candidateVolatilityPercent: Double,
    val candidateSpreadPercent: Double,
    val candidateHealthScore: Double,
    val estimatedFeePercent: Double,
    val estimatedSlippagePercent: Double,
    val riskAllowsCandidate: Boolean
)

data class OpportunityDecision(
    val heldMarket: String,
    val candidateMarket: String?,
    val keepScore: Double,
    val rotateScore: Double,
    val doNothingScore: Double,
    val action: OpportunityAction,
    val reason: String,
    val computedAt: Long = System.currentTimeMillis()
)

object OpportunityCostEngine {
    fun evaluate(input: OpportunityInput): OpportunityDecision {
        val keep = (input.heldScore * 0.35 + input.heldPnlPercent.coerceIn(-10.0, 10.0) * 1.5 +
            input.heldMomentumPercent.coerceIn(-10.0, 10.0) * 1.5 +
            input.heldHealthScore * 0.25 - input.heldSpreadPercent * 3.0 -
            input.heldVolatilityPercent.coerceAtLeast(0.0) * 0.5 - input.heldAgeMinutes.coerceAtMost(360) / 360.0 * 2.0)
            .coerceIn(0.0, 100.0)
        val candidateQuality = (input.candidateScore * 0.45 + input.candidateMomentumPercent.coerceIn(-10.0, 10.0) * 1.5 +
            input.candidateVolumeChangePercent.coerceIn(-100.0, 100.0) * 0.08 + input.candidateHealthScore * 0.25 -
            input.candidateVolatilityPercent.coerceAtLeast(0.0) * 0.5 - input.candidateSpreadPercent * 3.0)
            .coerceIn(0.0, 100.0)
        val cost = (input.estimatedFeePercent + input.estimatedSlippagePercent) * 2.0
        val rotate = if (input.candidateMarket != null && input.riskAllowsCandidate) (candidateQuality - cost).coerceIn(0.0, 100.0) else 0.0
        val doNothing = maxOf(0.0, (keep + if (input.riskAllowsCandidate) candidateQuality else 0.0) / 2.0 - cost)
        val action = when {
            rotate >= keep + 8.0 && rotate >= doNothing + 4.0 -> OpportunityAction.ROTATE
            keep >= doNothing && keep >= rotate -> OpportunityAction.KEEP
            else -> OpportunityAction.DO_NOTHING
        }
        val reason = when (action) {
            OpportunityAction.ROTATE -> "${input.candidateMarket} 후보가 보유 ${input.heldMarket}보다 비용 차감 후 ${"%.1f".format(rotate - keep)}점 높음 (강제교체 없이 Shadow 추천)"
            OpportunityAction.KEEP -> "보유 ${input.heldMarket} 유지 점수가 신규 후보/무행동보다 높음"
            OpportunityAction.DO_NOTHING -> "수수료·슬리피지 포함 시 행동 우위가 작아 대기"
        }
        return OpportunityDecision(input.heldMarket, input.candidateMarket, keep, rotate, doNothing, action, reason)
    }
}

enum class EntryQualityHorizon(val minutes: Int) { M1(1), M3(3), M5(5), M15(15), M30(30), M60(60) }

data class EntryQualityStats(
    val sampleCount: Int = 0,
    val averageReturnByHorizon: Map<Int, Double> = emptyMap(),
    val immediateRiseProbability: Double = 0.0,
    val averageMfe: Double = 0.0,
    val averageMae: Double = 0.0
)

object PostEntryQualityEngine {
    fun mfe(entryPrice: Double, highestPrice: Double): Double =
        if (entryPrice > 0.0 && highestPrice.isFinite()) (highestPrice / entryPrice - 1.0) * 100.0 else 0.0
    fun mae(entryPrice: Double, lowestPrice: Double): Double =
        if (entryPrice > 0.0 && lowestPrice.isFinite()) (lowestPrice / entryPrice - 1.0) * 100.0 else 0.0

    fun stats(snapshots: List<PostEntrySnapshotEntity>): EntryQualityStats {
        if (snapshots.isEmpty()) return EntryQualityStats()
        val grouped = snapshots.groupBy { it.horizonMinutes }
        val averages = grouped.mapValues { (_, rows) -> rows.map { it.changePercent }.average() }
        val immediate = snapshots.filter { it.horizonMinutes == 1 }
        return EntryQualityStats(
            sampleCount = snapshots.map { it.buyTradeId }.distinct().size,
            averageReturnByHorizon = averages,
            immediateRiseProbability = if (immediate.isEmpty()) 0.0 else immediate.count { it.changePercent > 0.0 }.toDouble() / immediate.size,
            averageMfe = snapshots.map { it.mfePercent }.average(),
            averageMae = snapshots.map { it.maePercent }.average()
        )
    }
}

enum class ShadowStrategyType { CURRENT, AGGRESSIVE, CONSERVATIVE, AI_RECOMMENDED }

data class ShadowPosition(val market: String, val quantity: Double, val avgPrice: Double, val openedAt: Long)

data class ShadowStepResult(val portfolio: ShadowPortfolioEntity, val trades: List<ShadowTradeEntity>)

object ShadowSimulationEngine {
    private const val FEE_RATE = 0.0025
    private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
    private val positionAdapter = moshi.adapter<List<ShadowPosition>>(Types.newParameterizedType(List::class.java, ShadowPosition::class.java))

    fun initial(type: ShadowStrategyType, initialKrw: Double): ShadowPortfolioEntity =
        ShadowPortfolioEntity(strategy = type.name, initialValue = initialKrw, cash = initialKrw, equity = initialKrw, positionsJson = "[]")

    fun positions(entity: ShadowPortfolioEntity): List<ShadowPosition> =
        runCatching { positionAdapter.fromJson(entity.positionsJson).orEmpty() }.getOrDefault(emptyList())

    fun step(
        entity: ShadowPortfolioEntity,
        signals: List<StrategySignalModel>,
        now: Long,
        baseSettings: TradingSettings = TradingSettings(),
        aiProposal: RecommendationProposal? = null,
        regime: String = MarketRegime.UNKNOWN.name
    ): ShadowStepResult {
        val type = runCatching { ShadowStrategyType.valueOf(entity.strategy) }.getOrDefault(ShadowStrategyType.CURRENT)
        val thresholdOffset = when (type) { ShadowStrategyType.AGGRESSIVE -> -10.0; ShadowStrategyType.CONSERVATIVE -> 10.0; else -> 0.0 }
        val aiOffset = if (type == ShadowStrategyType.AI_RECOMMENDED) aiProposal?.scoreThresholdDelta ?: 0.0 else 0.0
        val threshold = (baseSettings.scoreThreshold + thresholdOffset + aiOffset).coerceIn(0.0, 100.0)
        val maxPositions = when (type) {
            ShadowStrategyType.AGGRESSIVE -> (baseSettings.maxPositions + 2).coerceAtMost(50)
            ShadowStrategyType.CONSERVATIVE -> (baseSettings.maxPositions - 1).coerceAtLeast(1)
            ShadowStrategyType.AI_RECOMMENDED -> (aiProposal?.maxPositionsDelta?.let { baseSettings.maxPositions + it } ?: baseSettings.maxPositions).coerceIn(1, 50)
            else -> baseSettings.maxPositions
        }
        val stopLoss = when (type) {
            ShadowStrategyType.CONSERVATIVE -> baseSettings.stopLossPercent.coerceAtLeast(-2.0)
            ShadowStrategyType.AI_RECOMMENDED -> (baseSettings.stopLossPercent + (aiProposal?.stopLossDelta ?: 0.0)).coerceIn(-80.0, -0.1)
            else -> baseSettings.stopLossPercent
        }
        val takeProfit = when (type) {
            ShadowStrategyType.AGGRESSIVE -> baseSettings.takeProfitPercent + 2.0
            ShadowStrategyType.CONSERVATIVE -> baseSettings.takeProfitPercent.coerceAtMost(5.0)
            ShadowStrategyType.AI_RECOMMENDED -> (baseSettings.takeProfitPercent + (aiProposal?.takeProfitDelta ?: 0.0)).coerceAtLeast(0.1)
            else -> baseSettings.takeProfitPercent
        }
        val positions = positions(entity).toMutableList()
        var cash = entity.cash
        var realized = entity.realizedPnl
        var wins = entity.winCount
        var losses = entity.lossCount
        var grossProfit = entity.grossProfit
        var grossLoss = entity.grossLoss
        var consecutiveLosses = entity.consecutiveLosses
        var totalHoldingMinutes = entity.totalHoldingMinutes
        val trades = mutableListOf<ShadowTradeEntity>()

        positions.toList().forEach { position ->
            val signal = signals.firstOrNull { it.market == position.market } ?: return@forEach
            val price = signal.currentPrice
            if (price <= 0.0) return@forEach
            val pnl = (price / position.avgPrice - 1.0) * 100.0
            if (pnl <= stopLoss || pnl >= takeProfit || signal.score < threshold - 20.0) {
                val amount = position.quantity * price * (1.0 - FEE_RATE)
                val pnlRate = if (position.avgPrice > 0.0) (amount / (position.quantity * position.avgPrice) - 1.0) * 100.0 else 0.0
                cash += amount
                realized += amount - position.quantity * position.avgPrice
                if (pnlRate > 0.0) { wins++; grossProfit += pnlRate; consecutiveLosses = 0 } else { losses++; grossLoss += abs(pnlRate); consecutiveLosses++ }
                totalHoldingMinutes += ((now - position.openedAt).coerceAtLeast(0L) / 60_000L)
                positions.remove(position)
                trades += ShadowTradeEntity(strategy = type.name, time = now, market = position.market, side = "SELL", amount = amount, quantity = position.quantity, price = price, pnlRate = pnlRate, holdingMinutes = (now - position.openedAt).coerceAtLeast(0L) / 60_000L, reason = "SHADOW_EXIT", regime = regime)
            }
        }
        val candidate = signals.filter { it.currentPrice > 0.0 && it.score >= threshold && !positions.any { p -> p.market == it.market } }
            .maxByOrNull { it.score }
        if (candidate != null && positions.size < maxPositions && cash > 1_000.0) {
            val amount = (cash * 0.2).coerceAtLeast(1_000.0).coerceAtMost(cash)
            val quantity = amount * (1.0 - FEE_RATE) / candidate.currentPrice
            if (quantity.isFinite() && quantity > 0.0) {
                cash -= amount
                positions += ShadowPosition(candidate.market, quantity, candidate.currentPrice, now)
                trades += ShadowTradeEntity(strategy = type.name, time = now, market = candidate.market, side = "BUY", amount = amount, quantity = quantity, price = candidate.currentPrice, pnlRate = 0.0, holdingMinutes = 0, reason = "SHADOW_ENTRY", regime = regime)
            }
        }
        val equity = cash + positions.sumOf { p -> p.quantity * (signals.firstOrNull { it.market == p.market }?.currentPrice ?: p.avgPrice) }
        val peak = maxOf(entity.peakEquity, equity)
        val mdd = minOf(entity.maxDrawdownPercent, if (peak > 0.0) (equity / peak - 1.0) * 100.0 else 0.0)
        val updated = entity.copy(cash = cash, equity = equity, positionsJson = positionAdapter.toJson(positions), tradeCount = entity.tradeCount + trades.count { it.side == "SELL" }, winCount = wins, lossCount = losses, grossProfit = grossProfit, grossLoss = grossLoss, realizedPnl = realized, peakEquity = peak, maxDrawdownPercent = mdd, consecutiveLosses = consecutiveLosses, totalHoldingMinutes = totalHoldingMinutes, updatedAt = now)
        return ShadowStepResult(updated, trades)
    }

    fun riskAdjustedScore(entity: ShadowPortfolioEntity, minimumSamples: Int = 10): Double {
        if (entity.tradeCount < minimumSamples) return Double.NEGATIVE_INFINITY
        val profitFactor = if (entity.grossLoss > 0.0) entity.grossProfit / entity.grossLoss else if (entity.grossProfit > 0.0) 10.0 else 0.0
        val expected = if (entity.tradeCount > 0) entity.realizedPnl / entity.tradeCount else 0.0
        return entity.returnPercent * 0.35 + profitFactor.coerceAtMost(5.0) * 12.0 + expected * 2.0 - abs(entity.maxDrawdownPercent) * 0.8 - entity.consecutiveLosses * 1.5
    }
}

enum class MissedOpportunityOutcome { PENDING, GOOD_REJECTION, MISSED_WIN, NEUTRAL }

object MissedOpportunityEngine {
    fun classify(maxReturnPercent: Double, minReturnPercent: Double): MissedOpportunityOutcome = when {
        maxReturnPercent >= 3.0 -> MissedOpportunityOutcome.MISSED_WIN
        minReturnPercent <= -2.5 -> MissedOpportunityOutcome.GOOD_REJECTION
        else -> MissedOpportunityOutcome.NEUTRAL
    }
}

data class AiResearchDatasetSummary(
    val postEntrySnapshots: Int,
    val missedOpportunities: Int,
    val shadowTrades: Int,
    val opportunityDecisions: Int,
    val postExitSnapshots: Int = 0,
    val lossRootCauses: Int = 0,
    val scalpingDiagnostics: Int = 0,
    val smartReentryAttempts: Int = 0
)

/** 기존 AI 학습 파이프라인이 연구 데이터를 읽을 수 있는 단일 집계 진입점. */
object AiResearchDatasetBuilder {
    fun summarize(
        postEntrySnapshots: List<PostEntrySnapshotEntity>,
        missedOpportunities: List<MissedOpportunityEntity>,
        shadowTrades: List<ShadowTradeEntity>,
        opportunityDecisions: List<OpportunityDecisionEntity>,
        postExitSnapshots: List<PostExitSnapshotEntity> = emptyList(),
        lossRootCauses: List<LossRootCauseRecordEntity> = emptyList(),
        scalpingDiagnostics: List<ScalpingExecutionDiagnosticEntity> = emptyList(),
        smartReentryAttempts: List<SmartReentryAttemptEntity> = emptyList()
    ) = AiResearchDatasetSummary(
        postEntrySnapshots = postEntrySnapshots.size,
        missedOpportunities = missedOpportunities.size,
        shadowTrades = shadowTrades.size,
        opportunityDecisions = opportunityDecisions.size,
        postExitSnapshots = postExitSnapshots.size,
        lossRootCauses = lossRootCauses.size,
        scalpingDiagnostics = scalpingDiagnostics.size,
        smartReentryAttempts = smartReentryAttempts.size
    )
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/AdvancedResearch.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/AiSignal.kt =====
package com.example.bithumbtrader

import android.content.Context
import com.squareup.moshi.Json
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlin.math.ln

data class AiModelArtifact(
    @Json(name = "modelVersion") val modelVersion: Int,
    @Json(name = "featureNames") val featureNames: List<String>,
    @Json(name = "means") val means: List<Double>,
    @Json(name = "scales") val scales: List<Double>,
    @Json(name = "weights") val weights: List<Double>,
    @Json(name = "bias") val bias: Double,
    @Json(name = "positiveThreshold") val positiveThreshold: Double = 0.55
)

data class AiPrediction(
    val score: Double,
    val positive: Boolean,
    val label: String
)

interface OnDeviceAiModel {
    val version: Int
    fun predict(features: List<Double>): AiPrediction

    companion object {
        fun fromAsset(context: Context): OnDeviceAiModel {
            return runCatching {
                val json = context.assets.open("ai_model.json").bufferedReader().use { it.readText() }
                val adapter = Moshi.Builder()
                    .add(KotlinJsonAdapterFactory())
                    .build()
                    .adapter(AiModelArtifact::class.java)
                BundledLogisticAiModel(adapter.fromJson(json) ?: error("AI 모델 비어 있음"))
            }.getOrElse { DisabledAiModel("AI 모델 로드 실패") }
        }

        fun fromJson(json: String): OnDeviceAiModel {
            val adapter = Moshi.Builder()
                .add(KotlinJsonAdapterFactory())
                .build()
                .adapter(AiModelArtifact::class.java)
            return BundledLogisticAiModel(adapter.fromJson(json) ?: error("AI 모델 비어 있음"))
        }

        fun toJson(artifact: AiModelArtifact): String {
            val adapter = Moshi.Builder()
                .add(KotlinJsonAdapterFactory())
                .build()
                .adapter(AiModelArtifact::class.java)
            return adapter.toJson(artifact)
        }
    }
}

class BundledLogisticAiModel(private val artifact: AiModelArtifact) : OnDeviceAiModel {
    override val version: Int = artifact.modelVersion
    init {
        require(artifact.means.size == artifact.weights.size)
        require(artifact.scales.size == artifact.weights.size)
        require(artifact.weights.isNotEmpty())
        require(artifact.weights.all { it.isFinite() })
        require(artifact.scales.all { it.isFinite() && it > 0.0 })
        require(artifact.positiveThreshold in 0.0..1.0)
    }

    override fun predict(features: List<Double>): AiPrediction {
        if (features.size != artifact.weights.size || features.any { !it.isFinite() }) {
            return AiPrediction(50.0, false, "AI 데이터 부족")
        }
        val logit = artifact.bias + features.indices.sumOf { index ->
            artifact.weights[index] * ((features[index] - artifact.means[index]) / artifact.scales[index])
        }
        val probability = (1.0 / (1.0 + kotlin.math.exp(-logit))).coerceIn(0.0, 1.0)
        val label = if (probability >= artifact.positiveThreshold) "AI 추천: 매수 참고 (확률 양호)" else "AI 추천: 매수 비추천 (확률 낮음)"
        return AiPrediction(probability * 100.0, probability >= artifact.positiveThreshold, label)
    }
}

class DisabledAiModel(private val reason: String) : OnDeviceAiModel {
    override val version: Int = 0
    override fun predict(features: List<Double>) = AiPrediction(0.0, false, reason)
}

class AiModelOtaClient(
    private val url: String = "https://riderapp.duckdns.org/bithumb-ai-model.json",
    private val client: okhttp3.OkHttpClient = okhttp3.OkHttpClient.Builder()
        .connectTimeout(java.time.Duration.ofSeconds(8))
        .readTimeout(java.time.Duration.ofSeconds(8))
        .build()
) {
    fun fetch(): Pair<AiModelArtifact, String> {
        val request = okhttp3.Request.Builder().url(url).get().build()
        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) error("AI OTA HTTP ${response.code}")
            val json = response.body?.string().orEmpty()
            val adapter = Moshi.Builder()
                .add(KotlinJsonAdapterFactory())
                .build()
                .adapter(AiModelArtifact::class.java)
            return (adapter.fromJson(json) ?: error("AI OTA JSON 파싱 실패")) to json
        }
    }
}

object AiFeatureBuilder {
    fun build(ticker: TickerModel?, candles: List<CandleModel>): List<Double> {
        val closes = candles.sortedBy { it.timestamp }.map { it.close }.filter { it.isFinite() && it > 0.0 }
        val volumes = candles.sortedBy { it.timestamp }.map { it.volume }.map { if (it.isFinite() && it > 0.0) it else 0.0 }
        val price = ticker?.tradePrice ?: closes.lastOrNull() ?: 0.0
        if (price <= 0.0 || closes.size < 61) return emptyList()
        val previousVolume = volumes.takeLast(21).dropLast(1).average()
        val volumeRatio = if (previousVolume > 0.0) volumes.last() / previousVolume else 0.0
        val returns = closes.takeLast(21).zipWithNext().mapNotNull { (a, b) ->
            if (a > 0.0) ((b / a) - 1.0).takeIf { it.isFinite() } else null
        }
        val averageReturn = returns.average()
        val volatility = if (returns.size > 1) {
            kotlin.math.sqrt(returns.sumOf { (it - averageReturn) * (it - averageReturn) } / returns.size)
        } else 0.0
        return listOf(
            Indicators.ema(closes.takeLast(5), 5) / price - 1.0,
            Indicators.ema(closes.takeLast(20), 20) / price - 1.0,
            Indicators.ema(closes.takeLast(60), 60) / price - 1.0,
            Indicators.rsi(closes, 14) / 100.0,
            (Indicators.ema(closes.takeLast(12), 12) - Indicators.ema(closes.takeLast(26), 26)) / price,
            ln(1.0 + volumeRatio.coerceAtLeast(0.0)),
            if (closes[closes.size - 4] > 0.0) price / closes[closes.size - 4] - 1.0 else 0.0,
            volatility
        )
    }
}

/**
 * 새 후보 모델을 언제 채택할지 판단하는 순수 정책 규칙.
 * "AI 가 이상한 전략을 갑자기 밀어넣는" 위험을 줄이기 위해,
 * 최소 데이터량을 충족하고 검증 정확도가 기존 모델보다 실제로 나을 때만 채택한다.
 */
object AiRetrainPolicy {
    const val RETRAIN_TRIGGER_COUNT = 10
    const val MIN_TRAINING_SAMPLES = 30
    const val MIN_VALIDATION_SAMPLES = 10
    const val IMPROVEMENT_MARGIN = 0.03

    fun hasEnoughData(resolvedSampleCount: Int): Boolean = resolvedSampleCount >= MIN_TRAINING_SAMPLES

    fun validationSize(totalExamples: Int): Int = maxOf(MIN_VALIDATION_SAMPLES, totalExamples / 5)

    fun shouldAdopt(candidateAccuracy: Double, baselineAccuracy: Double): Boolean =
        candidateAccuracy >= baselineAccuracy + IMPROVEMENT_MARGIN
}

/**
 * 페이퍼 트레이딩 결과(실현 손익 라벨)로 로지스틱 회귀 가중치를 기기 내에서 재학습한다.
 * 서버나 외부 네트워크 없이 순수 Kotlin 연산으로만 동작하며, 학습 자체는 후보 모델만
 * 만들어낼 뿐 채택 여부는 AiRetrainPolicy 와 TradingRepository 의 상대평가에 맡긴다.
 */
object OnDeviceModelTrainer {
    data class TrainingExample(val features: List<Double>, val label: Double)

    fun train(examples: List<TrainingExample>, iterations: Int = 300): AiModelArtifact? {
        if (examples.size < 8) return null
        val dimension = examples.first().features.size
        if (dimension == 0 || examples.any { it.features.size != dimension || it.features.any { v -> !v.isFinite() } }) return null

        val means = DoubleArray(dimension) { i -> examples.map { it.features[i] }.average() }
        val scales = DoubleArray(dimension) { i ->
            val variance = examples.map { val d = it.features[i] - means[i]; d * d }.average()
            val sd = kotlin.math.sqrt(variance)
            if (sd < 1e-6) 1.0 else sd
        }
        val weights = DoubleArray(dimension)
        var bias = 0.0
        val rate = 0.08 / examples.size
        repeat(iterations) {
            val gradients = DoubleArray(dimension)
            var biasGradient = 0.0
            examples.forEach { example ->
                val normalized = DoubleArray(dimension) { i -> (example.features[i] - means[i]) / scales[i] }
                var logit = bias
                for (i in 0 until dimension) logit += weights[i] * normalized[i]
                val prediction = 1.0 / (1.0 + kotlin.math.exp(-logit.coerceIn(-30.0, 30.0)))
                val error = prediction - example.label
                for (i in 0 until dimension) gradients[i] += error * normalized[i]
                biasGradient += error
            }
            for (i in 0 until dimension) weights[i] -= rate * gradients[i]
            bias -= rate * biasGradient
        }
        return AiModelArtifact(
            modelVersion = 0,
            featureNames = List(dimension) { "f$it" },
            means = means.toList(),
            scales = scales.toList(),
            weights = weights.toList(),
            bias = bias,
            positiveThreshold = 0.5
        )
    }

    fun accuracyOfModel(model: OnDeviceAiModel, examples: List<TrainingExample>): Double {
        if (examples.isEmpty()) return 0.0
        val correct = examples.count { example ->
            val prediction = model.predict(example.features).positive
            (prediction && example.label >= 0.5) || (!prediction && example.label < 0.5)
        }
        return correct.toDouble() / examples.size
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/AiSignal.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/AutonomousCapitalGrowthEngine.kt =====
package com.example.bithumbtrader

import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt

enum class CapitalGrowthState {
    GROWTH,
    NORMAL,
    CAUTION,
    DEFENSE,
    RECOVERY,
    PRESERVATION
}

enum class RuinRiskLevel {
    LOW,
    MEDIUM,
    HIGH,
    CRITICAL,
    INSUFFICIENT_SAMPLE
}

data class AutonomousCapitalSnapshot(
    val totalEquity: Double = 0.0,
    val peakEquity: Double = 0.0,
    val protectedReserve: Double = 0.0,
    val tradingCapital: Double = 0.0,
    val capitalState: CapitalGrowthState = CapitalGrowthState.NORMAL,
    val growthConfidence: Double = 70.0,
    val riskBudgetKrw: Double = 0.0,
    val positionSizeMultiplier: Double = 1.0,
    val reinvestmentRatio: Double = 0.5,
    val drawdownFromPeakPercent: Double = 0.0,
    val ruinRisk: RuinRiskLevel = RuinRiskLevel.INSUFFICIENT_SAMPLE,
    val tradingActive: Boolean = true,
    val reason: String = "정상"
)

data class StrategyCapitalAllocationItem(
    val strategy: String,
    val targetPercent: Double,
    val reason: String
)

data class CounterfactualCapitalLabResult(
    val fixedCapitalFinal: Double,
    val fullCompoundFinal: Double,
    val adaptiveCompoundFinal: Double,
    val conservativeCompoundFinal: Double,
    val autonomousGrowthFinal: Double,
    val bestStrategy: String
)

data class AutonomousCapitalSettings(
    val baseRiskPercent: Double = 2.0,
    val normalReinvestmentRatio: Double = 0.50,
    val growthReinvestmentRatio: Double = 0.70,
    val cautionReinvestmentRatio: Double = 0.30,
    val defenseReinvestmentRatio: Double = 0.10,
    val reserveLockProfitPercent: Double = 20.0,
    val reserveAccumulationRatio: Double = 0.25,
    val dynamicCashBufferEnabled: Boolean = true
)

object AutonomousCapitalGrowthEngine {

    fun evaluate(
        currentEquity: Double,
        availableCash: Double,
        dayStartEquity: Double,
        equityPeak: Double,
        recentStats: PerformanceStats,
        marketRegime: MarketRegimeSnapshot,
        marketHealth: MarketHealthScore,
        tailRisk: TailRiskSnapshot,
        portfolioHeat: PortfolioHeatSnapshot,
        profitProtection: ProfitProtectionSnapshot,
        consecutiveLosses: Int,
        settings: AutonomousCapitalSettings
    ): AutonomousCapitalSnapshot {
        val equity = currentEquity.takeIf { it.isFinite() && it > 0.0 } ?: 100_000.0
        val peak = maxOf(equityPeak.takeIf { it.isFinite() && it > 0.0 } ?: equity, equity)
        val peakDd = if (peak > 0.0) (equity / peak - 1.0) * 100.0 else 0.0
        val gainOverInitial = (equity - dayStartEquity).coerceAtLeast(0.0)

        // Calculate protected reserve from accumulated profits
        val protectedReserve = if (dayStartEquity > 0.0 && (equity / dayStartEquity - 1.0) * 100.0 >= settings.reserveLockProfitPercent - 0.0001) {
            gainOverInitial * settings.reserveAccumulationRatio
        } else {
            0.0
        }
        val tradingCapital = (equity - protectedReserve).coerceAtLeast(1_000.0)

        // Ruin Risk Estimation
        val ruinRisk = RiskOfRuinEstimator.estimate(
            winRate = recentStats.winRate,
            profitFactor = recentStats.profitFactor,
            sampleCount = recentStats.sampleCount,
            lossStreak = consecutiveLosses,
            tailRisk = tailRisk
        )

        // Multi-condition assessment
        val hasPositiveExpectancy = recentStats.expectedReturnPercent > 0.0 && recentStats.winRate >= 0.50
        val isMarketFavorable = marketHealth.level == MarketHealthLevel.HEALTHY &&
            marketRegime.regime in setOf(MarketRegime.BULL, MarketRegime.STRONG_BULL, MarketRegime.RECOVERY)
        val isTailRiskAcceptable = tailRisk.score < 50.0
        val isHeatAcceptable = portfolioHeat.level != PortfolioHeatLevel.CRITICAL && portfolioHeat.level != PortfolioHeatLevel.HIGH

        val baseMultiplier = when {
            ruinRisk == RuinRiskLevel.CRITICAL || marketHealth.level == MarketHealthLevel.CRASH || marketRegime.regime == MarketRegime.CRASH -> {
                0.0
            }
            peakDd <= -6.0 || consecutiveLosses >= 6 -> {
                0.35
            }
            peakDd <= -3.0 || consecutiveLosses >= 3 -> {
                0.70
            }
            recentStats.sampleCount >= 10 && hasPositiveExpectancy && isMarketFavorable && isTailRiskAcceptable && isHeatAcceptable && peakDd >= -1.5 -> {
                1.25
            }
            recentStats.sampleCount >= 5 && hasPositiveExpectancy && peakDd > -3.0 -> {
                0.85
            }
            else -> {
                1.0
            }
        }
        val (state, reinvestRatio, _, stateReason) = when {
            ruinRisk == RuinRiskLevel.CRITICAL || marketHealth.level == MarketHealthLevel.CRASH || marketRegime.regime == MarketRegime.CRASH -> {
                Tuple4(CapitalGrowthState.PRESERVATION, 0.0, 0.0, "급락/치명적 파산위험으로 자본보존 모드")
            }
            peakDd <= -6.0 || consecutiveLosses >= 6 -> {
                Tuple4(CapitalGrowthState.DEFENSE, settings.defenseReinvestmentRatio, 0.35, "낙폭 6% 초과 또는 연속손실로 방어 모드")
            }
            peakDd <= -3.0 || consecutiveLosses >= 3 -> {
                Tuple4(CapitalGrowthState.CAUTION, settings.cautionReinvestmentRatio, 0.70, "낙폭 3% 초과 또는 연속손실 3회로 주의 모드")
            }
            recentStats.sampleCount >= 10 && hasPositiveExpectancy && isMarketFavorable && isTailRiskAcceptable && isHeatAcceptable && peakDd >= -1.5 -> {
                Tuple4(CapitalGrowthState.GROWTH, settings.growthReinvestmentRatio, 1.25, "검증된 기대수익 및 시장 호조로 성장 모드")
            }
            recentStats.sampleCount >= 5 && hasPositiveExpectancy && peakDd > -3.0 -> {
                Tuple4(CapitalGrowthState.RECOVERY, settings.normalReinvestmentRatio, 0.85, "손실 후 점진적 회복 사다리 진행 중")
            }
            else -> {
                Tuple4(CapitalGrowthState.NORMAL, settings.normalReinvestmentRatio, 1.0, "안정적 정상 자본운용")
            }
        }

        // Calculate Growth Confidence (0 ~ 100)
        var conf = 50.0
        if (hasPositiveExpectancy) conf += 20.0
        if (isMarketFavorable) conf += 15.0
        if (isTailRiskAcceptable) conf += 10.0
        if (isHeatAcceptable) conf += 10.0
        if (consecutiveLosses == 0) conf += 5.0
        if (consecutiveLosses >= 3) conf -= 20.0
        if (peakDd <= -4.0) conf -= 25.0
        val clampedConfidence = conf.coerceIn(0.0, 100.0)

        // Compounding Risk Budget calculation
        val baseRiskBudget = tradingCapital * (settings.baseRiskPercent / 100.0)
        val finalMultiplier = baseMultiplier *
            (clampedConfidence / 100.0).coerceIn(0.2, 1.25) *
            DrawdownRecoveryController.exposureMultiplier(peakDd) *
            portfolioHeat.multiplier

        val riskBudgetKrw = baseRiskBudget * finalMultiplier.coerceIn(0.0, 1.5)
        val tradingActive = state != CapitalGrowthState.PRESERVATION

        return AutonomousCapitalSnapshot(
            totalEquity = equity,
            peakEquity = peak,
            protectedReserve = protectedReserve,
            tradingCapital = tradingCapital,
            capitalState = state,
            growthConfidence = clampedConfidence,
            riskBudgetKrw = riskBudgetKrw,
            positionSizeMultiplier = finalMultiplier.coerceIn(0.0, 1.5),
            reinvestmentRatio = reinvestRatio,
            drawdownFromPeakPercent = peakDd,
            ruinRisk = ruinRisk,
            tradingActive = tradingActive,
            reason = "$stateReason (GrowthConf ${clampedConfidence.toInt()} / RuinRisk $ruinRisk)"
        )
    }

    private data class Tuple4<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
}

object RiskOfRuinEstimator {
    fun estimate(
        winRate: Double,
        profitFactor: Double,
        sampleCount: Int,
        lossStreak: Int,
        tailRisk: TailRiskSnapshot
    ): RuinRiskLevel {
        if (sampleCount < 8) return RuinRiskLevel.INSUFFICIENT_SAMPLE
        return when {
            lossStreak >= 8 || tailRisk.score >= 85.0 || (profitFactor < 0.6 && sampleCount >= 15) -> RuinRiskLevel.CRITICAL
            lossStreak >= 5 || tailRisk.score >= 65.0 || profitFactor < 0.85 -> RuinRiskLevel.HIGH
            lossStreak >= 3 || tailRisk.score >= 45.0 || winRate < 0.45 -> RuinRiskLevel.MEDIUM
            else -> RuinRiskLevel.LOW
        }
    }
}

object CapitalStrategyAllocator {
    fun recommend(
        regime: MarketRegimeSnapshot,
        capitalState: CapitalGrowthState,
        shadowPortfolios: List<ShadowPortfolioEntity>
    ): List<StrategyCapitalAllocationItem> {
        if (capitalState == CapitalGrowthState.PRESERVATION) {
            return listOf(
                StrategyCapitalAllocationItem("CASH_RESERVE", 100.0, "자본 보존 상태 — 신규 할당 정지")
            )
        }

        return when (regime.regime) {
            MarketRegime.STRONG_BULL, MarketRegime.BULL -> listOf(
                StrategyCapitalAllocationItem("MOMENTUM", 40.0, "상승장 주도 모멘텀 극대화"),
                StrategyCapitalAllocationItem("BREAKOUT", 30.0, "신고가 돌파 추종"),
                StrategyCapitalAllocationItem("MEAN_REVERSION", 15.0, "눌림목 반등 포착"),
                StrategyCapitalAllocationItem("DEFENSIVE", 5.0, "하방 리스크 헤지"),
                StrategyCapitalAllocationItem("DYNAMIC_CASH", 10.0, "유동성 여유 버퍼")
            )
            MarketRegime.SIDEWAYS -> listOf(
                StrategyCapitalAllocationItem("MEAN_REVERSION", 45.0, "박스권 밴드 매매"),
                StrategyCapitalAllocationItem("MOMENTUM", 15.0, "단기 개별 강세주"),
                StrategyCapitalAllocationItem("DEFENSIVE", 20.0, "변동성 축소 방어"),
                StrategyCapitalAllocationItem("DYNAMIC_CASH", 20.0, "횡보장 현금 확보")
            )
            MarketRegime.HIGH_VOLATILITY -> listOf(
                StrategyCapitalAllocationItem("DEFENSIVE", 40.0, "고변동성 방어 최우선"),
                StrategyCapitalAllocationItem("MEAN_REVERSION", 20.0, "과대낙폭 기술적 반등"),
                StrategyCapitalAllocationItem("DYNAMIC_CASH", 40.0, "현금 비중 대폭 확대")
            )
            MarketRegime.WEAK_BEAR, MarketRegime.BEAR, MarketRegime.STRONG_BEAR -> listOf(
                StrategyCapitalAllocationItem("DEFENSIVE", 30.0, "최소 방어 포지션"),
                StrategyCapitalAllocationItem("DYNAMIC_CASH", 70.0, "하락장 자본 보존")
            )
            MarketRegime.CRASH -> listOf(
                StrategyCapitalAllocationItem("CASH_RESERVE", 100.0, "급락장 전면 현금화 및 진입 차단")
            )
            else -> listOf(
                StrategyCapitalAllocationItem("CURRENT", 60.0, "국면 관측 중 — 기본 전략 유지"),
                StrategyCapitalAllocationItem("DYNAMIC_CASH", 40.0, "안전 마진 현금")
            )
        }
    }
}

object CounterfactualCapitalLabEngine {
    fun simulate(
        tradePnlRates: List<Double>,
        initialCapital: Double = 100_000.0
    ): CounterfactualCapitalLabResult {
        if (tradePnlRates.isEmpty()) {
            return CounterfactualCapitalLabResult(
                initialCapital, initialCapital, initialCapital, initialCapital, initialCapital, "EQUAL"
            )
        }

        var fixedEquity = initialCapital
        var fullCompoundEquity = initialCapital
        var adaptiveCompoundEquity = initialCapital
        var conservativeEquity = initialCapital
        var autoGrowthEquity = initialCapital

        val fixedBet = initialCapital * 0.20

        tradePnlRates.forEach { rate ->
            val pnlFrac = rate / 100.0

            // A: Fixed Capital
            fixedEquity += fixedBet * pnlFrac

            // B: 100% Full Compound
            fullCompoundEquity += (fullCompoundEquity * 0.20) * pnlFrac

            // C: Adaptive Compound (reduces on drawdown)
            val adaptiveBetRatio = if (pnlFrac < 0) 0.12 else 0.22
            adaptiveCompoundEquity += (adaptiveCompoundEquity * adaptiveBetRatio) * pnlFrac

            // D: Conservative
            conservativeEquity += (conservativeEquity * 0.10) * pnlFrac

            // E: Autonomous Capital Growth (dynamic scaling with reserve protection)
            val growthBetRatio = if (pnlFrac < 0) 0.08 else 0.20
            autoGrowthEquity += (autoGrowthEquity * growthBetRatio) * pnlFrac
        }

        val results = mapOf(
            "FIXED" to fixedEquity,
            "FULL_COMPOUND" to fullCompoundEquity,
            "ADAPTIVE_COMPOUND" to adaptiveCompoundEquity,
            "CONSERVATIVE" to conservativeEquity,
            "AUTONOMOUS_GROWTH" to autoGrowthEquity
        )
        val best = results.maxByOrNull { it.value }?.key ?: "AUTONOMOUS_GROWTH"

        return CounterfactualCapitalLabResult(
            fixedCapitalFinal = fixedEquity,
            fullCompoundFinal = fullCompoundEquity,
            adaptiveCompoundFinal = adaptiveCompoundEquity,
            conservativeCompoundFinal = conservativeEquity,
            autonomousGrowthFinal = autoGrowthEquity,
            bestStrategy = best
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/AutonomousCapitalGrowthEngine.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/BithumbApi.kt =====

package com.example.bithumbtrader

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.*
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.security.MessageDigest
import java.util.Base64
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

interface BithumbPublicApi {
    @GET("/v1/market/all") suspend fun markets(@Query("isDetails") details:Boolean = true): Response<List<MarketDto>>
    @GET("/v1/ticker") suspend fun ticker(@Query("markets") markets:String): Response<List<TickerDto>>
    @GET("/v1/orderbook") suspend fun orderbook(@Query("markets") markets:String): Response<List<OrderbookDto>>
    @GET("/v1/candles/minutes/{unit}") suspend fun minuteCandles(@Path("unit") unit:Int, @Query("market") market:String, @Query("count") count:Int = 120): Response<List<CandleDto>>
}
interface BithumbPrivateApi {
    @GET("/v1/orders/chance") suspend fun orderChance(@Header("Authorization") auth:String, @Query("market") market:String): Response<OrderChanceDto>
    @POST("/v2/orders") suspend fun createOrder(@Header("Authorization") auth:String, @Body body:OrderRequestDto): Response<OrderResponseDto>
    @GET("/v2/orders/{order_id}") suspend fun order(@Header("Authorization") auth:String, @Path("order_id") orderId:String): Response<Map<String, Any?>>
    @GET("/v2/orders/open") suspend fun openOrders(@Header("Authorization") auth:String): Response<List<Map<String, Any?>>> 
}
object ApiFactory {
    private val client = OkHttpClient.Builder().connectTimeout(java.time.Duration.ofSeconds(10)).readTimeout(java.time.Duration.ofSeconds(15)).writeTimeout(java.time.Duration.ofSeconds(15)).build()
    private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
    fun publicApi(): BithumbPublicApi = Retrofit.Builder().baseUrl("https://api.bithumb.com").client(client).addConverterFactory(MoshiConverterFactory.create(moshi)).build().create(BithumbPublicApi::class.java)
    fun privateApi(): BithumbPrivateApi = Retrofit.Builder().baseUrl("https://api.bithumb.com").client(client).addConverterFactory(MoshiConverterFactory.create(moshi)).build().create(BithumbPrivateApi::class.java)
    fun wsClient(): OkHttpClient = client
}
class BithumbJwt(private val accessKey:***REDACTED*** private val secretKey:***REDACTED*** {
    fun queryString(params: List<Pair<String, Any?>>): String = params.filter { it.second != null }.joinToString("&") { (k,v) -> "$k=${v.toString()}" }
    fun sha512Hex(data:String): String = MessageDigest.getInstance("SHA-512").digest(data.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
    fun token(params: List<Pair<String, Any?>> = emptyList(), nonce:String = UUID.randomUUID().toString(), timestamp:Long = System.currentTimeMillis()): String {
        val header = json(mapOf("alg" to "HS256", "typ" to "JWT"))
        val payload = linkedMapOf<String, Any>("access_key" to accessKey, "nonce" to nonce, "timestamp" to timestamp)
        val query = queryString(params)
        if (query.isNotEmpty()) { payload["query_hash"] = sha512Hex(query); payload["query_hash_alg"] = "SHA512" }
        val body = base64Url(header) + "." + base64Url(json(payload))
        val mac = Mac.getInstance("HmacSHA256"); mac.init(SecretKeySpec(secretKey.toByteArray(Charsets.UTF_8), "HmacSHA256"))
        return "Bearer $body." + Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(body.toByteArray(Charsets.UTF_8)))
    }
    private fun base64Url(s:String)=Base64.getUrlEncoder().withoutPadding().encodeToString(s.toByteArray(Charsets.UTF_8))
    private fun json(map: Map<String, Any>) = map.entries.joinToString(prefix="{", postfix="}") { (k,v) -> "\"$k\":" + if (v is Number) v.toString() else "\"${v.toString().replace("\"","\\\"")}\"" }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/BithumbApi.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/BithumbWebSocket.kt =====
package com.example.bithumbtrader

import com.squareup.moshi.Json
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min

interface BithumbTickerStream {
    val status: StateFlow<ConnectionStatus>
    val lastMessageAt: Long
    fun subscribe(markets: Collection<String>)
    fun latest(markets: Collection<String>, maxAgeMillis: Long): Map<String, TickerModel>
    fun recentMicroSamples(markets: Collection<String>, maxAgeMillis: Long = 5 * 60_000L): Map<String, List<MicroMarketSample>>
    fun stop()
}

data class BithumbWebSocketTickerMessage(
    @Json(name = "code") val code: String?,
    @Json(name = "trade_price") val tradePrice: Double?,
    @Json(name = "acc_trade_price_24h") val accTradePrice24h: Double?,
    @Json(name = "signed_change_rate") val signedChangeRate: Double?,
    @Json(name = "trade_volume") val tradeVolume: Double?,
    @Json(name = "timestamp") val timestamp: Long?
)

object BithumbWebSocketTickerParser {
    private val adapter = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
        .adapter(BithumbWebSocketTickerMessage::class.java)

    fun parse(text: String): TickerModel? {
        val message = runCatching { adapter.fromJson(text) }.getOrNull() ?: return null
        val market = message.code?.takeIf { it.startsWith("KRW-") } ?: return null
        val price = message.tradePrice?.takeIf { it.isFinite() && it > 0.0 } ?: return null
        return TickerModel(
            market = market,
            tradePrice = price,
            accTradePrice24h = message.accTradePrice24h?.takeIf(Double::isFinite) ?: 0.0,
            signedChangeRate = message.signedChangeRate?.takeIf(Double::isFinite) ?: 0.0,
            tradeVolume = message.tradeVolume?.takeIf(Double::isFinite) ?: 0.0,
            timestamp = message.timestamp ?: System.currentTimeMillis()
        )
    }
}

/**
 * Bithumb public WebSocket ticker stream.
 *
 * - One connection subscribes to every KRW market.
 * - Failures reconnect with capped exponential backoff.
 * - TradingRepository still falls back to REST for missing/stale symbols.
 */
class BithumbTickerWebSocket(
    private val client: OkHttpClient,
    private val url: String = "wss://ws-api.bithumb.com/websocket/v1",
    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) : BithumbTickerStream {
    private val latestTickers = ConcurrentHashMap<String, TickerModel>()
    private val microHistory = ConcurrentHashMap<String, ArrayDeque<MicroMarketSample>>()
    private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
    override val status: StateFlow<ConnectionStatus> = _status

    @Volatile
    override var lastMessageAt: Long = 0L
        private set

    @Volatile
    private var desiredMarkets: Set<String> = emptySet()

    private val lock = Any()
    private var webSocket: WebSocket? = null
    private var reconnectJob: Job? = null
    private var generation = 0
    private var reconnectAttempt = 0

    override fun subscribe(markets: Collection<String>) {
        val normalized = markets.filter { it.startsWith("KRW-") }.toSortedSet()
        if (normalized.isEmpty()) return
        synchronized(lock) {
            if (normalized == desiredMarkets && (webSocket != null || reconnectJob?.isActive == true)) return
            desiredMarkets = normalized
            connectLocked()
        }
    }

    override fun latest(markets: Collection<String>, maxAgeMillis: Long): Map<String, TickerModel> {
        val now = System.currentTimeMillis()
        return markets.mapNotNull { market ->
            latestTickers[market]
                ?.takeIf { now - it.timestamp.coerceAtMost(now) <= maxAgeMillis }
                ?.let { market to it }
        }.toMap()
    }

    override fun recentMicroSamples(markets: Collection<String>, maxAgeMillis: Long): Map<String, List<MicroMarketSample>> {
        val cutoff = System.currentTimeMillis() - maxAgeMillis
        return markets.associateWith { market ->
            val history = microHistory[market] ?: return@associateWith emptyList()
            synchronized(history) {
                history.filter { it.time >= cutoff && it.price > 0.0 }
            }
        }
    }

    override fun stop() {
        synchronized(lock) {
            generation += 1
            desiredMarkets = emptySet()
            reconnectJob?.cancel()
            reconnectJob = null
            webSocket?.close(1000, "app stop")
            webSocket = null
            _status.value = ConnectionStatus.DISCONNECTED
        }
    }

    private fun connectLocked() {
        generation += 1
        val currentGeneration = generation
        reconnectJob?.cancel()
        reconnectJob = null
        webSocket?.cancel()
        webSocket = null
        _status.value = ConnectionStatus.CONNECTING
        val request = Request.Builder().url(url).build()
        webSocket = client.newWebSocket(request, listener(currentGeneration))
    }

    private fun listener(listenerGeneration: Int) = object : WebSocketListener() {
        override fun onOpen(webSocket: WebSocket, response: Response) {
            synchronized(lock) {
                if (listenerGeneration != generation) {
                    webSocket.close(1000, "stale connection")
                    return
                }
                this@BithumbTickerWebSocket.webSocket = webSocket
                reconnectAttempt = 0
                _status.value = ConnectionStatus.CONNECTED
                webSocket.send(subscriptionMessage(desiredMarkets))
            }
        }

        override fun onMessage(webSocket: WebSocket, text: String) {
            handleMessage(listenerGeneration, text)
        }

        override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
            handleMessage(listenerGeneration, bytes.utf8())
        }

        override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
            scheduleReconnect(listenerGeneration)
        }

        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            _status.value = ConnectionStatus.ERROR
            scheduleReconnect(listenerGeneration)
        }
    }

    private fun handleMessage(listenerGeneration: Int, text: String) {
        if (listenerGeneration != generation) return
        val ticker = BithumbWebSocketTickerParser.parse(text) ?: return
        latestTickers[ticker.market] = ticker
        val history = microHistory.computeIfAbsent(ticker.market) { ArrayDeque() }
        synchronized(history) {
            history.addLast(MicroMarketSample(System.currentTimeMillis(), ticker.tradePrice, ticker.tradeVolume))
            val cutoff = System.currentTimeMillis() - 10 * 60_000L
            while (history.size > 600 || history.firstOrNull()?.time ?: 0L < cutoff) history.removeFirst()
        }
        lastMessageAt = System.currentTimeMillis()
        ExecutionDataPipelineDiagnostics.recordWebSocketMessage()
    }

    private fun scheduleReconnect(listenerGeneration: Int) {
        synchronized(lock) {
            if (listenerGeneration != generation || desiredMarkets.isEmpty() || reconnectJob?.isActive == true) return
            webSocket = null
            val waitMillis = min(60_000L, 1_000L shl reconnectAttempt.coerceAtMost(6))
            reconnectAttempt += 1
            reconnectJob = scope.launch {
                delay(waitMillis)
                synchronized(lock) {
                    if (listenerGeneration == generation && desiredMarkets.isNotEmpty()) connectLocked()
                }
            }
        }
    }

    private fun subscriptionMessage(markets: Set<String>): String {
        val codes = markets.joinToString(",") { "\"${it.replace("\"", "")}\"" }
        return """[{"ticket":"${UUID.randomUUID()}"},{"type":"ticker","codes":[$codes],"is_only_realtime":true},{"format":"DEFAULT"}]"""
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/BithumbWebSocket.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/BootReceiver.kt =====

package com.example.bithumbtrader

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat

class BootReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
        if (!TradingSettingsStore(context).autoResumeAfterBoot()) return

        ContextCompat.startForegroundService(
            context,
            Intent(context, TradingForegroundService::class.java)
                .setAction(TradingForegroundService.ACTION_START)
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/BootReceiver.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ContinuousLearning.kt =====
package com.example.bithumbtrader

import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sqrt

enum class LearningDataSource { HISTORICAL, PAPER, LIVE }
enum class LearningLoopStage { COLLECTING, TRAINING, VALIDATING, SHADOW_TESTING, PAPER_TESTING, STABLE, DRIFT_DETECTED }
enum class LearningDriftState { STABLE, MINOR_DRIFT, MAJOR_DRIFT, UNKNOWN }
enum class LearningModelStatus { PROPOSED, BACKTESTING, WALK_FORWARD, OOS, REPLAY, SHADOW, PAPER_CANDIDATE, PAPER_TESTING, PROMOTION_CANDIDATE, REJECTED }

data class HistoricalLearningConfig(
    val feePercent: Double = 0.25,
    val slippagePercent: Double = 0.10,
    val minimumSamples: Int = 60,
    val replayBufferSize: Int = 2_000
)

data class LearningMetrics(
    val sampleCount: Int = 0,
    val accuracy5m: Double = 0.0,
    val accuracy15m: Double = 0.0,
    val brier5m: Double = 0.0,
    val returnMae5m: Double = 0.0,
    val returnRmse5m: Double = 0.0,
    val tradingExpectancy: Double = 0.0,
    val profitFactor: Double = 0.0,
    val maxDrawdown: Double = 0.0,
    val netReturn: Double = 0.0
)

data class LearningPrediction(
    val expectedReturn30s: Double,
    val expectedReturn1m: Double,
    val expectedReturn3m: Double,
    val expectedReturn5m: Double,
    val expectedReturn15m: Double,
    val expectedReturn30m: Double,
    val expectedReturn60m: Double,
    val probabilityPositive5m: Double,
    val probabilityPositive15m: Double,
    val probabilityStopBeforeProfit: Double,
    val probabilityReboundAfterPullback: Double,
    val probabilityChaseFailure: Double,
    val confidence: Double
)

data class ContinuousLearningState(
    val stage: LearningLoopStage = LearningLoopStage.COLLECTING,
    val championVersion: String = "CURRENT_STRATEGY",
    val challengerVersion: String = "-",
    val historicalSamples: Int = 0,
    val paperSamples: Int = 0,
    val liveSamples: Int = 0,
    val hardExamples: Int = 0,
    val replaySamples: Int = 0,
    val lastTrainingAt: Long = 0L,
    val nextTrainingAt: Long = 0L,
    val drift: LearningDriftState = LearningDriftState.UNKNOWN,
    val driftReason: String = "데이터 수집 중",
    val lastMetrics: LearningMetrics = LearningMetrics(),
    val modelStatus: LearningModelStatus = LearningModelStatus.SHADOW,
    val lastHypothesis: String = "",
    val statusMessage: String = "Historical/Paper 데이터 수집 중"
)

object HistoricalBootstrapEngine {
    fun generate(
        market: String,
        candles: List<CandleModel>,
        config: HistoricalLearningConfig = HistoricalLearningConfig()
    ): List<AiTrainingSampleEntity> {
        val rows = candles.sortedBy { it.timestamp }.filter { it.close.isFinite() && it.close > 0.0 }
        if (rows.size < 61) return emptyList()
        val closes = rows.map { it.close }
        val result = mutableListOf<AiTrainingSampleEntity>()
        val feeAndSlippage = config.feePercent * 2.0 + config.slippagePercent * 2.0
        val horizons = listOf(1, 3, 5, 15, 30, 60)
        for (index in 60 until rows.size) {
            if (index + 60 >= rows.size) continue
            val price = closes[index]
            val past = rows.subList(0, index + 1)
            val features = AiFeatureBuilder.build(
                TickerModel(market, price, 0.0, 0.0, rows[index].volume, rows[index].timestamp),
                past
            )
            if (features.size != 8) continue
            fun futureReturn(horizon: Int): Double = (closes[index + horizon] / price - 1.0) * 100.0
            val futurePrices = closes.subList(index, index + 61)
            val future5 = futureReturn(5)
            val future15 = futureReturn(15)
            val maxFuture = futurePrices.maxOrNull() ?: price
            val minFuture = futurePrices.minOrNull() ?: price
            val mfe = (maxFuture / price - 1.0) * 100.0
            val mae = (minFuture / price - 1.0) * 100.0
            val net5 = future5 - feeAndSlippage
            val stopHit = futurePrices.take(16).any { it / price - 1.0 <= -0.025 }
            val takeProfitHit = futurePrices.take(61).any { it / price - 1.0 >= 0.06 }
            result += AiTrainingSampleEntity(
                id = "HIST_${market}_${rows[index].timestamp}",
                market = market,
                time = rows[index].timestamp,
                featuresJson = features.joinToString(","),
                strategyScore = 0.0,
                aiScoreAtCapture = 0.0,
                buyTradeId = null,
                // Labels use Net after fee/slippage — not gross move.
                outcomeLabel = if (net5 >= 0.10) 1.0 else 0.0,
                realizedPnlRate = net5,
                resolvedAt = rows[index + 5].timestamp,
                marketRegime = MarketRegime.UNKNOWN.name,
                source = LearningDataSource.HISTORICAL.name,
                futureReturn30s = if (horizons.contains(1)) futureReturn(1) else null,
                futureReturn1m = futureReturn(1),
                futureReturn3m = futureReturn(3),
                futureReturn5m = future5,
                futureReturn15m = future15,
                futureReturn30m = futureReturn(30),
                futureReturn60m = futureReturn(60),
                mfePercent = mfe,
                maePercent = mae,
                immediateDrawdown = futureReturn(3) <= -2.0,
                stopHit = stopHit,
                takeProfitHit = takeProfitHit,
                goodEntry = net5 >= 0.10 && mae > -2.0,
                chaseEntry = features[6] > 0.04 || features[3] > 0.82,
                profitableAfterCost = net5 > 0.0,
                netEdgeRealized = net5,
                hardExample = net5 < -2.0 || (features[6] > 0.04 && net5 < 0.0) || (net5 > 0.0 && net5 < 0.10),
                lookaheadSafe = true
            )
        }
        return result
    }
}

object LearningReplayBuffer {
    fun select(samples: List<AiTrainingSampleEntity>, maxSize: Int): List<AiTrainingSampleEntity> {
        val valid = samples.filter { it.lookaheadSafe && it.featuresJson.split(",").size == 8 }
        if (valid.size <= maxSize) return valid.sortedBy { it.time }
        val hard = valid.filter { it.hardExample }.takeLast(maxSize / 4)
        val byRegime = valid.groupBy { it.marketRegime }
        val remaining = (maxSize - hard.size).coerceAtLeast(0)
        val balanced = byRegime.values.flatMap { bucket ->
            bucket.sortedBy { it.time }.takeLast(max(1, remaining / byRegime.size))
        }
        return (hard + balanced).distinctBy { it.id }.sortedBy { it.time }.takeLast(maxSize)
    }
}

data class DeepLearningTrainingExample(
    val time: Long,
    val source: LearningDataSource,
    val features: List<Double>,
    val positive5m: Double,
    val positive15m: Double,
    val stopBeforeProfit: Double,
    val reboundAfterPullback: Double,
    val chaseFailure: Double,
    val return5m: Double
)

@JsonClass(generateAdapter = false)
data class DeepLearningArtifact(
    @Json(name = "modelVersion") val modelVersion: String,
    @Json(name = "featureNames") val featureNames: List<String>,
    @Json(name = "means") val means: List<Double>,
    @Json(name = "scales") val scales: List<Double>,
    @Json(name = "hiddenWeights") val hiddenWeights: List<List<Double>>,
    @Json(name = "hiddenBias") val hiddenBias: List<Double>,
    @Json(name = "headWeights") val headWeights: List<List<Double>>,
    @Json(name = "headBias") val headBias: List<Double>,
    @Json(name = "trainedAt") val trainedAt: Long,
    @Json(name = "trainingSampleCount") val trainingSampleCount: Int,
    @Json(name = "historicalSampleCount") val historicalSampleCount: Int,
    @Json(name = "paperSampleCount") val paperSampleCount: Int
)

class DeepLearningCandidateModel(private val artifact: DeepLearningArtifact) {
    fun predict(features: List<Double>): LearningPrediction {
        if (features.size != artifact.means.size || features.any { !it.isFinite() }) {
            return LearningPrediction(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.0)
        }
        val normalized = features.indices.map { (features[it] - artifact.means[it]) / artifact.scales[it].coerceAtLeast(1e-6) }
        val hidden = artifact.hiddenWeights.mapIndexed { index, weights ->
            kotlin.math.tanh(weights.indices.sumOf { weights[it] * normalized[it] } + artifact.hiddenBias[index])
        }
        fun head(index: Int): Double = 1.0 / (1.0 + exp(-(artifact.headWeights[index].indices.sumOf { artifact.headWeights[index][it] * hidden[it] } + artifact.headBias[index])))
        val p5 = head(0)
        val p15 = head(1)
        val stop = head(2)
        val rebound = head(3)
        val chase = head(4)
        val return5 = artifact.headWeights[5].indices.sumOf { artifact.headWeights[5][it] * hidden[it] } + artifact.headBias[5]
        val confidence = ((abs(p5 - 0.5) + abs(p15 - 0.5)) * 100.0).coerceIn(0.0, 100.0)
        return LearningPrediction(return5 * 0.75, return5 * 0.9, return5, return5, return5 * 1.1, return5 * 1.2, return5 * 1.3, p5, p15, stop, rebound, chase, confidence)
    }
}

object ContinuousLearningEngine {
    private const val INPUT_SIZE = 8
    private const val HIDDEN_SIZE = 8
    private const val HEADS = 6
    private val featureNames = List(INPUT_SIZE) { "f$it" }

    fun examples(samples: List<AiTrainingSampleEntity>): List<DeepLearningTrainingExample> =
        samples.filter { it.lookaheadSafe && it.featuresJson.split(",").size == INPUT_SIZE }.mapNotNull { sample ->
            val features = sample.featuresJson.split(",").mapNotNull { it.toDoubleOrNull() }
            if (features.size != INPUT_SIZE) null else DeepLearningTrainingExample(
                time = sample.time,
                source = runCatching { LearningDataSource.valueOf(sample.source) }.getOrDefault(LearningDataSource.PAPER),
                features = features,
                positive5m = if ((sample.futureReturn5m ?: sample.realizedPnlRate ?: -1.0) - 0.7 > 0.0) 1.0 else 0.0,
                positive15m = if ((sample.futureReturn15m ?: sample.realizedPnlRate ?: -1.0) > 0.0) 1.0 else 0.0,
                stopBeforeProfit = if (sample.stopHit) 1.0 else 0.0,
                reboundAfterPullback = if ((sample.mfePercent ?: 0.0) > 1.0 && (sample.maePercent ?: 0.0) < -0.5) 1.0 else 0.0,
                chaseFailure = if (sample.chaseEntry && (sample.realizedPnlRate ?: 0.0) < 0.0) 1.0 else 0.0,
                return5m = sample.futureReturn5m ?: sample.realizedPnlRate ?: 0.0
            )
        }

    fun train(examples: List<DeepLearningTrainingExample>, version: String, now: Long = System.currentTimeMillis()): DeepLearningArtifact? {
        if (examples.size < 16) return null
        val means = List(INPUT_SIZE) { index -> examples.map { it.features[index] }.average() }
        val scales = List(INPUT_SIZE) { index ->
            sqrt(examples.map { (it.features[index] - means[index]).pow(2) }.average()).coerceAtLeast(1e-6)
        }
        val hidden = MutableList(HIDDEN_SIZE) { h -> MutableList(INPUT_SIZE) { i -> (((h + 1) * (i + 2)) % 11 - 5) * 0.01 } }
        val hiddenBias = MutableList(HIDDEN_SIZE) { 0.0 }
        val heads = MutableList(HEADS) { h -> MutableList(HIDDEN_SIZE) { (((h + 2) * (it + 3)) % 7 - 3) * 0.01 } }
        val headBias = MutableList(HEADS) { 0.0 }
        val rate = 0.04 / examples.size.coerceAtLeast(1)
        repeat(120) {
            examples.forEach { example ->
                val normalized = example.features.indices.map { (example.features[it] - means[it]) / scales[it] }
                val hiddenValues = hidden.mapIndexed { h, weights -> kotlin.math.tanh(weights.indices.sumOf { weights[it] * normalized[it] } + hiddenBias[h]) }
                val targets = listOf(example.positive5m, example.positive15m, example.stopBeforeProfit, example.reboundAfterPullback, example.chaseFailure, example.return5m / 10.0)
                val headErrors = heads.indices.map { h ->
                    val raw = heads[h].indices.sumOf { heads[h][it] * hiddenValues[it] } + headBias[h]
                    val prediction = if (h == 5) raw else 1.0 / (1.0 + exp(-raw))
                    prediction - targets[h]
                }
                val hiddenErrors = MutableList(HIDDEN_SIZE) { h ->
                    heads.indices.sumOf { headErrors[it] * heads[it][h] * if (it == 5 || true) 1.0 else 1.0 } *
                        (1.0 - hiddenValues[h] * hiddenValues[h])
                }
                heads.forEachIndexed { h, weights ->
                    weights.indices.forEach { j -> weights[j] -= rate * headErrors[h] * hiddenValues[j] }
                    headBias[h] -= rate * headErrors[h]
                }
                hidden.forEachIndexed { h, weights ->
                    weights.indices.forEach { i -> weights[i] -= rate * hiddenErrors[h] * normalized[i] }
                    hiddenBias[h] -= rate * hiddenErrors[h]
                }
            }
        }
        return DeepLearningArtifact(version, featureNames, means, scales, hidden, hiddenBias, heads, headBias, now, examples.size,
            examples.count { it.source == LearningDataSource.HISTORICAL }, examples.count { it.source == LearningDataSource.PAPER })
    }

    fun metrics(model: DeepLearningCandidateModel, examples: List<DeepLearningTrainingExample>): LearningMetrics {
        if (examples.isEmpty()) return LearningMetrics()
        val predictions = examples.map { model.predict(it.features) }
        val accuracy5 = predictions.zip(examples).count { it.first.probabilityPositive5m >= 0.5 == (it.second.positive5m >= 0.5) }.toDouble() / examples.size
        val accuracy15 = predictions.zip(examples).count { it.first.probabilityPositive15m >= 0.5 == (it.second.positive15m >= 0.5) }.toDouble() / examples.size
        val brier = predictions.zip(examples).map { (prediction, example) -> (prediction.probabilityPositive5m - example.positive5m).pow(2) }.average()
        val errors = predictions.zip(examples).map { (prediction, example) -> prediction.expectedReturn5m - example.return5m }
        val realized = examples.map { it.return5m }
        val perf = StrategyPerformanceEngine.fromPnlRates(realized)
        return LearningMetrics(examples.size, accuracy5, accuracy15, brier, errors.map { abs(it) }.average(), sqrt(errors.map { it.pow(2) }.average()),
            perf.expectedReturnPercent, perf.profitFactor, perf.maxDrawdownPercent, realized.sum())
    }

    fun promotionAllowed(validation: LearningMetrics, oos: LearningMetrics, baseline: LearningMetrics, minimumSamples: Int): Boolean =
        oos.sampleCount >= minimumSamples &&
            oos.tradingExpectancy >= baseline.tradingExpectancy &&
            oos.profitFactor >= baseline.profitFactor &&
            oos.maxDrawdown >= baseline.maxDrawdown - 2.0 &&
            oos.accuracy5m >= baseline.accuracy5m - 0.03 &&
            oos.brier5m <= validation.brier5m + 0.05

    fun drift(samples: List<AiTrainingSampleEntity>): Pair<LearningDriftState, String> {
        val historical = samples.filter { it.source == LearningDataSource.HISTORICAL.name }.mapNotNull { it.futureReturn5m }
        val recent = samples.filter { it.source == LearningDataSource.PAPER.name }.takeLast(30).mapNotNull { it.realizedPnlRate }
        if (historical.size < 20 || recent.size < 10) return LearningDriftState.UNKNOWN to "분포 비교 표본 부족"
        val difference = abs(historical.average() - recent.average())
        return when {
            difference >= 3.0 -> LearningDriftState.MAJOR_DRIFT to "Historical/Paper 5분 수익 분포 차이 ${"%.2f".format(difference)}%p"
            difference >= 1.0 -> LearningDriftState.MINOR_DRIFT to "Historical/Paper 수익 분포 경미한 차이"
            else -> LearningDriftState.STABLE to "Historical/Paper 분포 안정"
        }
    }

    fun toJson(artifact: DeepLearningArtifact): String =
        Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(DeepLearningArtifact::class.java).toJson(artifact)

    fun fromJson(json: String): DeepLearningArtifact =
        Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(DeepLearningArtifact::class.java).fromJson(json)
            ?: error("Deep Learning artifact empty")
}

@Entity(tableName = "prediction_journal")
data class PredictionJournalEntity(
    @PrimaryKey val predictionId: String,
    val modelVersion: String,
    val market: String,
    val timestamp: Long,
    val featuresJson: String,
    val predictionJson: String,
    val confidence: Double,
    val decision: String,
    val reason: String,
    val source: String = LearningDataSource.PAPER.name,
    val tradeId: String? = null,
    val expectedReturn5m: Double = 0.0,
    val outcomeReturn5m: Double? = null,
    val outcomeReturn15m: Double? = null,
    val predictionError5m: Double? = null,
    val resolvedAt: Long? = null
)

===== END FILE: app/src/main/java/com/example/bithumbtrader/ContinuousLearning.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/Database.kt =====

package com.example.bithumbtrader

import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import kotlinx.coroutines.flow.Flow

@Dao interface TradingDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertTrade(v: TradeEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertOrder(v: OrderEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPosition(v: PositionEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertBalance(v: BalanceSnapshotEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertDaily(v: DailyPerformanceEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertSignal(v: StrategySignalEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertEvent(v: AppEventEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertSetting(v: SettingEntity)
    @Query("SELECT * FROM trades ORDER BY time DESC LIMIT 200") fun trades(): Flow<List<TradeEntity>>
    @Query("SELECT * FROM app_events ORDER BY time DESC LIMIT 300") fun events(): Flow<List<AppEventEntity>>
    @Query("SELECT * FROM positions") fun positions(): Flow<List<PositionEntity>>
    @Query("SELECT * FROM positions WHERE quantity > 0 AND mode = :mode") suspend fun currentPositions(mode: String): List<PositionEntity>
    @Query("SELECT * FROM orders WHERE state NOT IN ('FILLED','CANCELED','FAILED')") suspend fun openOrders(): List<OrderEntity>
    @Query("SELECT * FROM settings") suspend fun settings(): List<SettingEntity>
    @Query("SELECT COALESCE(SUM(realizedPnl), 0.0) FROM trades WHERE side = 'SELL' AND mode = 'PAPER'") suspend fun realizedPnl(): Double
    @Query("SELECT COALESCE(SUM(realizedPnl), 0.0) FROM trades WHERE side = 'SELL' AND mode = 'PAPER' AND time >= :startOfDay") suspend fun todayRealizedPnl(startOfDay: Long): Double
    @Query("DELETE FROM positions WHERE mode = 'PAPER'") suspend fun clearPaperPositions()
    @Query("DELETE FROM orders WHERE mode = 'PAPER'") suspend fun clearPaperOrders()
    @Query("DELETE FROM trades WHERE mode = 'PAPER'") suspend fun clearPaperTrades()
    @Query("SELECT * FROM trades WHERE market = :market AND side = 'SELL' ORDER BY time DESC LIMIT 1") suspend fun latestSellTrade(market: String): TradeEntity?
    @Query("SELECT * FROM trades WHERE market = :market AND side = 'BUY' AND mode = :mode ORDER BY time DESC LIMIT 1") suspend fun latestBuyTrade(market: String, mode: String = "PAPER"): TradeEntity?
    @Query("SELECT * FROM trades WHERE side = 'SELL' AND mode = :mode ORDER BY time ASC") suspend fun sellTradesByMode(mode: String): List<TradeEntity>
    @Query("SELECT * FROM trades WHERE mode = :mode ORDER BY time ASC") suspend fun tradesByMode(mode: String): List<TradeEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPredictionJournal(v: PredictionJournalEntity)
    @Query("SELECT * FROM prediction_journal ORDER BY timestamp DESC LIMIT 1000") suspend fun recentPredictionJournals(): List<PredictionJournalEntity>
    @Query("SELECT COUNT(*) FROM prediction_journal") suspend fun predictionJournalCount(): Int
    @Query("SELECT * FROM prediction_journal WHERE predictionId = :predictionId LIMIT 1") suspend fun predictionJournal(predictionId: String): PredictionJournalEntity?
    @Query("SELECT * FROM prediction_journal WHERE tradeId = :tradeId ORDER BY timestamp DESC") suspend fun predictionJournalsForTrade(tradeId: String): List<PredictionJournalEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRecommendation(v: StrategyRecommendationEntity)
    @Query("SELECT * FROM strategy_recommendations ORDER BY createdAt DESC LIMIT :limit") suspend fun recentRecommendations(limit: Int = 10): List<StrategyRecommendationEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAiSample(v: AiTrainingSampleEntity)
    @Query("SELECT * FROM ai_training_samples WHERE market = :market AND outcomeLabel IS NULL AND buyTradeId IS NOT NULL ORDER BY time DESC LIMIT 1") suspend fun pendingAiSample(market: String): AiTrainingSampleEntity?
    @Query("SELECT * FROM ai_training_samples WHERE outcomeLabel IS NOT NULL AND source = 'PAPER' ORDER BY time ASC") suspend fun resolvedAiSamples(): List<AiTrainingSampleEntity>
    @Query("SELECT * FROM ai_training_samples WHERE lookaheadSafe = 1 ORDER BY time ASC") suspend fun continuousLearningSamples(): List<AiTrainingSampleEntity>
    @Query("SELECT COUNT(*) FROM ai_training_samples") suspend fun totalAiSampleCount(): Int
    @Query("SELECT COUNT(*) FROM ai_training_samples WHERE outcomeLabel IS NOT NULL") suspend fun resolvedAiSampleCount(): Int
    @Query("SELECT COUNT(*) FROM ai_training_samples WHERE source = :source") suspend fun aiSampleCountBySource(source: String): Int
    @Query("SELECT COUNT(*) FROM ai_training_samples WHERE hardExample = 1") suspend fun hardExampleCount(): Int

    @Insert suspend fun insertModelVersion(v: StrategyModelVersionEntity): Long
    @Query("SELECT * FROM strategy_model_versions ORDER BY id DESC LIMIT :limit") suspend fun recentModelVersions(limit: Int = 10): List<StrategyModelVersionEntity>
    @Query("SELECT * FROM strategy_model_versions WHERE adopted = 1 AND source = 'LOCAL_RETRAIN' ORDER BY id DESC LIMIT 1") suspend fun latestAdoptedLocalModel(): StrategyModelVersionEntity?
    @Query("SELECT * FROM strategy_model_versions WHERE source = 'CONTINUOUS_DEEP_LEARNING' AND reason LIKE 'PROMOTION_CANDIDATE%' ORDER BY id DESC LIMIT 1") suspend fun latestContinuousCandidateModel(): StrategyModelVersionEntity?

    @Query("SELECT * FROM daily_performance ORDER BY yyyymmdd DESC LIMIT 30") fun dailyPerformanceFlow(): Flow<List<DailyPerformanceEntity>>
    @Query("SELECT * FROM balance_snapshots ORDER BY time DESC LIMIT 200") fun balanceSnapshotsFlow(): Flow<List<BalanceSnapshotEntity>>
    @Query("SELECT * FROM balance_snapshots ORDER BY time ASC LIMIT 200") suspend fun recentBalanceSnapshots(): List<BalanceSnapshotEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRegimeSnapshot(v: MarketRegimeEntity)
    @Query("SELECT * FROM market_regime_history ORDER BY time DESC LIMIT 10") suspend fun recentRegimeHistory(): List<MarketRegimeEntity>
    @Query("SELECT * FROM ai_training_samples WHERE outcomeLabel IS NOT NULL AND source = 'PAPER' AND marketRegime = :regime ORDER BY time ASC") suspend fun resolvedAiSamplesByRegime(regime: String): List<AiTrainingSampleEntity>
    @Query("SELECT message FROM app_events WHERE time >= :since ORDER BY time DESC LIMIT 200") suspend fun recentEventMessagesSince(since: Long): List<String>

    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertCrashEvent(v: CrashEventEntity)
    @Query("UPDATE crash_events SET endedAt = :endedAt, minHealthScore = :minHealthScore, resolvedTotalValue = :resolvedTotalValue WHERE id = :id") suspend fun finalizeCrashEvent(id: String, endedAt: Long, minHealthScore: Double, resolvedTotalValue: Double)
    @Query("SELECT * FROM crash_events ORDER BY startedAt DESC LIMIT 10") suspend fun recentCrashEvents(): List<CrashEventEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRegimePerformance(v: RegimeStrategyPerformanceEntity)
    @Query("SELECT * FROM regime_strategy_performance WHERE regime = :regime") suspend fun regimePerformances(regime: String): List<RegimeStrategyPerformanceEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRegimeTransition(v: RegimeTransitionEntity)
    @Query("SELECT * FROM regime_transitions ORDER BY time DESC LIMIT 10") suspend fun recentRegimeTransitions(): List<RegimeTransitionEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertStrategyPromotion(v: StrategyPromotionEntity)
    @Query("SELECT * FROM strategy_promotions ORDER BY createdAt DESC LIMIT 10") suspend fun recentStrategyPromotions(): List<StrategyPromotionEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertConfidenceCalibration(v: ConfidenceCalibrationEntity)
    @Query("SELECT * FROM confidence_calibration ORDER BY createdAt DESC LIMIT 500") suspend fun confidenceCalibrations(): List<ConfidenceCalibrationEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertNewsEvent(v: NewsEventEntity)
    @Query("SELECT * FROM news_events WHERE receivedAt >= :since ORDER BY publishedAt DESC LIMIT 200") suspend fun recentNewsEvents(since: Long): List<NewsEventEntity>
    @Query("SELECT * FROM news_events ORDER BY publishedAt DESC LIMIT 200") suspend fun latestNewsEvents(): List<NewsEventEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertLiquidityScan(v: LiquidityScanEntity)
    @Query("SELECT * FROM liquidity_scan_diagnostics ORDER BY time DESC LIMIT 1") suspend fun latestLiquidityScan(): LiquidityScanEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertReentryGuard(v: MarketReentryGuardEntity)
    @Query("SELECT * FROM market_reentry_guards") suspend fun allReentryGuards(): List<MarketReentryGuardEntity>
    @Query("SELECT * FROM market_reentry_guards WHERE market = :market LIMIT 1") suspend fun reentryGuard(market: String): MarketReentryGuardEntity?
    @Query("DELETE FROM market_reentry_guards") suspend fun clearReentryGuards()
    @Query("SELECT * FROM news_events WHERE fingerprint = :fingerprint LIMIT 1") suspend fun newsByFingerprint(fingerprint: String): NewsEventEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertNewsReaction(v: NewsReactionEntity)
    @Query("SELECT * FROM news_reactions ORDER BY capturedAt DESC LIMIT 1000") suspend fun allNewsReactions(): List<NewsReactionEntity>
    @Query("SELECT * FROM news_reactions WHERE eventId = :eventId") suspend fun newsReactions(eventId: String): List<NewsReactionEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertNewsPrediction(v: NewsPredictionEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertNewsModel(v: NewsModelRegistryEntity)
    @Query("SELECT * FROM news_model_registry ORDER BY createdAt DESC LIMIT 20") suspend fun newsModels(): List<NewsModelRegistryEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertNewsDrift(v: NewsDriftEventEntity)
    @Query("SELECT * FROM news_drift_events ORDER BY createdAt DESC LIMIT 20") suspend fun newsDrifts(): List<NewsDriftEventEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertResearchHypothesis(v: ResearchHypothesisEntity)
    @Query("SELECT * FROM research_hypotheses ORDER BY createdAt DESC LIMIT 20") suspend fun researchHypotheses(): List<ResearchHypothesisEntity>
    @Query("SELECT COUNT(*) FROM research_hypotheses") suspend fun researchHypothesisCount(): Int

    @Query("SELECT * FROM shadow_trades WHERE side = 'SELL' AND regime = :regime ORDER BY time ASC") suspend fun shadowSellTradesByRegime(regime: String): List<ShadowTradeEntity>
    @Query("SELECT * FROM post_entry_snapshots ORDER BY capturedAt ASC") suspend fun postEntrySnapshotsForResearch(): List<PostEntrySnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertOpportunity(v: OpportunityDecisionEntity)
    @Query("SELECT * FROM opportunity_decisions ORDER BY time DESC LIMIT :limit") suspend fun recentOpportunities(limit: Int = 10): List<OpportunityDecisionEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPostEntryTracker(v: PostEntryTrackerEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPostEntrySnapshot(v: PostEntrySnapshotEntity)
    @Query("SELECT COUNT(*) FROM post_entry_snapshots WHERE buyTradeId = :buyTradeId AND horizonMinutes = :horizon") suspend fun postEntrySnapshotExists(buyTradeId: String, horizon: Int): Int
    @Query("SELECT * FROM post_entry_trackers") suspend fun postEntryTrackers(): List<PostEntryTrackerEntity>
    @Query("SELECT * FROM post_entry_snapshots") suspend fun allPostEntrySnapshots(): List<PostEntrySnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertShadowPortfolio(v: ShadowPortfolioEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertShadowTrade(v: ShadowTradeEntity)
    @Query("SELECT * FROM shadow_portfolios") suspend fun shadowPortfolios(): List<ShadowPortfolioEntity>
    @Query("SELECT * FROM shadow_trades ORDER BY time DESC LIMIT 500") suspend fun recentShadowTrades(): List<ShadowTradeEntity>
    @Query("SELECT COUNT(*) FROM shadow_trades") suspend fun shadowTradeCount(): Int
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertMissedOpportunity(v: MissedOpportunityEntity)
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertMissedSnapshot(v: MissedOpportunitySnapshotEntity)
    @Query("SELECT COUNT(*) FROM missed_opportunity_snapshots WHERE opportunityId = :id AND horizonMinutes = :horizon") suspend fun missedSnapshotExists(id: String, horizon: Int): Int
    @Query("SELECT * FROM missed_opportunities WHERE status = 'PENDING' ORDER BY capturedAt ASC") suspend fun pendingMissedOpportunities(): List<MissedOpportunityEntity>
    @Query("SELECT * FROM missed_opportunities WHERE market = :market AND reason = :reason AND status = 'PENDING' ORDER BY capturedAt DESC LIMIT 1") suspend fun latestPendingMissed(market: String, reason: String): MissedOpportunityEntity?
    @Query("SELECT * FROM missed_opportunities ORDER BY capturedAt DESC LIMIT :limit") suspend fun recentMissedOpportunities(limit: Int = 200): List<MissedOpportunityEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertExecutionQuality(v: ExecutionQualitySnapshotEntity)
    @Query("SELECT * FROM execution_quality_snapshots ORDER BY time DESC LIMIT 200") suspend fun recentExecutionQuality(): List<ExecutionQualitySnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertNetEdgeDecision(v: NetEdgeDecisionEntity)
    @Query("SELECT * FROM net_edge_decisions ORDER BY time DESC LIMIT 200") suspend fun recentNetEdgeDecisions(): List<NetEdgeDecisionEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPortfolioRiskSnapshot(v: PortfolioRiskSnapshotEntity)
    @Query("SELECT * FROM portfolio_risk_snapshots ORDER BY time DESC LIMIT 1") suspend fun latestPortfolioRiskSnapshot(): PortfolioRiskSnapshotEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertDataQualityEvent(v: DataQualityEventEntity)
    @Query("SELECT * FROM data_quality_events ORDER BY time DESC LIMIT 50") suspend fun recentDataQualityEvents(): List<DataQualityEventEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertCapitalGrowthSnapshot(v: CapitalGrowthSnapshotEntity)
    @Query("SELECT * FROM capital_growth_snapshots ORDER BY time DESC LIMIT 1") suspend fun latestCapitalGrowthSnapshot(): CapitalGrowthSnapshotEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPostExitTracker(v: PostExitTrackerEntity)
    @Query("SELECT * FROM post_exit_trackers") suspend fun allPostExitTrackers(): List<PostExitTrackerEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertPostExitSnapshot(v: PostExitSnapshotEntity)
    @Query("SELECT COUNT(*) FROM post_exit_snapshots WHERE sellTradeId = :sellTradeId AND horizonMinutes = :horizon") suspend fun postExitSnapshotExists(sellTradeId: String, horizon: Int): Int
    @Query("SELECT * FROM post_exit_snapshots") suspend fun allPostExitSnapshots(): List<PostExitSnapshotEntity>
    @Query("SELECT * FROM post_exit_snapshots WHERE sellTradeId = :sellTradeId") suspend fun postExitSnapshotsForTrade(sellTradeId: String): List<PostExitSnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertLossRootCause(v: LossRootCauseRecordEntity)
    @Query("SELECT * FROM loss_root_cause_records") suspend fun allLossRootCauses(): List<LossRootCauseRecordEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertCounterfactualExitSnapshot(v: CounterfactualExitSnapshotEntity)
    @Query("SELECT * FROM counterfactual_exit_snapshots") suspend fun allCounterfactualExitSnapshots(): List<CounterfactualExitSnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertEntryDiagnostic(v: EntryDiagnosticEntity)
    @Query("SELECT * FROM entry_diagnostics ORDER BY time DESC LIMIT 1000") suspend fun recentEntryDiagnostics(): List<EntryDiagnosticEntity>
    @Query("SELECT * FROM entry_diagnostics WHERE tradeId = :tradeId ORDER BY time DESC LIMIT 1") suspend fun entryDiagnosticByTradeId(tradeId: String): EntryDiagnosticEntity?
    @Query("SELECT * FROM entry_diagnostics WHERE market = :market ORDER BY time DESC LIMIT 1") suspend fun latestEntryDiagnostic(market: String): EntryDiagnosticEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertScalpingDiagnostic(v: ScalpingExecutionDiagnosticEntity)
    @Query("SELECT * FROM scalping_execution_diagnostics ORDER BY time DESC LIMIT 1000") suspend fun recentScalpingDiagnostics(): List<ScalpingExecutionDiagnosticEntity>
    @Query("SELECT COUNT(*) FROM scalping_execution_diagnostics") suspend fun scalpingDiagnosticCount(): Int
    @Query("SELECT * FROM scalping_execution_diagnostics WHERE tradeId = :tradeId ORDER BY time DESC LIMIT 1") suspend fun scalpingDiagnosticByTradeId(tradeId: String): ScalpingExecutionDiagnosticEntity?
    @Query("SELECT * FROM scalping_execution_diagnostics WHERE market = :market ORDER BY time DESC LIMIT 1") suspend fun latestScalpingDiagnostic(market: String): ScalpingExecutionDiagnosticEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertScalpingModel(v: ScalpingExecutionModelEntity)
    @Query("SELECT * FROM scalping_execution_models ORDER BY createdAt DESC") suspend fun scalpingModels(): List<ScalpingExecutionModelEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertScalpingShadow(v: ScalpingShadowAccountEntity)
    @Query("SELECT * FROM scalping_shadow_accounts WHERE accountId = 'SCALPING_EXECUTION' LIMIT 1") suspend fun scalpingShadow(): ScalpingShadowAccountEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertDerivativesSnapshot(v: DerivativesSnapshotEntity)
    @Query("SELECT * FROM derivatives_snapshots ORDER BY timestamp DESC LIMIT 500") suspend fun recentDerivativesSnapshots(): List<DerivativesSnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertSmartReentryState(v: SmartReentryStateEntity)
    @Query("SELECT * FROM smart_reentry_states") suspend fun allSmartReentryStates(): List<SmartReentryStateEntity>
    @Query("SELECT COUNT(*) FROM smart_reentry_states") suspend fun smartReentryStateCount(): Int
    @Query("SELECT * FROM smart_reentry_states WHERE market = :market LIMIT 1") suspend fun smartReentryState(market: String): SmartReentryStateEntity?
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertSmartReentryAttempt(v: SmartReentryAttemptEntity)
    @Query("SELECT * FROM smart_reentry_attempts ORDER BY time DESC LIMIT 1000") suspend fun recentSmartReentryAttempts(): List<SmartReentryAttemptEntity>
    @Query("SELECT * FROM smart_reentry_attempts WHERE signalId = :signalId ORDER BY time DESC LIMIT 1") suspend fun smartReentryAttemptBySignal(signalId: String): SmartReentryAttemptEntity?
    @Query("DELETE FROM smart_reentry_states") suspend fun clearSmartReentryStates()
    @Query("DELETE FROM smart_reentry_attempts") suspend fun clearSmartReentryAttempts()
    @Query("DELETE FROM ai_training_samples") suspend fun clearAiTrainingSamples()
    @Query("DELETE FROM prediction_journal") suspend fun clearPredictionJournal()
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRegimeStrategySetSnapshot(v: RegimeStrategySetSnapshotEntity)
    @Query("SELECT * FROM regime_strategy_set_snapshots ORDER BY createdAt DESC LIMIT :limit") suspend fun recentRegimeStrategySetSnapshots(limit: Int = 50): List<RegimeStrategySetSnapshotEntity>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRegimeAccuracySample(v: RegimeAccuracySampleEntity)
    @Query("SELECT * FROM regime_accuracy_samples ORDER BY createdAt DESC LIMIT :limit") suspend fun recentRegimeAccuracySamples(limit: Int = 500): List<RegimeAccuracySampleEntity>
    @Query("SELECT COUNT(*) FROM regime_accuracy_samples") suspend fun regimeAccuracySampleCount(): Int
}

object Migration20To21Sql {
    val statements = listOf(
        "ALTER TABLE `ai_training_samples` ADD COLUMN `source` TEXT NOT NULL DEFAULT 'PAPER'",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn30s` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn1m` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn3m` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn5m` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn15m` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn30m` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `futureReturn60m` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `mfePercent` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `maePercent` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `immediateDrawdown` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `stopHit` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `takeProfitHit` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `goodEntry` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `chaseEntry` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `profitableAfterCost` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `netEdgeRealized` REAL",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `hardExample` INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE `ai_training_samples` ADD COLUMN `lookaheadSafe` INTEGER NOT NULL DEFAULT 1",
        "CREATE TABLE IF NOT EXISTS `prediction_journal` (`predictionId` TEXT NOT NULL, `modelVersion` TEXT NOT NULL, `market` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `featuresJson` TEXT NOT NULL, `predictionJson` TEXT NOT NULL, `confidence` REAL NOT NULL, `decision` TEXT NOT NULL, `reason` TEXT NOT NULL, `source` TEXT NOT NULL, `tradeId` TEXT, `expectedReturn5m` REAL NOT NULL DEFAULT 0.0, `outcomeReturn5m` REAL, `outcomeReturn15m` REAL, `predictionError5m` REAL, `resolvedAt` INTEGER, PRIMARY KEY(`predictionId`))"
    )
}

object Migration21To22Sql {
    val statements = listOf(
        "CREATE TABLE IF NOT EXISTS `regime_strategy_set_snapshots` (`id` TEXT NOT NULL, `regime` TEXT NOT NULL, `setId` TEXT NOT NULL, `applied` INTEGER NOT NULL, `recommendOnly` INTEGER NOT NULL, `noNewEntries` INTEGER NOT NULL, `scoreThreshold` REAL NOT NULL, `stopLossPercent` REAL NOT NULL, `takeProfitPercent` REAL NOT NULL, `trailingStopPercent` REAL NOT NULL, `maxPositions` INTEGER NOT NULL, `maxOrderPercent` REAL NOT NULL, `reason` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
        "CREATE TABLE IF NOT EXISTS `regime_accuracy_samples` (`id` TEXT NOT NULL, `predictedRegime` TEXT NOT NULL, `confidence` REAL NOT NULL, `forwardAverageReturnPercent` REAL NOT NULL, `horizonMinutes` INTEGER NOT NULL, `correct` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))"
    )
}

@Database(entities=[TradeEntity::class, OrderEntity::class, PositionEntity::class, BalanceSnapshotEntity::class, DailyPerformanceEntity::class, StrategySignalEntity::class, AppEventEntity::class, SettingEntity::class, AiTrainingSampleEntity::class, StrategyModelVersionEntity::class, StrategyRecommendationEntity::class, MarketRegimeEntity::class, CrashEventEntity::class, RegimeStrategyPerformanceEntity::class, RegimeTransitionEntity::class, StrategyPromotionEntity::class, ConfidenceCalibrationEntity::class, NewsEventEntity::class, NewsReactionEntity::class, NewsPredictionEntity::class, NewsModelRegistryEntity::class, NewsDriftEventEntity::class, ResearchHypothesisEntity::class, LiquidityScanEntity::class, MarketReentryGuardEntity::class, OpportunityDecisionEntity::class, PostEntryTrackerEntity::class, PostEntrySnapshotEntity::class, ShadowPortfolioEntity::class, ShadowTradeEntity::class, MissedOpportunityEntity::class, MissedOpportunitySnapshotEntity::class, ExecutionQualitySnapshotEntity::class, NetEdgeDecisionEntity::class, PortfolioRiskSnapshotEntity::class, DataQualityEventEntity::class, CapitalGrowthSnapshotEntity::class, PostExitTrackerEntity::class, PostExitSnapshotEntity::class, LossRootCauseRecordEntity::class, CounterfactualExitSnapshotEntity::class, EntryDiagnosticEntity::class, ScalpingExecutionDiagnosticEntity::class, ScalpingExecutionModelEntity::class, ScalpingShadowAccountEntity::class, DerivativesSnapshotEntity::class, SmartReentryStateEntity::class, SmartReentryAttemptEntity::class, PredictionJournalEntity::class, RegimeStrategySetSnapshotEntity::class, RegimeAccuracySampleEntity::class], version=AppDatabase.SCHEMA_VERSION, exportSchema=false)
abstract class AppDatabase: RoomDatabase() { abstract fun dao(): TradingDao
    companion object {
        const val SCHEMA_VERSION = 22
        @Volatile private var instance: AppDatabase? = null
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("ALTER TABLE trades ADD COLUMN mode TEXT NOT NULL DEFAULT 'PAPER'")
                db.execSQL("ALTER TABLE orders ADD COLUMN mode TEXT NOT NULL DEFAULT 'PAPER'")
            }
        }
        private val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("ALTER TABLE positions ADD COLUMN mode TEXT NOT NULL DEFAULT 'PAPER'")
            }
        }
        private val MIGRATION_3_4 = object : Migration(3, 4) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `ai_training_samples` (
                        `id` TEXT NOT NULL,
                        `market` TEXT NOT NULL,
                        `time` INTEGER NOT NULL,
                        `featuresJson` TEXT NOT NULL,
                        `strategyScore` REAL NOT NULL,
                        `aiScoreAtCapture` REAL NOT NULL,
                        `buyTradeId` TEXT,
                        `outcomeLabel` REAL,
                        `realizedPnlRate` REAL,
                        `resolvedAt` INTEGER,
                        PRIMARY KEY(`id`)
                    )""".trimIndent()
                )
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `strategy_model_versions` (
                        `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                        `generation` INTEGER NOT NULL,
                        `source` TEXT NOT NULL,
                        `createdAt` INTEGER NOT NULL,
                        `trainingSamples` INTEGER NOT NULL,
                        `validationSamples` INTEGER NOT NULL,
                        `validationAccuracy` REAL NOT NULL,
                        `baselineAccuracy` REAL NOT NULL,
                        `adopted` INTEGER NOT NULL,
                        `reason` TEXT NOT NULL,
                        `modelJson` TEXT NOT NULL
                    )""".trimIndent()
                )
            }
        }
        private val MIGRATION_4_5 = object : Migration(4, 5) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `strategy_recommendations` (
                        `id` TEXT NOT NULL,
                        `createdAt` INTEGER NOT NULL,
                        `stage` TEXT NOT NULL,
                        `reason` TEXT NOT NULL,
                        `scoreThresholdDelta` REAL NOT NULL,
                        `stopLossDelta` REAL NOT NULL,
                        `takeProfitDelta` REAL NOT NULL,
                        `trailingStopDelta` REAL NOT NULL,
                        `maxPositionsDelta` INTEGER NOT NULL,
                        `baselineSampleCount` INTEGER NOT NULL,
                        `baselineWinRate` REAL NOT NULL,
                        `baselineProfitFactor` REAL NOT NULL,
                        `baselineMaxDrawdown` REAL NOT NULL,
                        `baselineExpectedReturn` REAL NOT NULL,
                        `backtestSampleCount` INTEGER NOT NULL,
                        `backtestWinRate` REAL NOT NULL,
                        `backtestProfitFactor` REAL NOT NULL,
                        `paperTrialSampleCount` INTEGER NOT NULL,
                        `paperTrialWinRate` REAL NOT NULL,
                        `paperTrialProfitFactor` REAL NOT NULL,
                        `resolvedAt` INTEGER,
                        PRIMARY KEY(`id`)
                    )""".trimIndent()
                )
            }
        }
        private val MIGRATION_5_6 = object : Migration(5, 6) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("ALTER TABLE `ai_training_samples` ADD COLUMN `marketRegime` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `market_regime_history` (
                        `time` INTEGER NOT NULL,
                        `regime` TEXT NOT NULL,
                        `breadthPositive` REAL NOT NULL,
                        `averageChangeRatePercent` REAL NOT NULL,
                        `sampleCount` INTEGER NOT NULL,
                        PRIMARY KEY(`time`)
                    )""".trimIndent()
                )
            }
        }
        private val MIGRATION_6_7 = object : Migration(6, 7) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `crash_events` (
                        `id` TEXT NOT NULL,
                        `startedAt` INTEGER NOT NULL,
                        `endedAt` INTEGER,
                        `minHealthScore` REAL NOT NULL,
                        `regimeAtStart` TEXT NOT NULL,
                        `averageChangeRatePercent` REAL NOT NULL,
                        `breadthPositive` REAL NOT NULL,
                        `reasons` TEXT NOT NULL,
                        `holdingCountAtStart` INTEGER NOT NULL,
                        `totalValueAtStart` REAL NOT NULL,
                        `resolvedTotalValue` REAL,
                        PRIMARY KEY(`id`)
                    )""".trimIndent()
                )
            }
        }
        private val MIGRATION_8_9 = object : Migration(8, 9) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `confidence` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `trendStrength` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `volatilityLevel` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `shortRegime` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `midRegime` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `longRegime` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL("ALTER TABLE `market_regime_history` ADD COLUMN `durationMinutes` INTEGER NOT NULL DEFAULT 0")
                db.execSQL("ALTER TABLE `shadow_trades` ADD COLUMN `regime` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL("CREATE TABLE IF NOT EXISTS `regime_strategy_performance` (`id` TEXT NOT NULL, `strategy` TEXT NOT NULL, `regime` TEXT NOT NULL, `sampleCount` INTEGER NOT NULL, `winRate` REAL NOT NULL, `profitFactor` REAL NOT NULL, `expectancy` REAL NOT NULL, `maxDrawdownPercent` REAL NOT NULL, `averagePnlRate` REAL NOT NULL, `averageHoldingMinutes` REAL NOT NULL, `averageMfe` REAL NOT NULL, `averageMae` REAL NOT NULL, `riskAdjustedReturn` REAL NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `regime_transitions` (`id` TEXT NOT NULL, `fromRegime` TEXT NOT NULL, `toRegime` TEXT NOT NULL, `time` INTEGER NOT NULL, `confidence` REAL NOT NULL, `healthScore` REAL NOT NULL, `return5m` REAL, `return15m` REAL, `return30m` REAL, `return60m` REAL, `return180m` REAL, `evaluatedAt` INTEGER, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `strategy_promotions` (`id` TEXT NOT NULL, `champion` TEXT NOT NULL, `challenger` TEXT NOT NULL, `status` TEXT NOT NULL, `reason` TEXT NOT NULL, `championScore` REAL NOT NULL, `challengerScore` REAL NOT NULL, `sampleCount` INTEGER NOT NULL, `walkForwardPassed` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `confidence_calibration` (`id` TEXT NOT NULL, `regime` TEXT NOT NULL, `confidenceBand` TEXT NOT NULL, `predictedConfidence` REAL NOT NULL, `correct` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))")
            }
        }

        private val MIGRATION_14_15 = object : Migration(14, 15) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `post_exit_trackers` (`sellTradeId` TEXT NOT NULL, `market` TEXT NOT NULL, `sellTime` INTEGER NOT NULL, `entryPrice` REAL NOT NULL, `exitPrice` REAL NOT NULL, `exitReason` TEXT NOT NULL, `pnlRate` REAL NOT NULL, `mfePercent` REAL NOT NULL, `maePercent` REAL NOT NULL, `entryScore` REAL NOT NULL, `aiScore` REAL NOT NULL, `netEdge` REAL NOT NULL, `regime` TEXT NOT NULL, `marketHealth` REAL NOT NULL, PRIMARY KEY(`sellTradeId`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `post_exit_snapshots` (`sellTradeId` TEXT NOT NULL, `market` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `horizonMinutes` INTEGER NOT NULL, `exitPrice` REAL NOT NULL, `currentPrice` REAL NOT NULL, `changeFromExitPercent` REAL NOT NULL, `priceFromEntryPercent` REAL NOT NULL, PRIMARY KEY(`sellTradeId`, `horizonMinutes`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `loss_root_cause_records` (`tradeId` TEXT NOT NULL, `market` TEXT NOT NULL, `exitTime` INTEGER NOT NULL, `rootCause` TEXT NOT NULL, `stopQuality` TEXT NOT NULL, `trailingQuality` TEXT NOT NULL, `mfePercent` REAL NOT NULL, `maePercent` REAL NOT NULL, `pnlRate` REAL NOT NULL, `exitReason` TEXT NOT NULL, `postExit30mReturn` REAL, `postExit60mReturn` REAL, PRIMARY KEY(`tradeId`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `counterfactual_exit_snapshots` (`id` TEXT NOT NULL, `tradeId` TEXT NOT NULL, `strategyType` TEXT NOT NULL, `simulatedPnlRate` REAL NOT NULL, `simulatedExitReason` TEXT NOT NULL, `simulatedHoldingMinutes` INTEGER NOT NULL, `capturedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))")
            }
        }

        private val MIGRATION_15_16 = object : Migration(15, 16) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `entry_diagnostics` (
                        `id` TEXT NOT NULL,
                        `market` TEXT NOT NULL,
                        `time` INTEGER NOT NULL,
                        `tradeId` TEXT,
                        `strategyScore` REAL NOT NULL,
                        `aiScore` REAL NOT NULL,
                        `entryTimingScore` REAL NOT NULL,
                        `chaseScore` REAL NOT NULL,
                        `state` TEXT NOT NULL,
                        `pullbackState` TEXT NOT NULL,
                        `retestState` TEXT NOT NULL,
                        `overextensionAtr` REAL NOT NULL,
                        `overextensionScore` REAL NOT NULL,
                        `parabolicMove` INTEGER NOT NULL,
                        `momentumExhaustion` INTEGER NOT NULL,
                        `volumeClimax` INTEGER NOT NULL,
                        `scoreVelocityPerMinute` REAL NOT NULL,
                        `signalLagMs` INTEGER NOT NULL,
                        `preEntry1mReturn` REAL NOT NULL,
                        `preEntry3mReturn` REAL NOT NULL,
                        `preEntry5mReturn` REAL NOT NULL,
                        `preEntry10mReturn` REAL NOT NULL,
                        `preEntry15mReturn` REAL NOT NULL,
                        `first5mReturn` REAL,
                        `first15mReturn` REAL,
                        `mfePercent` REAL,
                        `maePercent` REAL,
                        `exitReason` TEXT,
                        `pnlRate` REAL,
                        `classification` TEXT NOT NULL,
                        `decision` TEXT NOT NULL,
                        `reason` TEXT NOT NULL,
                        PRIMARY KEY(`id`)
                    )""".trimIndent()
                )
            }
        }

        private val MIGRATION_16_17 = object : Migration(16, 17) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL(
                    """CREATE TABLE IF NOT EXISTS `scalping_execution_diagnostics` (
                        `id` TEXT NOT NULL,
                        `market` TEXT NOT NULL,
                        `time` INTEGER NOT NULL,
                        `tradeId` TEXT,
                        `entryPrice` REAL NOT NULL,
                        `strategyScore` REAL NOT NULL,
                        `aiScore` REAL NOT NULL,
                        `entryTimingScore` REAL NOT NULL,
                        `chaseScore` REAL NOT NULL,
                        `executionScore` REAL NOT NULL,
                        `executionConfidence` REAL NOT NULL,
                        `state` TEXT NOT NULL,
                        `marketState` TEXT NOT NULL,
                        `momentumState` TEXT NOT NULL,
                        `volatilityRegime` TEXT NOT NULL,
                        `return30s` REAL NOT NULL,
                        `return1m` REAL NOT NULL,
                        `return3m` REAL NOT NULL,
                        `return5m` REAL NOT NULL,
                        `return15m` REAL,
                        `volumeAcceleration` REAL NOT NULL,
                        `spreadPercent` REAL NOT NULL,
                        `orderbookImbalance` REAL NOT NULL,
                        `depthChangePercent` REAL NOT NULL,
                        `shortNetEdge` REAL NOT NULL,
                        `recommendedHorizonSeconds` INTEGER NOT NULL,
                        `executionCostPercent` REAL NOT NULL,
                        `first5mReturn` REAL,
                        `mfePercent` REAL,
                        `maePercent` REAL,
                        `exitReason` TEXT,
                        `netPnl` REAL,
                        `outcome` TEXT,
                        `decision` TEXT NOT NULL,
                        `reasonCodes` TEXT NOT NULL,
                        PRIMARY KEY(`id`)
                    )""".trimIndent()
                )
            }
        }

        private val MIGRATION_17_18 = object : Migration(17, 18) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `volume10s` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `volume30s` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `volume1m` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `momentum` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `momentumSlope` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `momentumAcceleration` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `rsi` REAL NOT NULL DEFAULT 50.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `emaDistancePercent` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `breakoutDistancePercent` REAL NOT NULL DEFAULT 0.0")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `pullbackState` TEXT NOT NULL DEFAULT 'NONE'")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `retestState` TEXT NOT NULL DEFAULT 'NONE'")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `marketRegime` TEXT NOT NULL DEFAULT 'UNKNOWN'")
                db.execSQL("ALTER TABLE `scalping_execution_diagnostics` ADD COLUMN `marketHealth` REAL NOT NULL DEFAULT 100.0")
                db.execSQL("CREATE TABLE IF NOT EXISTS `scalping_execution_models` (`modelVersion` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `sampleCount` INTEGER NOT NULL, `validationProfitFactor` REAL NOT NULL, `validationExpectancy` REAL NOT NULL, `validationMdd` REAL NOT NULL, `reason` TEXT NOT NULL, PRIMARY KEY(`modelVersion`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `scalping_shadow_accounts` (`accountId` TEXT NOT NULL, `initialValue` REAL NOT NULL, `cash` REAL NOT NULL, `equity` REAL NOT NULL, `positionMarket` TEXT, `positionPrice` REAL NOT NULL, `positionAmount` REAL NOT NULL, `positionOpenedAt` INTEGER NOT NULL, `tradeCount` INTEGER NOT NULL, `winCount` INTEGER NOT NULL, `grossProfit` REAL NOT NULL, `grossLoss` REAL NOT NULL, `realizedPnl` REAL NOT NULL, `peakEquity` REAL NOT NULL, `maxDrawdownPercent` REAL NOT NULL, `totalFees` REAL NOT NULL, `totalSlippage` REAL NOT NULL, `totalHoldingSeconds` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`accountId`))")
            }
        }

        private val MIGRATION_18_19 = object : Migration(18, 19) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `derivatives_snapshots` (`market` TEXT NOT NULL, `symbol` TEXT, `supportStatus` TEXT NOT NULL, `providerStatus` TEXT NOT NULL, `freshness` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `ageMs` INTEGER NOT NULL, `markPrice` REAL, `indexPrice` REAL, `lastPrice` REAL, `openInterest` REAL, `oiChange1m` REAL, `oiChange5m` REAL, `oiChange15m` REAL, `oiChange1h` REAL, `fundingRate` REAL, `nextFundingTime` INTEGER, `fundingChange` REAL, `fundingPercentile` REAL, `fundingState` TEXT NOT NULL, `longRatio` REAL, `shortRatio` REAL, `derivativesVolume` REAL, `priceChangePercent` REAL, `basis` REAL, `premium` REAL, `longLiquidationIntensity` REAL, `shortLiquidationIntensity` REAL, `liquidationAcceleration` REAL, `liquidationState` TEXT NOT NULL, PRIMARY KEY(`market`, `timestamp`))")
            }
        }

        private val MIGRATION_19_20 = object : Migration(19, 20) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `smart_reentry_states` (`market` TEXT NOT NULL, `status` TEXT NOT NULL, `waveState` TEXT NOT NULL, `exitPrice` REAL NOT NULL, `exitTime` INTEGER NOT NULL, `entryPrice` REAL NOT NULL, `peakPrice` REAL NOT NULL, `realizedProfit` REAL NOT NULL, `realizedProfitPercent` REAL NOT NULL, `exitReason` TEXT NOT NULL, `strategyScoreAtExit` REAL NOT NULL, `aiScoreAtExit` REAL NOT NULL, `regimeAtExit` TEXT NOT NULL, `marketHealthAtExit` REAL NOT NULL, `consumedSignalId` TEXT NOT NULL, `cooldownUntil` INTEGER NOT NULL, `scoreResetObserved` INTEGER NOT NULL, `newSignalValid` INTEGER NOT NULL, `lastSignalId` TEXT NOT NULL, `lastSignalTime` INTEGER NOT NULL, `reentryQualityScore` REAL NOT NULL, `sameWaveProbability` REAL NOT NULL, `newWaveConfidence` REAL NOT NULL, `marketSessionRealizedProfit` REAL NOT NULL, `recentCycleProfit` REAL NOT NULL, `reentryRiskAmount` REAL NOT NULL, `profitGivenBackAmount` REAL NOT NULL, `reentryLossChain` INTEGER NOT NULL, `wasReentry` INTEGER NOT NULL, `roundTrips` INTEGER NOT NULL, `fees` REAL NOT NULL, `slippage` REAL NOT NULL, PRIMARY KEY(`market`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `smart_reentry_attempts` (`id` TEXT NOT NULL, `market` TEXT NOT NULL, `time` INTEGER NOT NULL, `signalId` TEXT NOT NULL, `previousExitPrice` REAL NOT NULL, `previousExitTime` INTEGER NOT NULL, `signalPrice` REAL NOT NULL, `priceVsExitPercent` REAL NOT NULL, `reentryQualityScore` REAL NOT NULL, `sameWaveProbability` REAL NOT NULL, `newWaveConfidence` REAL NOT NULL, `state` TEXT NOT NULL, `decision` TEXT NOT NULL, `reasonCodes` TEXT NOT NULL, `previousProfit` REAL NOT NULL, `isReentry` INTEGER NOT NULL, `resultPnl` REAL, `outcome` TEXT, PRIMARY KEY(`id`))")
            }
        }

        private val MIGRATION_20_21 = object : Migration(20, 21) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                Migration20To21Sql.statements.forEach(db::execSQL)
            }
        }

        private val MIGRATION_21_22 = object : Migration(21, 22) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                Migration21To22Sql.statements.forEach(db::execSQL)
            }
        }

        private val MIGRATION_13_14 = object : Migration(13, 14) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `capital_growth_snapshots` (`time` INTEGER NOT NULL, `totalEquity` REAL NOT NULL, `peakEquity` REAL NOT NULL, `protectedReserve` REAL NOT NULL, `tradingCapital` REAL NOT NULL, `capitalState` TEXT NOT NULL, `growthConfidence` REAL NOT NULL, `riskBudgetKrw` REAL NOT NULL, `positionSizeMultiplier` REAL NOT NULL, `reinvestmentRatio` REAL NOT NULL, `drawdownFromPeakPercent` REAL NOT NULL, `ruinRisk` TEXT NOT NULL, `tradingActive` INTEGER NOT NULL, `reason` TEXT NOT NULL, PRIMARY KEY(`time`))")
            }
        }

        private val MIGRATION_12_13 = object : Migration(12, 13) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `execution_quality_snapshots` (`id` TEXT NOT NULL, `time` INTEGER NOT NULL, `market` TEXT NOT NULL, `side` TEXT NOT NULL, `decisionPrice` REAL NOT NULL, `quotedPrice` REAL NOT NULL, `simulatedAverageFillPrice` REAL NOT NULL, `feeKrw` REAL NOT NULL, `spreadCostPercent` REAL NOT NULL, `slippageCostPercent` REAL NOT NULL, `marketImpactCostPercent` REAL NOT NULL, `grossReturnPercent` REAL NOT NULL, `netReturnPercent` REAL NOT NULL, `executionQualityScore` REAL NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `net_edge_decisions` (`id` TEXT NOT NULL, `time` INTEGER NOT NULL, `market` TEXT NOT NULL, `signalScore` REAL NOT NULL, `grossExpectedEdge` REAL NOT NULL, `executionCost` REAL NOT NULL, `netExpectedEdge` REAL NOT NULL, `confidence` REAL NOT NULL, `sampleCount` INTEGER NOT NULL, `allowed` INTEGER NOT NULL, `reason` TEXT NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `portfolio_risk_snapshots` (`time` INTEGER NOT NULL, `heatLevel` TEXT NOT NULL, `totalOpenRiskPercent` REAL NOT NULL, `portfolioExposurePercent` REAL NOT NULL, `correlatedExposurePercent` REAL NOT NULL, `clusterName` TEXT NOT NULL, `worstCaseLossPercent` REAL NOT NULL, `tailRiskScore` REAL NOT NULL, `var95Percent` REAL NOT NULL, `expectedShortfall95Percent` REAL NOT NULL, PRIMARY KEY(`time`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `data_quality_events` (`id` TEXT NOT NULL, `time` INTEGER NOT NULL, `market` TEXT NOT NULL, `score` REAL NOT NULL, `status` TEXT NOT NULL, `reasons` TEXT NOT NULL, PRIMARY KEY(`id`))")
            }
        }

        private val MIGRATION_11_12 = object : Migration(11, 12) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `market_reentry_guards` (`market` TEXT NOT NULL, `lossStreak` INTEGER NOT NULL, `cooldownUntil` INTEGER NOT NULL, `status` TEXT NOT NULL, `lastExitReason` TEXT NOT NULL, `exitScore` REAL NOT NULL, `exitTime` INTEGER NOT NULL, `signalResetRequired` INTEGER NOT NULL, `scoreResetObserved` INTEGER NOT NULL, PRIMARY KEY(`market`))")
            }
        }

        private val MIGRATION_10_11 = object : Migration(10, 11) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `liquidity_scan_diagnostics` (`time` INTEGER NOT NULL, `total` INTEGER NOT NULL, `pass` INTEGER NOT NULL, `rejectLowTradeValue` INTEGER NOT NULL, `p10` REAL NOT NULL, `p25` REAL NOT NULL, `p50` REAL NOT NULL, `p75` REAL NOT NULL, `p90` REAL NOT NULL, `requiredKrw` REAL NOT NULL, `percentileThreshold` REAL NOT NULL, `overblockingWarning` INTEGER NOT NULL, PRIMARY KEY(`time`))")
            }
        }

        private val MIGRATION_9_10 = object : Migration(9, 10) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `news_events` (`id` TEXT NOT NULL, `source` TEXT NOT NULL, `sourceTier` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `receivedAt` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT NOT NULL, `fingerprint` TEXT NOT NULL, `symbols` TEXT NOT NULL, `eventType` TEXT NOT NULL, `sentiment` REAL NOT NULL, `confidence` REAL NOT NULL, `expectedImpact` REAL NOT NULL, `urgency` REAL NOT NULL, `scope` TEXT NOT NULL, `horizon` TEXT NOT NULL, `status` TEXT NOT NULL, `impactScore` REAL NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `news_reactions` (`eventId` TEXT NOT NULL, `market` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `horizonMinutes` INTEGER NOT NULL, `referencePrice` REAL NOT NULL, `currentPrice` REAL NOT NULL, `changePercent` REAL NOT NULL, `volumeChangePercent` REAL NOT NULL, `spreadPercent` REAL NOT NULL, `volatilityPercent` REAL NOT NULL, `marketRegime` TEXT NOT NULL, `marketHealth` REAL NOT NULL, `mfePercent` REAL NOT NULL, `maePercent` REAL NOT NULL, `predictionCorrect` INTEGER NOT NULL, PRIMARY KEY(`eventId`, `horizonMinutes`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `news_predictions` (`id` TEXT NOT NULL, `eventId` TEXT NOT NULL, `market` TEXT NOT NULL, `predictedDirection` REAL NOT NULL, `expectedImpact` REAL NOT NULL, `actualReturnPercent` REAL, `predictionError` REAL, `resolvedAt` INTEGER, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `news_model_registry` (`modelVersion` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `trainingSampleCount` INTEGER NOT NULL, `trainingWindow` TEXT NOT NULL, `validationWindow` TEXT NOT NULL, `featureNames` TEXT NOT NULL, `performanceJson` TEXT NOT NULL, `mdd` REAL NOT NULL, `profitFactor` REAL NOT NULL, `expectancy` REAL NOT NULL, PRIMARY KEY(`modelVersion`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `news_drift_events` (`id` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `metric` TEXT NOT NULL, `recentValue` REAL NOT NULL, `longTermValue` REAL NOT NULL, `message` TEXT NOT NULL, `status` TEXT NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `research_hypotheses` (`id` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `hypothesis` TEXT NOT NULL, `status` TEXT NOT NULL, `evidenceJson` TEXT NOT NULL, `result` TEXT NOT NULL, PRIMARY KEY(`id`))")
            }
        }

        private val MIGRATION_7_8 = object : Migration(7, 8) {
            override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
                db.execSQL("CREATE TABLE IF NOT EXISTS `opportunity_decisions` (`id` TEXT NOT NULL, `time` INTEGER NOT NULL, `heldMarket` TEXT NOT NULL, `candidateMarket` TEXT, `keepScore` REAL NOT NULL, `rotateScore` REAL NOT NULL, `doNothingScore` REAL NOT NULL, `action` TEXT NOT NULL, `reason` TEXT NOT NULL, `strategyVersion` INTEGER NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `post_entry_trackers` (`buyTradeId` TEXT NOT NULL, `market` TEXT NOT NULL, `entryTime` INTEGER NOT NULL, `entryPrice` REAL NOT NULL, `strategyScore` REAL NOT NULL, `marketHealthAtEntry` REAL NOT NULL, `regimeAtEntry` TEXT NOT NULL, `strategyVersion` INTEGER NOT NULL, `volumeChangePercent` REAL NOT NULL, PRIMARY KEY(`buyTradeId`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `post_entry_snapshots` (`buyTradeId` TEXT NOT NULL, `market` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `horizonMinutes` INTEGER NOT NULL, `entryPrice` REAL NOT NULL, `currentPrice` REAL NOT NULL, `changePercent` REAL NOT NULL, `mfePercent` REAL NOT NULL, `maePercent` REAL NOT NULL, `strategyScore` REAL NOT NULL, `marketHealth` REAL NOT NULL, `volumeChangePercent` REAL NOT NULL, `marketRegime` TEXT NOT NULL, `strategyVersion` INTEGER NOT NULL, PRIMARY KEY(`buyTradeId`, `horizonMinutes`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `shadow_portfolios` (`strategy` TEXT NOT NULL, `initialValue` REAL NOT NULL, `cash` REAL NOT NULL, `equity` REAL NOT NULL, `positionsJson` TEXT NOT NULL, `tradeCount` INTEGER NOT NULL, `winCount` INTEGER NOT NULL, `lossCount` INTEGER NOT NULL, `grossProfit` REAL NOT NULL, `grossLoss` REAL NOT NULL, `realizedPnl` REAL NOT NULL, `peakEquity` REAL NOT NULL, `maxDrawdownPercent` REAL NOT NULL, `consecutiveLosses` INTEGER NOT NULL, `totalHoldingMinutes` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`strategy`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `shadow_trades` (`id` TEXT NOT NULL, `strategy` TEXT NOT NULL, `time` INTEGER NOT NULL, `market` TEXT NOT NULL, `side` TEXT NOT NULL, `amount` REAL NOT NULL, `quantity` REAL NOT NULL, `price` REAL NOT NULL, `pnlRate` REAL NOT NULL, `holdingMinutes` INTEGER NOT NULL, `reason` TEXT NOT NULL, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `missed_opportunities` (`id` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `market` TEXT NOT NULL, `reason` TEXT NOT NULL, `strategyScore` REAL NOT NULL, `aiScore` REAL NOT NULL, `entryPrice` REAL NOT NULL, `marketHealth` REAL NOT NULL, `marketRegime` TEXT NOT NULL, `strategyVersion` INTEGER NOT NULL, `status` TEXT NOT NULL, `maxReturnPercent` REAL NOT NULL, `minReturnPercent` REAL NOT NULL, `resolvedAt` INTEGER, PRIMARY KEY(`id`))")
                db.execSQL("CREATE TABLE IF NOT EXISTS `missed_opportunity_snapshots` (`opportunityId` TEXT NOT NULL, `market` TEXT NOT NULL, `capturedAt` INTEGER NOT NULL, `horizonMinutes` INTEGER NOT NULL, `entryPrice` REAL NOT NULL, `currentPrice` REAL NOT NULL, `changePercent` REAL NOT NULL, `strategyScore` REAL NOT NULL, `marketHealth` REAL NOT NULL, `marketRegime` TEXT NOT NULL, PRIMARY KEY(`opportunityId`, `horizonMinutes`))")
            }
        }

        fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
            instance ?: Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "bithumb_trader.db")
                .addMigrations(
                    MIGRATION_1_2,
                    MIGRATION_2_3,
                    MIGRATION_3_4,
                    MIGRATION_4_5,
                    MIGRATION_5_6,
                    MIGRATION_6_7,
                    MIGRATION_7_8,
                    MIGRATION_8_9,
                    MIGRATION_9_10,
                    MIGRATION_10_11,
                    MIGRATION_11_12,
                    MIGRATION_12_13,
                    MIGRATION_13_14,
                    MIGRATION_14_15,
                    MIGRATION_15_16,
                    MIGRATION_16_17,
                    MIGRATION_17_18,
                    MIGRATION_18_19,
                    MIGRATION_19_20,
                    MIGRATION_20_21,
                    MIGRATION_21_22
                )
                .build().also { instance = it }
        }
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/Database.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/DynamicPortfolioCapacity.kt =====
package com.example.bithumbtrader

import kotlin.math.abs

/**
 * Dynamic Portfolio Capacity — replaces fixed maxPositions=3 as the primary BUY limiter.
 * Reuses PortfolioHeatEngine correlation/cluster signals; does not delete RiskManager/Cash Reserve.
 *
 * Hard emergency cap remains (runaway/bug protection only).
 */
data class DynamicPortfolioCapacitySnapshot(
    val positionCount: Int = 0,
    val portfolioHeatKrw: Double = 0.0,
    val portfolioHeatPercent: Double = 0.0,
    val maxOpenRiskPercent: Double = 5.0,
    val remainingRiskBudgetPercent: Double = 5.0,
    val remainingRiskBudgetKrw: Double = 0.0,
    val availableCashKrw: Double = 0.0,
    val cashReserveKrw: Double = 0.0,
    val projectedCashPercent: Double = 100.0,
    val correlatedExposurePercent: Double = 0.0,
    val hardCap: Int = 8,
    val minimumViableOrderKrw: Double = 8_000.0,
    val additionalEntryAvailable: Boolean = true,
    val blockReasonCode: String = "AVAILABLE",
    val detail: String = "추가 진입 가능"
)

data class DynamicPortfolioCapacityDecision(
    val allowed: Boolean,
    val reasonCode: String,
    val reason: String,
    val snapshot: DynamicPortfolioCapacitySnapshot,
    val sizedOrderKrw: Double,
    val candidateRiskKrw: Double,
    val projectedHeatPercent: Double
)

object DynamicPortfolioCapacityEngine {
    private val majorBtcCluster = setOf("KRW-BTC", "KRW-ETH", "KRW-SOL", "KRW-XRP", "KRW-ADA", "KRW-AVAX")

    fun positionRiskKrw(positionValueKrw: Double, stopLossPercent: Double): Double =
        positionValueKrw.coerceAtLeast(0.0) * abs(stopLossPercent) / 100.0

    fun portfolioHeatKrw(positions: List<PositionModel>, stopLossPercent: Double): Double =
        positions.sumOf { positionRiskKrw(it.quantity * it.avgPrice, stopLossPercent) }

    fun portfolioHeatPercent(heatKrw: Double, totalEquityKrw: Double): Double =
        if (totalEquityKrw > 0.0) (heatKrw / totalEquityKrw * 100.0).coerceAtLeast(0.0) else 0.0

    /**
     * Floor order size so fee/spread drag cannot dominate tiny slices of a ~100k account.
     * Links absolute min net profit + coverage multiple to a conservative KRW floor.
     */
    fun minimumViableOrderKrw(settings: TradingSettings): Double {
        val configured = settings.minimumViableOrderKrw.coerceAtLeast(0.0)
        val costAware = settings.absoluteMinimumNetProfitKrw *
            settings.minimumCostCoverageMultiple.coerceAtLeast(1.0) *
            40.0 // ~2.5% gross move / coverage path → order scale
        return maxOf(configured, costAware, 1_000.0)
    }

    fun evaluateSnapshot(
        settings: TradingSettings,
        positions: List<PositionModel>,
        totalEquityKrw: Double,
        availableCashKrw: Double,
        heat: PortfolioHeatSnapshot
    ): DynamicPortfolioCapacitySnapshot {
        val equity = totalEquityKrw.coerceAtLeast(0.0)
        val heatKrw = portfolioHeatKrw(positions, settings.stopLossPercent)
        val heatPct = portfolioHeatPercent(heatKrw, equity)
        val maxRisk = settings.maxOpenRiskPercent.coerceIn(1.0, 20.0)
        val remainingPct = (maxRisk - heatPct).coerceAtLeast(0.0)
        val remainingKrw = equity * remainingPct / 100.0
        val reserve = equity * settings.minKrwCashPercent / 100.0
        val hardCap = settings.maxPositionsHardCap.coerceIn(1, 50)
        val minOrder = minimumViableOrderKrw(settings)
        val (available, code, detail) = when {
            positions.size >= hardCap ->
                Triple(false, "HARD_EMERGENCY_POSITION_CAP", "하드캡 ${positions.size}/$hardCap")
            heat.level == PortfolioHeatLevel.CRITICAL || heat.multiplier <= 0.0 ->
                Triple(false, "PORTFOLIO_HEAT_LIMIT", "Portfolio Heat CRITICAL")
            heatPct >= maxRisk ->
                Triple(false, "PORTFOLIO_HEAT_LIMIT", "Open risk ${"%.2f".format(heatPct)}% ≥ ${"%.2f".format(maxRisk)}%")
            availableCashKrw - minOrder < reserve ->
                Triple(false, "CASH_RESERVE_LIMIT", "현금 여유 부족 (reserve ${"%.0f".format(reserve)})")
            availableCashKrw < minOrder ->
                Triple(false, "CASH_RESERVE_LIMIT", "가용 현금 < 최소주문")
            else ->
                Triple(true, "AVAILABLE", "추가 진입 가능")
        }
        return DynamicPortfolioCapacitySnapshot(
            positionCount = positions.size,
            portfolioHeatKrw = heatKrw,
            portfolioHeatPercent = heatPct,
            maxOpenRiskPercent = maxRisk,
            remainingRiskBudgetPercent = remainingPct,
            remainingRiskBudgetKrw = remainingKrw,
            availableCashKrw = availableCashKrw,
            cashReserveKrw = reserve,
            projectedCashPercent = if (equity > 0) availableCashKrw / equity * 100.0 else 0.0,
            correlatedExposurePercent = heat.correlatedExposurePercent,
            hardCap = hardCap,
            minimumViableOrderKrw = minOrder,
            additionalEntryAvailable = available,
            blockReasonCode = code,
            detail = detail
        )
    }

    fun canOpenAdditionalPosition(
        settings: TradingSettings,
        positions: List<PositionModel>,
        totalEquityKrw: Double,
        availableCashKrw: Double,
        candidateOrderKrw: Double,
        candidateMarket: String,
        heat: PortfolioHeatSnapshot,
        netProfitAfterCostPassed: Boolean = true
    ): DynamicPortfolioCapacityDecision {
        val snap = evaluateSnapshot(settings, positions, totalEquityKrw, availableCashKrw, heat)
        val equity = totalEquityKrw.coerceAtLeast(0.0)
        val stopAbs = abs(settings.stopLossPercent)
        val minOrder = snap.minimumViableOrderKrw
        val hardCap = snap.hardCap

        // Size by remaining risk budget (4th/5th positions get smaller if budget is tight).
        val maxByRisk = if (stopAbs > 0.0) snap.remainingRiskBudgetKrw / (stopAbs / 100.0) else candidateOrderKrw
        val sized = minOf(
            candidateOrderKrw.coerceAtLeast(0.0),
            maxByRisk.coerceAtLeast(0.0),
            availableCashKrw.coerceAtLeast(0.0)
        )
        val candidateRisk = positionRiskKrw(sized, settings.stopLossPercent)
        val projectedHeatPct = portfolioHeatPercent(snap.portfolioHeatKrw + candidateRisk, equity)
        val projectedCash = availableCashKrw - sized
        val projectedCashPct = if (equity > 0.0) projectedCash / equity * 100.0 else 0.0
        val addingToCluster = candidateMarket in majorBtcCluster
        val correlatedAfter = if (addingToCluster) {
            val clusterNow = positions.filter { it.market in majorBtcCluster }
                .sumOf { it.quantity * it.avgPrice }
            if (equity > 0) ((clusterNow + sized) / equity * 100.0) else 0.0
        } else heat.correlatedExposurePercent

        val (allowed, code, reason) = when {
            !settings.dynamicPortfolioCapacityEnabled -> {
                // Legacy path: fixed maxPositions (kept for fail-closed if toggle off).
                if (positions.size >= settings.maxPositions) {
                    Triple(false, "POSITION_LIMIT", "최대 보유 종목 초과 (${positions.size}/${settings.maxPositions})")
                } else Triple(true, "AVAILABLE", "OK")
            }
            positions.size >= hardCap ->
                Triple(false, "HARD_EMERGENCY_POSITION_CAP", "HARD_EMERGENCY_POSITION_CAP (${positions.size}/$hardCap)")
            !netProfitAfterCostPassed ->
                Triple(false, "NET_PROFIT_TOO_SMALL", "NET_PROFIT_AFTER_COST 미통과 — 소액 분산 주문 차단")
            sized < minOrder || candidateOrderKrw < minOrder ->
                Triple(
                    false,
                    "MINIMUM_VIABLE_ORDER",
                    "MINIMUM_VIABLE_ORDER (주문 ${"%.0f".format(candidateOrderKrw)} < 최소 ${"%.0f".format(minOrder)})"
                )
            projectedCash < snap.cashReserveKrw || projectedCashPct < settings.minKrwCashPercent ->
                Triple(false, "CASH_RESERVE_LIMIT", "CASH_RESERVE_LIMIT (예상 현금비중 ${"%.1f".format(projectedCashPct)}%)")
            heat.level == PortfolioHeatLevel.CRITICAL || heat.multiplier <= 0.0 ->
                Triple(false, "PORTFOLIO_HEAT_LIMIT", "PORTFOLIO_HEAT_LIMIT (현재 Heat CRITICAL)")
            projectedHeatPct >= snap.maxOpenRiskPercent ->
                Triple(
                    false,
                    "PORTFOLIO_HEAT_LIMIT",
                    "PORTFOLIO_HEAT_LIMIT (예상 open risk ${"%.2f".format(projectedHeatPct)}% ≥ ${"%.2f".format(snap.maxOpenRiskPercent)}%)"
                )
            addingToCluster && correlatedAfter >= 65.0 ->
                Triple(
                    false,
                    "CORRELATED_PORTFOLIO_HEAT",
                    "CORRELATED_PORTFOLIO_HEAT (상관 군집 ${"%.1f".format(correlatedAfter)}%)"
                )
            sized <= 0.0 ->
                Triple(false, "PORTFOLIO_HEAT_LIMIT", "PORTFOLIO_HEAT_LIMIT (잔여 Risk Budget 없음)")
            else ->
                Triple(
                    true,
                    "AVAILABLE",
                    "DYNAMIC_CAPACITY_PASS (보유 ${positions.size} · Heat ${"%.2f".format(projectedHeatPct)}% · 주문 ${"%.0f".format(sized)})"
                )
        }

        return DynamicPortfolioCapacityDecision(
            allowed = allowed,
            reasonCode = code,
            reason = reason,
            snapshot = snap.copy(
                additionalEntryAvailable = allowed,
                blockReasonCode = code,
                detail = reason
            ),
            sizedOrderKrw = if (allowed) sized else 0.0,
            candidateRiskKrw = candidateRisk,
            projectedHeatPercent = projectedHeatPct
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/DynamicPortfolioCapacity.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/EntryTiming.kt =====
package com.example.bithumbtrader

import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import java.util.UUID

enum class EntryTimingState { SAFE_ENTRY, NORMAL, EXTENDED, CHASE, EXTREME_CHASE }
enum class PullbackState { NONE, TREND_DETECTED, EXTENDED, PULLBACK, PULLBACK_STABILIZING, REACCELERATION, ENTRY_READY }
enum class BreakoutRetestState { NONE, BREAKOUT, WAITING_RETEST, RETEST_CONFIRMED }
enum class EntryQualityClassification { EARLY_GOOD, GOOD, SLIGHTLY_LATE, LATE, CHASE, EXTREME_CHASE }

data class ScorePoint(val time: Long, val score: Double)

data class EntryTimingEvaluation(
    val chaseScore: Double = 0.0,
    val entryTimingScore: Double = 50.0,
    val state: EntryTimingState = EntryTimingState.NORMAL,
    val pullbackState: PullbackState = PullbackState.NONE,
    val retestState: BreakoutRetestState = BreakoutRetestState.NONE,
    val overextensionAtr: Double = 0.0,
    val overextensionScore: Double = 0.0,
    val parabolicMove: Boolean = false,
    val momentumExhaustion: Boolean = false,
    val volumeClimax: Boolean = false,
    val scoreVelocityPerMinute: Double = 0.0,
    val signalLagMs: Long = 0L,
    val priceMoveStartTime: Long = 0L,
    val scoreCrossTime: Long = 0L,
    val preEntry1mReturn: Double = 0.0,
    val preEntry3mReturn: Double = 0.0,
    val preEntry5mReturn: Double = 0.0,
    val preEntry10mReturn: Double = 0.0,
    val preEntry15mReturn: Double = 0.0,
    val distanceFromEmaPercent: Double = 0.0,
    val distanceFromVwapPercent: Double = 0.0,
    val volumeSpike: Double = 1.0,
    val volumeAcceleration: Double = 0.0,
    val rsi: Double = 50.0,
    val rsiSlope: Double = 0.0,
    val macdExtension: Double = 0.0,
    val momentumAcceleration: Double = 0.0,
    val breakoutDistancePercent: Double = 0.0,
    val atrPercent: Double = 0.0,
    val spreadPercent: Double = 0.0,
    val orderbookImbalance: Double = 0.5,
    val reason: String = "진입 타이밍 데이터 부족"
) {
    val classification: EntryQualityClassification
        get() = when (state) {
            EntryTimingState.EXTREME_CHASE -> EntryQualityClassification.EXTREME_CHASE
            EntryTimingState.CHASE -> EntryQualityClassification.CHASE
            EntryTimingState.EXTENDED -> if (entryTimingScore < 45.0) EntryQualityClassification.LATE else EntryQualityClassification.SLIGHTLY_LATE
            EntryTimingState.SAFE_ENTRY -> EntryQualityClassification.EARLY_GOOD
            EntryTimingState.NORMAL -> if (entryTimingScore >= 65.0) EntryQualityClassification.GOOD else EntryQualityClassification.SLIGHTLY_LATE
        }
}

data class EntryTimingGateDecision(
    val allowed: Boolean,
    val waitStatus: CandidateStatus? = null,
    val reason: String = "ENTRY_TIMING_PASS"
)

enum class EntryShadowPolicy { CURRENT_ENTRY, CHASE_FILTERED, WAIT_1M, WAIT_3M, PULLBACK, RETEST }

data class EntryShadowMetrics(
    val policy: EntryShadowPolicy,
    val trades: Int = 0,
    val winRate: Double = 0.0,
    val profitFactor: Double = 0.0,
    val expectancyPercent: Double = 0.0,
    val mddPercent: Double = 0.0,
    val netReturnPercent: Double = 0.0,
    val averageMfe: Double = 0.0,
    val averageMae: Double = 0.0,
    val missedWinners: Int = 0
)

object EntryShadowComparisonEngine {
    /**
     * 미래 가격을 BUY 의사결정에 사용하지 않는 연구 함수다.
     * 이미 종료된/과거 candle window만 받아 정책별 진입 시점을 사후 비교한다.
     */
    fun compare(closes: List<Double>, horizonMinutes: Int = 5): List<EntryShadowMetrics> {
        val clean = closes.filter { it.isFinite() && it > 0.0 }
        if (clean.size <= horizonMinutes + 3) return emptyList()
        val policies = EntryShadowPolicy.values().associateWith { mutableListOf<Double>() }.toMutableMap()
        val mfes = EntryShadowPolicy.values().associateWith { mutableListOf<Double>() }
        val maes = EntryShadowPolicy.values().associateWith { mutableListOf<Double>() }
        val missed = EntryShadowPolicy.values().associateWith { 0 }.toMutableMap()
        for (start in 0..(clean.size - horizonMinutes - 1)) {
            val end = start + horizonMinutes
            EntryShadowPolicy.values().forEach { policy ->
                val delay = when (policy) {
                    EntryShadowPolicy.CURRENT_ENTRY, EntryShadowPolicy.CHASE_FILTERED -> 0
                    EntryShadowPolicy.WAIT_1M -> 1
                    EntryShadowPolicy.WAIT_3M, EntryShadowPolicy.PULLBACK -> 3
                    EntryShadowPolicy.RETEST -> 2
                }
                val entry = start + delay
                if (entry >= end || clean[entry] <= 0.0) return@forEach
                val exit = clean[end]
                val returnPercent = (exit / clean[entry] - 1.0) * 100.0
                val window = clean.subList(entry, end + 1)
                policies.getValue(policy) += returnPercent
                mfes.getValue(policy) += (window.maxOrNull()!! / clean[entry] - 1.0) * 100.0
                maes.getValue(policy) += (window.minOrNull()!! / clean[entry] - 1.0) * 100.0
                if (policy != EntryShadowPolicy.CURRENT_ENTRY && returnPercent > 0.0 &&
                    (clean[end] / clean[start] - 1.0) * 100.0 > 0.0
                ) missed[policy] = missed.getValue(policy) + 1
            }
        }
        return EntryShadowPolicy.values().map { policy ->
            val returns = policies.getValue(policy)
            val performance = StrategyPerformanceEngine.fromPnlRates(returns)
            EntryShadowMetrics(
                policy = policy,
                trades = returns.size,
                winRate = performance.winRate,
                profitFactor = performance.profitFactor,
                expectancyPercent = performance.expectedReturnPercent,
                mddPercent = performance.maxDrawdownPercent,
                netReturnPercent = returns.sum(),
                averageMfe = mfes.getValue(policy).averageOrZero(),
                averageMae = maes.getValue(policy).averageOrZero(),
                missedWinners = missed.getValue(policy)
            )
        }
    }

    fun summary(closes: List<Double>): String {
        val metrics = compare(closes).takeIf { it.isNotEmpty() } ?: return "표본 부족"
        val current = metrics.first { it.policy == EntryShadowPolicy.CURRENT_ENTRY }
        val best = metrics.maxByOrNull { it.expectancyPercent } ?: current
        return "현재 ${"%.2f".format(current.expectancyPercent)}% vs ${best.policy.name} ${"%.2f".format(best.expectancyPercent)}% (거래유지 ${current.trades}/${best.trades})"
    }

    private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
}

object EntryTimingEngine {
    private fun returnPercent(closes: List<Double>, minutes: Int, current: Double): Double {
        val index = closes.size - 1 - minutes
        val base = closes.getOrNull(index)?.takeIf { it > 0.0 } ?: return 0.0
        return (current / base - 1.0) * 100.0
    }

    private fun averageVolume(candles: List<CandleModel>): Double =
        candles.map { it.volume }.filter { it.isFinite() && it > 0.0 }.takeLast(20).average().takeIf { it.isFinite() && it > 0.0 } ?: 0.0

    private fun atr(candles: List<CandleModel>): Double {
        val rows = candles.takeLast(21)
        if (rows.size < 2) return 0.0
        return rows.zipWithNext().mapNotNull { (previous, current) ->
            max(
                current.high - current.low,
                max(abs(current.high - previous.close), abs(current.low - previous.close))
            ).takeIf { it.isFinite() && it > 0.0 }
        }.average().takeIf { it.isFinite() } ?: 0.0
    }

    private fun vwap(candles: List<CandleModel>): Double {
        val rows = candles.takeLast(20)
        val volume = rows.sumOf { it.volume.coerceAtLeast(0.0) }
        return if (volume > 0.0) rows.sumOf { it.close * it.volume.coerceAtLeast(0.0) } / volume else 0.0
    }

    fun evaluate(
        currentPrice: Double,
        candles: List<CandleModel>,
        strategyCandles: List<CandleModel> = candles,
        orderbook: OrderbookModel? = null,
        marketRegime: MarketRegime = MarketRegime.UNKNOWN,
        marketHealth: MarketHealthScore = MarketHealthScore(),
        scoreHistory: List<ScorePoint> = emptyList(),
        currentStrategyScore: Double = 0.0,
        scoreThreshold: Double = 75.0
    ): EntryTimingEvaluation {
        val sorted = candles.sortedBy { it.timestamp }.filter { it.close.isFinite() && it.close > 0.0 }
        if (currentPrice <= 0.0 || sorted.size < 16) {
            return EntryTimingEvaluation(
                state = EntryTimingState.NORMAL,
                pullbackState = PullbackState.NONE,
                retestState = BreakoutRetestState.NONE,
                reason = "진입 타이밍 표본 부족 — 기존 전략 점수 유지"
            )
        }
        val closes = sorted.map { it.close }
        val recent = closes.takeLast(20)
        val ema20 = Indicators.ema(strategyCandles.map { it.close }, 20).takeIf { it > 0.0 } ?: Indicators.ema(closes, 20)
        val recentLow = recent.minOrNull() ?: currentPrice
        val previousBreakout = closes.dropLast(1).takeLast(30).maxOrNull() ?: currentPrice
        val atrPrice = atr(sorted)
        val atrPercent = if (currentPrice > 0.0) atrPrice / currentPrice * 100.0 else 0.0
        val emaDistance = if (ema20 > 0.0) (currentPrice / ema20 - 1.0) * 100.0 else 0.0
        val vwapValue = vwap(sorted)
        val vwapDistance = if (vwapValue > 0.0) (currentPrice / vwapValue - 1.0) * 100.0 else 0.0
        val extensionReference = min(ema20.takeIf { it > 0.0 } ?: currentPrice, previousBreakout)
        val extensionAtr = if (atrPrice > 0.0) (currentPrice - extensionReference) / atrPrice else 0.0
        val extensionScore = ((extensionAtr - 1.0) * 24.0).coerceIn(0.0, 100.0)

        val r1 = returnPercent(closes, 1, currentPrice)
        val r3 = returnPercent(closes, 3, currentPrice)
        val r5 = returnPercent(closes, 5, currentPrice)
        val r10 = returnPercent(closes, 10, currentPrice)
        val r15 = returnPercent(closes, 15, currentPrice)
        val rsi = Indicators.rsi(closes, 14)
        val priorRsi = if (closes.size > 5) Indicators.rsi(closes.dropLast(5), 14) else rsi
        val rsiSlope = rsi - priorRsi
        val macd = Indicators.macd(closes)
        val priorMacd = if (closes.size > 5) Indicators.macd(closes.dropLast(5)) else macd
        val macdExtension = if (currentPrice > 0.0) (macd / currentPrice * 100.0).coerceIn(-20.0, 20.0) else 0.0
        val momentumAcceleration = r1 - (r3 - r1) / 2.0
        val momentumDeceleration = r3 > 0.0 && r1 < r3 / 3.0
        val averageVol = averageVolume(sorted)
        val lastVolume = sorted.last().volume.coerceAtLeast(0.0)
        val previousVolume = sorted.getOrNull(sorted.lastIndex - 1)?.volume?.coerceAtLeast(0.0) ?: lastVolume
        val volumeSpike = if (averageVol > 0.0) lastVolume / averageVol else 1.0
        val volumeAcceleration = if (previousVolume > 0.0) (lastVolume / previousVolume - 1.0) * 100.0 else 0.0
        val range = (sorted.last().high - sorted.last().low).coerceAtLeast(0.0)
        val previousClose = sorted.getOrNull(sorted.lastIndex - 1)?.close ?: sorted.last().close
        val body = abs(sorted.last().close - previousClose)
        val wickRatio = if (range > 0.0) ((range - body) / range).coerceIn(0.0, 1.0) else 0.0
        val spread = orderbook?.let { if (it.askPrice > 0.0) ((it.askPrice - it.bidPrice) / it.askPrice * 100.0).coerceAtLeast(0.0) else 99.0 } ?: 0.0
        val imbalance = orderbook?.let { total -> if (total.askSize + total.bidSize > 0.0) total.bidSize / (total.askSize + total.bidSize) else 0.5 } ?: 0.5
        val breakoutDistance = if (previousBreakout > 0.0) (currentPrice / previousBreakout - 1.0) * 100.0 else 0.0

        val parabolic = r3 >= 4.0 && volumeSpike >= 2.5 && body >= range * 0.55 && rsi >= 78.0 && emaDistance >= 2.5
        val exhaustion = (r3 >= 2.0 || r5 >= 4.0) && momentumDeceleration && (rsi >= 78.0 || extensionAtr >= 2.0)
        val volumeClimax = volumeSpike >= 3.0 && r3 >= 2.5 && r1 < r3 / 3.0 && volumeAcceleration <= 20.0

        val trendDetected = Indicators.ema(closes, 5) > ema20 && r5 > 0.0
        val pullback = trendDetected && (currentPrice <= recent.maxOrNull()?.times(0.995) ?: currentPrice) &&
            (abs(emaDistance) <= max(1.2, atrPercent * 1.5) || abs(breakoutDistance) <= max(1.0, atrPercent))
        val stabilizing = pullback && abs(r1) <= max(0.7, atrPercent) && volumeSpike in 0.65..1.8 && wickRatio >= 0.25
        val reacceleration = stabilizing && r1 > 0.0 && r3 > r5 / 2.5 && volumeSpike >= 0.8
        val retestConfirmed = previousBreakout > 0.0 && breakoutDistance in -max(1.0, atrPercent)..max(1.0, atrPercent) &&
            r1 > 0.0 && volumeSpike >= 0.7 && !exhaustion
        val pullbackState = when {
            reacceleration -> PullbackState.REACCELERATION
            stabilizing -> PullbackState.PULLBACK_STABILIZING
            pullback -> PullbackState.PULLBACK
            trendDetected && extensionAtr > 1.5 -> PullbackState.EXTENDED
            trendDetected -> PullbackState.TREND_DETECTED
            else -> PullbackState.NONE
        }
        val retestState = when {
            retestConfirmed -> BreakoutRetestState.RETEST_CONFIRMED
            breakoutDistance > 0.0 -> BreakoutRetestState.WAITING_RETEST
            else -> BreakoutRetestState.NONE
        }

        val chase = (
            (r3 / 5.0 * 25.0).coerceIn(0.0, 25.0) +
                (r5 / 8.0 * 20.0).coerceIn(0.0, 20.0) +
                (r10 / 12.0 * 10.0).coerceIn(0.0, 10.0) +
                (r15 / 15.0 * 5.0).coerceIn(0.0, 5.0) +
                ((emaDistance - 1.5) / 5.0 * 12.0).coerceIn(0.0, 12.0) +
                ((extensionAtr - 1.0) / 3.0 * 10.0).coerceIn(0.0, 10.0) +
                ((rsi - 70.0) / 20.0 * 8.0).coerceIn(0.0, 8.0) +
                ((volumeSpike - 1.5) / 3.0 * 5.0).coerceIn(0.0, 5.0) +
                if (parabolic) 5.0 else 0.0 +
                if (exhaustion) 5.0 else 0.0
            ).coerceIn(0.0, 100.0)

        var timing = 62.0
        timing -= extensionScore * 0.28
        timing -= if (parabolic) 22.0 else 0.0
        timing -= if (exhaustion) 18.0 else 0.0
        timing -= if (volumeClimax) 14.0 else 0.0
        timing -= ((rsi - 82.0).coerceAtLeast(0.0) * 1.5)
        timing -= spread.coerceAtLeast(0.0) * 5.0
        timing += if (reacceleration) 18.0 else 0.0
        timing += if (retestConfirmed) 12.0 else 0.0
        timing += if (stabilizing) 8.0 else 0.0
        timing += ((imbalance - 0.5) * 18.0).coerceIn(-8.0, 8.0)
        timing = timing.coerceIn(0.0, 100.0)

        val velocity = scoreVelocity(scoreHistory, System.currentTimeMillis(), currentStrategyScore, scoreThreshold)
        val lag = signalLag(sorted, scoreHistory, scoreThreshold)
        val state = when {
            chase >= 85.0 || (parabolic && exhaustion) -> EntryTimingState.EXTREME_CHASE
            chase >= 65.0 || parabolic -> EntryTimingState.CHASE
            extensionAtr >= 1.75 || rsi >= 82.0 || volumeClimax -> EntryTimingState.EXTENDED
            timing >= 72.0 && extensionAtr < 1.25 -> EntryTimingState.SAFE_ENTRY
            else -> EntryTimingState.NORMAL
        }
        val reasonParts = mutableListOf<String>()
        if (parabolic) reasonParts += "PARABOLIC_MOVE"
        if (exhaustion) reasonParts += "MOMENTUM_EXHAUSTION"
        if (volumeClimax) reasonParts += "VOLUME_CLIMAX"
        if (extensionAtr >= 1.75) reasonParts += "OVEREXTENDED(${String.format("%.2f", extensionAtr)}ATR)"
        if (reacceleration) reasonParts += "REACCELERATION"
        if (retestConfirmed) reasonParts += "RETEST_CONFIRMED"
        if (reasonParts.isEmpty()) reasonParts += "건강한 진입 위치"
        return EntryTimingEvaluation(
            chaseScore = chase,
            entryTimingScore = timing,
            state = state,
            pullbackState = pullbackState,
            retestState = retestState,
            overextensionAtr = extensionAtr,
            overextensionScore = extensionScore,
            parabolicMove = parabolic,
            momentumExhaustion = exhaustion,
            volumeClimax = volumeClimax,
            scoreVelocityPerMinute = velocity,
            signalLagMs = lag.first,
            priceMoveStartTime = lag.second,
            scoreCrossTime = lag.third,
            preEntry1mReturn = r1,
            preEntry3mReturn = r3,
            preEntry5mReturn = r5,
            preEntry10mReturn = r10,
            preEntry15mReturn = r15,
            distanceFromEmaPercent = emaDistance,
            distanceFromVwapPercent = vwapDistance,
            volumeSpike = volumeSpike,
            volumeAcceleration = volumeAcceleration,
            rsi = rsi,
            rsiSlope = rsiSlope,
            macdExtension = macdExtension,
            momentumAcceleration = momentumAcceleration,
            breakoutDistancePercent = breakoutDistance,
            atrPercent = atrPercent,
            spreadPercent = spread,
            orderbookImbalance = imbalance,
            reason = reasonParts.joinToString(" · ") + " · 국면=${marketRegime.name} · Health=${marketHealth.score.toInt()} · Shadow=${EntryShadowComparisonEngine.summary(closes)}"
        )
    }

    fun gate(
        evaluation: EntryTimingEvaluation,
        enabled: Boolean = true,
        rejectScore: Double = 80.0,
        extremeScore: Double = 90.0,
        minimumScore: Double = 45.0,
        maxExtensionAtr: Double = 1.75
    ): EntryTimingGateDecision {
        if (!enabled) return EntryTimingGateDecision(true)
        if (evaluation.state == EntryTimingState.EXTREME_CHASE || evaluation.chaseScore >= extremeScore) {
            return EntryTimingGateDecision(false, CandidateStatus.REJECTED, "REJECTED_CHASE_ENTRY: Chase ${evaluation.chaseScore.toInt()} / Timing ${evaluation.entryTimingScore.toInt()}")
        }
        if (evaluation.state == EntryTimingState.CHASE || evaluation.chaseScore >= rejectScore) {
            return EntryTimingGateDecision(false, CandidateStatus.WAIT_PULLBACK, "WAIT_PULLBACK: Chase ${evaluation.chaseScore.toInt()} / Timing ${evaluation.entryTimingScore.toInt()}")
        }
        if (evaluation.state == EntryTimingState.EXTENDED || evaluation.overextensionAtr >= maxExtensionAtr || evaluation.entryTimingScore < minimumScore) {
            val status = when {
                evaluation.retestState == BreakoutRetestState.WAITING_RETEST -> CandidateStatus.WAIT_RETEST
                evaluation.momentumExhaustion -> CandidateStatus.WAIT_MOMENTUM
                else -> CandidateStatus.WAIT_RECONFIRMATION
            }
            return EntryTimingGateDecision(false, status, "ENTRY_TIMING_WAIT: ${evaluation.reason} · Timing ${evaluation.entryTimingScore.toInt()}")
        }
        return EntryTimingGateDecision(true, reason = "ENTRY_TIMING_PASS: Timing ${evaluation.entryTimingScore.toInt()} · Chase ${evaluation.chaseScore.toInt()}")
    }

    fun scoreVelocity(history: List<ScorePoint>, now: Long, currentScore: Double, threshold: Double): Double {
        val previous = history.filter { it.time < now }.lastOrNull() ?: return 0.0
        val minutes = ((now - previous.time).coerceAtLeast(1L)).toDouble() / 60_000.0
        return ((currentScore - previous.score) / minutes).takeIf { it.isFinite() } ?: 0.0
    }

    private fun signalLag(
        candles: List<CandleModel>,
        history: List<ScorePoint>,
        threshold: Double
    ): Triple<Long, Long, Long> {
        val current = candles.lastOrNull() ?: return Triple(0L, 0L, 0L)
        val base = candles.getOrNull((candles.size - 1 - 15).coerceAtLeast(0))?.close ?: current.close
        val moveStart = candles.firstOrNull { it.close >= base * 1.02 }?.timestamp ?: 0L
        val scoreCross = history.firstOrNull { it.score >= threshold }?.time ?: 0L
        val lag = if (moveStart > 0L && scoreCross > moveStart) scoreCross - moveStart else 0L
        return Triple(lag, moveStart, scoreCross)
    }
}

data class EntryTimingResearchStats(
    val sampleCount: Int = 0,
    val completedEntries: Int = 0,
    val goodEntryRate: Double = 0.0,
    val chaseEntryRate: Double = 0.0,
    val highScoreFailureRate: Double = 0.0,
    val immediateDrawdownRate: Double = 0.0,
    val averageSignalLagMs: Long = 0L,
    val p50SignalLagMs: Long = 0L,
    val p95SignalLagMs: Long = 0L,
    val averageChaseScore: Double = 0.0,
    val averageEntryTimingScore: Double = 0.0,
    val warning: String = "INSUFFICIENT_SAMPLE",
    val shadowSummary: String = "CURRENT_ENTRY / CHASE_FILTERED / WAIT_1M / WAIT_3M / PULLBACK / RETEST 연구 대기"
)

@Entity(tableName = "entry_diagnostics")
data class EntryDiagnosticEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val market: String,
    val time: Long,
    val tradeId: String? = null,
    val strategyScore: Double,
    val aiScore: Double,
    val entryTimingScore: Double,
    val chaseScore: Double,
    val state: String,
    val pullbackState: String,
    val retestState: String,
    val overextensionAtr: Double,
    val overextensionScore: Double,
    val parabolicMove: Boolean,
    val momentumExhaustion: Boolean,
    val volumeClimax: Boolean,
    val scoreVelocityPerMinute: Double,
    val signalLagMs: Long,
    val preEntry1mReturn: Double,
    val preEntry3mReturn: Double,
    val preEntry5mReturn: Double,
    val preEntry10mReturn: Double,
    val preEntry15mReturn: Double,
    val first5mReturn: Double? = null,
    val first15mReturn: Double? = null,
    val mfePercent: Double? = null,
    val maePercent: Double? = null,
    val exitReason: String? = null,
    val pnlRate: Double? = null,
    val classification: String,
    val decision: String,
    val reason: String
)

object EntryTimingAnalytics {
    fun summarize(rows: List<EntryDiagnosticEntity>, highScoreFailureWarningRate: Double = 0.40): EntryTimingResearchStats {
        if (rows.isEmpty()) return EntryTimingResearchStats()
        val trades = rows.filter { it.tradeId != null }
        val completed = trades.filter { it.first5mReturn != null || it.exitReason != null }
        val highScore = trades.filter { it.strategyScore >= 90.0 }
        val highScoreFailures = highScore.count { (it.first5mReturn ?: it.pnlRate ?: 0.0) <= -2.0 || it.classification in setOf("CHASE", "EXTREME_CHASE") }
        val lags = trades.map { it.signalLagMs }.filter { it > 0L }.sorted()
        fun percentile(percent: Double): Long = if (lags.isEmpty()) 0L else lags[((lags.size - 1) * percent).toInt().coerceIn(0, lags.lastIndex)]
        return EntryTimingResearchStats(
            sampleCount = rows.size,
            completedEntries = completed.size,
            goodEntryRate = if (trades.isEmpty()) 0.0 else trades.count { it.classification in setOf("EARLY_GOOD", "GOOD") }.toDouble() / trades.size,
            chaseEntryRate = if (trades.isEmpty()) 0.0 else trades.count { it.classification in setOf("CHASE", "EXTREME_CHASE") }.toDouble() / trades.size,
            highScoreFailureRate = if (highScore.isEmpty()) 0.0 else highScoreFailures.toDouble() / highScore.size,
            immediateDrawdownRate = if (completed.isEmpty()) 0.0 else completed.count { (it.first5mReturn ?: 0.0) <= -2.0 }.toDouble() / completed.size,
            averageSignalLagMs = if (lags.isEmpty()) 0L else lags.average().toLong(),
            p50SignalLagMs = percentile(0.50),
            p95SignalLagMs = percentile(0.95),
            averageChaseScore = trades.map { it.chaseScore }.average(),
            averageEntryTimingScore = trades.map { it.entryTimingScore }.average(),
            warning = when {
                highScore.size < 20 -> "INSUFFICIENT_SAMPLE: Score 90+ ${highScore.size}/20"
                highScoreFailures.toDouble() / highScore.size >= highScoreFailureWarningRate.coerceIn(0.1, 1.0) -> "HIGH_SCORE_CHASE_BIAS_WARNING"
                else -> "NORMAL"
            },
            shadowSummary = "CURRENT_ENTRY / CHASE_FILTERED / WAIT_1M / WAIT_3M / PULLBACK / RETEST · 표본 ${rows.size}건"
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/EntryTiming.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/EntryUrgencyAudit.kt =====
package com.example.bithumbtrader

import kotlin.math.abs

enum class HorizonConflictState {
    NONE,
    LONGER_TERM_POSITIVE_SHORT_NEGATIVE,
    LONGER_TERM_NEGATIVE_SHORT_POSITIVE,
    BOTH_POSITIVE,
    BOTH_NEGATIVE,
    DATA_INSUFFICIENT
}

enum class EntryUrgencyClass {
    EARLY_GOOD_ENTRY,
    GOOD_ENTRY,
    NORMAL_ENTRY,
    LATE_ENTRY,
    CHASE_ENTRY,
    EXTREME_CHASE_ENTRY,
    INSUFFICIENT_DATA
}

enum class EntrySpeedDiagnosis {
    SYSTEM_TOO_FAST,
    SIGNAL_TOO_LATE,
    BALANCED,
    INSUFFICIENT_DATA
}

data class ShortEdgeCostBreakdown(
    val grossExpectedMovePercent: Double,
    val feePercent: Double,
    val spreadPercent: Double,
    val slippagePercent: Double,
    val marketImpactPercent: Double,
    val safetyMargin: Double,
    val totalCostBeforeMargin: Double,
    val totalCostAfterMargin: Double,
    val finalShortEdgePercent: Double
) {
    fun format(): String =
        "Gross ${"%.2f".format(grossExpectedMovePercent)}% - Fee ${"%.2f".format(feePercent)}% - Spread ${"%.2f".format(spreadPercent)}% - Slip ${"%.2f".format(slippagePercent)}% - Impact ${"%.2f".format(marketImpactPercent)}% × Safety ${"%.2f".format(safetyMargin)} = ShortEdge ${"%.2f".format(finalShortEdgePercent)}%"
}

/**
 * 진입 긴급도/Horizon 감사 헬퍼.
 * 새 매매 엔진이 아니며 기존 EntryTiming/Scalping/NetEdge 결과를 재사용한다.
 * 임계값/정책을 자동 변경하지 않는다.
 */
object EntryUrgencyAudit {
    fun shortEdgeBreakdown(
        expectedMove30s: Double,
        expectedMove1m: Double,
        expectedMove3m: Double,
        expectedMove5m: Double,
        feePercent: Double,
        spreadPercent: Double,
        slippagePercent: Double,
        marketImpactPercent: Double,
        safetyMargin: Double
    ): ShortEdgeCostBreakdown {
        val gross = maxOf(expectedMove30s, expectedMove1m, expectedMove3m, expectedMove5m)
        val before = feePercent + spreadPercent.coerceAtLeast(0.0) + slippagePercent + marketImpactPercent
        val after = before * safetyMargin.coerceAtLeast(1.0)
        return ShortEdgeCostBreakdown(
            grossExpectedMovePercent = gross,
            feePercent = feePercent,
            spreadPercent = spreadPercent.coerceAtLeast(0.0),
            slippagePercent = slippagePercent,
            marketImpactPercent = marketImpactPercent,
            safetyMargin = safetyMargin,
            totalCostBeforeMargin = before,
            totalCostAfterMargin = after,
            finalShortEdgePercent = gross - after
        )
    }

    fun horizonConflict(generalNetEdgePercent: Double, shortEdgePercent: Double): HorizonConflictState = when {
        !generalNetEdgePercent.isFinite() || !shortEdgePercent.isFinite() -> HorizonConflictState.DATA_INSUFFICIENT
        generalNetEdgePercent > 0.35 && shortEdgePercent < 0.0 -> HorizonConflictState.LONGER_TERM_POSITIVE_SHORT_NEGATIVE
        generalNetEdgePercent < 0.0 && shortEdgePercent > 0.15 -> HorizonConflictState.LONGER_TERM_NEGATIVE_SHORT_POSITIVE
        generalNetEdgePercent > 0.0 && shortEdgePercent > 0.0 -> HorizonConflictState.BOTH_POSITIVE
        generalNetEdgePercent <= 0.0 && shortEdgePercent <= 0.0 -> HorizonConflictState.BOTH_NEGATIVE
        else -> HorizonConflictState.NONE
    }

    fun entryQualityFromExisting(
        chaseScore: Double,
        entryTimingScore: Double,
        classification: String,
        preEntry5mReturn: Double
    ): EntryUrgencyClass = when {
        classification == EntryQualityClassification.EXTREME_CHASE.name || chaseScore >= 90.0 -> EntryUrgencyClass.EXTREME_CHASE_ENTRY
        classification == EntryQualityClassification.CHASE.name || chaseScore >= 80.0 || preEntry5mReturn >= 4.0 -> EntryUrgencyClass.CHASE_ENTRY
        classification == EntryQualityClassification.LATE.name ||
            classification == EntryQualityClassification.SLIGHTLY_LATE.name ||
            preEntry5mReturn >= 2.0 -> EntryUrgencyClass.LATE_ENTRY
        entryTimingScore >= 70.0 && chaseScore < 50.0 && preEntry5mReturn < 1.5 -> EntryUrgencyClass.EARLY_GOOD_ENTRY
        entryTimingScore >= 55.0 && chaseScore < 65.0 -> EntryUrgencyClass.GOOD_ENTRY
        entryTimingScore <= 0.0 && chaseScore <= 0.0 -> EntryUrgencyClass.INSUFFICIENT_DATA
        else -> EntryUrgencyClass.NORMAL_ENTRY
    }

    fun speedDiagnosis(signalLagMs: Long, priceMoveStartTime: Long, scoreCrossTime: Long, buyTime: Long): EntrySpeedDiagnosis {
        if (priceMoveStartTime <= 0L || scoreCrossTime <= 0L || buyTime <= 0L) return EntrySpeedDiagnosis.INSUFFICIENT_DATA
        val moveToScore = (scoreCrossTime - priceMoveStartTime).coerceAtLeast(0L)
        val scoreToBuy = (buyTime - scoreCrossTime).coerceAtLeast(0L)
        return when {
            moveToScore >= 60_000L && scoreToBuy <= 15_000L -> EntrySpeedDiagnosis.SIGNAL_TOO_LATE
            signalLagMs >= 90_000L -> EntrySpeedDiagnosis.SIGNAL_TOO_LATE
            scoreToBuy <= 3_000L && chaseScoreLikelyLate(moveToScore) -> EntrySpeedDiagnosis.SYSTEM_TOO_FAST
            else -> EntrySpeedDiagnosis.BALANCED
        }
    }

    private fun chaseScoreLikelyLate(moveToScoreMs: Long): Boolean = moveToScoreMs >= 45_000L

    fun decisionSummary(
        scalpState: String,
        reasonCodes: List<String>,
        horizonConflict: HorizonConflictState,
        chaseScore: Double,
        shortEdge: Double,
        generalNetEdge: Double
    ): String {
        val reasons = reasonCodes.map { it.uppercase() }
        return when {
            scalpState == ScalpingExecutionState.DATA_INSUFFICIENT.name ||
                reasons.any { it.startsWith("MISSING_") || it.startsWith("INSUFFICIENT_") || it == "SHORT_EDGE_UNRELIABLE_PARTIAL_DATA" } ->
                "초단기 실행 데이터 부족 → 안전하게 대기 (추격 아님)"
            scalpState == ScalpingExecutionState.WARMING_UP.name ->
                "실행 데이터 Warm-up 중 → 샘플 축적 대기"
            "SCALP_EDGE_TOO_SMALL" in reasons || scalpState == ScalpingExecutionState.NO_EDGE.name ->
                "중기 기대 ${"%.1f".format(generalNetEdge)}%이나 초단기 Edge ${"%.2f".format(shortEdge)}% 부족 → Pullback/대기"
            horizonConflict == HorizonConflictState.LONGER_TERM_POSITIVE_SHORT_NEGATIVE ->
                "중기 상승 기대는 있으나 초단기 Edge 부족 → Pullback 대기"
            scalpState == ScalpingExecutionState.CHASE_RISK.name || chaseScore >= 80.0 || "MOMENTUM_DECELERATING" in reasons ->
                "이미 급등 후 Momentum 둔화/추격 위험 → 추격매수 방지"
            scalpState == ScalpingExecutionState.ENTER_NOW.name && shortEdge > 0.0 ->
                "상승 초기 + Momentum 가속 + 비용 후 Edge 양수 → 진입 가능"
            scalpState == ScalpingExecutionState.WAIT_PULLBACK.name || "PULLBACK_CONFIRMED" in reasons ->
                "Pullback/재가속 확인 대기"
            scalpState == ScalpingExecutionState.WAIT_RETEST.name ->
                "Breakout Retest 확인 대기"
            scalpState == ScalpingExecutionState.TOO_LATE.name || "PRICE_MOVED_AWAY" in reasons ->
                "신호 대비 가격 이탈/진입창 종료 → 너무 늦은 진입 차단"
            scalpState == ScalpingExecutionState.AVOID.name ->
                "데이터/스프레드/변동성 위험 → SCALP_AVOID (정상 차단)"
            else -> "상태 $scalpState · Short ${"%.2f".format(shortEdge)}% · 중기Net ${"%.1f".format(generalNetEdge)}%"
        }
    }

    fun liquidityRankLabel(rank: Int, total: Int): String =
        if (total <= 0 || rank <= 0) "순위 계산 전" else "$rank/$total"

    fun liquidityRequiredLabel(requiredKrw: Double, ready: Boolean): String =
        if (!ready || requiredKrw <= 0.0) "최소 거래대금 계산 전" else String.format(java.util.Locale.KOREA, "%,.0f KRW", requiredKrw)

    fun liquidityPercentileLabel(percentile: Double, total: Int): String =
        if (total <= 0) "유동성 백분위 계산 전" else String.format(java.util.Locale.KOREA, "상위 %.0f%%", (1.0 - percentile) * 100.0)

    /** Bithumb 캔들 timestamp=시작시각 가정. period 미경과면 진행 중 캔들. */
    fun candleSignalType(lastCandleTimestamp: Long, unitMinutes: Int, now: Long = System.currentTimeMillis()): String {
        if (lastCandleTimestamp <= 0L || unitMinutes <= 0) return "INSUFFICIENT_DATA"
        val periodMs = unitMinutes.toLong() * 60_000L
        val age = now - lastCandleTimestamp
        return if (age in 0 until periodMs) "IN_PROGRESS_CANDLE_SIGNAL" else "CONFIRMED_CANDLE_SIGNAL"
    }

    fun classifyRejectOutcome(forwardReturn5m: Double, costFloorPercent: Double = 0.5): String = when {
        !forwardReturn5m.isFinite() -> "INSUFFICIENT_DATA"
        forwardReturn5m >= costFloorPercent + 0.5 -> "FALSE_REJECT"
        forwardReturn5m <= -costFloorPercent -> "GOOD_REJECT"
        else -> "NEUTRAL_REJECT"
    }

    fun classifyEntryOutcome(forwardReturn5m: Double, maePercent: Double, costFloorPercent: Double = 0.5): String = when {
        !forwardReturn5m.isFinite() -> "INSUFFICIENT_DATA"
        maePercent <= -1.5 || forwardReturn5m <= -costFloorPercent -> "FALSE_ENTRY"
        forwardReturn5m >= costFloorPercent -> "GOOD_ENTRY"
        else -> "NEUTRAL_ENTRY"
    }

    fun runtimeThresholdTable(settings: TradingSettings): LinkedHashMap<String, String> = linkedMapOf(
        "Strategy Score threshold" to "%.1f".format(settings.scoreThreshold),
        "AI threshold" to "%.1f".format(settings.aiMinScore),
        "Execution Score threshold" to "%.1f".format(settings.scalpingMinimumExecutionScore),
        "Execution Confidence threshold" to "55.0 (ScalpingExecutionEngine fixed)",
        "Entry Timing threshold" to "%.1f".format(settings.minimumEntryTimingScore),
        "Chase reject threshold" to "%.1f".format(settings.chaseRejectScore),
        "Extreme Chase threshold" to "%.1f".format(settings.extremeChaseScore),
        "Short Edge minimum" to "%.2f%%".format(settings.scalpingMinimumShortNetEdgePercent),
        "General Net Edge minimum (margin)" to "%.2f%%".format(settings.minNetEdgeMarginPercent),
        "Liquidity absolute floor" to "%,.0f KRW".format(settings.min24hTradeValueKrw),
        "Liquidity percentile" to "%.0f%%".format(settings.liquidityPercentileThreshold * 100.0),
        "Spread maximum (risk)" to "%.2f%%".format(settings.maxSpreadPercent),
        "Scalp spread maximum" to "%.2f%%".format(settings.scalpingMaximumSpreadPercent),
        "Scalp safety margin" to "%.2f".format(settings.scalpingSafetyMargin),
        "Signal TTL (entry)" to "${settings.entrySignalMaxAgeMillis}ms",
        "Signal TTL (scalp)" to "${settings.scalpingSignalTtlMillis}ms",
        "Price Moved Away ATR×" to "%.2f".format(settings.scalpingPriceMovedAwayAtrMultiple),
        "Stop-loss reentry cooldown" to "${settings.stopLossCooldownMinutes}m",
        "Trailing reentry cooldown" to "${settings.trailingStopCooldownMinutes}m",
        "Profit reentry cooldown" to "${settings.profitReentryCooldownMinutes}m",
        "Take Profit (General Edge gross)" to "%.2f%%".format(settings.takeProfitPercent)
    )
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/EntryUrgencyAudit.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ExchangeIsolation.kt =====
package com.example.bithumbtrader

/**
 * Dual independent exchange identity helpers.
 * BITHUMB:KRW-BTC and UPBIT:KRW-BTC are never the same position.
 */
enum class ExchangeId {
    BITHUMB,
    UPBIT;

    companion object {
        fun from(raw: String?): ExchangeId = when (raw?.trim()?.uppercase()) {
            "UPBIT" -> UPBIT
            else -> BITHUMB
        }
    }
}

object ExchangeIsolation {
    fun positionKey(exchange: ExchangeId, market: String): String =
        "${exchange.name}:${market.trim().uppercase()}"

    fun positionKey(exchange: String?, market: String): String =
        positionKey(ExchangeId.from(exchange), market)

    fun duplicateKey(exchange: ExchangeId, market: String, decisionId: String): String =
        "${exchange.name}:${market.trim().uppercase()}:${decisionId.trim()}"

    fun reentryKey(exchange: ExchangeId, market: String): String =
        positionKey(exchange, market)

    fun parseExchange(positionKey: String): ExchangeId {
        val idx = positionKey.indexOf(':')
        if (idx <= 0) return ExchangeId.BITHUMB
        return ExchangeId.from(positionKey.substring(0, idx))
    }

    fun parseMarket(positionKey: String): String {
        val idx = positionKey.indexOf(':')
        if (idx < 0) return positionKey
        return positionKey.substring(idx + 1)
    }

    /** Holding on exchange A must never block BUY on exchange B for the same market. */
    fun alreadyHoldingBlocks(
        heldKeys: Set<String>,
        exchange: ExchangeId,
        market: String
    ): Boolean = positionKey(exchange, market) in heldKeys

    fun dailyLossBlocksSameExchangeOnly(
        lockedExchange: ExchangeId?,
        candidateExchange: ExchangeId
    ): Boolean = lockedExchange != null && lockedExchange == candidateExchange
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ExchangeIsolation.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ExecutionDataPipelineDiagnostics.kt =====
package com.example.bithumbtrader

import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong

/**
 * Execution data 공급/상태 매핑 진단.
 * 매매 임계값을 바꾸지 않으며, ScalpingExecutionEngine 우회도 하지 않는다.
 */
object TimestampUnits {
    /** Bithumb/Upbit epoch가 초 단위로 오면 ms로 보정. 이미 ms면 그대로. */
    fun toEpochMs(raw: Long): Long = when {
        raw <= 0L -> 0L
        raw < 10_000_000_000L -> raw * 1_000L
        else -> raw
    }

    fun ageMs(rawTimestamp: Long, now: Long = System.currentTimeMillis()): Long {
        val ts = toEpochMs(rawTimestamp)
        if (ts <= 0L) return Long.MAX_VALUE / 4
        return (now - ts).coerceAtLeast(0L)
    }
}

enum class WebSocketHealth { NORMAL, DEGRADED, WEBSOCKET_ZOMBIE, DISCONNECTED }

data class WebSocketHealthSnapshot(
    val health: WebSocketHealth,
    val status: String,
    val lastMessageAt: Long,
    val messageCount: Long,
    val messagesPerMinute: Double,
    val ageMs: Long
)

data class MarketExecutionGapTracker(
    val market: String,
    val firstSeenAt: Long,
    val lastSeenAt: Long,
    val consecutiveCount: Int,
    val durationMs: Long,
    val lastStatus: ExecutionDataStatus,
    val lastGaps: List<String>,
    val bugCandidate: Boolean
) {
    val label: String
        get() = when {
            bugCandidate -> "BUG_CANDIDATE"
            lastStatus == ExecutionDataStatus.WARMING_UP -> "WARMING_UP_EXECUTION_DATA"
            lastStatus == ExecutionDataStatus.DEGRADED -> "EXECUTION_DATA_DEGRADED"
            lastStatus == ExecutionDataStatus.MISSING -> "EXECUTION_DATA_MISSING"
            else -> "GOOD"
        }
}

data class ScalpingInputSnapshot(
    val market: String,
    val timestamp: Long,
    val tickerPrice: Double?,
    val tickerAgeMs: Long?,
    val orderbookAgeMs: Long?,
    val bidDepth: Double?,
    val askDepth: Double?,
    val imbalance: Double?,
    val buyPressure: Double?,
    val spread: Double?,
    val microSampleCount10s: Int,
    val microSampleCount30s: Int,
    val microSampleCount1m: Int,
    val tradeIntensity: Double?,
    val volumeAcceleration: Double?,
    val return10s: Double?,
    val return30s: Double?,
    val return1m: Double?,
    val return3m: Double?,
    val return5m: Double?,
    val atr: Double?,
    val momentum: Double?,
    val momentumSlope: Double?,
    val strategyScore: Double?,
    val aiScore: Double?,
    val entryTimingScore: Double?,
    val chaseScore: Double?,
    val shortEdge: Double?
) {
    fun formatLog(): String = buildString {
        append("SCALP_INPUT_SNAPSHOT market=$market ts=$timestamp")
        append(" tickerPrice=${tickerPrice ?: "null"} tickerAgeMs=${tickerAgeMs ?: "null"}")
        append(" orderbookAgeMs=${orderbookAgeMs ?: "null"} bidDepth=${bidDepth ?: "null"} askDepth=${askDepth ?: "null"}")
        append(" imbalance=${imbalance ?: "null"} buyPressure=${buyPressure ?: "null"} spread=${spread ?: "null"}")
        append(" micro10s=$microSampleCount10s micro30s=$microSampleCount30s micro1m=$microSampleCount1m")
        append(" tradeIntensity=${tradeIntensity ?: "null"} volumeAcceleration=${volumeAcceleration ?: "null"}")
        append(" r10s=${return10s ?: "null"} r30s=${return30s ?: "null"} r1m=${return1m ?: "null"} r3m=${return3m ?: "null"} r5m=${return5m ?: "null"}")
        append(" atr=${atr ?: "null"} momentum=${momentum ?: "null"} slope=${momentumSlope ?: "null"}")
        append(" strategy=${strategyScore ?: "null"} ai=${aiScore ?: "null"} timing=${entryTimingScore ?: "null"} chase=${chaseScore ?: "null"}")
        append(" shortEdge=${shortEdge ?: "null"}")
    }
}

data class ExecutionStateRateStats(
    val windowLabel: String,
    val total: Int,
    val counts: Map<String, Int>,
    val executionDataInsufficientRate: Double?,
    val pipelineDegraded: Boolean,
    val note: String = ""
) {
    companion object {
        fun noRuntime(windowLabel: String) = ExecutionStateRateStats(
            windowLabel = windowLabel,
            total = 0,
            counts = emptyMap(),
            executionDataInsufficientRate = null,
            pipelineDegraded = false,
            note = "NO_RUNTIME_DATA"
        )
    }
}

object ExecutionDataPipelineDiagnostics {
    const val BUG_CANDIDATE_DURATION_MS = 5 * 60_000L
    const val ZOMBIE_SILENCE_MS = 60_000L
    const val DEGRADED_SILENCE_MS = 15_000L

    private val gapTrackers = ConcurrentHashMap<String, MarketExecutionGapTracker>()
    private val stateEvents = java.util.concurrent.CopyOnWriteArrayList<Pair<Long, String>>()
    private val messageCount = AtomicLong(0L)
    private val restFallbackTriggered = AtomicLong(0L)
    private val restFallbackSuccess = AtomicLong(0L)
    private val restFallbackFailed = AtomicLong(0L)
    private val lastSnapshots = ConcurrentHashMap<String, ScalpingInputSnapshot>()

    fun recordWebSocketMessage() {
        messageCount.incrementAndGet()
    }

    fun webSocketHealth(
        connectionStatus: ConnectionStatus,
        lastMessageAt: Long,
        now: Long = System.currentTimeMillis(),
        messages: Long = messageCount.get()
    ): WebSocketHealthSnapshot {
        val age = if (lastMessageAt <= 0L) Long.MAX_VALUE / 4 else (now - lastMessageAt).coerceAtLeast(0L)
        val mpm = if (age <= 0L) 0.0 else messages.toDouble() / (age.coerceAtLeast(1L).toDouble() / 60_000.0)
        val health = when {
            connectionStatus == ConnectionStatus.DISCONNECTED || connectionStatus == ConnectionStatus.ERROR ->
                WebSocketHealth.DISCONNECTED
            connectionStatus == ConnectionStatus.CONNECTED && lastMessageAt > 0L && age >= ZOMBIE_SILENCE_MS ->
                WebSocketHealth.WEBSOCKET_ZOMBIE
            connectionStatus == ConnectionStatus.CONNECTED && (lastMessageAt <= 0L || age >= DEGRADED_SILENCE_MS) ->
                WebSocketHealth.DEGRADED
            connectionStatus == ConnectionStatus.CONNECTED -> WebSocketHealth.NORMAL
            else -> WebSocketHealth.DEGRADED
        }
        return WebSocketHealthSnapshot(health, connectionStatus.name, lastMessageAt, messages, mpm, age)
    }

    fun recordRestFallback(triggered: Boolean, success: Boolean?, latencyMs: Long): List<String> {
        if (!triggered) return emptyList()
        return when (success) {
            null -> {
                restFallbackTriggered.incrementAndGet()
                listOf("REST_FALLBACK_TRIGGERED")
            }
            true -> {
                restFallbackSuccess.incrementAndGet()
                listOf("REST_FALLBACK_SUCCESS REST_FALLBACK_LATENCY_MS=$latencyMs")
            }
            false -> {
                restFallbackFailed.incrementAndGet()
                listOf("REST_FALLBACK_FAILED REST_FALLBACK_LATENCY_MS=$latencyMs")
            }
        }
    }

    fun restFallbackCounters(): Triple<Long, Long, Long> =
        Triple(restFallbackTriggered.get(), restFallbackSuccess.get(), restFallbackFailed.get())

    fun recordGap(
        market: String,
        status: ExecutionDataStatus,
        gaps: List<String>,
        now: Long = System.currentTimeMillis()
    ): MarketExecutionGapTracker? {
        if (status == ExecutionDataStatus.GOOD) {
            gapTrackers.remove(market)
            return null
        }
        val prev = gapTrackers[market]
        val first = prev?.firstSeenAt ?: now
        val count = (prev?.consecutiveCount ?: 0) + 1
        val duration = now - first
        val tracker = MarketExecutionGapTracker(
            market = market,
            firstSeenAt = first,
            lastSeenAt = now,
            consecutiveCount = count,
            durationMs = duration,
            lastStatus = status,
            lastGaps = gaps,
            bugCandidate = duration >= BUG_CANDIDATE_DURATION_MS
        )
        gapTrackers[market] = tracker
        return tracker
    }

    fun gapTracker(market: String): MarketExecutionGapTracker? = gapTrackers[market]

    fun recordState(state: String, now: Long = System.currentTimeMillis()) {
        stateEvents.add(now to state)
        val cutoff = now - 3 * 60 * 60_000L
        stateEvents.removeAll { it.first < cutoff }
    }

    fun rateStats(windowMs: Long, now: Long = System.currentTimeMillis(), label: String): ExecutionStateRateStats {
        val rows = stateEvents.filter { now - it.first <= windowMs }
        if (rows.isEmpty()) return ExecutionStateRateStats.noRuntime(label)
        val counts = rows.groupingBy { it.second }.eachCount()
        val insufficient = counts.filterKeys {
            it == ScalpingExecutionState.DATA_INSUFFICIENT.name ||
                it == "EXECUTION_DATA_INSUFFICIENT" ||
                it.contains("DATA_INSUFFICIENT")
        }.values.sum()
        val rate = insufficient.toDouble() / rows.size
        return ExecutionStateRateStats(
            windowLabel = label,
            total = rows.size,
            counts = counts,
            executionDataInsufficientRate = rate,
            pipelineDegraded = rate >= 0.50,
            note = if (rate >= 0.50) "EXECUTION_DATA_PIPELINE_DEGRADED" else "OK"
        )
    }

    fun countMicroWindows(samples: List<MicroMarketSample>, now: Long = System.currentTimeMillis()): Triple<Int, Int, Int> {
        val c10 = samples.count { now - it.time <= 10_000L }
        val c30 = samples.count { now - it.time <= 30_000L }
        val c1m = samples.count { now - it.time <= 60_000L }
        return Triple(c10, c30, c1m)
    }

    fun buildInputSnapshot(
        market: String,
        now: Long,
        tickerPrice: Double?,
        tickerAgeMs: Long?,
        orderbookAgeMs: Long?,
        bidDepth: Double?,
        askDepth: Double?,
        imbalance: Double?,
        buyPressure: Double?,
        spread: Double?,
        samples: List<MicroMarketSample>,
        tradeIntensity: Double?,
        volumeAcceleration: Double?,
        flow: MicroFlowMetrics,
        atr: Double?,
        momentum: Double?,
        momentumSlope: Double?,
        strategyScore: Double?,
        aiScore: Double?,
        entryTimingScore: Double?,
        chaseScore: Double?,
        shortEdge: Double?
    ): ScalpingInputSnapshot {
        val (c10, c30, c1m) = countMicroWindows(samples, now)
        val snap = ScalpingInputSnapshot(
            market = market,
            timestamp = now,
            tickerPrice = tickerPrice,
            tickerAgeMs = tickerAgeMs,
            orderbookAgeMs = orderbookAgeMs,
            bidDepth = bidDepth,
            askDepth = askDepth,
            imbalance = imbalance,
            buyPressure = buyPressure,
            spread = spread,
            microSampleCount10s = c10,
            microSampleCount30s = c30,
            microSampleCount1m = c1m,
            tradeIntensity = tradeIntensity,
            volumeAcceleration = volumeAcceleration,
            return10s = flow.return10s.takeUnless { samples.isEmpty() },
            return30s = flow.return30s.takeUnless { samples.isEmpty() },
            return1m = flow.return1m.takeUnless { samples.isEmpty() },
            return3m = flow.return3m.takeUnless { samples.isEmpty() },
            return5m = flow.return5m.takeUnless { samples.isEmpty() },
            atr = atr,
            momentum = momentum,
            momentumSlope = momentumSlope,
            strategyScore = strategyScore,
            aiScore = aiScore,
            entryTimingScore = entryTimingScore,
            chaseScore = chaseScore,
            shortEdge = shortEdge
        )
        lastSnapshots[market] = snap
        return snap
    }

    fun lastSnapshot(market: String): ScalpingInputSnapshot? = lastSnapshots[market]

    fun clearForTests() {
        gapTrackers.clear()
        stateEvents.clear()
        lastSnapshots.clear()
        messageCount.set(0L)
        restFallbackTriggered.set(0L)
        restFallbackSuccess.set(0L)
        restFallbackFailed.set(0L)
    }

    fun fieldStatuses(
        tickerPresent: Boolean,
        tickerAgeMs: Long,
        orderbookPresent: Boolean,
        orderbookAgeMs: Long,
        bidDepth: Double,
        askDepth: Double,
        microCount: Int,
        tradeIntensity: Double
    ): Map<String, String> {
        fun ageStatus(present: Boolean, age: Long, maxAge: Long) = when {
            !present -> "MISSING"
            age > maxAge -> "STALE"
            else -> "AVAILABLE"
        }
        return mapOf(
            "ticker" to if (tickerPresent) "AVAILABLE" else "MISSING",
            "tickerFreshness" to ageStatus(tickerPresent, tickerAgeMs, ScalpingExecutionEngine.MAX_TICKER_AGE_MS),
            "orderbook" to if (orderbookPresent) "AVAILABLE" else "MISSING",
            "orderbookFreshness" to ageStatus(orderbookPresent, orderbookAgeMs, ScalpingExecutionEngine.MAX_ORDERBOOK_AGE_MS),
            "orderbookDepth" to when {
                !orderbookPresent -> "MISSING"
                bidDepth <= 0.0 || askDepth <= 0.0 -> "INSUFFICIENT"
                else -> "AVAILABLE"
            },
            "bidDepth" to if (bidDepth > 0.0) "AVAILABLE" else "MISSING",
            "askDepth" to if (askDepth > 0.0) "AVAILABLE" else "MISSING",
            "microSampleCount" to when {
                microCount <= 0 -> "MISSING"
                microCount < ScalpingExecutionEngine.MIN_MICRO_SAMPLES_ENTER -> "INSUFFICIENT"
                else -> "AVAILABLE"
            },
            "tradeIntensity" to when {
                tradeIntensity > 0.0 -> "AVAILABLE"
                microCount < ScalpingExecutionEngine.MIN_MICRO_SAMPLES_ENTER -> "INSUFFICIENT"
                else -> "MISSING"
            }
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ExecutionDataPipelineDiagnostics.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ExitOptimization.kt =====
package com.example.bithumbtrader

import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

enum class LossRootCause {
    BAD_ENTRY,
    EARLY_STOP,
    EARLY_TRAILING,
    BAD_EXIT,
    REAL_BAD_TRADE,
    EXECUTION_COST_LOSS,
    FALSE_BREAKOUT,
    REGIME_CHANGE,
    LIQUIDITY_FAILURE,
    NEWS_EVENT,
    CRASH_EVENT,
    REENTRY_FAILURE,
    UNKNOWN
}

enum class StopQuality {
    GOOD_STOP,
    NEUTRAL_STOP,
    EARLY_STOP
}

enum class TrailingQuality {
    TRAILING_GOOD,
    TRAILING_TOO_TIGHT,
    TRAILING_TOO_LATE,
    NOT_APPLICABLE
}

enum class NoiseStopStatus {
    NORMAL_NOISE,
    POSSIBLE_FAILURE,
    CONFIRMED_FAILURE
}

enum class EntryTimingMaturity {
    EARLY_ENTRY,
    GOOD_ENTRY,
    LATE_ENTRY,
    CHASE_ENTRY,
    CHASE_ENTRY_WITH_NORMAL_PULLBACK
}

enum class CounterfactualExitType {
    CURRENT_EXIT,
    STOP_MINUS_2_5,
    STOP_MINUS_3_0,
    STOP_MINUS_4_0,
    ATR_STOP,
    VOLATILITY_ADAPTIVE_STOP,
    CURRENT_TRAILING,
    WIDER_TRAILING,
    ATR_TRAILING,
    SCORE_DROP_EXIT,
    TIME_30M,
    TIME_60M,
    REGIME_EXIT,
    NO_EARLY_STOP_SHADOW
}

enum class ExitValidationStatus {
    RESEARCH,
    SHADOW,
    PAPER_CANDIDATE,
    PAPER_TESTING,
    PAPER_CHAMPION,
    REJECTED
}

data class PostExitHorizon(val minutes: Int)

object PostExitHorizons {
    val ALL = listOf(1, 3, 5, 10, 15, 30, 60)
}

data class LossRootCauseSummary(
    val totalTrades: Int = 0,
    val lossTrades: Int = 0,
    val causePercentages: Map<String, Double> = emptyMap(),
    val causeCounts: Map<String, Int> = emptyMap()
)

data class ExitQualityStats(
    val sampleCount: Int = 0,
    val earlyStopRate: Double = 0.0,
    val earlyTrailingRate: Double = 0.0,
    val goodStopRate: Double = 0.0,
    val mfeCaptureRatio: Double = 0.0,
    val profitGivebackPercent: Double = 0.0,
    val exitEfficiencyScore: Double = 0.0,
    val postExitMeanReturns: Map<Int, Double> = emptyMap(),
    val postExitMedianReturns: Map<Int, Double> = emptyMap(),
    val stopLossPost30mAvg: Double = 0.0,
    val stopLossPost60mAvg: Double = 0.0,
    val trailingPost30mAvg: Double = 0.0,
    val trailingPost60mAvg: Double = 0.0,
    val sampleTooSmall: Boolean = true
)

data class CounterfactualExitSummary(
    val type: CounterfactualExitType,
    val tradeCount: Int = 0,
    val winRate: Double = 0.0,
    val netReturnPercent: Double = 0.0,
    val profitFactor: Double = 0.0,
    val expectancyPercent: Double = 0.0,
    val mddPercent: Double = 0.0,
    val mfeCaptureRatio: Double = 0.0,
    val averageHoldingMinutes: Double = 0.0,
    val status: ExitValidationStatus = ExitValidationStatus.RESEARCH
)

data class ExitChampionChallengerState(
    val champion: String = "CURRENT_EXIT",
    val challenger: String = "-",
    val status: String = "RESEARCH",
    val championExpectancy: Double = 0.0,
    val challengerExpectancy: Double = 0.0,
    val sampleCount: Int = 0,
    val reason: String = "표본 부족 — 정책 자동변경 금지 (INSUFFICIENT_SAMPLE)"
)

object LossRootCauseEngine {

    fun analyze(
        pnlRate: Double,
        exitReason: String,
        mfePercent: Double,
        maePercent: Double,
        holdingMinutes: Long,
        postExit30mChangeFromExit: Double?,
        postExit60mChangeFromExit: Double?,
        marketRegime: String,
        marketHealthScore: Double,
        newsRiskActive: Boolean,
        liquidityPassed: Boolean,
        executionCostPercent: Double,
        entryMaturity: EntryTimingMaturity = EntryTimingMaturity.GOOD_ENTRY
    ): Pair<LossRootCause, String> {
        val post30 = postExit30mChangeFromExit ?: 0.0
        val post60 = postExit60mChangeFromExit ?: 0.0

        if (pnlRate >= 0.0) {
            return Pair(LossRootCause.UNKNOWN, "수익 실현 종료")
        }

        return when {
            newsRiskActive -> {
                Pair(LossRootCause.NEWS_EVENT, "악재 뉴스 발생으로 인한 손실 종료")
            }
            marketRegime == "CRASH" || marketHealthScore < 40.0 -> {
                Pair(LossRootCause.CRASH_EVENT, "시장 급락(CRASH) 국면 동반 손실")
            }
            !liquidityPassed -> {
                Pair(LossRootCause.LIQUIDITY_FAILURE, "유동성 부족/스프레드 확대로 인한 손실")
            }
            executionCostPercent > abs(pnlRate) * 0.7 && abs(pnlRate) <= 1.0 -> {
                Pair(LossRootCause.EXECUTION_COST_LOSS, "수수료·슬리피지·스프레드 비용 과다로 인한 손실")
            }
            entryMaturity == EntryTimingMaturity.CHASE_ENTRY || entryMaturity == EntryTimingMaturity.LATE_ENTRY -> {
                Pair(LossRootCause.BAD_ENTRY, "급등 후 꼭대기 추격 매수(Chase/Late Entry)")
            }
            exitReason.contains("TRAILING") && (post30 > 2.0 || post60 > 3.0) -> {
                Pair(LossRootCause.EARLY_TRAILING, "Trailing Stop이 너무 타이트하여 상승 전 조기 청산")
            }
            (exitReason.contains("STOP LOSS") || exitReason.contains("STOP_LOSS")) && (post30 > 2.5 || post60 > 4.0) -> {
                Pair(LossRootCause.EARLY_STOP, "정상 변동성 구간에서 조기 손절 후 가격 회복 (Early Stop)")
            }
            mfePercent < 0.5 && maePercent < -2.5 && (post30 < 0.0 || post60 < 0.0) -> {
                Pair(LossRootCause.REAL_BAD_TRADE, "진입 후 상승 없이 지속 하락한 진짜 불량 거래")
            }
            mfePercent in 0.5..1.5 && maePercent < -2.0 -> {
                Pair(LossRootCause.FALSE_BREAKOUT, "돌파 후 즉시 되돌림 발생(가짜 돌파)")
            }
            marketRegime in setOf("STRONG_BEAR", "WEAK_BEAR", "BEAR") -> {
                Pair(LossRootCause.REGIME_CHANGE, "하락장 국면 전환으로 인한 추세 훼손")
            }
            else -> {
                Pair(LossRootCause.UNKNOWN, "일반 손실 거래")
            }
        }
    }

    fun summarize(records: List<LossRootCauseRecordEntity>): LossRootCauseSummary {
        if (records.isEmpty()) return LossRootCauseSummary()
        val losses = records.filter { it.pnlRate < 0.0 }
        val counts = records.groupBy { it.rootCause }.mapValues { it.value.size }
        val percentages = if (records.isNotEmpty()) {
            counts.mapValues { (it.value.toDouble() / records.size) * 100.0 }
        } else emptyMap()
        return LossRootCauseSummary(records.size, losses.size, percentages, counts)
    }
}

object StopQualityEngine {

    fun evaluateStop(
        exitReason: String,
        pnlRate: Double,
        postExit30mPriceFromEntry: Double?,
        postExit60mPriceFromEntry: Double?
    ): StopQuality {
        if (!exitReason.contains("STOP")) return StopQuality.NEUTRAL_STOP
        val p30 = postExit30mPriceFromEntry ?: 0.0
        val p60 = postExit60mPriceFromEntry ?: 0.0

        return when {
            p30 > 1.5 || p60 > 2.5 -> StopQuality.EARLY_STOP
            p30 < -2.0 || p60 < -3.0 -> StopQuality.GOOD_STOP
            else -> StopQuality.NEUTRAL_STOP
        }
    }

    fun evaluateTrailing(
        exitReason: String,
        mfePercent: Double,
        pnlRate: Double,
        postExit30mChangeFromExit: Double?,
        postExit60mChangeFromExit: Double?
    ): TrailingQuality {
        if (!exitReason.contains("TRAILING")) return TrailingQuality.NOT_APPLICABLE
        val p30 = postExit30mChangeFromExit ?: 0.0
        val p60 = postExit60mChangeFromExit ?: 0.0
        val giveback = mfePercent - pnlRate

        return when {
            p30 > 2.0 || p60 > 3.0 -> TrailingQuality.TRAILING_TOO_TIGHT
            mfePercent >= 4.0 && giveback >= 3.0 -> TrailingQuality.TRAILING_TOO_LATE
            else -> TrailingQuality.TRAILING_GOOD
        }
    }

    fun calculateMfeCaptureRatio(mfePercent: Double, realizedPnlRate: Double): Double {
        if (mfePercent <= 0.0) return if (realizedPnlRate >= 0.0) 1.0 else 0.0
        return (realizedPnlRate / mfePercent).coerceIn(-1.0, 1.0)
    }

    fun calculateProfitGiveback(mfePercent: Double, realizedPnlRate: Double): Double {
        if (mfePercent <= 0.0) return 0.0
        return max(0.0, mfePercent - realizedPnlRate)
    }
}

object ChaseEntryDetector {
    fun detect(
        currentPrice: Double,
        closes: List<Double>,
        ema20: Double,
        rsi: Double,
        volumeRatio: Double
    ): EntryTimingMaturity {
        if (closes.size < 6 || currentPrice <= 0.0) return EntryTimingMaturity.GOOD_ENTRY
        val recentSpike = if (closes[closes.size - 4] > 0.0) (currentPrice / closes[closes.size - 4] - 1.0) * 100.0 else 0.0
        val emaDistance = if (ema20 > 0.0) (currentPrice / ema20 - 1.0) * 100.0 else 0.0

        return when {
            recentSpike >= 5.0 && rsi >= 78.0 && emaDistance >= 4.0 -> EntryTimingMaturity.CHASE_ENTRY
            recentSpike >= 3.0 && rsi >= 72.0 && volumeRatio >= 3.0 -> EntryTimingMaturity.CHASE_ENTRY_WITH_NORMAL_PULLBACK
            emaDistance >= 3.0 || rsi >= 70.0 -> EntryTimingMaturity.LATE_ENTRY
            recentSpike in 0.5..2.5 && rsi in 45.0..65.0 -> EntryTimingMaturity.GOOD_ENTRY
            else -> EntryTimingMaturity.EARLY_ENTRY
        }
    }
}

object NoiseStopDetector {
    fun detect(
        atrPercent: Double,
        maePercent: Double,
        holdingMinutes: Long,
        exitReason: String
    ): NoiseStopStatus {
        if (!exitReason.contains("STOP")) return NoiseStopStatus.NORMAL_NOISE
        val normalNoiseBand = max(1.5, atrPercent * 1.5)
        return when {
            abs(maePercent) <= normalNoiseBand && holdingMinutes <= 10 -> NoiseStopStatus.NORMAL_NOISE
            abs(maePercent) <= normalNoiseBand * 1.5 -> NoiseStopStatus.POSSIBLE_FAILURE
            else -> NoiseStopStatus.CONFIRMED_FAILURE
        }
    }
}

object MathStatisticsUtil {
    fun mean(values: List<Double>): Double {
        val clean = values.filter { it.isFinite() }
        if (clean.isEmpty()) return 0.0
        return clean.average()
    }

    fun median(values: List<Double>): Double {
        val clean = values.filter { it.isFinite() }.sorted()
        if (clean.isEmpty()) return 0.0
        val size = clean.size
        return if (size % 2 == 1) clean[size / 2]
        else (clean[size / 2 - 1] + clean[size / 2]) / 2.0
    }

    fun trimmedMean(values: List<Double>, trimPercent: Double = 0.10): Double {
        val clean = values.filter { it.isFinite() }.sorted()
        if (clean.size < 5) return mean(clean)
        val k = (clean.size * trimPercent).toInt().coerceAtMost((clean.size - 1) / 2)
        val trimmed = clean.subList(k, clean.size - k)
        return if (trimmed.isNotEmpty()) trimmed.average() else mean(clean)
    }
}

object CounterfactualExitSimulator {

    data class SimulatedExit(
        val pnlRate: Double,
        val reason: String,
        val holdingMinutes: Long
    )

    fun simulate(
        type: CounterfactualExitType,
        entryPrice: Double,
        highestPrice: Double,
        lowestPrice: Double,
        currentOrFinalPrice: Double,
        actualPnlRate: Double,
        actualExitReason: String,
        actualHoldingMinutes: Long,
        atrPercent: Double = 1.5,
        post30mPrice: Double? = null,
        post60mPrice: Double? = null
    ): SimulatedExit {
        if (entryPrice <= 0.0) return SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)

        val mfe = if (entryPrice > 0) (highestPrice / entryPrice - 1.0) * 100.0 else 0.0
        val mae = if (entryPrice > 0) (lowestPrice / entryPrice - 1.0) * 100.0 else 0.0

        return when (type) {
            CounterfactualExitType.CURRENT_EXIT -> {
                SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.STOP_MINUS_2_5 -> {
                if (mae <= -2.5) SimulatedExit(-2.5, "STOP_-2.5", min(actualHoldingMinutes, 15L))
                else if (mfe >= 6.0) SimulatedExit(6.0, "TAKE_PROFIT", actualHoldingMinutes)
                else SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.STOP_MINUS_3_0 -> {
                if (mae <= -3.0) SimulatedExit(-3.0, "STOP_-3.0", min(actualHoldingMinutes, 20L))
                else if (mfe >= 6.0) SimulatedExit(6.0, "TAKE_PROFIT", actualHoldingMinutes)
                else {
                    val pnl = if (post30mPrice != null && post30mPrice > 0) (post30mPrice / entryPrice - 1.0) * 100.0 else actualPnlRate
                    SimulatedExit(pnl, "HELD_POST_3.0_STOP", actualHoldingMinutes + 30)
                }
            }
            CounterfactualExitType.STOP_MINUS_4_0 -> {
                if (mae <= -4.0) SimulatedExit(-4.0, "STOP_-4.0", min(actualHoldingMinutes, 25L))
                else if (mfe >= 6.0) SimulatedExit(6.0, "TAKE_PROFIT", actualHoldingMinutes)
                else {
                    val pnl = if (post60mPrice != null && post60mPrice > 0) (post60mPrice / entryPrice - 1.0) * 100.0 else actualPnlRate
                    SimulatedExit(pnl, "HELD_POST_4.0_STOP", actualHoldingMinutes + 60)
                }
            }
            CounterfactualExitType.ATR_STOP -> {
                val stopDist = max(2.0, atrPercent * 2.0)
                if (abs(mae) >= stopDist) SimulatedExit(-stopDist, "ATR_STOP", actualHoldingMinutes)
                else if (mfe >= 6.0) SimulatedExit(6.0, "TAKE_PROFIT", actualHoldingMinutes)
                else SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.VOLATILITY_ADAPTIVE_STOP -> {
                val stopDist = max(2.0, min(4.5, atrPercent * 2.2))
                if (abs(mae) >= stopDist) SimulatedExit(-stopDist, "VOL_ADAPTIVE_STOP", actualHoldingMinutes)
                else if (mfe >= 6.0) SimulatedExit(6.0, "TAKE_PROFIT", actualHoldingMinutes)
                else SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.CURRENT_TRAILING -> {
                SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.WIDER_TRAILING -> {
                val trailingDist = 3.5
                if (mfe >= 3.5 && (mfe - actualPnlRate) >= trailingDist) {
                    SimulatedExit(max(0.5, mfe - trailingDist), "WIDER_TRAILING", actualHoldingMinutes + 10)
                } else {
                    SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
                }
            }
            CounterfactualExitType.ATR_TRAILING -> {
                val trailingDist = max(2.0, atrPercent * 1.8)
                if (mfe >= trailingDist && (mfe - actualPnlRate) >= trailingDist) {
                    SimulatedExit(max(0.5, mfe - trailingDist), "ATR_TRAILING", actualHoldingMinutes + 10)
                } else {
                    SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
                }
            }
            CounterfactualExitType.SCORE_DROP_EXIT -> {
                SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.TIME_30M -> {
                val pnl = if (post30mPrice != null && post30mPrice > 0) (post30mPrice / entryPrice - 1.0) * 100.0 else actualPnlRate
                SimulatedExit(pnl, "TIME_30M_EXIT", 30L)
            }
            CounterfactualExitType.TIME_60M -> {
                val pnl = if (post60mPrice != null && post60mPrice > 0) (post60mPrice / entryPrice - 1.0) * 100.0 else actualPnlRate
                SimulatedExit(pnl, "TIME_60M_EXIT", 60L)
            }
            CounterfactualExitType.REGIME_EXIT -> {
                SimulatedExit(actualPnlRate, actualExitReason, actualHoldingMinutes)
            }
            CounterfactualExitType.NO_EARLY_STOP_SHADOW -> {
                val pnl = if (post60mPrice != null && post60mPrice > 0) (post60mPrice / entryPrice - 1.0) * 100.0
                else if (post30mPrice != null && post30mPrice > 0) (post30mPrice / entryPrice - 1.0) * 100.0
                else actualPnlRate
                SimulatedExit(pnl, "NO_EARLY_STOP_SHADOW", actualHoldingMinutes + 45)
            }
        }
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ExitOptimization.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/GlobalDerivatives.kt =====
package com.example.bithumbtrader

import androidx.room.Entity
import com.squareup.moshi.Json
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import retrofit2.Response as RetrofitResponse
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.max

enum class DerivativeSupportStatus { SUPPORTED, UNSUPPORTED, TEMP_UNAVAILABLE }
enum class DerivativesProviderStatus { CONNECTED, DEGRADED, REST_FALLBACK, UNAVAILABLE }
enum class DerivativeFreshness { FRESH, DEGRADED, STALE }
enum class DerivativesPositioningState {
    HEALTHY_LONG, EARLY_LONG_BUILDUP, CROWDED_LONG, LONG_SQUEEZE_RISK,
    HEALTHY_SHORT, EARLY_SHORT_BUILDUP, CROWDED_SHORT, SHORT_SQUEEZE_POTENTIAL,
    NEUTRAL, CHAOTIC, DATA_UNAVAILABLE
}
enum class FundingState { STRONG_NEGATIVE, NEGATIVE, NEUTRAL, POSITIVE, STRONG_POSITIVE, EXTREME }
enum class OpenInterestState { OI_RISING, OI_FALLING, OI_SURGE, OI_COLLAPSE, OI_STABLE, UNKNOWN }
enum class LiquidationState { LONG_LIQUIDATION, SHORT_LIQUIDATION, LIQUIDATION_CASCADE, NORMAL, UNAVAILABLE }
enum class SpotFuturesDivergence { NONE, SYNCHRONIZED, SPOT_LEADING, FUTURES_LEADING, DIVERGENCE }
enum class GlobalLeadState { BYBIT_LEADING, BITHUMB_LEADING, SYNCHRONIZED, NO_CLEAR_LEAD }
enum class GlobalDerivativesMarketState { GLOBAL_RISK_ON, GLOBAL_NEUTRAL, GLOBAL_RISK_OFF, GLOBAL_SQUEEZE, GLOBAL_LIQUIDATION_EVENT, DATA_UNAVAILABLE }

data class DerivativeMarketMapping(
    val bithumbMarket: String,
    val bybitSymbol: String?,
    val status: DerivativeSupportStatus
)

object DerivativeSymbolMapper {
    private val known = mapOf(
        "BTC" to "BTCUSDT", "ETH" to "ETHUSDT", "XRP" to "XRPUSDT",
        "SOL" to "SOLUSDT", "DOGE" to "DOGEUSDT", "ADA" to "ADAUSDT",
        "DOT" to "DOTUSDT", "AVAX" to "AVAXUSDT", "LINK" to "LINKUSDT",
        "TRX" to "TRXUSDT", "SUI" to "SUIUSDT", "TON" to "TONUSDT"
    )

    fun map(market: String): DerivativeMarketMapping {
        val base = market.removePrefix("KRW-").uppercase()
        return known[base]?.let { DerivativeMarketMapping(market, it, DerivativeSupportStatus.SUPPORTED) }
            ?: DerivativeMarketMapping(market, null, DerivativeSupportStatus.UNSUPPORTED)
    }
}

data class DerivativesDataSnapshot(
    val market: String,
    val symbol: String?,
    val supportStatus: DerivativeSupportStatus,
    val providerStatus: DerivativesProviderStatus,
    val freshness: DerivativeFreshness,
    val timestamp: Long,
    val ageMs: Long,
    val markPrice: Double? = null,
    val indexPrice: Double? = null,
    val lastPrice: Double? = null,
    val openInterest: Double? = null,
    val oiChange1m: Double? = null,
    val oiChange5m: Double? = null,
    val oiChange15m: Double? = null,
    val oiChange1h: Double? = null,
    val fundingRate: Double? = null,
    val nextFundingTime: Long? = null,
    val fundingChange: Double? = null,
    val fundingPercentile: Double? = null,
    val fundingState: FundingState = FundingState.NEUTRAL,
    val longRatio: Double? = null,
    val shortRatio: Double? = null,
    val derivativesVolume: Double? = null,
    val priceChangePercent: Double? = null,
    val basis: Double? = null,
    val premium: Double? = null,
    val longLiquidationIntensity: Double? = null,
    val shortLiquidationIntensity: Double? = null,
    val liquidationAcceleration: Double? = null,
    val liquidationState: LiquidationState = LiquidationState.UNAVAILABLE
)

@Entity(tableName = "derivatives_snapshots", primaryKeys = ["market", "timestamp"])
data class DerivativesSnapshotEntity(
    val market: String,
    val symbol: String?,
    val supportStatus: String,
    val providerStatus: String,
    val freshness: String,
    val timestamp: Long,
    val ageMs: Long,
    val markPrice: Double?,
    val indexPrice: Double?,
    val lastPrice: Double?,
    val openInterest: Double?,
    val oiChange1m: Double?,
    val oiChange5m: Double?,
    val oiChange15m: Double?,
    val oiChange1h: Double?,
    val fundingRate: Double?,
    val nextFundingTime: Long?,
    val fundingChange: Double?,
    val fundingPercentile: Double?,
    val fundingState: String,
    val longRatio: Double?,
    val shortRatio: Double?,
    val derivativesVolume: Double?,
    val priceChangePercent: Double?,
    val basis: Double?,
    val premium: Double?,
    val longLiquidationIntensity: Double?,
    val shortLiquidationIntensity: Double?,
    val liquidationAcceleration: Double?,
    val liquidationState: String
)

fun DerivativesDataSnapshot.toEntity() = DerivativesSnapshotEntity(
    market, symbol, supportStatus.name, providerStatus.name, freshness.name, timestamp, ageMs,
    markPrice, indexPrice, lastPrice, openInterest, oiChange1m, oiChange5m, oiChange15m, oiChange1h,
    fundingRate, nextFundingTime, fundingChange, fundingPercentile, fundingState.name, longRatio, shortRatio,
    derivativesVolume, priceChangePercent, basis, premium, longLiquidationIntensity, shortLiquidationIntensity,
    liquidationAcceleration, liquidationState.name
)

data class DerivativeHistorySample(val timestamp: Long, val price: Double, val openInterest: Double)

data class LeadLagResult(
    val state: GlobalLeadState = GlobalLeadState.NO_CLEAR_LEAD,
    val leadMs: Long? = null
)

data class GlobalDerivativesIntelligence(
    val market: String = "",
    val supportStatus: DerivativeSupportStatus,
    val providerStatus: DerivativesProviderStatus,
    val freshness: DerivativeFreshness,
    val derivativesSentiment: Double = 50.0,
    val derivativesRisk: Double = 50.0,
    val openInterestState: OpenInterestState = OpenInterestState.UNKNOWN,
    val fundingState: FundingState = FundingState.NEUTRAL,
    val positioningState: DerivativesPositioningState = DerivativesPositioningState.DATA_UNAVAILABLE,
    val shortSqueezeScore: Double = 0.0,
    val longSqueezeRisk: Double = 0.0,
    val spotFuturesDivergence: SpotFuturesDivergence = SpotFuturesDivergence.NONE,
    val globalLeadState: GlobalLeadState = GlobalLeadState.NO_CLEAR_LEAD,
    val leadTimeMs: Long? = null,
    val globalMoveConfirmed: Boolean = false,
    val derivativesConfidence: Double = 0.0,
    val liquidationState: LiquidationState = LiquidationState.UNAVAILABLE,
    val reasonCodes: List<String> = listOf("DATA_UNAVAILABLE"),
    val buyPenalty: Double = 0.0
)

data class BybitTickerDto(
    @Json(name = "symbol") val symbol: String?,
    @Json(name = "lastPrice") val lastPrice: String?,
    @Json(name = "indexPrice") val indexPrice: String?,
    @Json(name = "markPrice") val markPrice: String?,
    @Json(name = "price24hPcnt") val price24hPcnt: String?,
    @Json(name = "openInterest") val openInterest: String?,
    @Json(name = "fundingRate") val fundingRate: String?,
    @Json(name = "nextFundingTime") val nextFundingTime: String?,
    @Json(name = "turnover24h") val turnover24h: String?,
    @Json(name = "basis") val basis: String?,
    @Json(name = "bid1Size") val bid1Size: String?,
    @Json(name = "ask1Size") val ask1Size: String?
)
data class BybitTickerResult(@Json(name = "list") val list: List<BybitTickerDto>?)
data class BybitTickerResponse(val retCode: Int, val retMsg: String?, val result: BybitTickerResult?, val time: Long?)
data class BybitOiDto(@Json(name = "openInterest") val openInterest: String?, @Json(name = "timestamp") val timestamp: String?)
data class BybitOiResult(@Json(name = "list") val list: List<BybitOiDto>?)
data class BybitOiResponse(val retCode: Int, val retMsg: String?, val result: BybitOiResult?)
data class BybitFundingDto(@Json(name = "symbol") val symbol: String?, @Json(name = "fundingRate") val fundingRate: String?, @Json(name = "fundingRateTimestamp") val fundingRateTimestamp: String?)
data class BybitFundingResult(@Json(name = "list") val list: List<BybitFundingDto>?)
data class BybitFundingResponse(val retCode: Int, val retMsg: String?, val result: BybitFundingResult?)
data class BybitRatioDto(@Json(name = "symbol") val symbol: String?, @Json(name = "buyRatio") val buyRatio: String?, @Json(name = "sellRatio") val sellRatio: String?, @Json(name = "timestamp") val timestamp: String?)
data class BybitRatioResult(@Json(name = "list") val list: List<BybitRatioDto>?)
data class BybitRatioResponse(val retCode: Int, val retMsg: String?, val result: BybitRatioResult?)
data class BybitInstrumentDto(@Json(name = "symbol") val symbol: String?, @Json(name = "status") val status: String?, @Json(name = "contractType") val contractType: String?, @Json(name = "quoteCoin") val quoteCoin: String?)
data class BybitInstrumentResult(@Json(name = "list") val list: List<BybitInstrumentDto>?)
data class BybitInstrumentResponse(val retCode: Int, val retMsg: String?, val result: BybitInstrumentResult?)

interface BybitPublicMarketApi {
    @retrofit2.http.GET("v5/market/instruments-info")
    suspend fun instruments(@retrofit2.http.Query("category") category: String = "linear", @retrofit2.http.Query("limit") limit: Int = 1000): RetrofitResponse<BybitInstrumentResponse>
    @retrofit2.http.GET("v5/market/tickers")
    suspend fun ticker(@retrofit2.http.Query("category") category: String = "linear", @retrofit2.http.Query("symbol") symbol: String): RetrofitResponse<BybitTickerResponse>
    @retrofit2.http.GET("v5/market/open-interest")
    suspend fun openInterest(@retrofit2.http.Query("category") category: String = "linear", @retrofit2.http.Query("symbol") symbol: String, @retrofit2.http.Query("intervalTime") interval: String = "5min", @retrofit2.http.Query("limit") limit: Int = 50): RetrofitResponse<BybitOiResponse>
    @retrofit2.http.GET("v5/market/funding/history")
    suspend fun funding(@retrofit2.http.Query("category") category: String = "linear", @retrofit2.http.Query("symbol") symbol: String, @retrofit2.http.Query("limit") limit: Int = 20): RetrofitResponse<BybitFundingResponse>
    @retrofit2.http.GET("v5/market/account-ratio")
    suspend fun accountRatio(@retrofit2.http.Query("category") category: String = "linear", @retrofit2.http.Query("symbol") symbol: String, @retrofit2.http.Query("period") period: String = "5min", @retrofit2.http.Query("limit") limit: Int = 50): RetrofitResponse<BybitRatioResponse>
}

object BybitApiFactory {
    fun publicApi(client: OkHttpClient = ApiFactory.wsClient()): BybitPublicMarketApi {
        val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
        return Retrofit.Builder().baseUrl("https://api.bybit.com/").client(client)
            .addConverterFactory(MoshiConverterFactory.create(moshi))
            .build().create(BybitPublicMarketApi::class.java)
    }
}

data class BybitLiquidationMessage(
    @Json(name = "topic") val topic: String?,
    @Json(name = "data") val data: List<BybitLiquidationEvent>?
)
data class BybitLiquidationEvent(
    @Json(name = "T") val time: Long?,
    @Json(name = "S") val side: String?,
    @Json(name = "v") val size: String?,
    @Json(name = "p") val price: String?
)

class BybitLiquidationStream(
    private val client: OkHttpClient,
    private val url: String = "wss://stream.bybit.com/v5/public/linear"
) {
    private val adapter = Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(BybitLiquidationMessage::class.java)
    private val events = ConcurrentHashMap<String, ArrayDeque<BybitLiquidationEvent>>()
    @Volatile private var socket: WebSocket? = null
    @Volatile private var subscribedSymbols: Set<String> = emptySet()

    fun subscribe(symbols: Collection<String>) {
        val normalized = symbols.filter { it.isNotBlank() }.toSet()
        if (normalized == subscribedSymbols && socket != null) return
        subscribedSymbols = normalized
        val args = normalized.map { "allLiquidation.$it" }
        if (args.isEmpty()) return
        socket?.cancel()
        socket = client.newWebSocket(
            Request.Builder().url(url).build(),
            object : WebSocketListener() {
                override fun onOpen(webSocket: WebSocket, response: Response) {
                    webSocket.send("""{"op":"subscribe","args":[${args.joinToString(",") { "\"$it\"" }}]}""")
                }
                override fun onMessage(webSocket: WebSocket, text: String) { parse(text) }
                override fun onMessage(webSocket: WebSocket, bytes: ByteString) { parse(bytes.utf8()) }
            }
        )
    }

    fun recent(symbol: String, maxAgeMs: Long = 5 * 60_000L): List<BybitLiquidationEvent> {
        val cutoff = System.currentTimeMillis() - maxAgeMs
        return events[symbol]?.let { synchronized(it) { it.filter { event -> (event.time ?: 0L) >= cutoff } } }.orEmpty()
    }

    fun stop() { socket?.close(1000, "app stop"); socket = null; subscribedSymbols = emptySet() }

    private fun parse(text: String) {
        val message = runCatching { adapter.fromJson(text) }.getOrNull() ?: return
        val symbol = message.topic?.substringAfter("allLiquidation.") ?: return
        val queue = events.computeIfAbsent(symbol) { ArrayDeque() }
        synchronized(queue) {
            message.data.orEmpty().forEach { queue.addLast(it) }
            while (queue.size > 500) queue.removeFirst()
        }
    }
}

class BybitDerivativesDataProvider(
    private val api: BybitPublicMarketApi,
    private val limiter: CentralApiRateLimiter = CentralApiRateLimiter(150L),
    private val cacheTtlMs: Long = 30_000L,
    private val liquidationStream: BybitLiquidationStream? = null
) {
    private data class Cached(val snapshot: DerivativesDataSnapshot, val storedAt: Long)
    private val cache = ConcurrentHashMap<String, Cached>()
    private val history = ConcurrentHashMap<String, ArrayDeque<DerivativeHistorySample>>()
    @Volatile private var instruments: Set<String>? = null
    @Volatile private var instrumentLoadFailed = false

    suspend fun snapshot(market: String): DerivativesDataSnapshot {
        val mapping = DerivativeSymbolMapper.map(market)
        if (mapping.status == DerivativeSupportStatus.UNSUPPORTED) return unavailable(mapping, DerivativesProviderStatus.CONNECTED)
        val cached = cache[market]
        if (cached != null && System.currentTimeMillis() - cached.storedAt <= cacheTtlMs) return cached.snapshot
        val supported = ensureInstruments()
        if (supported == null) return unavailable(mapping, if (instrumentLoadFailed) DerivativesProviderStatus.UNAVAILABLE else DerivativesProviderStatus.DEGRADED)
        if (mapping.bybitSymbol !in supported) return unavailable(mapping.copy(status = DerivativeSupportStatus.UNSUPPORTED), DerivativesProviderStatus.CONNECTED)
        val symbol = mapping.bybitSymbol!!
        return try {
            limiter.acquire("bybit-ticker")
            val tickerResponse = api.ticker(symbol = symbol)
            val ticker = tickerResponse.body()?.takeIf { tickerResponse.isSuccessful && it.retCode == 0 }?.result?.list.orEmpty().firstOrNull()
                ?: return unavailable(mapping, DerivativesProviderStatus.REST_FALLBACK)
            limiter.acquire("bybit-open-interest")
            val oiResponse = api.openInterest(symbol = symbol)
            limiter.acquire("bybit-funding")
            val fundingResponse = api.funding(symbol = symbol)
            limiter.acquire("bybit-account-ratio")
            val ratioResponse = api.accountRatio(symbol = symbol)
            val now = tickerResponse.body()?.time ?: System.currentTimeMillis()
            val oiRows = oiResponse.body()?.result?.list.orEmpty().mapNotNull { row ->
                val time = row.timestamp?.toLongOrNull() ?: return@mapNotNull null
                val oi = row.openInterest?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 } ?: return@mapNotNull null
                time to oi
            }.sortedBy { it.first }
            val currentOi = ticker.openInterest?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 }
            val oiHistory = history.computeIfAbsent(market) { ArrayDeque() }
            currentOi?.let { value ->
                synchronized(oiHistory) {
                    oiHistory.addLast(DerivativeHistorySample(now, ticker.lastPrice?.toDoubleOrNull() ?: 0.0, value))
                    while (oiHistory.size > 200) oiHistory.removeFirst()
                }
            }
            val fundingRows = fundingResponse.body()?.result?.list.orEmpty().mapNotNull { it.fundingRate?.toDoubleOrNull()?.takeIf(Double::isFinite) }
            val latestFunding = ticker.fundingRate?.toDoubleOrNull()?.takeIf(Double::isFinite)
            val ratio = ratioResponse.body()?.result?.list.orEmpty().firstOrNull()
            val longRatio = ratio?.buyRatio?.toDoubleOrNull()?.takeIf { it.isFinite() && it in 0.0..1.0 }
            val shortRatio = ratio?.sellRatio?.toDoubleOrNull()?.takeIf { it.isFinite() && it in 0.0..1.0 }
            val mark = ticker.markPrice?.toDoubleOrNull()?.takeIf { it.isFinite() && it > 0.0 }
            val index = ticker.indexPrice?.toDoubleOrNull()?.takeIf { it.isFinite() && it > 0.0 }
            val last = ticker.lastPrice?.toDoubleOrNull()?.takeIf { it.isFinite() && it > 0.0 }
            val fundingState = fundingState(latestFunding)
            val liquidations = liquidationStream?.recent(symbol).orEmpty()
            val longLiq = liquidations.filter { it.side.equals("Sell", true) }.sumOf { it.size?.toDoubleOrNull()?.coerceAtLeast(0.0) ?: 0.0 }.takeIf { it > 0.0 }
            val shortLiq = liquidations.filter { it.side.equals("Buy", true) }.sumOf { it.size?.toDoubleOrNull()?.coerceAtLeast(0.0) ?: 0.0 }.takeIf { it > 0.0 }
            val liquidationState = when {
                longLiq != null && longLiq > 0.0 && longLiq >= (shortLiq ?: 0.0) * 2.0 -> LiquidationState.LONG_LIQUIDATION
                shortLiq != null && shortLiq > 0.0 && shortLiq >= (longLiq ?: 0.0) * 2.0 -> LiquidationState.SHORT_LIQUIDATION
                (longLiq ?: 0.0) + (shortLiq ?: 0.0) > 0.0 -> LiquidationState.NORMAL
                liquidationStream == null -> LiquidationState.UNAVAILABLE
                else -> LiquidationState.NORMAL
            }
            val snapshot = DerivativesDataSnapshot(
                market = market, symbol = symbol, supportStatus = DerivativeSupportStatus.SUPPORTED,
                providerStatus = DerivativesProviderStatus.CONNECTED, freshness = freshness(now),
                timestamp = now, ageMs = (System.currentTimeMillis() - now).coerceAtLeast(0L),
                markPrice = mark, indexPrice = index, lastPrice = last, openInterest = currentOi,
                oiChange1m = null,
                oiChange5m = changeFromHistory(oiRows, currentOi, now - 5 * 60_000L),
                oiChange15m = changeFromHistory(oiRows, currentOi, now - 15 * 60_000L),
                oiChange1h = changeFromHistory(oiRows, currentOi, now - 60 * 60_000L),
                fundingRate = latestFunding, nextFundingTime = ticker.nextFundingTime?.toLongOrNull(),
                fundingChange = if (fundingRows.size >= 2) (fundingRows.last() - fundingRows[fundingRows.lastIndex - 1]) else null,
                fundingPercentile = latestFunding?.let { percentile(it, fundingRows) },
                fundingState = fundingState, longRatio = longRatio, shortRatio = shortRatio,
                derivativesVolume = ticker.turnover24h?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 },
                priceChangePercent = ticker.price24hPcnt?.toDoubleOrNull()?.let { it * 100.0 }?.takeIf(Double::isFinite),
                basis = ticker.basis?.toDoubleOrNull()?.takeIf(Double::isFinite),
                premium = if (mark != null && index != null) (mark / index - 1.0) * 100.0 else null,
                longLiquidationIntensity = longLiq, shortLiquidationIntensity = shortLiq,
                liquidationState = liquidationState
            )
            cache[market] = Cached(snapshot, System.currentTimeMillis())
            snapshot
        } catch (_: Exception) {
            unavailable(mapping, DerivativesProviderStatus.DEGRADED)
        }
    }

    fun recentHistory(market: String): List<DerivativeHistorySample> =
        history[market]?.let { synchronized(it) { it.toList() } }.orEmpty()

    suspend fun subscribeLiquidations(markets: Collection<String>) {
        val supported = ensureInstruments().orEmpty()
        val symbols = markets.map { DerivativeSymbolMapper.map(it).bybitSymbol }
            .filterNotNull()
            .filter { it in supported }
            .distinct()
        liquidationStream?.subscribe(symbols)
    }

    fun stop() { liquidationStream?.stop() }

    private suspend fun ensureInstruments(): Set<String>? {
        instruments?.let { return it }
        return try {
            limiter.acquire("bybit-instruments")
            val response = api.instruments()
            val values = response.body()?.takeIf { response.isSuccessful && it.retCode == 0 }?.result?.list.orEmpty()
                .filter { it.status == "Trading" && it.contractType == "LinearPerpetual" && it.quoteCoin == "USDT" }
                .mapNotNull { it.symbol }.toSet()
            if (values.isEmpty()) { instrumentLoadFailed = true; null } else values.also { instruments = it }
        } catch (_: Exception) {
            instrumentLoadFailed = true
            null
        }
    }

    private fun unavailable(mapping: DerivativeMarketMapping, status: DerivativesProviderStatus) = DerivativesDataSnapshot(
        market = mapping.bithumbMarket, symbol = mapping.bybitSymbol, supportStatus = mapping.status,
        providerStatus = status, freshness = DerivativeFreshness.STALE, timestamp = 0L, ageMs = Long.MAX_VALUE
    )

    private fun freshness(timestamp: Long): DerivativeFreshness = when {
        timestamp <= 0L -> DerivativeFreshness.STALE
        System.currentTimeMillis() - timestamp <= 90_000L -> DerivativeFreshness.FRESH
        System.currentTimeMillis() - timestamp <= 5 * 60_000L -> DerivativeFreshness.DEGRADED
        else -> DerivativeFreshness.STALE
    }

    private fun changeFromHistory(rows: List<Pair<Long, Double>>, current: Double?, target: Long): Double? {
        if (current == null) return null
        val old = rows.minByOrNull { abs(it.first - target) }?.second ?: return null
        return if (old > 0.0) (current / old - 1.0) * 100.0 else null
    }

    private fun fundingState(rate: Double?): FundingState = when {
        rate == null -> FundingState.NEUTRAL
        abs(rate) >= 0.002 -> FundingState.EXTREME
        rate <= -0.001 -> FundingState.STRONG_NEGATIVE
        rate < -0.0003 -> FundingState.NEGATIVE
        rate >= 0.001 -> FundingState.STRONG_POSITIVE
        rate > 0.0003 -> FundingState.POSITIVE
        else -> FundingState.NEUTRAL
    }

    private fun percentile(value: Double, values: List<Double>): Double {
        if (values.isEmpty()) return 0.5
        val lower = values.count { it <= value }
        return lower.toDouble() / values.size
    }
}

object GlobalDerivativesIntelligenceEngine {
    fun evaluate(
        snapshot: DerivativesDataSnapshot?,
        spotChangePercent: Double,
        spotSamples: List<MicroMarketSample> = emptyList(),
        derivativeHistory: List<DerivativeHistorySample> = emptyList(),
        chaseScore: Double = 0.0,
        entryTimingScore: Double = 50.0,
        microMomentum: MicroMomentumState = MicroMomentumState.UNKNOWN,
        buyPressure: Double = 0.5,
        btcLongLiquidationCascade: Boolean = false
    ): GlobalDerivativesIntelligence {
        if (snapshot == null || snapshot.supportStatus != DerivativeSupportStatus.SUPPORTED ||
            snapshot.freshness == DerivativeFreshness.STALE || snapshot.lastPrice == null
        ) return GlobalDerivativesIntelligence(
            market = snapshot?.market.orEmpty(),
            supportStatus = snapshot?.supportStatus ?: DerivativeSupportStatus.TEMP_UNAVAILABLE,
            providerStatus = snapshot?.providerStatus ?: DerivativesProviderStatus.UNAVAILABLE,
            freshness = snapshot?.freshness ?: DerivativeFreshness.STALE
        )
        val priceChange = snapshot.priceChangePercent ?: 0.0
        val oiChange = snapshot.oiChange5m ?: 0.0
        val oiState = when {
            oiChange >= 5.0 -> OpenInterestState.OI_SURGE
            oiChange >= 1.0 -> OpenInterestState.OI_RISING
            oiChange <= -5.0 -> OpenInterestState.OI_COLLAPSE
            oiChange <= -1.0 -> OpenInterestState.OI_FALLING
            else -> OpenInterestState.OI_STABLE
        }
        val longRatio = snapshot.longRatio ?: 0.5
        val shortRatio = snapshot.shortRatio ?: 0.5
        val crowdedLong = priceChange > 1.5 && oiChange > 1.0 &&
            snapshot.fundingState in setOf(FundingState.STRONG_POSITIVE, FundingState.EXTREME) &&
            longRatio >= 0.62 && (microMomentum == MicroMomentumState.DECELERATING || chaseScore >= 65.0)
        val crowdedShort = priceChange < -1.5 && oiChange > 1.0 &&
            snapshot.fundingState in setOf(FundingState.STRONG_NEGATIVE, FundingState.EXTREME) &&
            shortRatio >= 0.62 && microMomentum == MicroMomentumState.DECELERATING
        val shortSqueeze = (
            (shortRatio - 0.5).coerceAtLeast(0.0) * 120.0 +
                (if (snapshot.fundingState in setOf(FundingState.STRONG_NEGATIVE, FundingState.EXTREME)) 25.0 else 0.0) +
                (if (abs(priceChange) < 1.0) 15.0 else 0.0) +
                (if (microMomentum == MicroMomentumState.ACCELERATING && buyPressure > 0.55) 25.0 else 0.0) +
                (if (oiState == OpenInterestState.OI_FALLING) 10.0 else 0.0)
            ).coerceIn(0.0, 100.0)
        val longSqueeze = (
            (longRatio - 0.5).coerceAtLeast(0.0) * 120.0 +
                (if (snapshot.fundingState in setOf(FundingState.STRONG_POSITIVE, FundingState.EXTREME)) 25.0 else 0.0) +
                (if (oiChange > 1.0) 15.0 else 0.0) +
                (if (microMomentum in setOf(MicroMomentumState.DECELERATING, MicroMomentumState.REVERSING)) 25.0 else 0.0) +
                (if (buyPressure < 0.45) 10.0 else 0.0)
            ).coerceIn(0.0, 100.0)
        val divergence = when {
            abs(spotChangePercent - priceChange) >= 3.0 -> SpotFuturesDivergence.DIVERGENCE
            spotChangePercent > 0.2 && priceChange > 0.2 -> SpotFuturesDivergence.SYNCHRONIZED
            spotChangePercent > 0.2 -> SpotFuturesDivergence.SPOT_LEADING
            priceChange > 0.2 -> SpotFuturesDivergence.FUTURES_LEADING
            else -> SpotFuturesDivergence.NONE
        }
        val lead = leadLag(spotSamples, derivativeHistory)
        val state = when {
            snapshot.providerStatus == DerivativesProviderStatus.UNAVAILABLE -> DerivativesPositioningState.DATA_UNAVAILABLE
            crowdedLong && longSqueeze >= 65.0 -> DerivativesPositioningState.LONG_SQUEEZE_RISK
            crowdedLong -> DerivativesPositioningState.CROWDED_LONG
            crowdedShort && shortSqueeze >= 65.0 -> DerivativesPositioningState.SHORT_SQUEEZE_POTENTIAL
            crowdedShort -> DerivativesPositioningState.CROWDED_SHORT
            priceChange > 0.5 && oiChange > 0.5 -> DerivativesPositioningState.EARLY_LONG_BUILDUP
            priceChange < -0.5 && oiChange > 0.5 -> DerivativesPositioningState.EARLY_SHORT_BUILDUP
            priceChange > 0.0 -> DerivativesPositioningState.HEALTHY_LONG
            priceChange < 0.0 -> DerivativesPositioningState.HEALTHY_SHORT
            else -> DerivativesPositioningState.NEUTRAL
        }
        val sentiment = (50.0 + priceChange.coerceIn(-10.0, 10.0) * 2.0 + oiChange.coerceIn(-10.0, 10.0) * 1.5 +
            (longRatio - shortRatio) * 35.0 + snapshot.fundingStateSentiment() +
            shortSqueeze * 0.12 - longSqueeze * 0.16).coerceIn(0.0, 100.0)
        val risk = (20.0 + longSqueeze * 0.55 + shortSqueeze * 0.20 +
            if (divergence == SpotFuturesDivergence.DIVERGENCE) 20.0 else 0.0 +
            if (snapshot.freshness == DerivativeFreshness.DEGRADED) 15.0 else 0.0 +
            if (btcLongLiquidationCascade) 25.0 else 0.0).coerceIn(0.0, 100.0)
        val confirmation = priceChange > 0.2 && oiChange > 0.2 &&
            snapshot.fundingState !in setOf(FundingState.EXTREME, FundingState.STRONG_POSITIVE) &&
            microMomentum == MicroMomentumState.ACCELERATING && chaseScore < 60.0 &&
            entryTimingScore >= 50.0 && divergence != SpotFuturesDivergence.DIVERGENCE
        val confidence = (45.0 + if (snapshot.oiChange5m != null) 15.0 else 0.0 +
            if (snapshot.longRatio != null) 10.0 else 0.0 +
            if (snapshot.fundingRate != null) 10.0 else 0.0 +
            if (lead.state != GlobalLeadState.NO_CLEAR_LEAD) 5.0 else 0.0 +
            if (snapshot.freshness == DerivativeFreshness.FRESH) 15.0 else 0.0).coerceIn(0.0, 100.0)
        val reasons = mutableListOf<String>()
        if (crowdedLong) reasons += "CROWDED_LONG"
        if (longSqueeze >= 65.0) reasons += "LONG_SQUEEZE_RISK"
        if (shortSqueeze >= 65.0) reasons += "SHORT_SQUEEZE_POTENTIAL"
        if (confirmation) reasons += "GLOBAL_MOVE_CONFIRMED"
        if (divergence == SpotFuturesDivergence.DIVERGENCE) reasons += "SPOT_FUTURES_DIVERGENCE"
        if (lead.state == GlobalLeadState.BYBIT_LEADING) reasons += "BYBIT_LEADING"
        if (reasons.isEmpty()) reasons += "DERIVATIVES_NEUTRAL"
        return GlobalDerivativesIntelligence(
            market = snapshot.market,
            supportStatus = snapshot.supportStatus, providerStatus = snapshot.providerStatus, freshness = snapshot.freshness,
            derivativesSentiment = sentiment, derivativesRisk = risk, openInterestState = oiState,
            fundingState = snapshot.fundingState, positioningState = state, shortSqueezeScore = shortSqueeze,
            longSqueezeRisk = longSqueeze, spotFuturesDivergence = divergence, globalLeadState = lead.state,
            leadTimeMs = lead.leadMs, globalMoveConfirmed = confirmation, derivativesConfidence = confidence,
            liquidationState = snapshot.liquidationState, reasonCodes = reasons,
            buyPenalty = if (state in setOf(DerivativesPositioningState.CROWDED_LONG, DerivativesPositioningState.LONG_SQUEEZE_RISK)) longSqueeze * 0.35 else 0.0
        )
    }

    fun leadLag(spot: List<MicroMarketSample>, futures: List<DerivativeHistorySample>): LeadLagResult {
        val spotMove = spot.firstOrNull { it.price > 0.0 } ?: return LeadLagResult()
        val futureMove = futures.firstOrNull { it.price > 0.0 } ?: return LeadLagResult()
        return when {
            futureMove.timestamp + 5_000L < spotMove.time -> LeadLagResult(GlobalLeadState.BYBIT_LEADING, spotMove.time - futureMove.timestamp)
            spotMove.time + 5_000L < futureMove.timestamp -> LeadLagResult(GlobalLeadState.BITHUMB_LEADING, futureMove.timestamp - spotMove.time)
            abs(futureMove.timestamp - spotMove.time) <= 5_000L -> LeadLagResult(GlobalLeadState.SYNCHRONIZED, 0L)
            else -> LeadLagResult()
        }
    }

    private fun DerivativesDataSnapshot.fundingStateSentiment(): Double = when (fundingState) {
        FundingState.STRONG_NEGATIVE -> -8.0
        FundingState.NEGATIVE -> -3.0
        FundingState.NEUTRAL -> 0.0
        FundingState.POSITIVE -> 3.0
        FundingState.STRONG_POSITIVE -> 7.0
        FundingState.EXTREME -> if ((fundingRate ?: 0.0) >= 0.0) 2.0 else -2.0
    }
}

object GlobalDerivativesMarketEngine {
    fun classify(items: List<GlobalDerivativesIntelligence>): GlobalDerivativesMarketState {
        val usable = items.filter {
            it.supportStatus == DerivativeSupportStatus.SUPPORTED &&
                it.freshness != DerivativeFreshness.STALE
        }
        if (usable.isEmpty()) return GlobalDerivativesMarketState.DATA_UNAVAILABLE
        if (usable.count { it.liquidationState == LiquidationState.LIQUIDATION_CASCADE } >= 2) {
            return GlobalDerivativesMarketState.GLOBAL_LIQUIDATION_EVENT
        }
        if (usable.count { it.positioningState in setOf(
                DerivativesPositioningState.LONG_SQUEEZE_RISK,
                DerivativesPositioningState.SHORT_SQUEEZE_POTENTIAL
            )
        } >= 2) return GlobalDerivativesMarketState.GLOBAL_SQUEEZE
        val averageSentiment = usable.map { it.derivativesSentiment }.average()
        val averageRisk = usable.map { it.derivativesRisk }.average()
        return when {
            averageRisk >= 70.0 -> GlobalDerivativesMarketState.GLOBAL_RISK_OFF
            averageSentiment >= 62.0 && averageRisk < 55.0 -> GlobalDerivativesMarketState.GLOBAL_RISK_ON
            else -> GlobalDerivativesMarketState.GLOBAL_NEUTRAL
        }
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/GlobalDerivatives.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/InstitutionalEdge.kt =====
package com.example.bithumbtrader

import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt

enum class ExecutionDepthStatus { FULL_FILL, PARTIAL_FILL, INSUFFICIENT_DEPTH }
enum class TradingSessionQuality { EXCELLENT, GOOD, NORMAL, WEAK, AVOID }
enum class PortfolioHeatLevel { LOW, MEDIUM, HIGH, CRITICAL }
enum class DataQualityStatus { GOOD, DEGRADED, BAD, QUARANTINED }
enum class StrategyValidationStatus { PROPOSED, BACKTESTING, WALK_FORWARD, OUT_OF_SAMPLE, STRESS_TEST, SHADOW, PAPER, PROMOTION_CANDIDATE, REJECTED }

data class OrderbookLevel(val price: Double, val size: Double)

data class DepthWalkResult(
    val status: ExecutionDepthStatus,
    val decisionPrice: Double,
    val quotedBestPrice: Double,
    val weightedAverageFillPrice: Double,
    val filledQuantity: Double,
    val unfilledQuantity: Double,
    val filledAmountKrw: Double,
    val feeKrw: Double,
    val slippagePercent: Double,
    val spreadPercent: Double,
    val marketImpactPercent: Double,
    val liquidityConsumedPercent: Double,
    val executionCostPercent: Double
)

object OrderbookDepthEngine {
    fun walkBuy(
        levels: List<OrderbookLevel>,
        krwTargetAmount: Double,
        decisionPrice: Double,
        feeRate: Double = 0.0025,
        defaultSlippageRate: Double = 0.001
    ): DepthWalkResult {
        val validLevels = levels.filter { it.price.isFinite() && it.price > 0.0 && it.size.isFinite() && it.size > 0.0 }.sortedBy { it.price }
        val fallbackPrice = decisionPrice.takeIf { it.isFinite() && it > 0.0 } ?: validLevels.firstOrNull()?.price ?: 1.0
        val quotedBest = validLevels.firstOrNull()?.price ?: fallbackPrice
        if (validLevels.isEmpty() || krwTargetAmount <= 0.0) {
            val slippagePrice = fallbackPrice * (1.0 + defaultSlippageRate)
            val fee = krwTargetAmount * feeRate
            val qty = ((krwTargetAmount - fee) / slippagePrice).coerceAtLeast(0.0)
            return DepthWalkResult(
                status = if (validLevels.isEmpty()) ExecutionDepthStatus.INSUFFICIENT_DEPTH else ExecutionDepthStatus.FULL_FILL,
                decisionPrice = fallbackPrice,
                quotedBestPrice = quotedBest,
                weightedAverageFillPrice = slippagePrice,
                filledQuantity = qty,
                unfilledQuantity = 0.0,
                filledAmountKrw = krwTargetAmount,
                feeKrw = fee,
                slippagePercent = defaultSlippageRate * 100.0,
                spreadPercent = 0.0,
                marketImpactPercent = 0.0,
                liquidityConsumedPercent = 100.0,
                executionCostPercent = (feeRate + defaultSlippageRate) * 100.0
            )
        }

        var remainingKrw = krwTargetAmount
        var totalQty = 0.0
        var totalGrossSpent = 0.0
        var totalAvailableKrw = validLevels.sumOf { it.price * it.size }
        val maxAvailable = totalAvailableKrw

        for (level in validLevels) {
            val levelMaxKrw = level.price * level.size
            if (remainingKrw <= levelMaxKrw) {
                val qty = remainingKrw / level.price
                totalQty += qty
                totalGrossSpent += remainingKrw
                remainingKrw = 0.0
                break
            } else {
                totalQty += level.size
                totalGrossSpent += levelMaxKrw
                remainingKrw -= levelMaxKrw
            }
        }

        val avgPrice = if (totalQty > 0.0) totalGrossSpent / totalQty else quotedBest
        val fee = totalGrossSpent * feeRate
        val slippage = if (quotedBest > 0.0) (avgPrice / quotedBest - 1.0) * 100.0 else 0.0
        val spread = if (fallbackPrice > 0.0) (quotedBest / fallbackPrice - 1.0) * 100.0 else 0.0
        val marketImpact = if (validLevels.isNotEmpty() && validLevels.last().price > 0.0 && quotedBest > 0.0) {
            (avgPrice / quotedBest - 1.0) * 100.0
        } else 0.0
        val consumedPct = if (maxAvailable > 0.0) (totalGrossSpent / maxAvailable) * 100.0 else 100.0
        val totalCost = feeRate * 100.0 + max(0.0, slippage) + max(0.0, spread)

        val status = when {
            remainingKrw <= 0.0 -> ExecutionDepthStatus.FULL_FILL
            totalQty > 0.0 -> ExecutionDepthStatus.PARTIAL_FILL
            else -> ExecutionDepthStatus.INSUFFICIENT_DEPTH
        }

        return DepthWalkResult(
            status = status,
            decisionPrice = fallbackPrice,
            quotedBestPrice = quotedBest,
            weightedAverageFillPrice = avgPrice,
            filledQuantity = totalQty,
            unfilledQuantity = if (avgPrice > 0.0) remainingKrw / avgPrice else 0.0,
            filledAmountKrw = totalGrossSpent,
            feeKrw = fee,
            slippagePercent = max(0.0, slippage),
            spreadPercent = max(0.0, spread),
            marketImpactPercent = max(0.0, marketImpact),
            liquidityConsumedPercent = consumedPct.coerceIn(0.0, 100.0),
            executionCostPercent = totalCost
        )
    }

    fun walkSell(
        levels: List<OrderbookLevel>,
        quantity: Double,
        decisionPrice: Double,
        feeRate: Double = 0.0025,
        defaultSlippageRate: Double = 0.001
    ): DepthWalkResult {
        val validLevels = levels.filter { it.price.isFinite() && it.price > 0.0 && it.size.isFinite() && it.size > 0.0 }.sortedByDescending { it.price }
        val fallbackPrice = decisionPrice.takeIf { it.isFinite() && it > 0.0 } ?: validLevels.firstOrNull()?.price ?: 1.0
        val quotedBest = validLevels.firstOrNull()?.price ?: fallbackPrice
        if (validLevels.isEmpty() || quantity <= 0.0) {
            val slippagePrice = fallbackPrice * (1.0 - defaultSlippageRate)
            val gross = quantity * slippagePrice
            val fee = gross * feeRate
            return DepthWalkResult(
                status = if (validLevels.isEmpty()) ExecutionDepthStatus.INSUFFICIENT_DEPTH else ExecutionDepthStatus.FULL_FILL,
                decisionPrice = fallbackPrice,
                quotedBestPrice = quotedBest,
                weightedAverageFillPrice = slippagePrice,
                filledQuantity = quantity,
                unfilledQuantity = 0.0,
                filledAmountKrw = gross,
                feeKrw = fee,
                slippagePercent = defaultSlippageRate * 100.0,
                spreadPercent = 0.0,
                marketImpactPercent = 0.0,
                liquidityConsumedPercent = 100.0,
                executionCostPercent = (feeRate + defaultSlippageRate) * 100.0
            )
        }

        var remainingQty = quantity
        var totalQtyFilled = 0.0
        var totalGrossProceeds = 0.0
        val maxAvailableQty = validLevels.sumOf { it.size }

        for (level in validLevels) {
            if (remainingQty <= level.size) {
                totalQtyFilled += remainingQty
                totalGrossProceeds += remainingQty * level.price
                remainingQty = 0.0
                break
            } else {
                totalQtyFilled += level.size
                totalGrossProceeds += level.size * level.price
                remainingQty -= level.size
            }
        }

        val avgPrice = if (totalQtyFilled > 0.0) totalGrossProceeds / totalQtyFilled else quotedBest
        val fee = totalGrossProceeds * feeRate
        val slippage = if (quotedBest > 0.0) (1.0 - avgPrice / quotedBest) * 100.0 else 0.0
        val spread = if (fallbackPrice > 0.0) (1.0 - quotedBest / fallbackPrice) * 100.0 else 0.0
        val marketImpact = if (quotedBest > 0.0) max(0.0, (1.0 - avgPrice / quotedBest) * 100.0) else 0.0
        val consumedPct = if (maxAvailableQty > 0.0) (totalQtyFilled / maxAvailableQty) * 100.0 else 100.0
        val totalCost = feeRate * 100.0 + max(0.0, slippage) + max(0.0, spread)

        val status = when {
            remainingQty <= 0.0 -> ExecutionDepthStatus.FULL_FILL
            totalQtyFilled > 0.0 -> ExecutionDepthStatus.PARTIAL_FILL
            else -> ExecutionDepthStatus.INSUFFICIENT_DEPTH
        }

        return DepthWalkResult(
            status = status,
            decisionPrice = fallbackPrice,
            quotedBestPrice = quotedBest,
            weightedAverageFillPrice = avgPrice,
            filledQuantity = totalQtyFilled,
            unfilledQuantity = remainingQty,
            filledAmountKrw = totalGrossProceeds,
            feeKrw = fee,
            slippagePercent = max(0.0, slippage),
            spreadPercent = max(0.0, spread),
            marketImpactPercent = marketImpact,
            liquidityConsumedPercent = consumedPct.coerceIn(0.0, 100.0),
            executionCostPercent = totalCost
        )
    }
}

data class NetEdgeDecision(
    val allowed: Boolean,
    val grossExpectedEdge: Double,
    val expectedExecutionCost: Double,
    val netExpectedEdge: Double,
    val confidence: Double,
    val sampleCount: Int,
    val reason: String
)

object NetEdgeGateEngine {
    fun evaluate(
        signalScore: Double,
        expectedReturnPercent: Double,
        feePercent: Double,
        spreadPercent: Double,
        slippagePercent: Double,
        marketImpactPercent: Double,
        riskPenaltyPercent: Double,
        historicalAccuracy: Double,
        sampleCount: Int,
        minNetEdgeMargin: Double = 0.35
    ): NetEdgeDecision {
        val gross = maxOf(expectedReturnPercent, (signalScore - 70.0) * 0.08)
        val executionCost = feePercent + spreadPercent + slippagePercent + marketImpactPercent
        val netEdge = gross - executionCost - riskPenaltyPercent
        val confidence = if (sampleCount >= 20) (historicalAccuracy * 100.0).coerceIn(40.0, 95.0) else 50.0
        val allowed = netEdge >= minNetEdgeMargin && (sampleCount < 20 || confidence >= 55.0)

        val reason = when {
            !allowed && netEdge < 0.0 -> "HIGH_SCORE_BUT_NEGATIVE_NET_EDGE (순기대수익 ${"%.2f".format(netEdge)}% < 0%, 예상비용 ${"%.2f".format(executionCost)}%)"
            !allowed && netEdge < minNetEdgeMargin -> "REJECTED_NET_EDGE (순기대수익 ${"%.2f".format(netEdge)}% < 최소 마진 ${"%.2f".format(minNetEdgeMargin)}%)"
            !allowed -> "LOW_CONFIDENCE_EDGE (신뢰도 ${confidence.toInt()}% 부족)"
            else -> "NET_EDGE_PASS (순기대수익 ${"%.2f".format(netEdge)}%, 비용 ${"%.2f".format(executionCost)}%, 신뢰도 ${confidence.toInt()}%)"
        }

        return NetEdgeDecision(allowed, gross, executionCost, netEdge, confidence, sampleCount, reason)
    }
}

object SessionEdgeEngine {
    fun sessionQuality(kstHour: Int): Pair<TradingSessionQuality, Double> = when (kstHour) {
        in 9..11 -> TradingSessionQuality.EXCELLENT to 1.55 // active domestic morning session
        in 18..21 -> TradingSessionQuality.GOOD to 1.35     // evening retail & macro window
        in 12..17 -> TradingSessionQuality.NORMAL to 1.15
        in 22..23, in 0..2 -> TradingSessionQuality.NORMAL to 1.05
        else -> TradingSessionQuality.WEAK to 0.85          // early dawn 3-6 KST
    }

    fun dayOfWeekQuality(dayOfWeekValue: Int): String = when (dayOfWeekValue) {
        1 -> "MON"
        2 -> "TUE"
        3 -> "WED"
        4 -> "THU"
        5 -> "FRI"
        6 -> "SAT"
        else -> "SUN"
    }
}

data class PortfolioHeatSnapshot(
    val level: PortfolioHeatLevel,
    val totalOpenRiskPercent: Double,
    val portfolioExposurePercent: Double,
    val correlatedExposurePercent: Double,
    val clusterName: String,
    val worstCaseLossPercent: Double,
    val multiplier: Double
)

object PortfolioHeatEngine {
    private val majorBtcCluster = setOf("KRW-BTC", "KRW-ETH", "KRW-SOL", "KRW-XRP", "KRW-ADA", "KRW-AVAX")

    fun evaluate(
        positions: List<PositionModel>,
        totalEquity: Double,
        stopLossPercent: Double
    ): PortfolioHeatSnapshot {
        if (totalEquity <= 0.0 || positions.isEmpty()) {
            return PortfolioHeatSnapshot(PortfolioHeatLevel.LOW, 0.0, 0.0, 0.0, "NONE", 0.0, 1.0)
        }
        val totalPositionValue = positions.sumOf { it.quantity * it.avgPrice }
        val exposurePct = (totalPositionValue / totalEquity * 100.0).coerceIn(0.0, 100.0)
        val clusterPositions = positions.filter { it.market in majorBtcCluster }
        val clusterValue = clusterPositions.sumOf { it.quantity * it.avgPrice }
        val clusterPct = (clusterValue / totalEquity * 100.0).coerceIn(0.0, 100.0)
        val openRiskPct = (positions.size * abs(stopLossPercent) * (exposurePct / 100.0)).coerceIn(0.0, 100.0)
        val worstCase = openRiskPct * 1.5

        val level = when {
            exposurePct >= 80.0 || clusterPct >= 65.0 || openRiskPct >= 8.0 -> PortfolioHeatLevel.CRITICAL
            exposurePct >= 60.0 || clusterPct >= 45.0 || openRiskPct >= 5.0 -> PortfolioHeatLevel.HIGH
            exposurePct >= 35.0 || clusterPct >= 25.0 -> PortfolioHeatLevel.MEDIUM
            else -> PortfolioHeatLevel.LOW
        }

        val mult = when (level) {
            PortfolioHeatLevel.CRITICAL -> 0.0 // block new buy
            PortfolioHeatLevel.HIGH -> 0.5
            PortfolioHeatLevel.MEDIUM -> 0.8
            PortfolioHeatLevel.LOW -> 1.0
        }

        return PortfolioHeatSnapshot(level, openRiskPct, exposurePct, clusterPct, if (clusterPositions.size >= 2) "BTC_CORRELATED_CLUSTER" else "ISOLATED", worstCase, mult)
    }
}

data class TailRiskSnapshot(
    val score: Double,
    val level: String,
    val var95Percent: Double,
    val expectedShortfall95Percent: Double,
    val reason: String
)

object TailRiskEngine {
    fun evaluate(
        recentReturns: List<Double>,
        healthScore: Double,
        regime: MarketRegime,
        portfolioExposurePercent: Double
    ): TailRiskSnapshot {
        val clean = recentReturns.filter { it.isFinite() }.sorted()
        if (clean.size < 10) {
            return TailRiskSnapshot(30.0, "LOW", -2.5, -3.5, "표본 부족으로 보수적 기본값 적용")
        }
        val varIndex = ((clean.size - 1) * 0.05).toInt().coerceIn(0, clean.lastIndex)
        val var95 = clean[varIndex].coerceAtMost(0.0)
        val shortfallValues = clean.take(maxOf(1, varIndex + 1))
        val es95 = if (shortfallValues.isNotEmpty()) shortfallValues.average().coerceAtMost(var95) else var95

        var tailScore = abs(es95) * 10.0 + (100.0 - healthScore) * 0.35 + (portfolioExposurePercent * 0.2)
        if (regime == MarketRegime.CRASH || regime == MarketRegime.STRONG_BEAR) tailScore += 30.0
        val clamped = tailScore.coerceIn(0.0, 100.0)
        val level = when {
            clamped >= 75.0 -> "HIGH"
            clamped >= 45.0 -> "MEDIUM"
            else -> "LOW"
        }
        val reason = "95% VaR ${"%.2f".format(var95)}%, ES ${"%.2f".format(es95)}%, TailScore ${clamped.toInt()} ($level)"
        return TailRiskSnapshot(clamped, level, var95, es95, reason)
    }
}

data class DataQualityEvaluation(
    val score: Double,
    val status: DataQualityStatus,
    val reasons: List<String>
)

object DataIntegrityGuardian {
    fun evaluate(
        ticker: TickerModel?,
        wsTicker: TickerModel?,
        orderbook: OrderbookModel?,
        candles: List<CandleModel>,
        now: Long = System.currentTimeMillis()
    ): DataQualityEvaluation {
        val reasons = mutableListOf<String>()
        var score = 100.0

        if (ticker == null) {
            return DataQualityEvaluation(0.0, DataQualityStatus.BAD, listOf("티커 누락"))
        }

        if (ticker.tradePrice <= 0.0 || !ticker.tradePrice.isFinite()) {
            reasons += "비정상 가격 (${ticker.tradePrice})"
            score -= 60.0
        }

        if (ticker.accTradePrice24h < 0.0 || !ticker.accTradePrice24h.isFinite()) {
            reasons += "비정상 거래대금"
            score -= 30.0
        }

        if (orderbook != null) {
            if (orderbook.askPrice <= 0.0 || orderbook.bidPrice <= 0.0) {
                reasons += "호가 가격 비정상"
                score -= 40.0
            } else if (orderbook.bidPrice > orderbook.askPrice) {
                reasons += "호가 역전 (bid > ask)"
                score -= 70.0
            }
        }

        if (wsTicker != null && wsTicker.tradePrice > 0.0 && ticker.tradePrice > 0.0) {
            val diffPct = abs(wsTicker.tradePrice / ticker.tradePrice - 1.0) * 100.0
            if (diffPct >= 15.0) {
                reasons += "REST/WS 가격 불일치 (${"%.1f".format(diffPct)}%)"
                score -= 50.0
            }
        }

        if (candles.isNotEmpty()) {
            val sorted = candles.sortedBy { it.timestamp }
            if (sorted.any { it.close <= 0.0 || !it.close.isFinite() || it.volume < 0.0 }) {
                reasons += "비정상 캔들 데이터 포함"
                score -= 35.0
            }
        }

        val clamped = score.coerceIn(0.0, 100.0)
        val status = when {
            clamped >= 80.0 -> DataQualityStatus.GOOD
            clamped >= 50.0 -> DataQualityStatus.DEGRADED
            clamped >= 30.0 -> DataQualityStatus.BAD
            else -> DataQualityStatus.QUARANTINED
        }

        return DataQualityEvaluation(clamped, status, reasons)
    }
}

object DrawdownRecoveryController {
    fun exposureMultiplier(dailyDrawdownFromPeak: Double): Double {
        val dd = abs(dailyDrawdownFromPeak)
        return when {
            dd <= 2.0 -> 1.0
            dd <= 4.0 -> 0.75
            dd <= 6.0 -> 0.50
            else -> 0.25
        }
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/InstitutionalEdge.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/LearningAssetContinuity.kt =====
package com.example.bithumbtrader

import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import java.security.MessageDigest

enum class LearningAssetPreservationStatus {
    BASELINE_CREATED, PRESERVED, LOSS_DETECTED, RESTORE_FAILED
}

data class LearningAssetManifest(
    val schemaVersion: Int,
    val appVersion: String,
    val appVersionCode: Int,
    val learningLineageId: String,
    val aiTrainingSampleCount: Int,
    val historicalSampleCount: Int,
    val paperSampleCount: Int,
    val liveSampleCount: Int,
    val resolvedSampleCount: Int,
    val predictionJournalCount: Int,
    val hardExampleCount: Int,
    val modelVersion: String,
    val modelSource: String,
    val modelChecksum: String,
    val productionModelChecksum: String,
    val continuousModelChecksum: String,
    val continuousLearningLastCount: Int,
    val continuousLearningLastAt: Long,
    val championVersion: String,
    val challengerVersion: String,
    val shadowTradeCount: Int,
    val researchHypothesisCount: Int,
    val smartReentryStateCount: Int,
    val scalpingDiagnosticCount: Int,
    val createdAt: Long,
    val updatedAt: Long
)

data class LearningAssetComparison(
    val status: LearningAssetPreservationStatus,
    val reasons: List<String>,
    val checksumMatches: Boolean,
    val lineageMatches: Boolean
)

data class LearningAssetContinuityState(
    val status: LearningAssetPreservationStatus = LearningAssetPreservationStatus.BASELINE_CREATED,
    val message: String = "학습 자산 기준선 생성 전",
    val previousAppVersion: String = "-",
    val currentAppVersion: String = "-",
    val previousSchemaVersion: Int = 0,
    val currentSchemaVersion: Int = 0,
    val migrationStatus: String = "UNKNOWN",
    val modelRestoreStatus: String = "PENDING",
    val checksumStatus: String = "UNKNOWN",
    val lineageStatus: String = "UNKNOWN",
    val manifest: LearningAssetManifest? = null
)

object LearningAssetIntegrity {
    private val adapter = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
        .adapter(LearningAssetManifest::class.java)

    fun checksum(value: String?): String {
        if (value.isNullOrEmpty()) return ""
        return MessageDigest.getInstance("SHA-256")
            .digest(value.toByteArray(Charsets.UTF_8))
            .joinToString("") { "%02x".format(it) }
    }

    fun toJson(manifest: LearningAssetManifest): String = adapter.toJson(manifest)

    fun fromJson(json: String): LearningAssetManifest =
        adapter.fromJson(json) ?: error("LearningAssetManifest empty")

    fun compare(before: LearningAssetManifest?, after: LearningAssetManifest): LearningAssetComparison {
        if (before == null) {
            return LearningAssetComparison(
                LearningAssetPreservationStatus.BASELINE_CREATED,
                listOf("PREVIOUS_MANIFEST_NOT_AVAILABLE"),
                checksumMatches = true,
                lineageMatches = true
            )
        }
        val reasons = mutableListOf<String>()
        fun nonDecreasing(name: String, old: Int, current: Int) {
            if (current < old) reasons += "$name 감소: $old → $current"
        }
        nonDecreasing("AI_SAMPLES", before.aiTrainingSampleCount, after.aiTrainingSampleCount)
        nonDecreasing("HISTORICAL_SAMPLES", before.historicalSampleCount, after.historicalSampleCount)
        nonDecreasing("PAPER_SAMPLES", before.paperSampleCount, after.paperSampleCount)
        nonDecreasing("LIVE_SAMPLES", before.liveSampleCount, after.liveSampleCount)
        nonDecreasing("RESOLVED_SAMPLES", before.resolvedSampleCount, after.resolvedSampleCount)
        nonDecreasing("PREDICTION_JOURNAL", before.predictionJournalCount, after.predictionJournalCount)
        nonDecreasing("HARD_EXAMPLES", before.hardExampleCount, after.hardExampleCount)
        nonDecreasing("SHADOW_TRADES", before.shadowTradeCount, after.shadowTradeCount)
        nonDecreasing("RESEARCH_HYPOTHESES", before.researchHypothesisCount, after.researchHypothesisCount)
        nonDecreasing("SMART_REENTRY_STATES", before.smartReentryStateCount, after.smartReentryStateCount)
        nonDecreasing("SCALPING_DIAGNOSTICS", before.scalpingDiagnosticCount, after.scalpingDiagnosticCount)
        val checksumMatches = before.modelChecksum.isBlank() || before.modelChecksum == after.modelChecksum
        if (!checksumMatches) reasons += "MODEL_CHECKSUM_MISMATCH"
        val lineageMatches = before.learningLineageId == after.learningLineageId
        if (!lineageMatches) reasons += "LEARNING_LINEAGE_RESET"
        if (before.modelVersion.isNotBlank() && after.modelVersion.isBlank()) reasons += "MODEL_VERSION_LOST"
        if (before.championVersion.isNotBlank() && after.championVersion.isBlank()) reasons += "CHAMPION_VERSION_LOST"
        if (before.challengerVersion.isNotBlank() && before.challengerVersion != "-" && after.challengerVersion == "-") {
            reasons += "CHALLENGER_VERSION_LOST"
        }
        return LearningAssetComparison(
            status = if (reasons.isEmpty()) LearningAssetPreservationStatus.PRESERVED else LearningAssetPreservationStatus.LOSS_DETECTED,
            reasons = if (reasons.isEmpty()) listOf("LEARNING_ASSETS_PRESERVED") else reasons,
            checksumMatches = checksumMatches,
            lineageMatches = lineageMatches
        )
    }
}

object AiModelPrecedencePolicy {
    fun shouldRestorePersisted(
        source: String,
        persistedVersion: Int,
        bundledVersion: Int,
        localValidated: Boolean
    ): Boolean = when {
        source == "LOCAL_RETRAIN" && localValidated -> true
        source == "OTA_VALIDATED" || source == "OTA" -> persistedVersion >= bundledVersion
        else -> persistedVersion >= bundledVersion
    }

    fun shouldApplyOta(
        currentSource: String,
        otaVersion: Int,
        currentVersion: Int,
        localValidated: Boolean
    ): Boolean {
        if (currentSource == "LOCAL_RETRAIN" && localValidated) return false
        return otaVersion > currentVersion
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/LearningAssetContinuity.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/LiquidityDiagnostics.kt =====
package com.example.bithumbtrader

import kotlin.math.roundToInt

data class LiquidityDistribution(
    val p10: Double = 0.0,
    val p25: Double = 0.0,
    val p50: Double = 0.0,
    val p75: Double = 0.0,
    val p90: Double = 0.0
)

data class LiquidityFilterDecision(
    val passed: Boolean,
    val code: String,
    val actualKrw: Double,
    val requiredKrw: Double,
    val ratioPercent: Double,
    val rank: Int,
    val total: Int,
    val percentile: Double,
    val detail: String
)

data class LiquidityDiagnostics(
    val total: Int = 0,
    val pass: Int = 0,
    val rejectLowTradeValue: Int = 0,
    val distribution: LiquidityDistribution = LiquidityDistribution(),
    val requiredKrw: Double = 0.0,
    val percentileThreshold: Double = 0.0,
    val overblockingWarning: Boolean = false,
    val overblockingMessage: String = ""
)

object LiquidityFilterEngine {
    fun percentile(values: List<Double>, percentile: Double): Double {
        val sorted = values.filter { it.isFinite() && it >= 0.0 }.sorted()
        if (sorted.isEmpty()) return 0.0
        val index = ((sorted.size - 1) * percentile.coerceIn(0.0, 1.0)).roundToInt().coerceIn(0, sorted.lastIndex)
        return sorted[index]
    }

    fun distribution(values: List<Double>): LiquidityDistribution = LiquidityDistribution(
        p10 = percentile(values, 0.10), p25 = percentile(values, 0.25), p50 = percentile(values, 0.50),
        p75 = percentile(values, 0.75), p90 = percentile(values, 0.90)
    )

    fun requiredThreshold(
        values: List<Double>,
        absoluteFloorKrw: Double,
        percentileThreshold: Double,
        dynamicEnabled: Boolean,
        regime: MarketRegime,
        healthScore: Double
    ): Double {
        val absolute = absoluteFloorKrw.coerceAtLeast(0.0)
        if (!dynamicEnabled) return absolute
        val relative = percentile(values, percentileThreshold)
        val base = maxOf(absolute, relative)
        return when {
            regime == MarketRegime.CRASH || healthScore < 40.0 -> maxOf(base, percentile(values, 0.75))
            else -> base
        }
    }

    fun decide(actualKrw: Double, requiredKrw: Double, sortedValues: List<Double>): LiquidityFilterDecision {
        val clean = sortedValues.filter { it.isFinite() && it >= 0.0 }.sortedDescending()
        val total = clean.size
        if (total == 0) {
            val readyRequired = requiredKrw.isFinite() && requiredKrw > 0.0
            val passed = readyRequired && actualKrw.isFinite() && actualKrw >= requiredKrw
            return LiquidityFilterDecision(
                passed = false,
                code = "LIQUIDITY_UNINITIALIZED",
                actualKrw = actualKrw,
                requiredKrw = requiredKrw,
                ratioPercent = 0.0,
                rank = 0,
                total = 0,
                percentile = 0.0,
                detail = if (readyRequired) "LIQUIDITY_RANK_PENDING actual=${actualKrw.krwText()} required=${requiredKrw.krwText()}"
                else "LIQUIDITY_UNINITIALIZED required/rank 계산 전"
            )
        }
        val rank = clean.indexOfFirst { it <= actualKrw }.let { if (it < 0) clean.size else it + 1 }
        val percentile = clean.count { it <= actualKrw }.toDouble() / total
        val ratio = if (requiredKrw > 0.0) actualKrw / requiredKrw * 100.0 else 100.0
        val passed = when {
            requiredKrw <= 0.0 -> true // 테스트/비활성 floor — 분포만 표시
            else -> actualKrw.isFinite() && actualKrw >= requiredKrw
        }
        val code = when {
            requiredKrw <= 0.0 -> "LIQUIDITY_FLOOR_DISABLED"
            passed -> "LIQUIDITY_PASS"
            else -> "LOW_24H_TRADE_VALUE"
        }
        val detail = if (passed) "$code actual=${actualKrw.krwText()} required=${requiredKrw.krwText()} rank=$rank/$total percentile=${"%.1f".format(percentile * 100)}%"
        else "$code actual=${actualKrw.krwText()} required=${requiredKrw.krwText()} ratio=${"%.1f".format(ratio)}% rank=$rank/$total percentile=${"%.1f".format(percentile * 100)}%"
        return LiquidityFilterDecision(passed, code, actualKrw, requiredKrw, ratio, rank, total, percentile, detail)
    }

    fun diagnose(tickers: Collection<TickerModel>, settings: TradingSettings, regime: MarketRegime, healthScore: Double, overblockingWarning: Boolean = false, overblockingMessage: String = ""): LiquidityDiagnostics {
        val values = tickers.map { it.accTradePrice24h }.filter { it.isFinite() && it >= 0.0 }
        val required = requiredThreshold(values, settings.min24hTradeValueKrw, settings.liquidityPercentileThreshold, settings.dynamicLiquidityEnabled, regime, healthScore)
        val pass = values.count { it >= required }
        return LiquidityDiagnostics(values.size, pass, values.size - pass, distribution(values), required, settings.liquidityPercentileThreshold, overblockingWarning, overblockingMessage)
    }

    private fun Double.krwText(): String = String.format(java.util.Locale.KOREA, "%,.0f KRW", this)
}

class LiquidityOverblockingTracker(private val windowSize: Int = 10) {
    private val samples = ArrayDeque<Pair<Int, Int>>()
    fun record(candidateCount: Int, lowLiquidityRejectCount: Int, buyReadyCount: Int): Pair<Boolean, String> {
        samples.addLast(lowLiquidityRejectCount to if (candidateCount > 0) candidateCount else 0)
        while (samples.size > windowSize) samples.removeFirst()
        val totalCandidates = samples.sumOf { it.second }
        val lowRejects = samples.sumOf { it.first }
        val ratio = if (totalCandidates == 0) 0.0 else lowRejects.toDouble() / totalCandidates
        val warning = samples.size >= windowSize && totalCandidates >= 20 && ratio >= 0.8 && buyReadyCount == 0
        return warning to if (warning) "LIQUIDITY_FILTER_OVERBLOCKING_WARNING 최근 ${samples.size}회 후보 ${totalCandidates}개 중 ${lowRejects}개(${"%.1f".format(ratio * 100)}%) 거래대금 차단" else ""
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/LiquidityDiagnostics.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/MainActivity.kt =====

package com.example.bithumbtrader

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.text.SimpleDateFormat
import java.util.*
class MainActivity: ComponentActivity(){ override fun onCreate(savedInstanceState: Bundle?){ super.onCreate(savedInstanceState); setContent { MaterialTheme { TraderScreen() } } } }

@Composable fun TraderScreen(vm: MainViewModel = viewModel()){
    val state by vm.state.collectAsState()
    var tab by remember { mutableStateOf(0) }
    var access by remember { mutableStateOf("") }
    var secret by remember { mutableStateOf("") }
    Column(Modifier.fillMaxSize().background(Color(0xfff4f7fb))) {
        ModeBanner(state.mode, state.testMode)
        TabRow(selectedTabIndex=tab){ listOf("대시보드","설정","거래내역","통계","로그").forEachIndexed{ i,t -> Tab(selected=tab==i,onClick={tab=i},text={Text(t)}) } }
        when(tab){
            0 -> Dashboard(state, vm)
            1 -> Settings(state, access, secret, {access=it},{secret=it}, { vm.saveKeys(access,secret) }, vm)
            2 -> TransactionHistory(vm)
            3 -> Statistics(state, vm)
            4 -> Logs(state.logs)
        }
    }
}
@Composable fun ModeBanner(mode:TradeMode, testMode:Boolean){
    val live=mode==TradeMode.LIVE
    Box(Modifier.fillMaxWidth().background(if(live) Color(0xffb00020) else Color(0xff0b5cad)).padding(14.dp), contentAlignment=Alignment.Center){
        Text(if(live) "LIVE - 실전매매" else "PAPER - 모의매매${if(testMode) " / TEST" else ""} · app v${BuildConfig.VERSION_NAME}", color=Color.White, fontWeight=FontWeight.Bold)
    }
}

enum class DashboardSection {
    CORE_ACCOUNT_SUMMARY, CURRENT_POSITIONS, BUY_CANDIDATES, MARKET_EXECUTION_STATUS,
    SCALPING_AI, GLOBAL_DERIVATIVES, MARKET_REGIME_HEALTH, NEWS_RISK,
    RESEARCH_SHADOW_OPPORTUNITY, OTHER_DETAILS
}

val DASHBOARD_SECTION_ORDER = listOf(
    DashboardSection.CORE_ACCOUNT_SUMMARY,
    DashboardSection.CURRENT_POSITIONS,
    DashboardSection.BUY_CANDIDATES,
    DashboardSection.MARKET_EXECUTION_STATUS,
    DashboardSection.SCALPING_AI,
    DashboardSection.GLOBAL_DERIVATIVES,
    DashboardSection.MARKET_REGIME_HEALTH,
    DashboardSection.NEWS_RISK,
    DashboardSection.RESEARCH_SHADOW_OPPORTUNITY,
    DashboardSection.OTHER_DETAILS
)

@Composable fun Dashboard(s:DashboardState, vm:MainViewModel){
    val positions by vm.positions.collectAsState()
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp), verticalArrangement=Arrangement.spacedBy(10.dp)){
        ExchangeSwitchPanel(s, vm)
        // PHASE6: Primary must be visible on dashboard — Settings-only toggle kept failing device tests.
        ServerPrimaryControlPanel(s, vm)
        // 고정 순서: 핵심 계좌 → 현재 포지션 → 매수 후보. 새 카드는 아래 Section에만 추가한다.
        CoreAccountSummary(s)
        AutoTradeLiveStatusPanel(s)
        PaperLossAutopsyPanel(s)
        CurrentPositionsPanel(s, positions)
        BuyCandidatePanel(s)
        SmartReentryPanel(s)
        // MARKET_EXECUTION_STATUS
        InstitutionalSafetyPanel(s)
        AnalysisSpeedPanel(s)
        LiquidityDiagnosticsPanel(s)
        ProfitProtectionPanel(s)
        AutonomousCapitalPanel(s)
        // SCALPING_AI → GLOBAL_DERIVATIVES
        ScalpingExecutionPanel(s)
        GlobalDerivativesPanel(s)
        // MARKET_REGIME_HEALTH
        AdaptiveRegimePanel(s)
        MarketHealthPanel(s)
        // NEWS_RISK
        NewsIntelligencePanel(s)
        if(s.dailyLossLocked) Alert("일일 손실 한도 도달")
        s.anomalies.forEach { Alert("이상 징후: $it") }
        // RESEARCH_SHADOW_OPPORTUNITY → OTHER_DETAILS
        ResearchPanels(s)
        RiskAndRecommendationPanel(s, vm)
        AiLearningPanel(s, vm)
        SelfLearningPanel(s)
        MetricGrid(s)
        Row(horizontalArrangement=Arrangement.spacedBy(10.dp)){
            Button(onClick={vm.start()}, modifier=Modifier.weight(1f)){Text("자동매매 시작")}
            OutlinedButton(onClick={vm.stop()}, modifier=Modifier.weight(1f)){Text("자동매매 중지")}
        }
        Button(onClick={vm.emergencyStop()}, colors=ButtonDefaults.buttonColors(containerColor=Color(0xffb00020)), modifier=Modifier.fillMaxWidth()){Text("긴급 중지")}
        OutlinedButton(onClick={vm.scan()}, modifier=Modifier.fillMaxWidth()){Text("지금 스캔")}
    }
}

@Composable fun SelfLearningPanel(s:DashboardState){
    val learning = s.continuousLearning
    val continuity = s.learningAssetContinuity
    val manifest = continuity.manifest
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("지속 자기학습 루프", fontWeight=FontWeight.Bold)
            Text(
                "학습 데이터 보존: ${continuity.status} · 업데이트 후 복원 ${continuity.modelRestoreStatus}",
                color=if(continuity.status == LearningAssetPreservationStatus.LOSS_DETECTED || continuity.status == LearningAssetPreservationStatus.RESTORE_FAILED) Color(0xffb00020) else Color(0xff0b7a32),
                fontWeight=FontWeight.SemiBold
            )
            Text("학습 계보 ${continuity.lineageStatus} · ${manifest?.learningLineageId?.take(8) ?: "-"} · Checksum ${continuity.checksumStatus}")
            Text("APP ${continuity.previousAppVersion} → ${continuity.currentAppVersion} · DB ${continuity.previousSchemaVersion} → ${continuity.currentSchemaVersion} ${continuity.migrationStatus}", color=Color(0xff64748b))
            Text("상태 ${learning.stage} · Champion ${learning.championVersion} · Challenger ${learning.challengerVersion}")
            Text("AI Sample ${manifest?.aiTrainingSampleCount ?: learning.historicalSamples + learning.paperSamples + learning.liveSamples} · Historical ${manifest?.historicalSampleCount ?: learning.historicalSamples} · PAPER ${manifest?.paperSampleCount ?: learning.paperSamples} · LIVE ${manifest?.liveSampleCount ?: learning.liveSamples}")
            Text("Prediction Journal ${manifest?.predictionJournalCount ?: 0} · Hard Example ${manifest?.hardExampleCount ?: learning.hardExamples} · Replay ${learning.replaySamples}")
            Text("Model ${manifest?.modelVersion ?: learning.challengerVersion} · 마지막 학습 ${time(manifest?.continuousLearningLastAt ?: learning.lastTrainingAt)}")
            Text("모델 상태 ${learning.modelStatus} · 마지막 학습 ${time(learning.lastTrainingAt)} · 다음 학습 ${time(learning.nextTrainingAt)}")
            Text("Drift ${learning.drift} · ${learning.driftReason}", color=if(learning.drift == LearningDriftState.MAJOR_DRIFT) Color(0xffb00020) else Color(0xff64748b))
            Text("OOS/성과: 표본 ${learning.lastMetrics.sampleCount} · 5분 정확도 ${pct(learning.lastMetrics.accuracy5m*100)} · PF ${if(learning.lastMetrics.profitFactor.isInfinite()) "∞" else String.format(Locale.KOREA, "%.2f", learning.lastMetrics.profitFactor)} · 기대값 ${pct(learning.lastMetrics.tradingExpectancy)} · MDD ${pct(learning.lastMetrics.maxDrawdown)}")
            Text(learning.statusMessage, color=Color(0xff0b5cad))
            Text("정상 APK 덮어쓰기는 학습 자산을 유지합니다. 앱 삭제 또는 Android 설정의 데이터 삭제는 별도 Backup 없이는 복구되지 않습니다.", color=Color(0xffb45309))
            if(learning.lastHypothesis.isNotBlank()) Text("연구 가설: ${learning.lastHypothesis}", color=Color(0xff64748b))
        }
    }
}

@Composable fun SmartReentryPanel(s:DashboardState){
    val research = s.smartReentryResearch
    val guarded = s.smartReentryStates.filter { it.anchor != null && it.status != ProfitReentryStatus.NONE }
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("익절 후 Smart Re-Entry", fontWeight=FontWeight.Bold)
            Text("재진입 ${research.reentries}건 · 승리 ${research.reentryWins} · 손실 ${research.reentryLosses} · 평균 손익 ${won(research.averageReentryPnl)} · 반납 추정 ${won(research.profitGivenBack)}")
            Text("동일파동 ${research.sameWaveCount}건 · Churn ${research.churn.roundTrips}회 · 수수료 ${won(research.churn.fees)} · 상태 ${research.warning}", color=if(research.warning == "REENTRY_CHAIN_RISK" || research.warning == "OVERTRADING_COST_WARNING") Color(0xffb00020) else Color(0xff64748b))
            if(guarded.isEmpty()) Text("활성 익절 후 재진입 보호 없음", color=Color(0xff64748b))
            guarded.take(3).forEach { state ->
                val anchor = state.anchor
                val signal = s.topSignals.firstOrNull { it.market == state.market }
                Text("${state.market} · ${state.status} · ${state.waveState} · ReEntry Quality ${state.reentryQualityScore.toInt()}")
                Text("이전 매도 ${won(anchor?.exitPrice ?: 0.0)} · 현재 ${won(signal?.currentPrice ?: 0.0)} · 사유 ${signal?.failureReason ?: "새 Setup 대기"}", color=Color(0xff64748b))
            }
        }
    }
}

@Composable fun ExchangeSwitchPanel(s: DashboardState, vm: MainViewModel) {
    val selected = s.selectedExchange
    Card(colors = CardDefaults.cardColors(containerColor = Color(0xffeef2ff))) {
        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Text("거래소 (독립 엔진)", fontWeight = FontWeight.Bold)
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                FilterChip(
                    selected = selected == ExchangeId.BITHUMB.name,
                    onClick = { vm.selectExchange(ExchangeId.BITHUMB) },
                    label = { Text("빗썸") }
                )
                FilterChip(
                    selected = selected == ExchangeId.UPBIT.name,
                    onClick = { vm.selectExchange(ExchangeId.UPBIT) },
                    label = { Text("업비트") }
                )
            }
            Text("BITHUMB BRAIN: ${s.bithumbBrainStatus.ifBlank { s.aiBrainStatus }}")
            Text("UPBIT BRAIN: ${s.upbitBrainStatus}")
            if (selected == ExchangeId.UPBIT.name) {
                Text("Upbit markets=${s.upbitMarketCount} micro=${s.upbitMicroReady} · LIVE DISABLED · PAPER isolated")
            }
            Text("Android full-market analysis: ${s.androidFullMarketAnalysis}")
            Text("보기: ${if (selected == ExchangeId.UPBIT.name) "업비트 결과/안전/실행" else "빗썸 결과/안전/실행"}", color = Color(0xff334155))
        }
    }
}

@Composable fun ServerPrimaryControlPanel(s: DashboardState, vm: MainViewModel) {
    val primary = s.settings.remoteAiPrimary && s.settings.remoteAiEnabled
    val tokenOk = vm.hasTradingAiToken()
    val bg = when {
        primary && s.androidAnalysisMode == "SERVER_PRIMARY_VIEWER" -> Color(0xffecfdf5)
        primary -> Color(0xfffff7ed)
        else -> Color(0xfffff1f2)
    }
    Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = bg)) {
        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text("SERVER PRIMARY  v${BuildConfig.VERSION_NAME}", fontWeight = FontWeight.Bold)
                Switch(
                    checked = s.settings.remoteAiPrimary,
                    onCheckedChange = { vm.setRemoteAiPrimary(it) },
                    enabled = s.settings.remoteAiEnabled
                )
            }
            Text(
                if (primary) "ON — 서버 PAPER SoT · 로컬 FAST/DEEP/decision 중지 · /dashboard · /paper/state"
                else "OFF — 지금 LOCAL 스캔 + /decision 경로 (서버 Primary 미사용)",
                color = if (primary) Color(0xff0f766e) else Color(0xffb00020),
                fontWeight = FontWeight.SemiBold
            )
            Text(
                "Token ${if (tokenOk) "설정됨(${vm.tradingAiTokenMasked()})" else "미설정 — Settings에서 Trading API Token 저장"} · Mode ${s.androidAnalysisMode} · RemoteAI ${if (s.settings.remoteAiEnabled) "ON" else "OFF"}",
                color = if (tokenOk) Color(0xff64748b) else Color(0xffb00020)
            )
            if (!primary) {
                Text("재테스트: 이 스위치를 ON → 자동매매 시작 → 강제종료 → 재실행", color = Color(0xffb45309))
            }
        }
    }
}

@Composable fun CoreAccountSummary(s:DashboardState){
    val state = tradingStateText(s)
    val candidateBuyable = s.topSignals.filter { it.status == CandidateStatus.BUY_READY }.maxOfOrNull { it.estimatedInvestment } ?: 0.0
    val primary = s.settings.remoteAiPrimary && s.settings.remoteAiEnabled
    val initialCash = if (primary && s.serverPaperInitialCash > 0.0) s.serverPaperInitialCash else s.settings.paperInitialKrw
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                Text("핵심 계좌 · ${if (s.selectedExchange == ExchangeId.UPBIT.name) "업비트" else "빗썸"}", fontWeight=FontWeight.Bold)
                Text(if(s.mode == TradeMode.PAPER) "PAPER" else "LIVE", color=if(s.mode == TradeMode.PAPER) Color(0xff0b5cad) else Color(0xffb00020), fontWeight=FontWeight.Bold)
            }
            if (primary) {
                Text(
                    "SoT HETZNER · synced ${if (s.serverPaperUiSynced) "YES" else "NO"} · status ${s.serverStatusLabel} · tick ${s.serverPaperTickCount} · upd ${time(s.serverPaperLastTickAt.takeIf { it > 0L } ?: s.serverPaperUpdatedAt)}",
                    color = Color(0xff0f766e),
                    fontWeight = FontWeight.SemiBold
                )
            }
            Text("자동매매 ${engineStatusText(s.engineStatus)} · 현재 상태 $state", fontWeight=FontWeight.SemiBold)
            Text("초기자산 ${won(initialCash)} · 현재 평가금액 ${won(s.totalValue)}")
            val ddPct = s.serverPaperTotalPnlRate.takeIf { s.serverPaperUiSynced } ?: s.cumulativePnlRate
            val buyState = when {
                s.serverPaperBuyResumeMode.isNotBlank() && s.serverPaperBuyResumeMode != "NORMAL" ->
                    s.serverPaperBuyResumeMode
                s.serverPaperNewBuyPaused -> "PAUSED_DIAGNOSTIC"
                else -> "NORMAL"
            }
            Text(
                "PAPER EQUITY ${won(s.totalValue)} · DRAWDOWN ${pct(ddPct)} · STATE $buyState",
                color = if (ddPct <= -20.0 || s.serverPaperNewBuyPaused) Color(0xffb00020) else Color(0xff0b5cad),
                fontWeight = FontWeight.Bold
            )
            Text(
                "MARKET ANALYSIS ${engineStatusText(s.engineStatus)} · NEW PAPER BUY $buyState · EXIT MONITOR RUNNING · SERVER BRAIN ${s.serverStatusLabel}",
                color = Color(0xff64748b)
            )
            Text(
                "Initial ${won(initialCash)} · Peak ${won(s.paperLossAutopsy.peakEquity)} · Current ${won(s.totalValue)} · FromInitial ${pct(s.paperLossAutopsy.peakAudit.returnFromInitialPercent)} · FromPeak ${pct(s.paperLossAutopsy.peakAudit.drawdownFromPeakPercent)}"
            )
            if (s.serverPaperNewBuyPaused || buyState.contains("PAUSE") || buyState.contains("SHADOW")) {
                Text(
                    "신규 PAPER BUY 일시중지 · 분석/청산/학습 계속${if (s.serverPaperPauseReason.isNotBlank()) " · ${s.serverPaperPauseReason}" else ""}",
                    color = Color(0xffb45309)
                )
            }
            val topCauses = if (s.serverPaperTopLossCauses.isNotEmpty()) {
                s.serverPaperTopLossCauses
            } else {
                s.paperLossAutopsy.topCauses.take(3).map {
                    RemoteTopLossCause(it.cause.name, it.krw, it.percentOfTotalLoss)
                }
            }
            if (topCauses.isNotEmpty()) {
                topCauses.take(3).forEachIndexed { idx, c ->
                    Text(
                        "TOP LOSS CAUSE ${idx + 1}: ${c.cause ?: "-"} ${won(c.krw ?: 0.0)} (${String.format("%.1f", c.percentOfLosses ?: 0.0)}%)",
                        color = Color(0xffb00020)
                    )
                }
            }
            Text("보유 KRW ${won(s.krwBalance)} · 코인 평가금액 ${won(s.coinValue)} · 후보 기준 매수 가능 예상액 ${won(candidateBuyable)}")
            Text("총 손익 ${won(s.cumulativePnl)} (${pct(s.cumulativePnlRate)}) · 실현 ${won(s.realizedPnl)} · 미실현 ${won(s.unrealizedPnl)}")
            Text("오늘 손익 ${won(s.todayPnl)} (${pct(s.todayPnlRate)}) · 보유 ${s.holdingCount}종목 (하드캡 ${s.settings.maxPositionsHardCap})")
            val cap = s.portfolioCapacity
            Text(
                "Portfolio Heat ${pct(cap.portfolioHeatPercent)} · Remaining Risk ${pct(cap.remainingRiskBudgetPercent)} · 현금 ${won(cap.availableCashKrw)} · 추가진입 ${if (cap.additionalEntryAvailable) "AVAILABLE" else "BLOCKED · ${cap.blockReasonCode}"}",
                color = if (cap.additionalEntryAvailable) Color(0xff0b7a32) else Color(0xffb00020)
            )
            Text("Risk ${paperStateText(s.paperRisk.state)} · ${if(s.killSwitchEngaged) "Kill Switch 발동" else if(s.crashProtectionEngaged) "Crash 보호" else if(s.cooldownActive) "Cooldown" else "정상"}", color=if(s.killSwitchEngaged || s.crashProtectionEngaged) Color(0xffb00020) else Color(0xff64748b))
            val a = s.paperLossAutopsy
            if (a.closedRoundTrips > 0 || a.accounting.accountingMismatch) {
                val top = a.topCauses.firstOrNull()
                Text(
                    "손실해부 Equity ${won(a.currentEquity)} · DD ${pct(a.drawdownPercent)} · Net ${won(a.netProfitKrw + a.netLossKrw)} · TOP ${top?.cause?.name ?: "-"} ${won(top?.krw ?: 0.0)}",
                    color = if (a.drawdownPercent < -5.0) Color(0xffb00020) else Color(0xff0b5cad),
                    fontWeight = FontWeight.SemiBold
                )
            }
        }
    }
}

@Composable fun AutoTradeLiveStatusPanel(s:DashboardState){
    val hb = s.scanHeartbeat
    val funnel = s.gateFunnel
    val diag = s.noTradeDiagnosis
    val stageAge = if (hb.stageStartedAt > 0L) ((System.currentTimeMillis() - hb.stageStartedAt) / 1000L) else 0L
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("자동매매 실시간 상태", fontWeight=FontWeight.Bold)
            Text("자동매매: ${engineStatusText(s.engineStatus)} · Scan #${hb.scanSequence} · 단계 ${pipelineStageText(hb.currentStage)} (${stageAge}s)")
            Text("마지막 Scan ${time(hb.lastSuccessfulScanAt)} · 다음 약 ${((hb.nextExpectedScanAt - System.currentTimeMillis()).coerceAtLeast(0L)/1000L)}초 · 소요 ${hb.scanDurationMs}ms")
            Text("감시 KRW ${hb.watchingMarkets} · FAST ${hb.fastCandidates} · DEEP ${hb.deepCandidates} · BUY가능 ${hb.buyReady} · 이번 BUY ${hb.actualBuysThisScan} / SELL평가 ${hb.sellEvaluationsThisScan}·체결 ${hb.actualSellsThisScan}")
            Text("마지막 판단 ${hb.lastJudgedMarket} · 차단 Gate ${hb.lastBlockGate}")
            if (hb.lastBlockReason.isNotBlank() && hb.lastBlockReason != "-") Text("차단 사유: ${hb.lastBlockReason}", color=Color(0xffb45309))
            Text("Funnel KRW ${funnel.totalKrw} → FAST ${funnel.fastCandidates} → DEEP ${funnel.deepCandidates} → Strat ${funnel.strategyPass} → Chase ${funnel.chasePass} → Timing ${funnel.timingPass} → Scalp ${funnel.scalpPass} → Edge ${funnel.shortEdgePass} → Liq ${funnel.liquidityPass} → Net ${funnel.netEdgePass} → BUY_READY ${funnel.buyReady} → BUY ${funnel.actualBuys}", color=Color(0xff64748b))
            if (diag.topBlockers.isNotEmpty()) {
                Text("최근 1시간 TOP 차단: " + diag.topBlockers.joinToString(" · ") { "${it.first} ${"%.0f".format(it.second)}%" })
            } else {
                Text("최근 1시간 TOP 차단: NO_RUNTIME_DATA (스캔 누적 대기)", color=Color(0xff64748b))
            }
            Text(diag.healthClass + if (diag.systemStalled) " · ENGINE_STALL_WARNING" else "", color=if(diag.systemStalled) Color(0xffb00020) else Color(0xff0b5cad), fontWeight=FontWeight.SemiBold)
            if (hb.stallWarning) Text(hb.stallMessage, color=Color(0xffb00020), fontWeight=FontWeight.SemiBold)
            Text("PaperRisk ${paperStateText(s.paperRisk.state)} · Profit ${s.profitProtection.state} · Capital ${s.capitalGrowth.capitalState} · Crash ${if(s.crashProtectionEngaged) "ON" else "OFF"}", color=Color(0xff64748b))
            Text("WS ${s.websocketStatus}/${s.websocketHealthLabel} · 마지막WS ${time(s.lastWebSocketAt)} · ExecDataRate30m ${s.executionDataInsufficientRate30m} · 1h ${s.executionDataInsufficientRate1h} · 3h ${s.executionDataInsufficientRate3h}", color=Color(0xff64748b))
            Text(
                "AI Brain ${s.aiBrainStatus} · 지연 ${if (s.aiBrainLatencyMs >= 0) "${s.aiBrainLatencyMs}ms" else "-"} · Model ${s.aiBrainModelVersion} · ServerWS ${s.aiBrainBithumbWs} · MicroReady ${s.aiBrainMicroBufferReady} · Local ${s.aiBrainLocalFallback}",
                color = Color(0xff0b5cad)
            )
            Text(
                "AI LEARNING: ${s.hetznerLearningStatus} · BRAIN ${s.hetznerBrainState} · HEALTH ${s.hetznerLearningHealth} · MODEL ${s.hetznerModelStatus} · REAL DATA ${s.hetznerLearningProofSource}",
                color = Color(0xff0f766e),
                fontWeight = FontWeight.SemiBold
            )
            Text(
                "AI 2층 ${s.hetznerLayer2Status} · LEARNING ${s.hetznerIsLearning} · IMPROVING ${s.hetznerIsImproving} · HEALTH ${s.hetznerLearningHealth}",
                color = Color(0xff0f766e),
                fontWeight = FontWeight.SemiBold
            )
            Text(
                "REAL AI EVIDENCE ${s.hetznerProductionEvidence} · REAL SAMPLE ${s.hetznerRealSampleCount} · REAL_SHADOW ${s.hetznerRealShadowSampleCount} · REAL CYCLE ${s.hetznerRealCycleCount}",
                color = Color(0xff0f766e),
                fontWeight = FontWeight.SemiBold
            )
            Text(
                "OOS ${s.hetznerOosStatus} · SHADOW ${s.hetznerShadowStatus} · PREDICTION Δ ${s.hetznerPredictionChangeRate} · NEW PAPER BUY PAUSED_DIAGNOSTIC · LIVE DISABLED",
                color = Color(0xffb45309),
                fontWeight = FontWeight.SemiBold
            )
            Text(
                "ACTIVE MODEL ${s.hetznerActiveModel} · hash ${s.hetznerActiveModelHash.take(12)} · LAST LEARNING ${time(s.hetznerLastLearningAt)} · NEW SAMPLES ${s.hetznerSamplesSinceLearning}/${s.hetznerSamplesTotal}",
                color = Color(0xff64748b)
            )
            Text(
                "CHALLENGER ${s.hetznerChallengerVersion} · SHADOW ${s.hetznerShadowStatus} · RESEARCH ${s.hetznerRecentLearningStatus} · BADGE ${s.hetznerRecentLearningBadge}",
                color = Color(0xff64748b)
            )
            if (s.hetznerRecentLearningProblem.isNotBlank() && s.hetznerRecentLearningProblem != "-") {
                Text("RECENT LEARNING · Problem: ${s.hetznerRecentLearningProblem}", color = Color(0xff334155))
                Text("Hypothesis: ${s.hetznerRecentLearningHypothesis}", color = Color(0xff334155))
                Text("Candidate: ${s.hetznerRecentLearningCandidate} · Status: ${s.hetznerRecentLearningStatus}", color = Color(0xff334155))
            }
            Text(
                "AndroidMode ${s.androidAnalysisMode} · ServerFAST ${s.serverFastScanCount} · ServerDEEP ${s.serverDeepScanCount} · DashAge ${if (s.serverDashboardAgeMs >= 0) "${s.serverDashboardAgeMs}ms" else "-"} · Primary ${if (s.settings.remoteAiPrimary) "ON" else "OFF"}",
                color = when (s.androidAnalysisMode) {
                    "SERVER_PRIMARY_VIEWER" -> Color(0xff0f766e)
                    "POSITION_SAFETY_ONLY" -> Color(0xffb00020)
                    else -> Color(0xff64748b)
                }
            )
            if (s.settings.remoteAiPrimary) {
                Text(
                    "Server PAPER AUTO ${if (s.serverPaperAuto) "ON" else "OFF"} · 서버현금 ${won(s.serverPaperCash)} · 평가 ${won(s.serverPaperTotalValue)} · 포지션 ${s.serverPaperPositionCount} · tick ${s.serverPaperTickCount} · independent ${if (s.serverPaperIndependent) "YES" else "NO"}",
                    color = Color(0xff0f766e),
                    fontWeight = FontWeight.SemiBold
                )
                Text("자동매매 시작/중지 = 서버 PAPER AUTO Remote Control (앱 종료 후에도 서버 거래 지속)", color=Color(0xff64748b))
            }
            if (s.aiBrainStatus == AiBrainLinkStatus.OFFLINE.name && s.settings.remoteAiPrimary) {
                Text("AI BRAIN OFFLINE · NEW BUY BLOCKED · 포지션 보호만 동작", color=Color(0xffb00020), fontWeight=FontWeight.SemiBold)
            }
            if (s.lastRemoteDecisionSummary != "-") Text("마지막 서버 판단: ${s.lastRemoteDecisionSummary}", color=Color(0xff64748b))
        }
    }
}

@Composable fun CurrentPositionsPanel(s:DashboardState, positions:List<PositionEntity>){
    val primary = s.settings.remoteAiPrimary && s.settings.remoteAiEnabled
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(8.dp)){
            Text(
                if (primary) "현재 보유 포지션 (SERVER SoT)" else "현재 보유 포지션",
                fontWeight=FontWeight.Bold
            )
            if (primary) {
                val serverActive = s.serverPaperPositions.filter { (it.quantity ?: 0.0) > 0.0 }
                if (serverActive.isEmpty()) Text("현재 보유 포지션 없음 (서버)", color=Color(0xff64748b))
                serverActive.forEach { position ->
                    val qty = position.quantity ?: 0.0
                    val avg = position.avgPrice ?: 0.0
                    val currentPrice = position.markPrice?.takeIf { it > 0.0 } ?: avg
                    val buyAmount = avg * qty
                    val currentValue = currentPrice * qty
                    // Primary: use server unrealizedPnL / pnlRate — do not recompute locally.
                    val pnl = position.unrealizedPnl ?: (currentValue - buyAmount)
                    val pnlRate = position.pnlRate
                        ?: if (buyAmount > 0.0) pnl / buyAmount * 100.0 else 0.0
                    val stopPrice = avg * (1.0 + s.settings.stopLossPercent / 100.0)
                    val takePrice = avg * (1.0 + s.settings.takeProfitPercent / 100.0)
                    val highest = position.highestPrice ?: avg
                    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color(0xfff0fdfa))){
                        Column(Modifier.padding(10.dp), verticalArrangement=Arrangement.spacedBy(3.dp)){
                            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                                Text(position.market.orEmpty(), fontWeight=FontWeight.Bold)
                                Text("SERVER", color=Color(0xff0f766e), fontWeight=FontWeight.Bold)
                            }
                            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                                Text("매수가 ${priceKrw(avg)}", fontWeight=FontWeight.SemiBold)
                                Text("현재가 ${priceKrw(currentPrice)}", fontWeight=FontWeight.SemiBold)
                            }
                            Text("수량 ${String.format(Locale.KOREA, "%.8f", qty)} · 매수금액 ${won(buyAmount)} · 평가금액 ${won(currentValue)}")
                            Text("손익 ${won(pnl)} (${pct(pnlRate)})", color=if(pnl >= 0.0) Color(0xff0b7a32) else Color(0xffb00020), fontWeight=FontWeight.Bold)
                            Text("최고가 ${priceKrw(highest)} · Stop ${priceKrw(stopPrice)} · Take Profit ${priceKrw(takePrice)}")
                            Text("보유시간 ${holdingDuration(position.openedAt ?: 0L)} · 서버 미실현 그대로 표시", color=Color(0xff64748b))
                        }
                    }
                }
            } else {
                val active = positions.filter { it.quantity > 0.0 && it.mode == s.mode.name }
                if(active.isEmpty()) Text("현재 보유 포지션 없음", color=Color(0xff64748b))
                active.forEach { position ->
                    val signal = s.topSignals.firstOrNull { it.market == position.market }
                    val currentPrice = signal?.currentPrice?.takeIf { it > 0.0 } ?: position.avgPrice
                    val buyAmount = position.avgPrice * position.quantity
                    val currentValue = currentPrice * position.quantity
                    val pnl = currentValue - buyAmount
                    val pnlRate = if(buyAmount > 0.0) pnl / buyAmount * 100.0 else 0.0
                    val stopPrice = position.avgPrice * (1.0 + s.settings.stopLossPercent / 100.0)
                    val takePrice = position.avgPrice * (1.0 + s.settings.takeProfitPercent / 100.0)
                    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color(0xfff8fafc))){
                        Column(Modifier.padding(10.dp), verticalArrangement=Arrangement.spacedBy(3.dp)){
                            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                                Text(position.market, fontWeight=FontWeight.Bold)
                                Text("HELD", color=Color(0xff0b5cad), fontWeight=FontWeight.Bold)
                            }
                            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                                Text("매수가 ${priceKrw(position.avgPrice)}", fontWeight=FontWeight.SemiBold)
                                Text("현재가 ${priceKrw(currentPrice)}", fontWeight=FontWeight.SemiBold)
                            }
                            Text("수량 ${String.format(Locale.KOREA, "%.8f", position.quantity)} · 매수금액 ${won(buyAmount)} · 평가금액 ${won(currentValue)}")
                            Text("손익 ${won(pnl)} (${pct(pnlRate)})", color=if(pnl >= 0.0) Color(0xff0b7a32) else Color(0xffb00020), fontWeight=FontWeight.Bold)
                            Text("최고가 ${priceKrw(position.highestPrice)} · Stop ${priceKrw(stopPrice)} · Take Profit ${priceKrw(takePrice)}")
                            Text("Trailing ${if(currentPrice >= position.highestPrice) "고점 갱신" else "추적 중"} · 보유시간 ${holdingDuration(position.openedAt)}")
                            Text("Entry Score ${signal?.score?.toInt() ?: "-"} · AI ${signal?.aiScore?.toInt() ?: "-"} · Execution ${signal?.scalpExecutionScore?.toInt() ?: "-"}", color=Color(0xff64748b))
                        }
                    }
                }
            }
        }
    }
}

fun tradingStateText(s:DashboardState):String = when {
    s.killSwitchEngaged -> "RISK_BLOCKED"
    s.crashProtectionEngaged || s.cooldownActive -> "COOLDOWN"
    s.smartReentryStates.any { it.status == ProfitReentryStatus.PROFIT_EXIT_COOLDOWN } -> "PROFIT_EXIT_COOLDOWN"
    s.topSignals.any { it.status == CandidateStatus.ORDERING } -> "BUY_PENDING"
    s.holdingCount > 0 -> "HELD"
    s.topSignals.any { it.status == CandidateStatus.BUY_READY } -> "BUY_READY"
    s.topSignals.any { it.status == CandidateStatus.WAIT_PULLBACK } -> "WAIT_PULLBACK"
    s.topSignals.any { it.status == CandidateStatus.WAIT_RETEST } -> "WAIT_RETEST"
    s.topSignals.any { it.status == CandidateStatus.WAIT_RECONFIRMATION } -> "WAIT_RECONFIRMATION"
    s.topSignals.any { it.status == CandidateStatus.WAIT_MOMENTUM } -> "WAIT_MOMENTUM"
    s.engineStatus == EngineStatus.RUNNING -> "SCANNING"
    else -> "STOPPED"
}

fun holdingDuration(openedAt:Long):String{
    if(openedAt <= 0L) return "-"
    val seconds=((System.currentTimeMillis()-openedAt).coerceAtLeast(0L))/1000L
    return String.format(Locale.KOREA, "%02d:%02d:%02d", seconds/3600, (seconds%3600)/60, seconds%60)
}

@Composable fun BuyCandidatePanel(s:DashboardState){
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(8.dp)){
            Text("실시간 매수 후보", fontWeight=FontWeight.Bold)
            Text("전체 감시 ${s.watchingCount}개 · 분석 배치 ${s.analysisBatchIndex}/${s.analysisBatchTotal} (${s.analysisProgressPercent}%)", color=Color(0xff64748b))
            Text("매수 대기(BUY_READY) ${s.buyReadyCount}개 · 후보 ${s.candidateCount}개 · 마지막 분석 ${time(s.lastAnalysisAt)}", color=Color(0xff64748b))
            if(s.topSignals.isEmpty()) Text("아직 분석 결과가 없습니다. '지금 스캔' 또는 자동매매 시작을 누르세요.", color=Color(0xff64748b))
            s.topSignals.sortedByDescending { it.score }.take(10).forEach { CandidateRow(it) }
        }
    }
}
@Composable fun ScalpingExecutionPanel(s:DashboardState){
    val scalp = s.scalpingResearch
    val candidate = s.topSignals.firstOrNull {
        it.scalpExecutionState != ScalpingExecutionState.AVOID.name
    }
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("초단기 실행 AI(Scalping Execution)", fontWeight=FontWeight.Bold)
            Text("모델 ${scalp.modelVersion} · 현재 전략은 Champion, Scalping AI는 타이밍 Challenger/추천 전용", color=Color(0xff64748b))
            if (candidate != null) {
                Text("실행 AI ${candidate.scalpExecutionScore.toInt()} · 신뢰도(판단확신) ${candidate.scalpExecutionConfidence.toInt()} · 상태 ${candidate.scalpExecutionState}")
                Text("실행 데이터 ${candidate.executionDataStatus} · micro ${candidate.microSampleCount} · ShortEdge신뢰 ${if (candidate.shortEdgeReliable) "YES" else "NO"}")
                if (candidate.executionDataGaps.isNotEmpty()) Text("부족: ${candidate.executionDataGaps.joinToString(" · ")}", color=Color(0xffb45309))
                Text("초단기 Edge(30초~5분) ${pct(candidate.shortHorizonNetEdge)} · 중기/익절목표 Net Edge ${pct(candidate.netExpectedEdge)} · Horizon ${candidate.horizonConflict}")
                if (candidate.shortEdgeCostBreakdownText.isNotBlank()) Text(candidate.shortEdgeCostBreakdownText, color=Color(0xff64748b))
                Text("Chase ${candidate.chaseEntryScore.toInt()} · Timing ${candidate.entryTimingScore.toInt()} · Urgency ${candidate.entryUrgencyClass} · 캔들 ${candidate.candleSignalType}")
                if (candidate.entryDecisionSummary.isNotBlank()) Text(candidate.entryDecisionSummary, color=Color(0xff0b5cad), fontWeight=FontWeight.SemiBold)
                Text("30초/1분/3분/5분 ${pct(candidate.microReturn30s)} / ${pct(candidate.microReturn1m)} / ${pct(candidate.microReturn3m)} / ${pct(candidate.microReturn5m)} · 호가 imbalance ${String.format(Locale.KOREA, "%.2f", candidate.microOrderbookImbalance)}")
                Text("이유: ${candidate.scalpReasonCodes.joinToString(" · ")}", color=Color(0xff64748b))
            } else {
                Text("현재 초단기 실행 후보 없음(데이터/안전 조건 확인 중)", color=Color(0xff64748b))
            }
            Text("신호 ${scalp.totalSignals} · 진입 ${scalp.entered} · 거절 ${scalp.rejected} · 대기 ${scalp.waited}")
            Text("Good Entry ${scalp.goodEntry} · False Entry ${scalp.falseEntry} · Good Reject ${scalp.goodReject} · False Reject ${scalp.falseReject}")
            Text("평균 실행점수 ${scalp.averageExecutionScore.toInt()} · 평균 신뢰도 ${scalp.averageConfidence.toInt()} · 평균 Short Edge ${pct(scalp.shortNetEdgeAverage)} · Calibration ${scalp.calibration}", color=if(scalp.calibration == "OVERCONFIDENT") Color(0xffb00020) else Color(0xff64748b))
            Text("Scalping Shadow · 거래 ${scalp.shadowTrades} · 승률 ${pct(scalp.shadowWinRate*100)} · PF ${String.format(Locale.KOREA, "%.2f", scalp.shadowProfitFactor)} · 기대값 ${pct(scalp.shadowExpectancy)} · MDD ${pct(scalp.shadowMdd)} · 순수익 ${pct(scalp.shadowNetReturn)}")
            Text("Shadow 비용 · 수수료 ${won(scalp.shadowFees)} · 슬리피지 ${won(scalp.shadowSlippage)} · 상태 ${scalp.modelStatus}", color=Color(0xff64748b))
        }
    }
}
@Composable fun GlobalDerivativesPanel(s:DashboardState){
    val items = s.globalDerivatives
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("글로벌 파생 수급 보조 AI", fontWeight=FontWeight.Bold)
            Text("시장 상태 ${s.globalDerivativesMarketState} · 지원 ${s.derivativesSupportedCount}개 · 미지원 ${s.derivativesUnsupportedCount}개 · 일시불가 ${s.derivativesUnavailableCount}개 · Bithumb 현물 분석이 주 데이터", color=Color(0xff64748b))
            if(items.isEmpty()) Text("현재 파생 데이터 없음 — 기존 Bithumb PAPER 분석 계속", color=Color(0xff64748b))
            items.take(5).forEach { d ->
                Text("${d.market} · ${d.supportStatus} · ${d.providerStatus} · ${d.freshness} · Sentiment ${d.derivativesSentiment.toInt()} / Risk ${d.derivativesRisk.toInt()}")
                Text("OI ${d.openInterestState} · Funding ${d.fundingState} · Position ${d.positioningState} · Short Squeeze ${d.shortSqueezeScore.toInt()} · Long Squeeze ${d.longSqueezeRisk.toInt()}")
                Text("Spot/Futures ${d.spotFuturesDivergence} · Lead ${d.globalLeadState} · ${d.reasonCodes.joinToString(" · ")}", color=if(d.longSqueezeRisk >= 80.0) Color(0xffb00020) else Color(0xff64748b))
            }
        }
    }
}
@Composable fun AdaptiveRegimePanel(s:DashboardState){
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Text("적응형 시장 국면", fontWeight=FontWeight.Bold)
            Text("${regimeText(s.currentRegime.regime)} · 신뢰도 ${String.format(Locale.KOREA,"%.0f", s.currentRegime.confidence*100)}% · 추세강도 ${s.currentRegime.trendStrength.toInt()} · 변동성 ${s.currentRegime.volatilityLevel}")
            Text("단기 ${regimeText(s.currentRegime.shortRegime)} / 중기 ${regimeText(s.currentRegime.midRegime)} / 장기 ${regimeText(s.currentRegime.longRegime)} · 지속 ${s.currentRegime.durationMinutes}분")
            Text("국면별 전략 세트", fontWeight=FontWeight.SemiBold)
            val set = s.regimeStrategySet.active
            Text("${set.displayName} (${set.setId}) · ${if (set.applied) "PAPER 적용 중" else "추천만"} · ${if (set.noNewEntries) "신규진입 차단" else "신규진입 허용"}")
            Text("점수기준 ${String.format(Locale.KOREA,"%.1f", set.scoreThreshold)} (Δ${String.format(Locale.KOREA,"%.1f", set.scoreThresholdDelta)}) · 손절 ${pct(set.stopLossPercent)} · 익절 ${pct(set.takeProfitPercent)} · 트레일 ${pct(set.trailingStopPercent)}")
            Text("최대보유 ${set.maxPositions} · 주문비중 ${String.format(Locale.KOREA,"%.1f", set.maxOrderPercent)}% · 배수 ${String.format(Locale.KOREA,"%.2f", set.orderSizeMultiplier)}x")
            Text(set.reason, color=Color(0xff0b5cad))
            Text("국면 정확도: ${s.regimeStrategySet.accuracy.status} · 표본 ${s.regimeStrategySet.accuracy.sampleCount} · 적중 ${pct(s.regimeStrategySet.accuracy.accuracyRate*100)} · ${s.regimeStrategySet.accuracy.message}", color=if(s.regimeStrategySet.accuracy.status == "POOR") Color(0xffb00020) else Color(0xff64748b))
            Text("세트 Shadow: ${s.regimeStrategySet.shadow.winner} · 기준 기대 ${pct(s.regimeStrategySet.shadow.baselineExpectedReturn)} vs 적응 ${pct(s.regimeStrategySet.shadow.adaptiveExpectedReturn)} · ${s.regimeStrategySet.shadow.reason}", color=Color(0xff64748b))
            Text("국면별 전략 추천(가중치 · 실제 전략 자동교체 없음)", fontWeight=FontWeight.SemiBold)
            s.regimeSelector.weights.forEach { weight -> Text("${weight.strategy}: ${String.format(Locale.KOREA,"%.0f", weight.weightPercent)}% · ${weight.reason}", color=Color(0xff64748b)) }
            Text(s.regimeSelector.reason, color=Color(0xff0b5cad))
            Text("주력 전략(Champion) vs 도전자(Challenger)", fontWeight=FontWeight.SemiBold)
            Text("${s.championChallenger.champion} vs ${s.championChallenger.challenger} · 상태: ${s.championChallenger.status} · ${s.championChallenger.reason}")
            if(s.recentRegimeTransitions.isNotEmpty()){
                Text("최근 국면 전환", fontWeight=FontWeight.SemiBold)
                s.recentRegimeTransitions.take(5).forEach { transition -> Text("${regimeText(runCatching { MarketRegime.valueOf(transition.from) }.getOrDefault(MarketRegime.UNKNOWN))} → ${regimeText(runCatching { MarketRegime.valueOf(transition.to) }.getOrDefault(MarketRegime.UNKNOWN))} ${time(transition.time)} · 신뢰도 ${String.format(Locale.KOREA,"%.0f", transition.confidence*100)}%", color=Color(0xff64748b)) }
            }
            if(s.confidenceCalibration.isNotEmpty()) Text("신뢰도 보정(Calibration): " + s.confidenceCalibration.entries.joinToString(" · ") { "${it.key} ${String.format(Locale.KOREA,"%.0f",it.value*100)}%" }, color=Color(0xff64748b))
        }
    }
}
@Composable fun NewsIntelligencePanel(s:DashboardState){
    val news = s.newsResearch
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Text("뉴스 분석 · 지속 학습(News Intelligence)", fontWeight=FontWeight.Bold)
            Text("수집 상태: ${news.engineStatus.name} · ${news.lastMessage} · 마지막 확인 ${time(news.lastCheckAt)}")
            Text("뉴스 리스크: ${newsRiskText(news.risk.level)} · ${news.risk.message}", color=if(news.risk.newEntryBlocked) Color(0xffb00020) else Color.Unspecified)
            news.recentEvents.take(5).forEach { event ->
                Text("${event.symbols.ifBlank { "시장 전체" }} · ${event.eventType} · 영향도 ${event.impactScore.toInt()} · 신뢰도 ${event.confidence.toInt()} · ${event.title}", fontWeight=FontWeight.SemiBold)
            }
            Text("뉴스 반응: 표본 ${news.reactionStats.sampleCount}건 · 5분 ${pct(news.reactionStats.averageReturnByHorizon[5] ?: 0.0)} · 30분 ${pct(news.reactionStats.averageReturnByHorizon[30] ?: 0.0)} · 예측정확도 ${pct(news.reactionStats.predictionAccuracy*100)}")
            Text("모델 레지스트리: ${news.modelVersion} · ${news.modelStatus} · 시장 드리프트 ${if(news.driftWarning) "경고(WARNING)" else "정상(NORMAL)"}", color=if(news.driftWarning) Color(0xffb00020) else Color.Unspecified)
            if(news.hypothesis.isNotBlank()) Text("AI 연구 가설(검증 중): ${news.hypothesis}", color=Color(0xff0b5cad))
        }
    }
}
@Composable fun ResearchPanels(s:DashboardState){
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Text("AI 전략 연구소 · 신규 분석", fontWeight=FontWeight.Bold)
            Text("기회비용 비교(추천만)", fontWeight=FontWeight.SemiBold)
            if(s.opportunityDecisions.isEmpty()) Text("현재 보유 포지션이 없어 비교 대기 중", color=Color(0xff64748b))
            s.opportunityDecisions.take(5).forEach { d ->
                Text("${opportunityActionText(d.action)}: ${d.heldMarket} → ${d.candidateMarket ?: "-"} · 유지점수 ${d.keepScore.toInt()} / 교체점수 ${d.rotateScore.toInt()} / 대기점수 ${d.doNothingScore.toInt()}")
                Text(d.reason, color=Color(0xff64748b))
            }
            Text("진입 품질", fontWeight=FontWeight.SemiBold)
            val quality = s.postEntryQuality
            Text("표본 ${quality.sampleCount}건 · 즉시 상승확률 ${pct(quality.immediateRiseProbability*100)} · 평균 최대수익(MFE) ${pct(quality.averageMfe)} · 평균 최대손실(MAE) ${pct(quality.averageMae)}")
            Text("1분 ${pct(quality.averageReturnByHorizon[1] ?: 0.0)} · 5분 ${pct(quality.averageReturnByHorizon[5] ?: 0.0)} · 15분 ${pct(quality.averageReturnByHorizon[15] ?: 0.0)} · 30분 ${pct(quality.averageReturnByHorizon[30] ?: 0.0)} · 60분 ${pct(quality.averageReturnByHorizon[60] ?: 0.0)}")
            val timing = s.entryTimingResearch
            Text("진입 타이밍 품질(사후 연구 전용)", fontWeight=FontWeight.SemiBold)
            Text("Good Entry ${pct(timing.goodEntryRate*100)} · Chase ${pct(timing.chaseEntryRate*100)} · 즉시 하락률(첫 5분 -2% 이하) ${pct(timing.immediateDrawdownRate*100)}")
            Text("고득점(90+) 실패율 ${pct(timing.highScoreFailureRate*100)} · 평균 Chase ${timing.averageChaseScore.toInt()} · 평균 Timing ${timing.averageEntryTimingScore.toInt()} · Signal Lag 평균/P50/P95 ${timing.averageSignalLagMs}/${timing.p50SignalLagMs}/${timing.p95SignalLagMs}ms")
            Text("진입 경고: ${timing.warning} · ${timing.shadowSummary}", color=if(timing.warning.contains("WARNING")) Color(0xffb00020) else Color(0xff64748b))
            Text("전략 경쟁(Shadow 가상 계좌 · 실제 잔액과 완전 분리)", fontWeight=FontWeight.SemiBold)
            if(s.shadowPortfolios.isEmpty()) Text("Shadow 포트폴리오 초기화 중", color=Color(0xff64748b))
            s.shadowPortfolios.sortedByDescending { ShadowSimulationEngine.riskAdjustedScore(it) }.forEach { p ->
                val score = ShadowSimulationEngine.riskAdjustedScore(p)
                Text("${shadowStrategyText(p.strategy)} · 누적 ${pct(p.returnPercent)} · 승률 ${pct(p.winRate*100)} · PF ${if(p.profitFactor.isInfinite()) "무한대(∞)" else String.format(Locale.KOREA,"%.2f",p.profitFactor)} · 최대낙폭 ${pct(p.maxDrawdownPercent)} · 거래 ${p.tradeCount}건 · 위험조정점수 ${if(score == Double.NEGATIVE_INFINITY) "표본 부족" else String.format(Locale.KOREA,"%.1f",score)}")
            }
            Text("놓친 기회 분석: 총 ${s.missedOpportunityStats.total}건 · 유효차단(GOOD) ${s.missedOpportunityStats.goodRejection} · 놓친상승(MISSED) ${s.missedOpportunityStats.missedWin} · 중립(NEUTRAL) ${s.missedOpportunityStats.neutral}")
            s.missedOpportunityStats.byReason.entries.take(5).forEach { (reason, value) -> Text("$reason: $value", color=Color(0xff64748b)) }
        }
    }
}
@Composable fun CandidateRow(c:StrategySignalModel){
    val statusColor = when(c.status){ CandidateStatus.BUY_READY -> Color(0xff0b7a32); CandidateStatus.ORDERING -> Color(0xffb45309); CandidateStatus.HOLDING -> Color(0xff0b5cad); CandidateStatus.REJECTED -> Color(0xffb00020); CandidateStatus.WATCHING -> Color(0xff475569); CandidateStatus.WAIT_PULLBACK, CandidateStatus.WAIT_RETEST, CandidateStatus.WAIT_RECONFIRMATION, CandidateStatus.WAIT_MOMENTUM -> Color(0xffb45309) }
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color(0xfff8fafc))){
        Column(Modifier.padding(10.dp), verticalArrangement=Arrangement.spacedBy(4.dp)){
            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){ Text(c.market, fontWeight=FontWeight.Bold); Text("Trading Score ${c.score.toInt()}", fontWeight=FontWeight.Bold) }
            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){ Text("현재가 ${won(c.currentPrice)}"); Text("상태 ${statusText(c)}", color=statusColor, fontWeight=FontWeight.SemiBold) }
            Text("전략 점수: ${c.score.toInt()}점 · ${c.aiLabel}")
            Text("진입 타이밍 ${c.entryTimingScore.toInt()}점 · 추격 ${c.chaseEntryScore.toInt()}점 · ${c.entryTimingState} · ${c.entryQualityClassification}")
            Text("사전 등락 1/3/5분 ${pct(c.preEntry1mReturn)} / ${pct(c.preEntry3mReturn)} / ${pct(c.preEntry5mReturn)} · 확장 ${String.format(Locale.KOREA, "%.2f", c.overextensionAtr)} ATR · Score 속도 ${String.format(Locale.KOREA, "%.1f", c.scoreVelocityPerMinute)}/분")
            Text("실행 AI ${c.scalpExecutionScore.toInt()}점 · 신뢰도(판단확신) ${c.scalpExecutionConfidence.toInt()} · ${c.scalpExecutionState}")
            Text("실행 데이터 ${c.executionDataStatus} · micro샘플 ${c.microSampleCount} · ShortEdge신뢰 ${if (c.shortEdgeReliable) "YES" else "NO"} · DataQuality ${c.dataQualityStatus}")
            if (c.executionDataGaps.isNotEmpty()) Text("실행 데이터 부족: ${c.executionDataGaps.joinToString(" · ")}", color=Color(0xffb45309))
            Text("초단기 Edge(30초~5분) ${pct(c.shortHorizonNetEdge)} · 중기/익절목표 Net Edge ${pct(c.netExpectedEdge)} · ${c.horizonConflict}")
            if (c.shortEdgeCostBreakdownText.isNotBlank()) Text(c.shortEdgeCostBreakdownText, color=Color(0xff64748b))
            if (c.entryDecisionSummary.isNotBlank()) Text(c.entryDecisionSummary, color=Color(0xff0b5cad), fontWeight=FontWeight.SemiBold)
            Text("파생 보조: ${c.derivativesPositioningState} · OI ${c.derivativesOiChange5m?.let { String.format(Locale.KOREA, "%.2f%%", it) } ?: "-"} · Funding ${c.derivativesFundingState} · Long Squeeze ${c.longSqueezeRisk.toInt()} · ${c.spotFuturesDivergence}")
            Text("예상 투자 ${won(c.estimatedInvestment)} (${String.format(Locale.KOREA,"%.1f", c.investmentRatio)}%)")
            Text("예상 손절가 ${won(c.expectedStopPrice)} / 익절가 ${won(c.expectedTakeProfitPrice)}")
            Text("거래대금 ${won(c.actual24hTradeValueKrw)} / ${EntryUrgencyAudit.liquidityRequiredLabel(c.required24hTradeValueKrw, c.liquidityReady)} · ${EntryUrgencyAudit.liquidityRankLabel(c.liquidityRank, c.liquidityTotal)} · ${EntryUrgencyAudit.liquidityPercentileLabel(c.liquidityPercentile, c.liquidityTotal)}")
            Text("중기 순기대수익: 총 ${pct(c.grossExpectedEdge)} - 비용 ${pct(c.expectedExecutionCost)} = ${pct(c.netExpectedEdge)} (익절목표 기준, 초단기와 별개) · 체결추정 ${won(c.depthWeightedFillPrice)}")
            if (c.expectedGrossProfitKrw != 0.0 || c.expectedRoundTripCostKrw != 0.0 || c.expectedNetProfitKrw != 0.0) {
                Text(
                    "예상 총수익 ${won(c.expectedGrossProfitKrw)} · 예상 왕복비용 ${won(-c.expectedRoundTripCostKrw)} · 예상 순이익 ${won(c.expectedNetProfitKrw)} · 비용비중 ${String.format(Locale.KOREA,"%.1f", c.costToGrossProfitRatio * 100.0)}% · coverage ${String.format(Locale.KOREA,"%.2f", c.costCoverageMultiple)}x · 손익분기 ${won(c.breakEvenPrice)}",
                    color = if (c.netProfitAfterCostPassed) Color(0xff0f766e) else Color(0xffb00020)
                )
                if (c.netProfitAfterCostReason.isNotBlank()) Text(c.netProfitAfterCostReason, color=Color(0xff64748b))
            }
            Text("데이터 품질: ${c.dataQualityStatus} (${c.dataQualityScore.toInt()}/100) · 유동성 ${if(c.liquidityReady && c.liquidityPassed) "통과" else if(!c.liquidityReady) "계산 전" else "차단"} · NetEdge ${if(c.netEdgePassed) "통과" else "차단"} · Urgency ${c.entryUrgencyClass}")
            if (c.parabolicMove || c.momentumExhaustion || c.volumeClimax) {
                Text("주의: ${listOfNotNull(if(c.parabolicMove) "PARABOLIC" else null, if(c.momentumExhaustion) "MOMENTUM_EXHAUSTION" else null, if(c.volumeClimax) "VOLUME_CLIMAX" else null).joinToString(" · ")}", color=Color(0xffb00020))
            }
            Text("검증 단계: ${c.decisionPipeline}", color=Color(0xff64748b))
            if((c.status == CandidateStatus.REJECTED || c.status.name.startsWith("WAIT_")) && c.failureReason.isNotBlank()) Text("판정: ${c.failureReason}", color=if(c.status == CandidateStatus.REJECTED) Color(0xffb00020) else Color(0xffb45309))
        }
    }
}
fun opportunityActionText(action: OpportunityAction) = when(action) {
    OpportunityAction.KEEP -> "유지(KEEP)"
    OpportunityAction.ROTATE -> "교체 추천(ROTATE)"
    OpportunityAction.DO_NOTHING -> "관망(DO_NOTHING)"
}

fun shadowStrategyText(strategy: String) = when(strategy) {
    "CURRENT" -> "현재 전략(CURRENT)"
    "AGGRESSIVE" -> "공격형 전략(AGGRESSIVE)"
    "CONSERVATIVE" -> "보수형 전략(CONSERVATIVE)"
    "AI_RECOMMENDED" -> "AI 추천 파라미터(AI)"
    else -> strategy
}

fun newsRiskText(level: NewsRiskLevel) = when(level) {
    NewsRiskLevel.LOW -> "낮음(LOW)"
    NewsRiskLevel.MEDIUM -> "보통(MEDIUM)"
    NewsRiskLevel.HIGH -> "높음(HIGH)"
    NewsRiskLevel.CRITICAL -> "치명적(CRITICAL)"
}

fun statusText(c:StrategySignalModel)= when(c.status){ CandidateStatus.BUY_READY -> "매수 예정"; CandidateStatus.ORDERING -> "주문 중"; CandidateStatus.HOLDING -> "보유 중"; CandidateStatus.REJECTED -> c.failureReason.ifBlank{"제외"}; CandidateStatus.WATCHING -> "감시 중"; CandidateStatus.WAIT_PULLBACK -> "Pullback 대기"; CandidateStatus.WAIT_RETEST -> "Retest 대기"; CandidateStatus.WAIT_RECONFIRMATION -> "재확인 대기"; CandidateStatus.WAIT_MOMENTUM -> "Momentum 대기" }
@Composable fun AiLearningPanel(s:DashboardState, vm:MainViewModel){
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Text("AI 반자동 학습", fontWeight=FontWeight.Bold)
            Text("AI는 실전매매를 실행하지 않고, Paper 시뮬레이션에서 매수 확률만 추천합니다. 새 모델은 검증 정확도가 기존보다 실제로 나을 때만 채택됩니다.", color=Color(0xff64748b))
            Text("학습 라벨 완료 ${s.aiResolvedSampleCount}/${s.aiTrainingSampleCount}건 · 현재 모델 ${aiSourceText(s.aiModelSource)} (로컬 세대 v${s.aiLocalGeneration})")
            Text("마지막 재학습 시각: ${time(s.lastAiLocalRetrainAt)}")
            Text("재학습 상태: ${otaText(s.aiRetrainStatus)} ${s.aiRetrainMessage}")
            if(s.recentModelVersions.isNotEmpty()){
                Text("최근 버전 이력", fontWeight=FontWeight.SemiBold)
                s.recentModelVersions.forEach { v ->
                    val color = if(v.adopted) Color(0xff0b7a32) else Color(0xff94a3b8)
                    Text(
                        "세대 ${v.generation} · ${if(v.adopted) "채택" else "기각"} · 검증 ${String.format(Locale.KOREA,"%.1f", v.validationAccuracy*100)}% (기존 ${String.format(Locale.KOREA,"%.1f", v.baselineAccuracy*100)}%) · 샘플 ${v.trainingSamples}건",
                        color=color
                    )
                }
            }
            OutlinedButton(onClick={vm.forceLocalRetrain()}, modifier=Modifier.fillMaxWidth()){ Text("지금 재학습 시도") }
        }
    }
}
fun aiSourceText(source:String)= when(source){ "BUNDLED" -> "내장 모델"; "OTA" -> "OTA 모델"; "LOCAL_RETRAIN" -> "기기 내 재학습"; else -> source }
fun regimeText(regime:MarketRegime)= when(regime){ MarketRegime.STRONG_BULL -> "강한 상승장"; MarketRegime.BULL -> "상승장"; MarketRegime.SIDEWAYS -> "횡보장"; MarketRegime.HIGH_VOLATILITY -> "고변동성"; MarketRegime.WEAK_BEAR -> "약한 하락장"; MarketRegime.BEAR -> "하락장"; MarketRegime.STRONG_BEAR -> "강한 하락장"; MarketRegime.CRASH -> "급락장"; MarketRegime.RECOVERY -> "회복장"; MarketRegime.UNKNOWN -> "판단 중(표본 부족)" }
fun healthLevelText(level:MarketHealthLevel)= when(level){ MarketHealthLevel.HEALTHY -> "정상"; MarketHealthLevel.CAUTION -> "주의"; MarketHealthLevel.CRASH -> "급락(Crash)" }
fun healthLevelColor(level:MarketHealthLevel)= when(level){ MarketHealthLevel.HEALTHY -> Color(0xff0b7a32); MarketHealthLevel.CAUTION -> Color(0xffb45309); MarketHealthLevel.CRASH -> Color(0xffb00020) }
fun capitalStateText(state: CapitalGrowthState) = when(state) {
    CapitalGrowthState.GROWTH -> "성장(GROWTH)"
    CapitalGrowthState.NORMAL -> "정상(NORMAL)"
    CapitalGrowthState.CAUTION -> "주의(CAUTION)"
    CapitalGrowthState.DEFENSE -> "방어(DEFENSE)"
    CapitalGrowthState.RECOVERY -> "회복(RECOVERY)"
    CapitalGrowthState.PRESERVATION -> "보존(PRESERVATION)"
}

fun ruinRiskText(ruin: RuinRiskLevel) = when(ruin) {
    RuinRiskLevel.LOW -> "낮음(LOW)"
    RuinRiskLevel.MEDIUM -> "보통(MEDIUM)"
    RuinRiskLevel.HIGH -> "높음(HIGH)"
    RuinRiskLevel.CRITICAL -> "위험(CRITICAL)"
    RuinRiskLevel.INSUFFICIENT_SAMPLE -> "표본 부족"
}

fun profitStateText(state: ProfitProtectionState) = when(state) {
    ProfitProtectionState.NORMAL -> "정상(NORMAL)"
    ProfitProtectionState.PROFIT_RUNNING -> "수익 지속(RUNNING)"
    ProfitProtectionState.PROFIT_CAUTION -> "수익 반납 주의(CAUTION)"
    ProfitProtectionState.PROFIT_DEFENSE -> "수익 방어(DEFENSE)"
    ProfitProtectionState.PROFIT_LOCKED -> "수익 잠금(LOCKED)"
}

fun profitVelocityText(state: ProfitVelocityState) = when(state) {
    ProfitVelocityState.ACCELERATING -> "가속 상승"
    ProfitVelocityState.DECELERATING -> "감속 하락"
    ProfitVelocityState.FLAT -> "횡보/유지"
    ProfitVelocityState.UNKNOWN -> "측정 중"
}

@Composable fun PaperLossAutopsyPanel(s: DashboardState) {
    val a = s.paperLossAutopsy
    Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color.White)) {
        Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
            Text("PAPER 손실 원인 자동해부", fontWeight = FontWeight.Bold)
            Text(
                "Equity ${won(a.currentEquity)} · Peak ${won(a.peakEquity)}${if (!a.peakAudit.peakValid) " (LEDGER/${won(a.peakAudit.ledgerPeakEquity)})" else ""} · Drawdown ${pct(a.drawdownPercent)} · 왕복 ${a.closedRoundTrips}건",
                fontWeight = FontWeight.SemiBold
            )
            Text(
                "ReturnFromInitial ${pct(a.peakAudit.returnFromInitialPercent)} · DrawdownFromPeak ${pct(a.peakAudit.drawdownFromPeakPercent)} · Peak→Now ${won(-a.peakAudit.peakToCurrentLossKrw)}",
                color = Color(0xff64748b)
            )
            if (!a.peakAudit.peakValid) {
                Text("PEAK_VALID=NO · ${a.peakAudit.peakErrorCause}", color = Color(0xffb45309))
            }
            Text("Recent Net ${won(a.netProfitKrw + a.netLossKrw)} · Fees ${won(a.feesKrw)} · StopLoss ${won(a.stopLossTotalLossKrw)} · Trailing ${won(a.trailingTotalLossKrw)}")
            Text(
                "REENTRY ${a.reentryChainCount} / ${won(a.reentryChainLossKrw)} · PRIMARY_SUM ${won(a.primaryAttributedLossSum)}",
                color = Color(0xff64748b)
            )
            if (a.topCauses.isEmpty()) {
                Text("손실 표본 부족 — TOP 원인 대기", color = Color(0xff64748b))
            } else {
                Text("PRIMARY (exclusive):", fontWeight = FontWeight.SemiBold)
                a.topCauses.take(5).forEachIndexed { i, c ->
                    Text(
                        "${i + 1}. ${c.cause.name} ${won(c.krw)} (${String.format(Locale.KOREA, "%.1f", c.percentOfTotalLoss)}% · ${c.tradeCount}건)",
                        color = if (i == 0) Color(0xffb00020) else Color(0xff64748b)
                    )
                }
                if (a.contributingFactors.isNotEmpty()) {
                    Text(
                        "Factors: " + a.contributingFactors.take(3).joinToString(" · ") { "${it.cause.name} ${won(it.krw)}" },
                        color = Color(0xff64748b)
                    )
                }
            }
            val acc = a.accounting
            Text(
                "ACCOUNTING ${acc.accountingStatus} ${won(acc.accountingDifferenceKrw)} · ATTRIBUTION ${acc.attributionStatus}" +
                    if (acc.attributionGapKrw != 0.0) " gap ${won(acc.attributionGapKrw)}" else "" +
                    " · ${acc.note}",
                color = if (acc.accountingStatus == "MISMATCH") Color(0xffb00020) else Color(0xff0b7a32)
            )
            Text(
                "보호 ${a.protectionHint} · PaperRisk ${paperStateText(s.paperRisk.state)} · Overtrading ${if (a.overtradingAlert) "ALERT" else "OK"} · PF ${if (a.profitFactor.isInfinite()) "∞" else String.format(Locale.KOREA, "%.2f", a.profitFactor)} · Exp ${won(a.netExpectancyKrw)}",
                color = if (a.protectionHint != "NORMAL" && a.protectionHint != "LOW_SAMPLE") Color(0xffb45309) else Color(0xff64748b)
            )
            if (a.windows.isNotEmpty()) {
                Text(
                    "Window " + a.windows.joinToString(" · ") { "N${it.tradeLimit} net ${won(it.netPnlKrw)}" },
                    color = Color(0xff64748b)
                )
            }
        }
    }
}

@Composable fun ProfitProtectionPanel(s:DashboardState){
    val p=s.profitProtection
    val v=s.profitVelocity
    val cost = s.tradingCostLedger
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("일일 수익 · 수익 보호", fontWeight=FontWeight.Bold)
            Text("시작 ${won(p.dayStartEquity)} · 현재 ${won(p.currentEquity)} · 당일 수익률 ${pct(p.dailyReturnPercent)}")
            Text("당일 고점 ${won(p.dayPeakEquity)} · 고점 수익률 ${pct(p.peakReturnPercent)} · 수익 반납 ${pct(p.givebackPercentPoints)}p · 고점 대비 낙폭 ${pct(p.drawdownFromPeakPercent)}")
            Text("상태: ${profitStateText(p.state)} · 보호 레벨 ${p.level} · 주문 배수 ${String.format(Locale.KOREA,"%.2f",p.positionSizeMultiplier)} · 신규 진입 ${if(p.newEntryAllowed) "허용" else "차단"}", color=if(p.newEntryAllowed) Color(0xff0b7a32) else Color(0xffb00020), fontWeight=FontWeight.SemiBold)
            Text("수익 속도(최근 30분): ${pct(v.returnPercent)} · ${profitVelocityText(v.state)} · 분당 변화율 ${String.format(Locale.KOREA,"%.3f",v.slopePercentPerMinute)}%p/분")
            Text("판정 이유: ${p.reason}", color=Color(0xff64748b))
            Text(
                "거래비용: Gross ${won(cost.grossPnlKrw)} · Fees ${won(cost.totalFeesKrw)} · Net ${won(cost.netPnlKrw)} · 비용비중 ${String.format(Locale.KOREA,"%.1f", cost.tradingCostSharePercent)}% · Gross→Net손실 ${cost.grossWinNetLossCount}건 · ${cost.status}",
                color = if (cost.overtradingCostDrag || cost.warning != "NORMAL") Color(0xffb00020) else Color(0xff64748b)
            )
            if (cost.warning != "NORMAL") Text("경고: ${cost.warning}", color=Color(0xffb00020), fontWeight=FontWeight.SemiBold)
        }
    }
}
@Composable fun AutonomousCapitalPanel(s:DashboardState){
    val cap = s.capitalGrowth
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("자율 자본 성장 관리", fontWeight=FontWeight.Bold)
            Text("총 자산 ${won(cap.totalEquity)} · 고점 자산 ${won(cap.peakEquity)} · 보호 적립금 ${won(cap.protectedReserve)} · 운용 자본 ${won(cap.tradingCapital)}")
            Text("상태: ${capitalStateText(cap.capitalState)} · 성장 신뢰도 ${cap.growthConfidence.toInt()} · 위험 예산 ${won(cap.riskBudgetKrw)}")
            Text("주문 배수 ${String.format(Locale.KOREA,"%.2f",cap.positionSizeMultiplier)} · 재투자율 ${(cap.reinvestmentRatio*100).toInt()}% · 파산 위험: ${ruinRiskText(cap.ruinRisk)} · 거래 상태: ${if(cap.tradingActive) "활성(ACTIVE)" else "보존(PRESERVE)"}", color=if(cap.tradingActive) Color(0xff0b7a32) else Color(0xffb00020), fontWeight=FontWeight.SemiBold)
            Text("사유: ${cap.reason}", color=Color(0xff64748b))
            if (s.capitalStrategyAllocations.isNotEmpty()) {
                Text("전략별 자본배분 추천 (검증/연구용)", fontWeight=FontWeight.SemiBold)
                s.capitalStrategyAllocations.take(4).forEach { alloc ->
                    Text("${alloc.strategy}: ${alloc.targetPercent.toInt()}% · ${alloc.reason}", color=Color(0xff64748b))
                }
            }
        }
    }
}

@Composable fun InstitutionalSafetyPanel(s:DashboardState){
    val heat = s.portfolioHeat
    val tail = s.tailRisk
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("기관형 안전 관리 · 중기 순기대수익(익절목표 Net Edge)", fontWeight=FontWeight.Bold)
            Text("포트폴리오 위험도(Heat): ${portfolioHeatText(heat.level)} · 총 노출 ${pct(heat.portfolioExposurePercent)} · 총 위험 ${pct(heat.totalOpenRiskPercent)} · 상관 군집 ${clusterText(heat.clusterName)} (${pct(heat.correlatedExposurePercent)})")
            val cap = s.portfolioCapacity
            Text(
                "Dynamic Capacity: 보유 ${cap.positionCount} · Heat ${pct(cap.portfolioHeatPercent)} · Remaining ${pct(cap.remainingRiskBudgetPercent)} (${won(cap.remainingRiskBudgetKrw)}) · 최소주문 ${won(cap.minimumViableOrderKrw)} · ${if (cap.additionalEntryAvailable) "AVAILABLE" else "BLOCKED · ${cap.blockReasonCode}"}",
                color = if (cap.additionalEntryAvailable) Color(0xff0b7a32) else Color(0xffb00020),
                fontWeight = FontWeight.SemiBold
            )
            Text("꼬리 위험(Tail Risk): ${tailRiskLevelText(tail.level)} (${tail.score.toInt()}/100) · 95% VaR ${pct(tail.var95Percent)} · 95% 예상 손실(ES) ${pct(tail.expectedShortfall95Percent)}")
            Text("데이터 품질: ${dataQualityText(s.dataQualityStatus)} (${s.dataQualityScore.toInt()}/100) · 시간대 세션: ${sessionQualityText(s.sessionQuality)}")
            val topReady = s.topSignals.firstOrNull { it.status == CandidateStatus.BUY_READY }
            if (topReady != null) {
                Text("최우선: ${topReady.market} · 초단기 Edge ${pct(topReady.shortHorizonNetEdge)} / 중기 Net Edge ${pct(topReady.netExpectedEdge)} · ${topReady.horizonConflict}", color=Color(0xff0b5cad), fontWeight=FontWeight.SemiBold)
                if (topReady.entryDecisionSummary.isNotBlank()) Text(topReady.entryDecisionSummary, color=Color(0xff0b5cad))
            }
        }
    }
}
@Composable fun AnalysisSpeedPanel(s:DashboardState){
    val p=s.scanPerformance
    val c=s.candidateLatency
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("분석 처리 속도", fontWeight=FontWeight.Bold)
            Text("Android 분석모드: ${s.androidAnalysisMode}", color=when(s.androidAnalysisMode){ "SERVER_PRIMARY_VIEWER"->Color(0xff0f766e); "POSITION_SAFETY_ONLY"->Color(0xffb00020); else->Color.Unspecified }, fontWeight=FontWeight.SemiBold)
            Text("파이프라인: ${pipelineStageText(s.pipelineStage)} · 빠른스캔 ${s.fastCandidateCount}개 → 정밀스캔 ${s.deepCandidateCount}개 → 최종후보 ${s.finalCandidateCount}개")
            Text("이번 주기 소요: 전체 ${p.totalScanMs}ms · 시세 ${p.marketDataMs}ms · 빠른스캔 ${p.fastScanMs}ms · 정밀스캔 ${p.deepScanMs}ms · 포지션 우선보호 ${p.positionFastLaneMs}ms")
            Text("후보 포착 반응시간 (빠른스캔 → 매수준비): 평균 ${c.averageMs}ms · P50 ${c.p50Ms}ms · P95 ${c.p95Ms}ms (표본 ${c.samples}건)")
            Text("캐시 적중률 ${(p.cacheHitRate*100).toInt()}% · API 요청 ${p.apiCallCount}회 · 대기 ${p.rateLimitWaitMs}ms · 웹소켓 갱신 ${p.websocketUpdates}", color=Color(0xff64748b))
            if (s.androidAnalysisMode == "SERVER_PRIMARY_VIEWER") {
                Text("SERVER PRIMARY: Android deepScan=${p.deepScanMs}ms · API ${p.apiCallCount}회 (서버 결과 수신). FAST/DEEP는 Hetzner.", color=Color(0xff0f766e))
            }
        }
    }
}
@Composable fun LiquidityDiagnosticsPanel(s:DashboardState){
    val d=s.liquidityDiagnostics
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(5.dp)){
            Text("유동성 필터 진단", fontWeight=FontWeight.Bold)
            val ready = d.total > 0 && d.requiredKrw > 0.0
            Text("전체 ${if (d.total > 0) "${d.total}개" else "계산 전"} · 통과 ${if (d.total > 0) "${d.pass}개" else "-"} · 거래대금 부족 차단 ${if (d.total > 0) "${d.rejectLowTradeValue}개" else "-"} · ${EntryUrgencyAudit.liquidityRequiredLabel(d.requiredKrw, ready)}")
            if (d.total > 0) {
                Text("시장 분포: P10 ${won(d.distribution.p10)} · P25 ${won(d.distribution.p25)} · P50 ${won(d.distribution.p50)} · P75 ${won(d.distribution.p75)} · P90 ${won(d.distribution.p90)}")
                Text("상대 기준 상위 ${(d.percentileThreshold*100).toInt()}% · 동적 필터 ${if(s.settings.dynamicLiquidityEnabled) "사용" else "미사용"}")
            } else {
                Text("유동성 분포/순위는 스캔 후 계산됩니다 (0/0 표시 금지)", color=Color(0xff64748b))
            }
            if(d.overblockingWarning) Text(d.overblockingMessage, color=Color(0xffb00020), fontWeight=FontWeight.SemiBold)
        }
    }
}
fun portfolioHeatText(level: PortfolioHeatLevel) = when(level) {
    PortfolioHeatLevel.LOW -> "낮음(LOW)"
    PortfolioHeatLevel.MEDIUM -> "보통(MEDIUM)"
    PortfolioHeatLevel.HIGH -> "높음(HIGH)"
    PortfolioHeatLevel.CRITICAL -> "위험(CRITICAL)"
}

fun tailRiskLevelText(level: String) = when(level) {
    "HIGH" -> "높음(HIGH)"
    "MEDIUM" -> "보통(MEDIUM)"
    "LOW" -> "낮음(LOW)"
    else -> level
}

fun dataQualityText(status: DataQualityStatus) = when(status) {
    DataQualityStatus.GOOD -> "정상(GOOD)"
    DataQualityStatus.DEGRADED -> "저하(DEGRADED)"
    DataQualityStatus.BAD -> "불량(BAD)"
    DataQualityStatus.QUARANTINED -> "격리(QUARANTINED)"
}

fun sessionQualityText(quality: TradingSessionQuality) = when(quality) {
    TradingSessionQuality.EXCELLENT -> "최상(EXCELLENT)"
    TradingSessionQuality.GOOD -> "양호(GOOD)"
    TradingSessionQuality.NORMAL -> "보통(NORMAL)"
    TradingSessionQuality.WEAK -> "비활성(WEAK)"
    TradingSessionQuality.AVOID -> "회피(AVOID)"
}

fun clusterText(cluster: String) = when(cluster) {
    "BTC_CORRELATED_CLUSTER" -> "비트코인 연동 군집"
    "ISOLATED" -> "개별 종목"
    "NONE" -> "없음"
    else -> cluster
}

fun pipelineStageText(stage: String) = when(stage) {
    "FAST", "FAST_SCAN" -> "빠른스캔(FAST)"
    "DEEP", "DEEP_SCAN" -> "정밀스캔(DEEP)"
    "LOADING_MARKETS" -> "마켓 로드"
    "TICKER" -> "시세 수집"
    "POSITION_FAST_LANE" -> "포지션 보호"
    "ENTRY_TIMING" -> "진입 타이밍"
    "SCALPING_AI" -> "Scalping AI"
    "LIQUIDITY" -> "유동성"
    "NET_EDGE" -> "Net Edge"
    "RISK" -> "Risk"
    "ORDER" -> "주문"
    "POST_PROCESSING" -> "후처리"
    "COMPLETE" -> "완료(COMPLETE)"
    "IDLE" -> "대기(IDLE)"
    else -> stage
}

@Composable fun MarketHealthPanel(s:DashboardState){
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Text("시장 건강도 · 급락 감지(Crash Detection)", fontWeight=FontWeight.Bold)
            Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                Text("건강도 ${s.marketHealth.score.toInt()}/100")
                Text(healthLevelText(s.marketHealth.level), color=healthLevelColor(s.marketHealth.level), fontWeight=FontWeight.Bold)
            }
            if(s.marketHealth.reasons.isNotEmpty()) Text("사유: ${s.marketHealth.reasons.joinToString(", ")}", color=Color(0xff64748b))
            Text("신규 진입 보호: ${if(s.crashProtectionEngaged) "차단 중(급락 감지)" else "정상"}", color=if(s.crashProtectionEngaged) Color(0xffb00020) else Color.Unspecified)
            Text(
                if(s.cooldownActive) "쿨다운 모드: 진입 — ${time(s.cooldownUntil)}까지 신규 매수 대기" else "쿨다운 모드: 없음",
                color = if(s.cooldownActive) Color(0xffb45309) else Color.Unspecified
            )
            Text("다이내믹 익스포저: 현재 한도의 ${(ExposureControlEngine.exposureFactor(s.marketHealth.score)*100).toInt()}%만 사용")
            if(s.crashHistory.isNotEmpty()){
                Text("급락 이력(Crash Replay)", fontWeight=FontWeight.SemiBold)
                s.crashHistory.forEach { c ->
                    val duration = if(c.endedAt != null) "${(c.endedAt - c.startedAt) / 60000}분" else "진행 중"
                    Text(
                        "${time(c.startedAt)} 시작 · 지속 $duration · 최저 건강도 ${c.minHealthScore.toInt()} · ${c.regimeAtStart} · ${c.reasons}",
                        color=Color(0xff64748b)
                    )
                }
            }
        }
    }
}
fun recommendationStageText(stage:RecommendationStage)= when(stage){
    RecommendationStage.NONE -> "없음"
    RecommendationStage.BACKTESTING -> "백테스트 중"
    RecommendationStage.BACKTEST_REJECTED -> "백테스트 기각"
    RecommendationStage.PAPER_TRIAL -> "Paper 검증 중"
    RecommendationStage.PAPER_TRIAL_REJECTED -> "Paper 검증 기각"
    RecommendationStage.AWAITING_LIVE_VALIDATION -> "소액 Live 검증 대기"
    RecommendationStage.LIVE_VALIDATION_REJECTED -> "Live 검증 기각"
    RecommendationStage.OTA_READY -> "OTA 후보 승격"
}
@Composable fun RiskAndRecommendationPanel(s:DashboardState, vm:MainViewModel){
    var showKillDialog by remember { mutableStateOf(false) }
    Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
        Column(Modifier.padding(12.dp), verticalArrangement=Arrangement.spacedBy(6.dp)){
            Text("전략 · 리스크 · AI 추천", fontWeight=FontWeight.Bold)
            Text("현재 전략 v${s.strategyVersion} (${otaText(s.otaStatus)})")
            Text("시장 국면: ${regimeText(s.currentRegime.regime)} (표본 ${s.currentRegime.sampleCount}건, 등락률 평균 ${String.format(Locale.KOREA,"%.2f", s.currentRegime.averageChangeRatePercent)}%, 상승비중 ${String.format(Locale.KOREA,"%.0f", s.currentRegime.breadthPositive*100)}%)")
            Text("최근 성과(표본 ${s.baselinePerformance.sampleCount}건): 승률 ${String.format(Locale.KOREA,"%.1f", s.baselinePerformance.winRate*100)}% · PF ${String.format(Locale.KOREA,"%.2f", s.baselinePerformance.profitFactor)} · MDD ${String.format(Locale.KOREA,"%.1f", s.baselinePerformance.maxDrawdownPercent)}% · 기대수익 ${String.format(Locale.KOREA,"%.2f", s.baselinePerformance.expectedReturnPercent)}%")
            Text("리스크 상태", fontWeight=FontWeight.SemiBold)
            if (s.mode == TradeMode.PAPER) {
                Text("모의매매 상태: ${paperStateText(s.paperRisk.state)} · 주문 배수 ${String.format(Locale.KOREA,"%.2f", s.paperRisk.positionSizeMultiplier)} · 거래 상태: ${if(s.paperRisk.tradingActive) "활성(ACTIVE)" else "정지(STOPPED)"}", fontWeight=FontWeight.SemiBold, color=Color(0xff0b5cad))
                Text("모의매매 리스크 사유: ${s.paperRisk.reason}", color=Color(0xff64748b))
            }
            Text("일일 손실 한도: ${if(s.dailyLossLocked) (if(s.mode == TradeMode.PAPER) "경고(Paper 거래/학습 유지)" else "도달(매수 잠금)") else "정상"}")
            Text(
                "연속 손실: ${s.consecutiveLossCount}/${s.settings.maxConsecutiveLosses}회${if(s.consecutiveLossLocked) (if(s.mode == TradeMode.PAPER) " — Paper Risk 축소" else " — 잠금(수동 해제 필요)") else ""}",
                color = if(s.consecutiveLossLocked) (if(s.mode == TradeMode.PAPER) Color(0xff0b5cad) else Color(0xffb00020)) else Color.Unspecified
            )
            if(s.consecutiveLossLocked) OutlinedButton(onClick={vm.resetConsecutiveLossLock()}, modifier=Modifier.fillMaxWidth()){ Text("연속 손실 잠금 해제") }
            Text(
                "킬 스위치: ${if(s.killSwitchEngaged) "발동 중 (${s.killSwitchReason})" else "정상"}",
                color = if(s.killSwitchEngaged) Color(0xffb00020) else Color.Unspecified
            )
            if(s.killSwitchEngaged) {
                Button(onClick={vm.resetKillSwitch()}, modifier=Modifier.fillMaxWidth()){ Text("킬 스위치 해제") }
            } else {
                OutlinedButton(onClick={showKillDialog=true}, colors=ButtonDefaults.outlinedButtonColors(contentColor=Color(0xffb00020)), modifier=Modifier.fillMaxWidth()){ Text("킬 스위치 발동 (즉시 전량 청산)") }
            }
            Text("AI 전략 추천", fontWeight=FontWeight.SemiBold)
            val rec = s.activeRecommendation
            if(rec == null) {
                Text("현재 추천 없음 (데이터 누적 중이거나 안정적)", color=Color(0xff64748b))
            } else {
                Text("[${recommendationStageText(rec.stage)}] ${rec.reason}")
                Text("기준 성과: 승률 ${String.format(Locale.KOREA,"%.1f", rec.baseline.winRate*100)}% · PF ${String.format(Locale.KOREA,"%.2f", rec.baseline.profitFactor)}")
                rec.backtest?.let { Text("백테스트(과거 신호 재적용): 승률 ${String.format(Locale.KOREA,"%.1f", it.winRate*100)}% · PF ${String.format(Locale.KOREA,"%.2f", it.profitFactor)} (표본 ${it.sampleCount}건)") }
                if(rec.stage == RecommendationStage.PAPER_TRIAL) Text("Paper 검증 진행 중: ${rec.trialSamplesCollected}/${rec.trialSamplesRequired}건")
                rec.paperTrial?.let { Text("Paper 검증 결과: 승률 ${String.format(Locale.KOREA,"%.1f", it.winRate*100)}% · PF ${String.format(Locale.KOREA,"%.2f", it.profitFactor)} (표본 ${it.sampleCount}건)") }
                if(rec.stage == RecommendationStage.AWAITING_LIVE_VALIDATION) Text("소액 Live 검증 대기 — 이 앱은 Live 미연결 상태라 자동 승격되지 않습니다(수동 검토 필요).", color=Color(0xffb45309))
            }
            if(s.recommendationHistory.isNotEmpty()){
                Text("추천 이력", fontWeight=FontWeight.SemiBold)
                s.recommendationHistory.forEach { h ->
                    Text("${time(h.createdAt)} [${recommendationStageText(RecommendationStage.valueOf(h.stage))}] ${h.reason}", color=Color(0xff64748b))
                }
            }
        }
    }
    if(showKillDialog){
        AlertDialog(
            onDismissRequest={showKillDialog=false},
            title={Text("킬 스위치 발동")},
            text={Text("신규 매수를 즉시 차단하고 보유 중인 모든 Paper 포지션을 시장가로 즉시 청산합니다. 계속할까요?")},
            confirmButton={TextButton(onClick={showKillDialog=false; vm.engageKillSwitch("사용자 수동 발동")}){Text("발동")}},
            dismissButton={TextButton(onClick={showKillDialog=false}){Text("취소")}}
        )
    }
}
fun paperStateText(state: PaperRiskState) = when(state) {
    PaperRiskState.NORMAL -> "정상(NORMAL)"
    PaperRiskState.PAPER_CAUTION -> "주의(CAUTION)"
    PaperRiskState.PAPER_DEFENSE -> "방어(DEFENSE)"
    PaperRiskState.PAPER_SHADOW_MODE -> "그림자 가상 모드(SHADOW)"
}

fun engineStatusText(status: EngineStatus) = when(status) {
    EngineStatus.STOPPED -> "정지(STOPPED)"
    EngineStatus.STARTING -> "시작 중(STARTING)"
    EngineStatus.RUNNING -> "실행 중(RUNNING)"
    EngineStatus.STOPPING -> "정지 중(STOPPING)"
    EngineStatus.ERROR -> "오류(ERROR)"
    EngineStatus.DAILY_LOSS_LOCKED -> "일일 손실 잠금"
}

fun connectionStatusText(status: ConnectionStatus) = when(status) {
    ConnectionStatus.CONNECTED -> "연결됨(CONNECTED)"
    ConnectionStatus.CONNECTING -> "연결 중(CONNECTING)"
    ConnectionStatus.DISCONNECTED -> "연결 끊김(DISCONNECTED)"
    ConnectionStatus.ERROR -> "오류(ERROR)"
}

@Composable fun MetricGrid(s:DashboardState){
    val primary = s.settings.remoteAiPrimary && s.settings.remoteAiEnabled
    val apiLabel = if (primary) {
        when {
            s.aiBrainStatus == AiBrainLinkStatus.ONLINE.name -> "Hetzner 연결됨(ONLINE)"
            s.aiBrainStatus == AiBrainLinkStatus.OFFLINE.name -> "Hetzner 끊김(OFFLINE)"
            s.aiBrainStatus == AiBrainLinkStatus.DEGRADED.name -> "Hetzner 저하(DEGRADED)"
            else -> "Hetzner ${s.aiBrainStatus}"
        }
    } else connectionStatusText(s.apiStatus)
    val lastPrice = if (primary && s.lastWebSocketAt > s.lastTickerAt) time(s.lastWebSocketAt) else time(s.lastTickerAt)
    val rows=listOf(
        "현재 분석 중" to s.currentAnalyzingMarket,
        "감시 중 코인 수" to s.watchingCount.toString(),
        "분석 배치 진행률" to "${s.analysisBatchIndex}/${s.analysisBatchTotal} (${s.analysisProgressPercent}%)",
        "마지막 분석" to time(s.lastAnalysisAt),
        "현재 전략 버전" to "v${s.strategyVersion}",
        "전략 OTA 상태" to otaText(s.otaStatus),
        "AI 모델 버전" to "v${s.aiModelVersion}",
        "AI OTA 상태" to otaText(s.aiOtaStatus),
        "AI 모델 출처" to aiSourceText(s.aiModelSource),
        "AI 로컬 세대" to "v${s.aiLocalGeneration}",
        "AI 학습 라벨 샘플" to "${s.aiResolvedSampleCount}/${s.aiTrainingSampleCount}",
        "연속 손실" to "${s.consecutiveLossCount}/${s.settings.maxConsecutiveLosses}회",
        "킬 스위치" to if(s.killSwitchEngaged) "발동 중" else "정상",
        "AI 추천 상태" to (s.activeRecommendation?.let{recommendationStageText(it.stage)} ?: "없음"),
        "시장 국면" to regimeText(s.currentRegime.regime),
        "시장 건강도" to "${s.marketHealth.score.toInt()}(${healthLevelText(s.marketHealth.level)})",
        "급락 보호" to if(s.crashProtectionEngaged) "차단 중" else if(s.cooldownActive) "쿨다운" else "정상",
        "마지막 시세 수신" to lastPrice,
        "엔진 상태" to engineStatusText(s.engineStatus),
        "웹소켓 상태" to connectionStatusText(s.websocketStatus),
        "마지막 웹소켓 수신" to time(s.lastWebSocketAt),
        (if (primary) "서버 API 상태" else "API 상태") to apiLabel
    )
    rows.forEach{ (k,v)-> Card(Modifier.fillMaxWidth()){ Row(Modifier.padding(12.dp), horizontalArrangement=Arrangement.SpaceBetween){ Text(k); Text(v, fontWeight=FontWeight.SemiBold) } } }
}
@Composable fun Settings(s:DashboardState, access:String, secret:String, setA:(String)->Unit, setS:(String)->Unit, save:()->Unit, vm:MainViewModel){
    var paper by remember(s.settings.paperInitialKrw) { mutableStateOf(s.settings.paperInitialKrw.toLong().toString()) }
    var threshold by remember(s.settings.scoreThreshold) { mutableStateOf(s.settings.scoreThreshold.toInt().toString()) }
    var maxConsecutiveLosses by remember(s.settings.maxConsecutiveLosses) { mutableStateOf(s.settings.maxConsecutiveLosses.toString()) }
    var crashCooldownMinutes by remember(s.settings.crashCooldownMinutes) { mutableStateOf(s.settings.crashCooldownMinutes.toString()) }
    var stopLossCooldown by remember(s.settings.stopLossCooldownMinutes) { mutableStateOf(s.settings.stopLossCooldownMinutes.toString()) }
    var trailingCooldown by remember(s.settings.trailingStopCooldownMinutes) { mutableStateOf(s.settings.trailingStopCooldownMinutes.toString()) }
    var liquidityFloor by remember(s.settings.min24hTradePrice) { mutableStateOf(s.settings.min24hTradePrice.toLong().toString()) }
    var liquidityPercentile by remember(s.settings.liquidityPercentileThreshold) { mutableStateOf((s.settings.liquidityPercentileThreshold*100).toInt().toString()) }
    var showResetDialog by remember { mutableStateOf(false) }
    var showAiResetDialog by remember { mutableStateOf(false) }
    Column(Modifier.verticalScroll(rememberScrollState()).padding(14.dp), verticalArrangement=Arrangement.spacedBy(10.dp)){
        Alert("기본값은 PAPER입니다. LIVE는 API 키/잔고/권한/시세 안전조건 확인 전 실제 주문하지 않습니다.")
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){ Text("테스트 모드(매수 조건 완화)"); Switch(checked=s.testMode, onCheckedChange={vm.setTestMode(it)}) }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("재부팅 후 Paper 자동매매 재개")
            Switch(checked=s.settings.autoResumeAfterBoot, onCheckedChange={vm.setAutoResumeAfterBoot(it)})
        }
        OutlinedTextField(paper,{paper=it; it.toDoubleOrNull()?.let(vm::updatePaperInitialKrw)},label={Text("Paper Trading 초기자금 KRW")},modifier=Modifier.fillMaxWidth())
        OutlinedButton(onClick={showResetDialog=true}, modifier=Modifier.fillMaxWidth()){ Text("Paper 계좌 초기화") }
        OutlinedButton(onClick={showAiResetDialog=true}, colors=ButtonDefaults.outlinedButtonColors(contentColor=Color(0xffb00020)), modifier=Modifier.fillMaxWidth()){ Text("RESET AI LEARNING (학습 자산 초기화)") }
        OutlinedTextField(threshold,{threshold=it; it.toDoubleOrNull()?.let(vm::updateScoreThreshold)},label={Text("매수 Score 기준")},modifier=Modifier.fillMaxWidth())
        OutlinedTextField(maxConsecutiveLosses,{maxConsecutiveLosses=it; it.toIntOrNull()?.let(vm::updateMaxConsecutiveLosses)},label={Text("연속 손실 잠금 기준(회)")},modifier=Modifier.fillMaxWidth())
        if(s.consecutiveLossLocked) OutlinedButton(onClick={vm.resetConsecutiveLossLock()}, modifier=Modifier.fillMaxWidth()){ Text("연속 손실 잠금 해제") }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("품질 기반 동적 투자비중(한도 내에서만 축소)")
            Switch(checked=s.settings.dynamicAllocationEnabled, onCheckedChange={vm.setDynamicAllocation(it)})
        }
        OutlinedTextField(crashCooldownMinutes,{crashCooldownMinutes=it; it.toIntOrNull()?.let(vm::updateCrashCooldownMinutes)},label={Text("급락 감지 후 쿨다운 시간(분)")},modifier=Modifier.fillMaxWidth())
        OutlinedTextField(stopLossCooldown,{stopLossCooldown=it; it.toIntOrNull()?.let(vm::updateStopLossCooldown)},label={Text("손절 후 동일종목 재진입 쿨다운(분)")},modifier=Modifier.fillMaxWidth())
        OutlinedTextField(trailingCooldown,{trailingCooldown=it; it.toIntOrNull()?.let(vm::updateTrailingCooldown)},label={Text("Trailing 손절 후 재진입 쿨다운(분)")},modifier=Modifier.fillMaxWidth())
        OutlinedTextField(liquidityFloor,{liquidityFloor=it; it.toDoubleOrNull()?.let(vm::updateLiquidityFloor)},label={Text("최소 24시간 거래대금(KRW)")},modifier=Modifier.fillMaxWidth())
        OutlinedTextField(liquidityPercentile,{liquidityPercentile=it; it.toDoubleOrNull()?.div(100.0)?.let(vm::updateLiquidityPercentile)},label={Text("동적 유동성 상대 기준(%)")},modifier=Modifier.fillMaxWidth())
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("동적 유동성 필터")
            Switch(checked=s.settings.dynamicLiquidityEnabled, onCheckedChange={vm.setDynamicLiquidity(it)})
        }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("국면별 전략 파라미터 세트")
            Switch(checked=s.settings.regimeStrategySetsEnabled, onCheckedChange={vm.setRegimeStrategySetsEnabled(it)})
        }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("국면 세트 Paper 자동적용 (LIVE는 추천만)")
            Switch(checked=s.settings.regimeStrategySetsPaperApplyEnabled, onCheckedChange={vm.setRegimeStrategySetsPaperApply(it)})
        }
        Text("Hetzner AI Brain (PAPER)", fontWeight=FontWeight.Bold)
        Text("SERVER PRIMARY ON이면 Android 전체시장 FAST/DEEP/AI/Scalp/학습을 중지하고 서버 결과만 표시·안전검증·실행합니다. 기본값 OFF(Phase1/2).", color=Color(0xff64748b))
        var tradingAiToken by remember { mutableStateOf("") }
        Text("Trading API Token 상태: ${if (vm.hasTradingAiToken()) "설정됨(${vm.tradingAiTokenMasked()})" else "미설정 — /paper/state 호출 불가"}", color=if (vm.hasTradingAiToken()) Color(0xff0f766e) else Color(0xffb00020))
        OutlinedTextField(
            tradingAiToken,
            { tradingAiToken = ***REDACTED*** },
            label = { Text("Hetzner Trading API Token") },
            modifier = Modifier.fillMaxWidth(),
            placeholder = { Text("Bearer 토큰 (소스에 하드코딩하지 않음)") }
        )
        Button(
            onClick = { vm.saveTradingAiToken(tradingAiToken); tradingAiToken = "" },
            modifier = Modifier.fillMaxWidth(),
            enabled = tradingAiToken.isNotBlank()
        ) { Text("Trading API Token 암호화 저장") }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("Remote AI 연결")
            Switch(checked=s.settings.remoteAiEnabled, onCheckedChange={vm.setRemoteAiEnabled(it)})
        }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("SERVER PRIMARY (PAPER Viewer + Remote Control)")
            Switch(checked=s.settings.remoteAiPrimary, onCheckedChange={vm.setRemoteAiPrimary(it)}, enabled=s.settings.remoteAiEnabled)
        }
        if (s.settings.remoteAiPrimary) {
            Text("Primary ON: 자동매매 시작/중지 = 서버 PAPER AUTO. 앱을 꺼도 서버 모의매매는 계속됩니다.", color=Color(0xff0f766e))
            if (!vm.hasTradingAiToken()) {
                Text("AUTH_TOKEN_MISSING: Token을 저장해야 /dashboard · /paper/state · device-verify 가 호출됩니다.", color=Color(0xffb00020), fontWeight=FontWeight.SemiBold)
            }
        }
        Row(verticalAlignment=Alignment.CenterVertically, horizontalArrangement=Arrangement.SpaceBetween, modifier=Modifier.fillMaxWidth()){
            Text("서버 학습 우선 (Android 학습 중지)")
            Switch(checked=s.settings.remoteLearningEnabled, onCheckedChange={vm.setRemoteLearningEnabled(it)})
        }
        var shadowInterval by remember(s.settings.localShadowIntervalMinutes) { mutableStateOf(s.settings.localShadowIntervalMinutes.toString()) }
        OutlinedTextField(shadowInterval,{shadowInterval=it; it.toIntOrNull()?.let(vm::updateLocalShadowIntervalMinutes)},label={Text("Local Shadow 검증 간격(분)")},modifier=Modifier.fillMaxWidth())
        OutlinedButton(onClick={vm.checkOta()}, modifier=Modifier.fillMaxWidth()){ Text("전략 OTA 지금 확인") }
        Text("OTA 상태: ${otaText(s.otaStatus)} ${s.otaMessage}")
        OutlinedTextField(access,setA,label={Text("Access Key")},modifier=Modifier.fillMaxWidth())
        OutlinedTextField(secret,setS,label={Text("Secret Key")},modifier=Modifier.fillMaxWidth())
        Button(save,Modifier.fillMaxWidth()){Text("암호화 저장")}
        Text("출금 권한 없는 API Key 사용 권장", color=Color(0xffb00020))
        Text("기본: 최대 3종목, 종목당 20%, 현금 30%, 손절 -2.5%, 익절 +6%, Trailing 2.5%, 일일손실 -5%")
    }
    if (showResetDialog) {
        AlertDialog(
            onDismissRequest={showResetDialog=false},
            title={Text("Paper 계좌 초기화")},
            text={Text("현재 Paper 현금, 보유 포지션, Paper 거래내역과 활성 재진입 Guard만 초기화하고 ${paper.toDoubleOrNull()?.toLong() ?: 0L} KRW로 다시 시작합니다.\n\nAI 학습 Sample, Historical 데이터, Prediction Journal, 검증 모델, Shadow/Research 및 Smart Re-entry 과거 이력은 삭제하지 않습니다.")},
            confirmButton={TextButton(onClick={showResetDialog=false; paper.toDoubleOrNull()?.let(vm::resetPaperAccount)}){Text("초기화")}},
            dismissButton={TextButton(onClick={showResetDialog=false}){Text("취소")}}
        )
    }
    if (showAiResetDialog) {
        AlertDialog(
            onDismissRequest={showAiResetDialog=false},
            title={Text("RESET AI LEARNING")},
            text={Text("경고: AI 학습 Sample, Prediction Journal, Continuous/Production 모델 설정을 삭제합니다.\n\nPaper 잔고/포지션/거래원장과 Shadow·Research 감사 기록은 유지됩니다.\n\n이 작업은 Paper 계좌 초기화와 완전히 별개이며, Backup 없이는 복구할 수 없습니다.\n\n정말 처음부터 다시 학습시키겠습니까?")},
            confirmButton={TextButton(onClick={showAiResetDialog=false; vm.resetAiLearning()}){Text("RESET AI LEARNING 실행", color=Color(0xffb00020))}},
            dismissButton={TextButton(onClick={showAiResetDialog=false}){Text("취소")}}
        )
    }
}
@Composable fun Logs(logs:List<String>){ Column(Modifier.verticalScroll(rememberScrollState()).padding(14.dp)){ Text("실시간 로그", fontWeight=FontWeight.Bold); logs.forEach{ Text(it, Modifier.padding(vertical=4.dp)) } } }
@Composable fun Placeholder(title:String, body:String){ Column(Modifier.padding(16.dp)){ Text(title, fontWeight=FontWeight.Bold); Text(body) } }
@Composable fun TransactionHistory(vm:MainViewModel){
    val s by vm.state.collectAsState()
    val localTrades by vm.trades.collectAsState()
    val settingsPrimary = s.settings.remoteAiEnabled && s.settings.remoteAiPrimary
    val useServerLedger = ServerPrimaryCoordinator.useServerPaperTradeLedger(s)
    LaunchedEffect(s.selectedExchange, s.androidAnalysisMode, s.serverPaperUiSynced, settingsPrimary) {
        // Always re-pull Hetzner ledger when opening this tab under SERVER PRIMARY.
        if (settingsPrimary || s.serverPaperUiSynced) {
            vm.refreshServerPaperTrades()
        }
    }
    val trades = if (useServerLedger) s.serverPaperTrades else localTrades
    val sourceLabel = if (useServerLedger) "SERVER PAPER" else "LOCAL ROOM"
    val sync = s.serverPaperTradesSyncStatus
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp), verticalArrangement=Arrangement.spacedBy(8.dp)){
        Text("거래내역 (최근 ${trades.size}건 · $sourceLabel · ${s.selectedExchange})", fontWeight=FontWeight.Bold)
        if (useServerLedger) {
            Text("SERVER PRIMARY: 체결은 Hetzner PAPER 원장 기준입니다. 로컬 Room 거래는 사용하지 않습니다.", color=Color(0xff0f766e))
            Text(
                "동기화: $sync" +
                    (if (s.serverPaperTradesSyncedAt > 0L) " · ${time(s.serverPaperTradesSyncedAt)}" else "") +
                    (if (s.serverPaperTradesSyncError.isNotBlank()) " · ${s.serverPaperTradesSyncError}" else ""),
                color = when (sync) {
                    "FAILED" -> Color(0xffb00020)
                    "EMPTY" -> Color(0xffb45309)
                    "SYNCING" -> Color(0xff0b5cad)
                    else -> Color(0xff334155)
                }
            )
            Text("거래소 태그: reason에 [BITHUMB]/[UPBIT] 표시. 자금/포지션은 거래소별 완전 분리.", color=Color(0xff334155))
            OutlinedButton(onClick = { vm.refreshServerPaperTrades() }, modifier = Modifier.fillMaxWidth()) {
                Text("서버 거래내역 다시 불러오기")
            }
        }
        if(trades.isEmpty()) Text(
            when {
                !useServerLedger -> "아직 체결된 거래가 없습니다."
                sync == "FAILED" -> "서버 거래내역 동기화 실패 (${s.serverPaperTradesSyncError.ifBlank { "UNKNOWN" }}). 위 버튼으로 재시도하세요."
                sync == "SYNCING" -> "서버 거래내역 불러오는 중…"
                sync == "EMPTY" -> "서버 PAPER 원장에 체결이 0건입니다. (대시보드 포지션/잔고는 있어도 매수·매도 체결이 아직 없을 수 있음)"
                else -> "서버 PAPER 체결 내역이 아직 없습니다. 동기화 상태: $sync"
            },
            color=Color(0xff64748b)
        )
        trades.forEach { t ->
            Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
                Column(Modifier.padding(10.dp), verticalArrangement=Arrangement.spacedBy(2.dp)){
                    Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){
                        Text("${t.market} · ${if(t.side=="BUY") "매수" else "매도"} (${t.mode})", fontWeight=FontWeight.Bold)
                        Text(time(t.time))
                    }
                    Text("체결금액 ${won(t.amount)} · 수량 ${String.format(Locale.KOREA,"%.6f", t.quantity)} · 평균가 ${priceKrw(t.avgPrice)}")
                    if (t.side == "SELL") {
                        val approxGross = t.realizedPnl + t.fee
                        Text("Sell Fee ${won(t.fee)} · Gross≈ ${won(approxGross)} · Net PnL ${won(t.realizedPnl)} (${pct(t.pnlRate)})")
                        Text("※ Net = 매도대금 − 매수원가(수수료포함) − 매도수수료", color=Color(0xff64748b))
                    } else {
                        Text("Buy Fee ${won(t.fee)}")
                    }
                    Text("사유: ${t.reason}", color=Color(0xff64748b))
                }
            }
        }
    }
}
@Composable fun Statistics(s:DashboardState, vm:MainViewModel){
    val daily by vm.dailyPerformance.collectAsState()
    Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp), verticalArrangement=Arrangement.spacedBy(10.dp)){
        Text("통계 (Paper, 전체 누적 · Net 기준)", fontWeight=FontWeight.Bold)
        val p = s.overallPerformance
        val cost = s.tradingCostLedger
        val rows = listOf(
            "표본(매도 체결)" to "${p.sampleCount}건",
            "승률" to pct(p.winRate*100.0),
            "NET Profit Factor" to if(p.profitFactor.isInfinite()) "∞" else String.format(Locale.KOREA,"%.2f", p.profitFactor),
            "NET MDD" to pct(p.maxDrawdownPercent),
            "NET Expectancy" to pct(p.expectedReturnPercent),
            "Gross PnL" to won(cost.grossPnlKrw),
            "Total Fees/Cost" to won(cost.totalFeesKrw),
            "Net PnL" to won(cost.netPnlKrw),
            "거래비용 비중" to String.format(Locale.KOREA,"%.1f%%", cost.tradingCostSharePercent),
            "Fee Drag" to String.format(Locale.KOREA,"%.1f%% · %s", cost.feeDragPercent, cost.status)
        )
        rows.forEach { (k,v) -> Card(Modifier.fillMaxWidth()){ Row(Modifier.padding(12.dp), horizontalArrangement=Arrangement.SpaceBetween){ Text(k); Text(v, fontWeight=FontWeight.SemiBold) } } }
        Text("손실 원인 TOP (KRW 기여도)", fontWeight=FontWeight.Bold)
        val autopsy = s.paperLossAutopsy
        if (autopsy.topCauses.isEmpty()) {
            Text("표본 부족 — 해부 대기", color=Color(0xff64748b))
        } else {
            autopsy.topCauses.take(5).forEach { c ->
                Card(Modifier.fillMaxWidth()){
                    Row(Modifier.padding(12.dp), horizontalArrangement=Arrangement.SpaceBetween){
                        Text("${c.cause.name} · ${c.tradeCount}건")
                        Text("${won(c.krw)} (${String.format(Locale.KOREA,"%.1f",c.percentOfTotalLoss)}%)", fontWeight=FontWeight.SemiBold)
                    }
                }
            }
        }
        Text("진입 품질 성과", fontWeight=FontWeight.Bold)
        Text("표본 ${s.postEntryQuality.sampleCount}건 · 5m ${pct(s.postEntryQuality.averageReturnByHorizon[5] ?: 0.0)} · 15m ${pct(s.postEntryQuality.averageReturnByHorizon[15] ?: 0.0)} · 30m ${pct(s.postEntryQuality.averageReturnByHorizon[30] ?: 0.0)} · 60m ${pct(s.postEntryQuality.averageReturnByHorizon[60] ?: 0.0)}")
        Text("BUY 후 즉시 상승확률 ${pct(s.postEntryQuality.immediateRiseProbability*100)} · 평균 MFE ${pct(s.postEntryQuality.averageMfe)} · 평균 MAE ${pct(s.postEntryQuality.averageMae)}")
        
        Text("손실 원인 및 매도 품질 진단", fontWeight=FontWeight.Bold)
        if (s.exitQualityStats.sampleTooSmall) {
            Text("표본 부족(${s.exitQualityStats.sampleCount}/30건) — 정책 자동변경 금지 (INSUFFICIENT_SAMPLE)", color=Color(0xffb45309))
        }
        val cause = s.lossRootCauseSummary
        Text("손실 원인 비율 (총 ${cause.totalTrades}건 중 손실 ${cause.lossTrades}건): " + cause.causePercentages.entries.joinToString(" · ") { "${it.key}: ${String.format(Locale.KOREA,"%.1f",it.value)}%" })
        val eq = s.exitQualityStats
        Text("매도 품질: 조기손절비율 ${pct(eq.earlyStopRate*100)} · 타이트트레일링 ${pct(eq.earlyTrailingRate*100)} · 유효손절비율 ${pct(eq.goodStopRate*100)} · MFE포착률 ${pct(eq.mfeCaptureRatio*100)} · 수익반납 ${pct(eq.profitGivebackPercent)} · 매도효율 ${eq.exitEfficiencyScore.toInt()}/100")
        Text("손절 후 평균수익: 30분 ${pct(eq.stopLossPost30mAvg)} · 60분 ${pct(eq.stopLossPost60mAvg)} | 트레일링 후 평균: 30분 ${pct(eq.trailingPost30mAvg)} · 60분 ${pct(eq.trailingPost60mAvg)}")

        Text("가상 매도(Counterfactual Exit) 비교 연구", fontWeight=FontWeight.Bold)
        s.counterfactualExitSummaries.take(8).forEach { ex ->
            Text("${ex.type.name}: 기대수익 ${pct(ex.expectancyPercent)} · 승률 ${pct(ex.winRate*100)} · PF ${if(ex.profitFactor.isInfinite()) "∞" else String.format(Locale.KOREA,"%.2f",ex.profitFactor)} · MDD ${pct(ex.mddPercent)} · 거래 ${ex.tradeCount}건 (${ex.status.name})", color=Color(0xff64748b))
        }
        Text("Exit Champion vs Challenger: ${s.exitChampionChallenger.champion} vs ${s.exitChampionChallenger.challenger} · ${s.exitChampionChallenger.reason}", color=Color(0xff0b5cad))

        Text("Shadow 전략 비교", fontWeight=FontWeight.Bold)
        s.shadowPortfolios.forEach { shadow -> Text("${shadow.strategy}: 누적 ${pct(shadow.returnPercent)} · 거래 ${shadow.tradeCount}건 · MDD ${pct(shadow.maxDrawdownPercent)} · Risk ${if(shadow.tradeCount < 10) "표본 부족" else String.format(Locale.KOREA,"%.1f", ShadowSimulationEngine.riskAdjustedScore(shadow))}") }
        Text("놓친 기회: 총 ${s.missedOpportunityStats.total} · GOOD ${s.missedOpportunityStats.goodRejection} · MISSED_WIN ${s.missedOpportunityStats.missedWin} · NEUTRAL ${s.missedOpportunityStats.neutral}")
        Text("시장국면별 성과 (참고용, 전략은 그대로)", fontWeight=FontWeight.Bold)
        if(s.regimePerformance.isEmpty()) Text("아직 국면별로 라벨된 표본이 부족합니다.", color=Color(0xff64748b))
        s.regimePerformance.forEach { item ->
            Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
                Column(Modifier.padding(10.dp), verticalArrangement=Arrangement.spacedBy(2.dp)){
                    Text(regimeText(MarketRegime.valueOf(item.regime)), fontWeight=FontWeight.Bold)
                    Text("표본 ${item.stats.sampleCount}건 · 승률 ${pct(item.stats.winRate*100)} · PF ${if(item.stats.profitFactor.isInfinite()) "∞" else String.format(Locale.KOREA,"%.2f", item.stats.profitFactor)} · 기대수익 ${pct(item.stats.expectedReturnPercent)}")
                }
            }
        }
        Text("일별 성과 (최근 ${daily.size}일)", fontWeight=FontWeight.Bold)
        if(daily.isEmpty()) Text("아직 하루가 지나지 않아 일별 기록이 없습니다.", color=Color(0xff64748b))
        daily.forEach { d ->
            val dayReturn = if(d.startValue > 0) (d.endValue/d.startValue - 1.0) * 100.0 else 0.0
            Card(Modifier.fillMaxWidth(), colors=CardDefaults.cardColors(containerColor=Color.White)){
                Column(Modifier.padding(10.dp), verticalArrangement=Arrangement.spacedBy(2.dp)){
                    Row(Modifier.fillMaxWidth(), horizontalArrangement=Arrangement.SpaceBetween){ Text(d.yyyymmdd, fontWeight=FontWeight.Bold); Text(pct(dayReturn)) }
                    Text("시작 ${won(d.startValue)} → 종료 ${won(d.endValue)} · 실현손익 ${won(d.realizedPnl)} · 당일 MDD ${pct(d.maxDrawdown)}")
                }
            }
        }
    }
}
@Composable fun Alert(t:String){ Card(colors=CardDefaults.cardColors(containerColor=Color(0xfffff3cd)), modifier=Modifier.fillMaxWidth()){ Text(t, Modifier.padding(12.dp), color=Color(0xff664d03)) } }
fun won(v:Double): String {
    val x = if (kotlin.math.abs(v) < 0.5) 0.0 else v
    return String.format(Locale.KOREA,"%,.0f KRW", x)
}
/** Coin unit price formatter — must not round sub-1 KRW coins (e.g. COS 0.408) to 0. */
fun priceKrw(v:Double): String = when {
    !v.isFinite() -> "-"
    kotlin.math.abs(v) >= 1_000.0 -> String.format(Locale.KOREA, "%,.0f KRW", v)
    kotlin.math.abs(v) >= 1.0 -> String.format(Locale.KOREA, "%,.2f KRW", v)
    kotlin.math.abs(v) >= 0.01 -> String.format(Locale.KOREA, "%,.4f KRW", v)
    else -> String.format(Locale.KOREA, "%,.8f KRW", v)
}
fun pct(v:Double)=String.format(Locale.KOREA,"%.2f%%", v)
fun otaText(s:OtaStatus)= when(s){ OtaStatus.IDLE -> "대기"; OtaStatus.CHECKING -> "확인 중"; OtaStatus.UPDATED -> "업데이트 완료"; OtaStatus.NO_UPDATE -> "최신"; OtaStatus.FAILED -> "실패"; OtaStatus.ROLLED_BACK -> "롤백" }
fun time(v:Long)= if(v<=0) "-" else SimpleDateFormat("HH:mm:ss", Locale.KOREA).format(Date(v))

===== END FILE: app/src/main/java/com/example/bithumbtrader/MainActivity.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/MainViewModel.kt =====

package com.example.bithumbtrader

import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class MainViewModel(app: Application): AndroidViewModel(app) {
    private val traderApp = app as TraderApp
    val state: StateFlow<DashboardState> = traderApp.repository.state
    val trades: StateFlow<List<TradeEntity>> = traderApp.database.dao().trades()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
    val dailyPerformance: StateFlow<List<DailyPerformanceEntity>> = traderApp.database.dao().dailyPerformanceFlow()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
    val positions: StateFlow<List<PositionEntity>> = traderApp.database.dao().positions()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
    private val credentials = SecureCredentialStore(app)
    fun start(){
        // Apply Primary BEFORE starting the service so the first scan is not LOCAL /decision.
        val settings = state.value.settings
        if (settings.remoteAiEnabled && !settings.remoteAiPrimary && credentials.hasTradingAiToken()) {
            traderApp.repository.updateSettings(settings.copy(remoteAiPrimary = true))
            viewModelScope.launch {
                traderApp.repository.log("SERVER PRIMARY auto-ON on Start (token ready) — Local full scan OFF")
                runCatching { traderApp.repository.restoreServerPaperOnAppStart(source = "START_AUTO_PRIMARY") }
                    .onFailure { traderApp.repository.log("START_AUTO_PRIMARY restore failed: ${it.message}") }
            }
        }
        viewModelScope.launch {
            val s = state.value.settings
            if (s.remoteAiEnabled && s.remoteAiPrimary) {
                // PHASE4: start button is Remote Control for server PAPER AUTO.
                traderApp.repository.setServerPaperAuto(true)
                traderApp.repository.log("PAPER AUTO ON → Hetzner (Android is remote control)")
            } else if (s.remoteAiEnabled && !s.remoteAiPrimary) {
                traderApp.repository.log("START on LOCAL path — Primary OFF (set SERVER PRIMARY ON or save Trading token)")
            }
        }
        getApplication<Application>().startForegroundService(Intent(getApplication(), TradingForegroundService::class.java).setAction(TradingForegroundService.ACTION_START))
    }
    fun stop(){
        viewModelScope.launch {
            val settings = state.value.settings
            if (settings.remoteAiEnabled && settings.remoteAiPrimary) {
                traderApp.repository.setServerPaperAuto(false)
                traderApp.repository.log("PAPER AUTO OFF → Hetzner (persisted on server)")
            }
        }
        getApplication<Application>().startService(Intent(getApplication(), TradingForegroundService::class.java).setAction(TradingForegroundService.ACTION_STOP))
    }
    fun emergencyStop(){ stop(); viewModelScope.launch { traderApp.repository.log("긴급 중지 실행") } }
    fun scan(){ viewModelScope.launch { runCatching { traderApp.repository.scanOnce() }.onFailure { traderApp.repository.reportEngineError("수동 스캔 실패: ${it.message}") } } }
    fun checkOta(){ viewModelScope.launch { traderApp.repository.checkStrategyOta(force = true); traderApp.repository.checkAiModelOta(force = true) } }
    fun forceLocalRetrain(){ viewModelScope.launch { runCatching { traderApp.repository.forceLocalRetrainCheck() }.onFailure { traderApp.repository.log("AI 재학습 수동 확인 실패: ${it.message}") } } }
    fun saveKeys(access:String, secret:String){ credentials.save(access, secret); viewModelScope.launch { traderApp.repository.log("API 키 저장: ${maskSecret(access)} / secret masked") } }
    fun saveTradingAiToken(token: ***REDACTED*** {
        credentials.saveTradingAiToken(token)
        viewModelScope.launch {
            traderApp.repository.log(
                if (credentials.hasTradingAiToken()) "AUTH_CLIENT_READY Trading API Token saved ${maskSecret(credentials.tradingAiToken())}"
                else "AUTH_TOKEN_MISSING after save attempt"
            )
            if (state.value.settings.remoteAiPrimary && credentials.hasTradingAiToken()) {
                runCatching { traderApp.repository.restoreServerPaperOnAppStart(source = "TOKEN_SAVED") }
            }
        }
    }
    fun hasTradingAiToken(): Boolean = credentials.hasTradingAiToken()
    fun tradingAiTokenMasked(): String = maskSecret(credentials.tradingAiToken())
    fun setTestMode(enabled:Boolean){ traderApp.repository.setTestMode(enabled); viewModelScope.launch { traderApp.repository.log("테스트 모드 ${if (enabled) "ON" else "OFF"}") } }
    fun setAutoResumeAfterBoot(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(autoResumeAfterBoot=enabled)); viewModelScope.launch { traderApp.repository.log("재부팅 후 자동매매 재개 ${if (enabled) "ON" else "OFF"}") } }
    fun updatePaperInitialKrw(value:Double){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(paperInitialKrw=value)); viewModelScope.launch { traderApp.repository.log("Paper 초기자금 변경: ${value.toLong()} KRW") } }
    fun updateScoreThreshold(value:Double){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(scoreThreshold=value)); viewModelScope.launch { traderApp.repository.log("Score 기준 변경: ${value.toInt()}") } }
    fun resetPaperAccount(initialKrw:Double){ viewModelScope.launch { traderApp.repository.resetPaperAccount(initialKrw) } }
    fun resetAiLearning(){ viewModelScope.launch { runCatching { traderApp.repository.resetAiLearning("RESET AI LEARNING") }.onFailure { traderApp.repository.log("RESET_AI_LEARNING 실패: ${it.message}") } } }
    fun updateMaxConsecutiveLosses(value:Int){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(maxConsecutiveLosses=value)); viewModelScope.launch { traderApp.repository.log("연속 손실 한도 변경: ${value}회") } }
    fun setDynamicAllocation(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(dynamicAllocationEnabled=enabled)); viewModelScope.launch { traderApp.repository.log("동적 투자비중 ${if (enabled) "ON" else "OFF"}") } }
    fun updateCrashCooldownMinutes(value:Int){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(crashCooldownMinutes=value)); viewModelScope.launch { traderApp.repository.log("급락 쿨다운 시간 변경: ${value}분") } }
    fun updateLiquidityFloor(value:Double){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(min24hTradePrice=value)); viewModelScope.launch { traderApp.repository.log("최소 24시간 거래대금 변경: ${value.toLong()} KRW") } }
    fun updateLiquidityPercentile(value:Double){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(liquidityPercentileThreshold=value)); viewModelScope.launch { traderApp.repository.log("유동성 상대 기준 변경: ${(value*100).toInt()}%") } }
    fun setDynamicLiquidity(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(dynamicLiquidityEnabled=enabled)); viewModelScope.launch { traderApp.repository.log("동적 유동성 필터 ${if(enabled) "ON" else "OFF"}") } }
    fun setRegimeStrategySetsEnabled(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(regimeStrategySetsEnabled=enabled)); viewModelScope.launch { traderApp.repository.log("국면 전략 세트 ${if(enabled) "ON" else "OFF"}") } }
    fun setRegimeStrategySetsPaperApply(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(regimeStrategySetsPaperApplyEnabled=enabled)); viewModelScope.launch { traderApp.repository.log("국면 전략 세트 Paper 자동적용 ${if(enabled) "ON" else "OFF"}") } }
    fun setRemoteAiEnabled(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(remoteAiEnabled=enabled, remoteAiPrimary=if(enabled) current.remoteAiPrimary else false)); viewModelScope.launch { traderApp.repository.log("Remote AI ${if(enabled) "ON" else "OFF"}") } }
    fun setRemoteAiPrimary(enabled:Boolean){
        val current=state.value.settings
        val next = enabled && current.remoteAiEnabled
        traderApp.repository.updateSettings(current.copy(remoteAiPrimary=next))
        viewModelScope.launch {
            traderApp.repository.log("SERVER PRIMARY(PAPER) ${if(next) "ON" else "OFF"} — Local full scan ${if(next) "OFF" else "ON"}")
            if (next) {
                runCatching { traderApp.repository.restoreServerPaperOnAppStart(source = "PRIMARY_TOGGLE_ON") }
                    .onFailure { traderApp.repository.log("PRIMARY restore failed: ${it.message}") }
            }
        }
    }
    fun setRemoteLearningEnabled(enabled:Boolean){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(remoteLearningEnabled=enabled)); viewModelScope.launch { traderApp.repository.log("Remote Learning ${if(enabled) "ON" else "OFF"}") } }
    fun updateLocalShadowIntervalMinutes(value:Int){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(localShadowIntervalMinutes=value)); viewModelScope.launch { traderApp.repository.log("Local Shadow 간격: ${value}분") } }
    fun updateStopLossCooldown(value:Int){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(stopLossCooldownMinutes=value)); viewModelScope.launch { traderApp.repository.log("손절 쿨다운 변경: ${value}분") } }
    fun updateTrailingCooldown(value:Int){ val current=state.value.settings; traderApp.repository.updateSettings(current.copy(trailingStopCooldownMinutes=value)); viewModelScope.launch { traderApp.repository.log("Trailing 손절 쿨다운 변경: ${value}분") } }
    fun resetConsecutiveLossLock(){ viewModelScope.launch { traderApp.repository.resetConsecutiveLossLock() } }
    fun engageKillSwitch(reason:String){ viewModelScope.launch { traderApp.repository.engageKillSwitch(reason) } }
    fun resetKillSwitch(){ viewModelScope.launch { traderApp.repository.resetKillSwitch() } }
    fun selectExchange(exchange: ExchangeId) {
        traderApp.repository.selectExchange(exchange)
        viewModelScope.launch {
            traderApp.repository.log("[EXCHANGE] UI -> ${exchange.name}")
            runCatching { traderApp.repository.restoreServerPaperOnAppStart(source = "EXCHANGE_SWITCH_${exchange.name}") }
            runCatching { traderApp.repository.refreshServerPaperTrades(reason = "EXCHANGE_SWITCH") }
        }
    }
    fun refreshServerPaperTrades() {
        viewModelScope.launch {
            runCatching { traderApp.repository.refreshServerPaperTrades(reason = "UI_TAB") }
                .onFailure { traderApp.repository.log("PAPER_TRADES_REFRESH failed: ${it.message}") }
        }
    }
    fun hasKeys(): Boolean = credentials.hasKeys()
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/MainViewModel.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/Models.kt =====

package com.example.bithumbtrader

import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import java.util.UUID

enum class TradeMode { PAPER, LIVE }
enum class EngineStatus { STOPPED, STARTING, RUNNING, STOPPING, ERROR, DAILY_LOSS_LOCKED }
enum class ConnectionStatus { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
enum class OrderState { IDLE, SUBMITTING, WAITING, PARTIAL_FILLED, FILLED, CANCELING, CANCELED, FAILED, UNKNOWN }
enum class OrderSide { BUY, SELL }
enum class CandidateStatus { WATCHING, BUY_READY, ORDERING, HOLDING, REJECTED, WAIT_PULLBACK, WAIT_RETEST, WAIT_RECONFIRMATION, WAIT_MOMENTUM }
enum class OtaStatus { IDLE, CHECKING, UPDATED, NO_UPDATE, FAILED, ROLLED_BACK }

data class DashboardState(
    val mode: TradeMode = TradeMode.PAPER,
    val totalValue: Double = 100_000.0,
    val krwBalance: Double = 100_000.0,
    val coinValue: Double = 0.0,
    val todayPnl: Double = 0.0,
    val todayPnlRate: Double = 0.0,
    val cumulativePnl: Double = 0.0,
    val cumulativePnlRate: Double = 0.0,
    val realizedPnl: Double = 0.0,
    val unrealizedPnl: Double = 0.0,
    val holdingCount: Int = 0,
    val watchingCount: Int = 0,
    val lastTickerAt: Long = 0L,
    val lastWebSocketAt: Long = 0L,
    val engineStatus: EngineStatus = EngineStatus.STOPPED,
    val websocketStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
    val apiStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
    val dailyLossLocked: Boolean = false,
    val topSignals: List<StrategySignalModel> = emptyList(),
    val logs: List<String> = emptyList(),
    val settings: TradingSettings = TradingSettings(),
    val testMode: Boolean = false,
    val currentAnalyzingMarket: String = "-",
    val candidateCount: Int = 0,
    val buyReadyCount: Int = 0,
    val analysisBatchIndex: Int = 0,
    val analysisBatchTotal: Int = 0,
    val analysisProgressPercent: Int = 0,
    val lastAnalysisAt: Long = 0L,
    val strategyVersion: Long = 1L,
    val lastStrategyUpdateAt: Long = 0L,
    val otaStatus: OtaStatus = OtaStatus.IDLE,
    val otaMessage: String = "",
    val aiModelVersion: Int = 1,
    val lastAiModelUpdateAt: Long = 0L,
    val aiOtaStatus: OtaStatus = OtaStatus.IDLE,
    val aiOtaMessage: String = "",
    val aiModelSource: String = "BUNDLED",
    val aiTrainingSampleCount: Int = 0,
    val aiResolvedSampleCount: Int = 0,
    val aiLocalGeneration: Int = 0,
    val lastAiLocalRetrainAt: Long = 0L,
    val aiRetrainStatus: OtaStatus = OtaStatus.IDLE,
    val aiRetrainMessage: String = "",
    val recentModelVersions: List<AiModelVersionSummary> = emptyList(),
    val consecutiveLossCount: Int = 0,
    val consecutiveLossLocked: Boolean = false,
    val killSwitchEngaged: Boolean = false,
    val killSwitchReason: String = "",
    val baselinePerformance: PerformanceStats = PerformanceStats(),
    val activeRecommendation: RecommendationRuntimeState? = null,
    val recommendationHistory: List<RecommendationHistoryItem> = emptyList(),
    val overallPerformance: PerformanceStats = PerformanceStats(),
    val currentRegime: MarketRegimeSnapshot = MarketRegimeSnapshot(),
    val regimePerformance: List<RegimePerformanceItem> = emptyList(),
    val anomalies: List<String> = emptyList(),
    val marketHealth: MarketHealthScore = MarketHealthScore(),
    val crashProtectionEngaged: Boolean = false,
    val cooldownUntil: Long = 0L,
    val cooldownActive: Boolean = false,
    val crashHistory: List<CrashHistoryItem> = emptyList(),
    val opportunityDecisions: List<OpportunityDecision> = emptyList(),
    val postEntryQuality: EntryQualityStats = EntryQualityStats(),
    val shadowPortfolios: List<ShadowPortfolioEntity> = emptyList(),
    val missedOpportunityStats: MissedOpportunityStats = MissedOpportunityStats(),
    val regimeSelector: RegimeSelectorResult = RegimeSelectorResult(MarketRegime.UNKNOWN, 0.0, false, emptyList(), ""),
    val championChallenger: ChampionChallengerState = ChampionChallengerState(),
    val recentRegimeTransitions: List<RegimeTransitionView> = emptyList(),
    val confidenceCalibration: Map<String, Double> = emptyMap(),
    val newsResearch: NewsResearchState = NewsResearchState(),
    val scanPerformance: ScanPerformanceSnapshot = ScanPerformanceSnapshot(),
    val candidateLatency: CandidateLatencyStats = CandidateLatencyStats(),
    val fastCandidateCount: Int = 0,
    val deepCandidateCount: Int = 0,
    val finalCandidateCount: Int = 0,
    val pipelineStage: String = "IDLE",
    val liquidityDiagnostics: LiquidityDiagnostics = LiquidityDiagnostics(),
    val profitProtection: ProfitProtectionSnapshot = ProfitProtectionSnapshot(),
    val profitVelocity: ProfitVelocity = ProfitVelocity(),
    val paperRisk: PaperRiskSnapshot = PaperRiskSnapshot(),
    val portfolioHeat: PortfolioHeatSnapshot = PortfolioHeatSnapshot(PortfolioHeatLevel.LOW, 0.0, 0.0, 0.0, "NONE", 0.0, 1.0),
    val portfolioCapacity: DynamicPortfolioCapacitySnapshot = DynamicPortfolioCapacitySnapshot(),
    val tailRisk: TailRiskSnapshot = TailRiskSnapshot(30.0, "LOW", -2.5, -3.5, "초기 정상"),
    val sessionQuality: TradingSessionQuality = TradingSessionQuality.NORMAL,
    val dataQualityScore: Double = 100.0,
    val dataQualityStatus: DataQualityStatus = DataQualityStatus.GOOD,
    val capitalGrowth: AutonomousCapitalSnapshot = AutonomousCapitalSnapshot(),
    val capitalStrategyAllocations: List<StrategyCapitalAllocationItem> = emptyList(),
    val counterfactualCapital: CounterfactualCapitalLabResult = CounterfactualCapitalLabResult(100000.0, 100000.0, 100000.0, 100000.0, 100000.0, "EQUAL"),
    val exitQualityStats: ExitQualityStats = ExitQualityStats(),
    val lossRootCauseSummary: LossRootCauseSummary = LossRootCauseSummary(),
    val counterfactualExitSummaries: List<CounterfactualExitSummary> = emptyList(),
    val exitChampionChallenger: ExitChampionChallengerState = ExitChampionChallengerState(),
    val entryTimingResearch: EntryTimingResearchStats = EntryTimingResearchStats(),
    val scalpingResearch: ScalpingResearchStats = ScalpingResearchStats(),
    val smartReentryResearch: SmartReentryResearchStats = SmartReentryResearchStats(),
    val smartReentryStates: List<SmartReentryState> = emptyList(),
    val continuousLearning: ContinuousLearningState = ContinuousLearningState(),
    val learningAssetContinuity: LearningAssetContinuityState = LearningAssetContinuityState(),
    val globalDerivatives: List<GlobalDerivativesIntelligence> = emptyList(),
    val derivativesSupportedCount: Int = 0,
    val derivativesUnsupportedCount: Int = 0,
    val derivativesUnavailableCount: Int = 0,
    val globalDerivativesMarketState: GlobalDerivativesMarketState = GlobalDerivativesMarketState.DATA_UNAVAILABLE,
    val regimeStrategySet: RegimeStrategySetState = RegimeStrategySetState(),
    val scanHeartbeat: ScanHeartbeatSnapshot = ScanHeartbeatSnapshot(),
    val gateFunnel: GateFunnelSnapshot = GateFunnelSnapshot(),
    val noTradeDiagnosis: NoTradeDiagnosisSnapshot = NoTradeDiagnosisSnapshot(),
    val executionDataInsufficientRate30m: String = "NO_RUNTIME_DATA",
    val executionDataInsufficientRate1h: String = "NO_RUNTIME_DATA",
    val executionDataInsufficientRate3h: String = "NO_RUNTIME_DATA",
    val websocketHealthLabel: String = WebSocketHealth.DISCONNECTED.name,
    val aiBrainStatus: String = AiBrainLinkStatus.DISABLED.name,
    val aiBrainLatencyMs: Long = -1L,
    val aiBrainLastDecisionAt: Long = 0L,
    val aiBrainModelVersion: String = "-",
    val aiBrainBithumbWs: String = "-",
    val aiBrainMicroBufferReady: Int = 0,
    val aiBrainLocalFallback: String = "SHADOW",
    val hetznerLearningStatus: String = "-",
    val hetznerBrainState: String = "-",
    val hetznerActiveModel: String = "-",
    val hetznerActiveModelHash: String = "-",
    val hetznerLastLearningAt: Long = 0L,
    val hetznerSamplesTotal: Int = 0,
    val hetznerSamplesSinceLearning: Int = 0,
    val hetznerChallengerVersion: String = "-",
    val hetznerShadowStatus: String = "-",
    val hetznerLearningHealth: String = "-",
    val hetznerRecentLearningProblem: String = "-",
    val hetznerRecentLearningHypothesis: String = "-",
    val hetznerRecentLearningCandidate: String = "-",
    val hetznerRecentLearningStatus: String = "-",
    val hetznerLearningProofSource: String = "-",
    val hetznerModelStatus: String = "-",
    val hetznerProductionEvidence: String = "NONE",
    val hetznerRealSampleCount: Int = 0,
    val hetznerRealShadowSampleCount: Int = 0,
    val hetznerRealCycleCount: Int = 0,
    val hetznerIsLearning: String = "NO",
    val hetznerIsImproving: String = "NOT_ENOUGH_EVIDENCE",
    val hetznerLayer2Status: String = "-",
    val hetznerOosStatus: String = "-",
    val hetznerPredictionChangeRate: String = "-",
    val hetznerRecentLearningBadge: String = "NONE",
    val lastRemoteDecisionId: String = "",
    val lastRemoteDecisionSummary: String = "-",
    val androidAnalysisMode: String = "LOCAL_FULL_SCAN",
    val serverDashboardAgeMs: Long = -1L,
    val serverFastScanCount: Int = 0,
    val serverDeepScanCount: Int = 0,
    /** PHASE4/5: server-resident PAPER. Source of truth is Hetzner when remoteAiPrimary. */
    val serverPaperAuto: Boolean = false,
    val serverPaperCash: Double = 0.0,
    val serverPaperTotalValue: Double = 0.0,
    val serverPaperRealizedPnl: Double = 0.0,
    val serverPaperUnrealizedPnl: Double = 0.0,
    val serverPaperPositionCount: Int = 0,
    val serverPaperTickCount: Int = 0,
    val serverPaperIndependent: Boolean = false,
    val serverPaperInitialCash: Double = 0.0,
    val serverPaperCoinValue: Double = 0.0,
    val serverPaperTotalPnl: Double = 0.0,
    val serverPaperTotalPnlRate: Double = 0.0,
    val serverPaperUpdatedAt: Long = 0L,
    val serverPaperLastTickAt: Long = 0L,
    val serverPaperPositions: List<RemotePaperPosition> = emptyList(),
    val serverPaperTrades: List<TradeEntity> = emptyList(),
    val serverPaperNewBuyPaused: Boolean = false,
    val serverPaperBuyResumeMode: String = "NORMAL",
    val serverPaperPauseReason: String = "",
    val serverPaperTopLossCauses: List<RemoteTopLossCause> = emptyList(),
    val serverStatusLabel: String = "-",
    /** True after at least one successful SERVER → UI paper restore/sync. */
    val serverPaperUiSynced: Boolean = false,
    /** IDLE | SYNCING | OK | EMPTY | FAILED */
    val serverPaperTradesSyncStatus: String = "IDLE",
    val serverPaperTradesSyncError: String = "",
    val serverPaperTradesSyncedAt: Long = 0L,
    val tradingCostLedger: TradingCostLedgerSnapshot = TradingCostLedgerSnapshot(
        tradeCount = 0,
        grossPnlKrw = 0.0,
        totalFeesKrw = 0.0,
        netPnlKrw = 0.0,
        tradingCostSharePercent = 0.0,
        feeDragPercent = 0.0,
        grossWinNetLossCount = 0,
        status = "INSUFFICIENT_DATA",
        overtradingCostDrag = false,
        warning = "NORMAL"
    ),
    /** PAPER 10만→급락 자동해부 (KRW 기여도 TOP 원인). */
    val paperLossAutopsy: PaperLossAutopsyReport = PaperLossAutopsyReport(),
    /** Dual independent exchange viewer: BITHUMB | UPBIT (default Bithumb preserved). */
    val selectedExchange: String = ExchangeId.BITHUMB.name,
    val bithumbBrainStatus: String = "-",
    val upbitBrainStatus: String = "-",
    val upbitMarketCount: Int = 0,
    val upbitMicroReady: Int = 0,
    val androidFullMarketAnalysis: String = "OFF"
)

data class RegimePerformanceItem(val regime: String, val stats: PerformanceStats)
data class MissedOpportunityStats(
    val total: Int = 0,
    val goodRejection: Int = 0,
    val missedWin: Int = 0,
    val neutral: Int = 0,
    val byReason: Map<String, String> = emptyMap()
)

data class AiModelVersionSummary(
    val generation: Int,
    val source: String,
    val createdAt: Long,
    val adopted: Boolean,
    val validationAccuracy: Double,
    val baselineAccuracy: Double,
    val trainingSamples: Int,
    val reason: String
)

data class TradingSettings(
    val mode: TradeMode = TradeMode.PAPER,
    val paperInitialKrw: Double = 100_000.0,
    val maxPositions: Int = 3,
    /**
     * Bug/runaway protection only — not the normal capacity limiter.
     * Normal capacity uses Portfolio Heat + cash + Net Profit After Cost.
     */
    val maxPositionsHardCap: Int = 8,
    val dynamicPortfolioCapacityEnabled: Boolean = true,
    /** Max open stop-risk as % of equity (projected after candidate). */
    val maxOpenRiskPercent: Double = 5.0,
    /** Floor order size to avoid fee-dominated micro slices on ~100k accounts. */
    val minimumViableOrderKrw: Double = 8_000.0,
    val maxAssetPercentPerCoin: Double = 20.0,
    val maxOrderPercent: Double = 20.0,
    val minKrwCashPercent: Double = 30.0,
    val scoreThreshold: Double = 75.0,
    val min24hTradePrice: Double = 500_000_000.0,
    val maxSpreadPercent: Double = 0.7,
    val minTradeVolume: Double = 0.0,
    val liquidityPercentileThreshold: Double = 0.25,
    val dynamicLiquidityEnabled: Boolean = true,
    val overblockingWarningThreshold: Double = 0.8,
    val stopLossPercent: Double = -2.5,
    val takeProfitPercent: Double = 6.0,
    val trailingStopPercent: Double = 2.5,
    /** Peak unrealized % required before trailing stop can fire (default = Profit Protection L1). */
    val trailingArmMinProfitPercent: Double = 1.0,
    val dailyMaxLossPercent: Double = -5.0,
    val staleTickerMillis: Long = 30_000L,
    val autoResumeAfterBoot: Boolean = false,
    val testMode: Boolean = false,
    val aiMinScore: Double = 55.0,
    val maxConsecutiveLosses: Int = 5,
    val dynamicAllocationEnabled: Boolean = true,
    val crashCooldownMinutes: Int = 30,
    val profitProtectionLevel1Percent: Double = 1.0,
    val profitProtectionLevel2Percent: Double = 3.0,
    val profitProtectionLevel3Percent: Double = 5.0,
    val profitProtectionLevel4Percent: Double = 10.0,
    val profitCautionGivebackPercentPoints: Double = 0.75,
    val profitDefenseGivebackPercentPoints: Double = 1.5,
    val profitLockedGivebackPercentPoints: Double = 2.5,
    val profitCautionMultiplier: Double = 0.7,
    val profitDefenseMultiplier: Double = 0.4,
    val profitLockedEntryAllowed: Boolean = false,
    val stopLossCooldownMinutes: Int = 15,
    val trailingStopCooldownMinutes: Int = 10,
    val minNetEdgeMarginPercent: Double = 0.35,
    val maxPortfolioHeatPercent: Double = 60.0,
    val executionDepthCheckEnabled: Boolean = true,
    val autonomousCapitalGrowthEnabled: Boolean = true,
    val baseRiskPercent: Double = 2.0,
    val normalReinvestmentRatio: Double = 0.50,
    val growthReinvestmentRatio: Double = 0.70,
    val cautionReinvestmentRatio: Double = 0.30,
    val defenseReinvestmentRatio: Double = 0.10,
    val reserveLockProfitPercent: Double = 20.0,
    val reserveAccumulationRatio: Double = 0.25,
    val dynamicCashBufferEnabled: Boolean = true,
    val entryTimingGateEnabled: Boolean = true,
    val chaseRejectScore: Double = 80.0,
    val extremeChaseScore: Double = 90.0,
    val minimumEntryTimingScore: Double = 45.0,
    val overextensionAtrMultiple: Double = 1.75,
    val highScoreFailureWarningRate: Double = 0.40,
    val entrySignalMaxAgeMillis: Long = 90_000L,
    val scalpingExecutionEnabled: Boolean = true,
    val scalpingMinimumExecutionScore: Double = 60.0,
    val scalpingMinimumShortNetEdgePercent: Double = 0.15,
    val scalpingSafetyMargin: Double = 1.35,
    val scalpingMaximumSpreadPercent: Double = 0.7,
    val scalpingSignalTtlMillis: Long = 60_000L,
    val scalpingPriceMovedAwayAtrMultiple: Double = 1.5,
    val profitReentryEnabled: Boolean = true,
    val profitReentryCooldownMinutes: Int = 10,
    val profitReentryScoreResetDrop: Double = 12.0,
    val minimumProfitReentryQuality: Double = 62.0,
    val maxProfitReentryRiskPercent: Double = 100.0,
    val reentryChainWindowMinutes: Int = 60,
    val churnWindowMinutes: Int = 60,
    val continuousLearningEnabled: Boolean = true,
    val continuousLearningTriggerSamples: Int = 20,
    val continuousLearningIntervalMinutes: Int = 60,
    val replayBufferSize: Int = 2_000,
    val historicalBootstrapEnabled: Boolean = true,
    val regimeStrategySetsEnabled: Boolean = true,
    val regimeStrategySetsPaperApplyEnabled: Boolean = true,
    /** Phase1: health/shadow ON 가능. PHASE4+ PAPER SoT는 Primary ON이 기본(로컬 /decision 폭주 방지). */
    val remoteAiEnabled: Boolean = true,
    val remoteAiPrimary: Boolean = true,
    val localAiShadowEnabled: Boolean = true,
    val remoteLearningEnabled: Boolean = false,
    val serverMarketDataEnabled: Boolean = false,
    val remoteAiBaseUrl: String = "https://riderapp.duckdns.org",
    val serverDecisionMaxAgeMillis: Long = 90_000L,
    /** Phase3: Local Shadow 전체 재분석 간격(분). Primary ON이면 기본 분석 루프에서 제외. */
    val localShadowIntervalMinutes: Int = 10,
    /** Phase3: 서버 Dashboard Snapshot을 UI/매수 신호 소스로 사용. */
    val remoteDashboardEnabled: Boolean = true,
    /**
     * Net Profit After Cost (conservative defaults — does not loosen Short/Net Edge thresholds).
     * Fee unit matches ScalpingExecutionEngine: 0.25 = 0.25%.
     */
    val paperFeeRate: Double = 0.0025,
    val paperSlippageRate: Double = 0.001,
    val absoluteMinimumNetProfitKrw: Double = 30.0,
    val minimumNetProfitPercentOfOrder: Double = 0.05,
    val minimumCostCoverageMultiple: Double = 1.5,
    val netProfitAfterCostGateEnabled: Boolean = true
)

val TradingSettings.min24hTradeValueKrw: Double
    get() = min24hTradePrice


data class RemoteStrategyConfig(
    @Json(name = "version") val version: Long,
    @Json(name = "minAppVersionCode") val minAppVersionCode: Int? = null,
    @Json(name = "scoreThreshold") val scoreThreshold: Double? = null,
    @Json(name = "min24hTradePrice") val min24hTradePrice: Double? = null,
    @Json(name = "min24hTradeValueKrw") val min24hTradeValueKrw: Double? = null,
    @Json(name = "liquidityPercentileThreshold") val liquidityPercentileThreshold: Double? = null,
    @Json(name = "dynamicLiquidityEnabled") val dynamicLiquidityEnabled: Boolean? = null,
    @Json(name = "overblockingWarningThreshold") val overblockingWarningThreshold: Double? = null,
    @Json(name = "profitProtectionLevel1Percent") val profitProtectionLevel1Percent: Double? = null,
    @Json(name = "profitProtectionLevel2Percent") val profitProtectionLevel2Percent: Double? = null,
    @Json(name = "profitProtectionLevel3Percent") val profitProtectionLevel3Percent: Double? = null,
    @Json(name = "profitProtectionLevel4Percent") val profitProtectionLevel4Percent: Double? = null,
    @Json(name = "profitCautionGivebackPercentPoints") val profitCautionGivebackPercentPoints: Double? = null,
    @Json(name = "profitDefenseGivebackPercentPoints") val profitDefenseGivebackPercentPoints: Double? = null,
    @Json(name = "profitLockedGivebackPercentPoints") val profitLockedGivebackPercentPoints: Double? = null,
    @Json(name = "profitCautionMultiplier") val profitCautionMultiplier: Double? = null,
    @Json(name = "profitDefenseMultiplier") val profitDefenseMultiplier: Double? = null,
    @Json(name = "profitLockedEntryAllowed") val profitLockedEntryAllowed: Boolean? = null,
    @Json(name = "stopLossCooldownMinutes") val stopLossCooldownMinutes: Int? = null,
    @Json(name = "trailingStopCooldownMinutes") val trailingStopCooldownMinutes: Int? = null,
    @Json(name = "minNetEdgeMarginPercent") val minNetEdgeMarginPercent: Double? = null,
    @Json(name = "maxPortfolioHeatPercent") val maxPortfolioHeatPercent: Double? = null,
    @Json(name = "executionDepthCheckEnabled") val executionDepthCheckEnabled: Boolean? = null,
    @Json(name = "autonomousCapitalGrowthEnabled") val autonomousCapitalGrowthEnabled: Boolean? = null,
    @Json(name = "baseRiskPercent") val baseRiskPercent: Double? = null,
    @Json(name = "normalReinvestmentRatio") val normalReinvestmentRatio: Double? = null,
    @Json(name = "growthReinvestmentRatio") val growthReinvestmentRatio: Double? = null,
    @Json(name = "cautionReinvestmentRatio") val cautionReinvestmentRatio: Double? = null,
    @Json(name = "defenseReinvestmentRatio") val defenseReinvestmentRatio: Double? = null,
    @Json(name = "reserveLockProfitPercent") val reserveLockProfitPercent: Double? = null,
    @Json(name = "reserveAccumulationRatio") val reserveAccumulationRatio: Double? = null,
    @Json(name = "dynamicCashBufferEnabled") val dynamicCashBufferEnabled: Boolean? = null,
    @Json(name = "entryTimingGateEnabled") val entryTimingGateEnabled: Boolean? = null,
    @Json(name = "chaseRejectScore") val chaseRejectScore: Double? = null,
    @Json(name = "extremeChaseScore") val extremeChaseScore: Double? = null,
    @Json(name = "minimumEntryTimingScore") val minimumEntryTimingScore: Double? = null,
    @Json(name = "overextensionAtrMultiple") val overextensionAtrMultiple: Double? = null,
    @Json(name = "highScoreFailureWarningRate") val highScoreFailureWarningRate: Double? = null,
    @Json(name = "entrySignalMaxAgeMillis") val entrySignalMaxAgeMillis: Long? = null,
    @Json(name = "scalpingExecutionEnabled") val scalpingExecutionEnabled: Boolean? = null,
    @Json(name = "scalpingMinimumExecutionScore") val scalpingMinimumExecutionScore: Double? = null,
    @Json(name = "scalpingMinimumShortNetEdgePercent") val scalpingMinimumShortNetEdgePercent: Double? = null,
    @Json(name = "scalpingSafetyMargin") val scalpingSafetyMargin: Double? = null,
    @Json(name = "scalpingMaximumSpreadPercent") val scalpingMaximumSpreadPercent: Double? = null,
    @Json(name = "scalpingSignalTtlMillis") val scalpingSignalTtlMillis: Long? = null,
    @Json(name = "scalpingPriceMovedAwayAtrMultiple") val scalpingPriceMovedAwayAtrMultiple: Double? = null,
    @Json(name = "profitReentryEnabled") val profitReentryEnabled: Boolean? = null,
    @Json(name = "profitReentryCooldownMinutes") val profitReentryCooldownMinutes: Int? = null,
    @Json(name = "profitReentryScoreResetDrop") val profitReentryScoreResetDrop: Double? = null,
    @Json(name = "minimumProfitReentryQuality") val minimumProfitReentryQuality: Double? = null,
    @Json(name = "maxProfitReentryRiskPercent") val maxProfitReentryRiskPercent: Double? = null,
    @Json(name = "reentryChainWindowMinutes") val reentryChainWindowMinutes: Int? = null,
    @Json(name = "churnWindowMinutes") val churnWindowMinutes: Int? = null,
    @Json(name = "continuousLearningEnabled") val continuousLearningEnabled: Boolean? = null,
    @Json(name = "continuousLearningTriggerSamples") val continuousLearningTriggerSamples: Int? = null,
    @Json(name = "continuousLearningIntervalMinutes") val continuousLearningIntervalMinutes: Int? = null,
    @Json(name = "replayBufferSize") val replayBufferSize: Int? = null,
    @Json(name = "historicalBootstrapEnabled") val historicalBootstrapEnabled: Boolean? = null,
    @Json(name = "regimeStrategySetsEnabled") val regimeStrategySetsEnabled: Boolean? = null,
    @Json(name = "regimeStrategySetsPaperApplyEnabled") val regimeStrategySetsPaperApplyEnabled: Boolean? = null,
    @Json(name = "maxSpreadPercent") val maxSpreadPercent: Double? = null,
    @Json(name = "maxPositions") val maxPositions: Int? = null,
    @Json(name = "maxOrderPercent") val maxOrderPercent: Double? = null,
    @Json(name = "maxAssetPercentPerCoin") val maxAssetPercentPerCoin: Double? = null,
    @Json(name = "minKrwCashPercent") val minKrwCashPercent: Double? = null,
    @Json(name = "stopLossPercent") val stopLossPercent: Double? = null,
    @Json(name = "takeProfitPercent") val takeProfitPercent: Double? = null,
    @Json(name = "trailingStopPercent") val trailingStopPercent: Double? = null,
    @Json(name = "dailyMaxLossPercent") val dailyMaxLossPercent: Double? = null,
    @Json(name = "staleTickerMillis") val staleTickerMillis: Long? = null,
    @Json(name = "maxConsecutiveLosses") val maxConsecutiveLosses: Int? = null,
    @Json(name = "message") val message: String? = null
)

data class MarketModel(val market: String, val koreanName: String, val englishName: String, val warning: String = "NONE")
data class TickerModel(val market: String, val tradePrice: Double, val accTradePrice24h: Double, val signedChangeRate: Double, val tradeVolume: Double, val timestamp: Long)
data class OrderbookModel(val market: String, val askPrice: Double, val bidPrice: Double, val askSize: Double, val bidSize: Double, val timestamp: Long)
data class CandleModel(val market: String, val timestamp: Long, val close: Double, val high: Double, val low: Double, val volume: Double)
data class StrategySignalModel(
    val market: String,
    val score: Double,
    val reason: String,
    val timestamp: Long = System.currentTimeMillis(),
    val signalId: String = UUID.randomUUID().toString(),
    val predictionId: String = UUID.randomUUID().toString(),
    val currentPrice: Double = 0.0,
    val estimatedInvestment: Double = 0.0,
    val investmentRatio: Double = 0.0,
    val expectedStopPrice: Double = 0.0,
    val expectedTakeProfitPrice: Double = 0.0,
    val status: CandidateStatus = CandidateStatus.WATCHING,
    val failureReason: String = "",
    val aiScore: Double = 50.0,
    val aiLabel: String = "AI 대기",
    val aiFeatures: List<Double> = emptyList(),
    val momentumPercent: Double = 0.0,
    val volatilityPercent: Double = 0.0,
    val volumeChangePercent: Double = 0.0,
    val actual24hTradeValueKrw: Double = 0.0,
    val required24hTradeValueKrw: Double = 0.0,
    val liquidityRank: Int = 0,
    val liquidityTotal: Int = 0,
    val liquidityPercentile: Double = 0.0,
    val liquidityPassed: Boolean = false,
    val decisionPipeline: String = "STRATEGY → AI → LIQUIDITY → RISK → EXECUTION",
    val grossExpectedEdge: Double = 0.0,
    val expectedExecutionCost: Double = 0.0,
    val netExpectedEdge: Double = 0.0,
    val netEdgePassed: Boolean = false,
    val depthWeightedFillPrice: Double = 0.0,
    val expectedSlippagePercent: Double = 0.0,
    val expectedMarketImpactPercent: Double = 0.0,
    val dataQualityScore: Double = 100.0,
    val dataQualityStatus: String = "GOOD",
    val entryTimingScore: Double = 50.0,
    val chaseEntryScore: Double = 0.0,
    val entryTimingState: String = EntryTimingState.NORMAL.name,
    val pullbackState: String = PullbackState.NONE.name,
    val breakoutRetestState: String = BreakoutRetestState.NONE.name,
    val overextensionAtr: Double = 0.0,
    val overextensionScore: Double = 0.0,
    val parabolicMove: Boolean = false,
    val momentumExhaustion: Boolean = false,
    val volumeClimax: Boolean = false,
    val scoreVelocityPerMinute: Double = 0.0,
    val signalLagMs: Long = 0L,
    val priceMoveStartTime: Long = 0L,
    val scoreCrossTime: Long = 0L,
    val preEntry1mReturn: Double = 0.0,
    val preEntry3mReturn: Double = 0.0,
    val preEntry5mReturn: Double = 0.0,
    val preEntry10mReturn: Double = 0.0,
    val preEntry15mReturn: Double = 0.0,
    val entryAtrPercent: Double = 0.0,
    val entryQualityClassification: String = EntryQualityClassification.GOOD.name,
    val scalpExecutionScore: Double = 0.0,
    val scalpExecutionConfidence: Double = 0.0,
    val scalpExecutionState: String = ScalpingExecutionState.AVOID.name,
    val scalpMarketState: String = ScalpingMarketState.COOLDOWN.name,
    val microMomentumState: String = MicroMomentumState.UNKNOWN.name,
    val microVolatilityRegime: String = MicroVolatilityRegime.CHAOTIC.name,
    val shortHorizonNetEdge: Double = 0.0,
    val recommendedScalpHorizonSeconds: Int = 0,
    val scalpReasonCodes: List<String> = emptyList(),
    val scalpEntryAllowed: Boolean = false,
    val scalpPriceMovedAway: Boolean = false,
    val scalpEntryWindowOpen: Boolean = false,
    val microReturn10s: Double = 0.0,
    val microReturn30s: Double = 0.0,
    val microReturn1m: Double = 0.0,
    val microReturn3m: Double = 0.0,
    val microReturn5m: Double = 0.0,
    val microVolume10s: Double = 0.0,
    val microVolume30s: Double = 0.0,
    val microVolume1m: Double = 0.0,
    val microVolumeAcceleration: Double = 0.0,
    val microSpreadPercent: Double = 0.0,
    val microOrderbookImbalance: Double = 0.5,
    val microDepthChangePercent: Double = 0.0,
    val microMomentum: Double = 0.0,
    val microMomentumSlope: Double = 0.0,
    val microMomentumAcceleration: Double = 0.0,
    val microRsi: Double = 50.0,
    val microEmaDistancePercent: Double = 0.0,
    val microBreakoutDistancePercent: Double = 0.0,
    val derivativesSupportStatus: String = DerivativeSupportStatus.TEMP_UNAVAILABLE.name,
    val derivativesProviderStatus: String = DerivativesProviderStatus.UNAVAILABLE.name,
    val derivativesFreshness: String = DerivativeFreshness.STALE.name,
    val derivativesSentiment: Double = 50.0,
    val derivativesRisk: Double = 50.0,
    val derivativesOiChange5m: Double? = null,
    val derivativesFundingState: String = FundingState.NEUTRAL.name,
    val derivativesPositioningState: String = DerivativesPositioningState.DATA_UNAVAILABLE.name,
    val shortSqueezeScore: Double = 0.0,
    val longSqueezeRisk: Double = 0.0,
    val spotFuturesDivergence: String = SpotFuturesDivergence.NONE.name,
    val globalLeadState: String = GlobalLeadState.NO_CLEAR_LEAD.name,
    val derivativesConfidence: Double = 0.0,
    val globalMoveConfirmed: Boolean = false,
    val derivativesReasonCodes: List<String> = emptyList(),
    val shortEdgeGrossMovePercent: Double = 0.0,
    val shortEdgeFeePercent: Double = 0.0,
    val shortEdgeSpreadPercent: Double = 0.0,
    val shortEdgeSlippagePercent: Double = 0.0,
    val shortEdgeImpactPercent: Double = 0.0,
    val shortEdgeSafetyMargin: Double = 1.0,
    val shortEdgeCostBreakdownText: String = "",
    val horizonConflict: String = HorizonConflictState.NONE.name,
    val entryDecisionSummary: String = "",
    val entryUrgencyClass: String = EntryUrgencyClass.INSUFFICIENT_DATA.name,
    val liquidityReady: Boolean = false,
    val candleSignalType: String = "INSUFFICIENT_DATA",
    val executionDataStatus: String = ExecutionDataStatus.GOOD.name,
    val executionDataGaps: List<String> = emptyList(),
    val shortEdgeReliable: Boolean = true,
    val microSampleCount: Int = 0,
    val expectedGrossProfitKrw: Double = 0.0,
    val expectedRoundTripCostKrw: Double = 0.0,
    val expectedRoundTripCostPercent: Double = 0.0,
    val expectedNetProfitKrw: Double = 0.0,
    val expectedNetProfitPercent: Double = 0.0,
    val costToGrossProfitRatio: Double = 0.0,
    val costCoverageMultiple: Double = 0.0,
    val breakEvenPrice: Double = 0.0,
    val netProfitAfterCostPassed: Boolean = false,
    val netProfitAfterCostReason: String = ""
)
data class PositionModel(val market: String, val quantity: Double, val avgPrice: Double, val highestPrice: Double, val openedAt: Long)

data class PaperFill(
    val quantity: Double,
    val grossAmount: Double,
    val fee: Double,
    val executionPrice: Double
)

object PaperTradingMath {
    fun highestPrice(previous: Double, current: Double): Double =
        if (current.isFinite() && current > 0.0) maxOf(previous, current) else previous

    fun buyFill(krwAmount: Double, marketPrice: Double, feeRate: Double, slippageRate: Double): PaperFill? {
        if (!krwAmount.isFinite() || !marketPrice.isFinite() || krwAmount <= 0.0 || marketPrice <= 0.0) return null
        val executionPrice = marketPrice * (1.0 + slippageRate.coerceAtLeast(0.0))
        val fee = krwAmount * feeRate.coerceAtLeast(0.0)
        val quantity = ((krwAmount - fee) / executionPrice).takeIf { it.isFinite() && it > 0.0 } ?: return null
        return PaperFill(quantity, quantity * executionPrice, fee, executionPrice)
    }

    fun sellFill(quantity: Double, marketPrice: Double, feeRate: Double, slippageRate: Double): PaperFill? {
        if (!quantity.isFinite() || !marketPrice.isFinite() || quantity <= 0.0 || marketPrice <= 0.0) return null
        val executionPrice = marketPrice * (1.0 - slippageRate.coerceAtLeast(0.0))
        val grossAmount = quantity * executionPrice
        val fee = grossAmount * feeRate.coerceAtLeast(0.0)
        return PaperFill(quantity, grossAmount, fee, executionPrice)
    }

    /**
     * Cash spent at BUY (includes buy fee). qty*avgPrice is post-fee notional only.
     * Slippage is already inside executionPrice — do not add again.
     */
    fun buyCashSpent(quantity: Double, avgBuyPrice: Double, feeRate: Double): Double {
        val notional = quantity * avgBuyPrice
        val rate = feeRate.coerceIn(0.0, 0.5)
        return if (rate >= 1.0) notional else notional / (1.0 - rate)
    }

    /**
     * Economic Net PnL = sellNetProceeds − buyCashSpent
     * = (sellGross − sellFee) − buyCashSpent
     * Equivalent to Gross(priceMove) − buyFee − sellFee; slippage already in prices.
     */
    fun economicRealizedPnl(
        quantity: Double,
        avgBuyPrice: Double,
        sellGrossAmount: Double,
        sellFee: Double,
        feeRate: Double,
        buyCashSpentOverride: Double? = null
    ): Double {
        val buyCash = buyCashSpentOverride?.takeIf { it.isFinite() && it > 0.0 }
            ?: buyCashSpent(quantity, avgBuyPrice, feeRate)
        return (sellGrossAmount - sellFee) - buyCash
    }
}

data class MarketDto(@Json(name="market") val market:String, @Json(name="korean_name") val koreanName:String?, @Json(name="english_name") val englishName:String?, @Json(name="market_warning") val marketWarning:String?)
data class TickerDto(@Json(name="market") val market:String, @Json(name="trade_price") val tradePrice:Double?, @Json(name="acc_trade_price_24h") val accTradePrice24h:Double?, @Json(name="signed_change_rate") val signedChangeRate:Double?, @Json(name="trade_volume") val tradeVolume:Double?, @Json(name="timestamp") val timestamp:Long?)
data class OrderbookUnitDto(@Json(name="ask_price") val askPrice:Double?, @Json(name="bid_price") val bidPrice:Double?, @Json(name="ask_size") val askSize:Double?, @Json(name="bid_size") val bidSize:Double?)
data class OrderbookDto(@Json(name="market") val market:String, @Json(name="timestamp") val timestamp:Long?, @Json(name="orderbook_units") val units:List<OrderbookUnitDto>?)
data class CandleDto(@Json(name="market") val market:String?, @Json(name="candle_date_time_utc") val timeUtc:String?, @Json(name="trade_price") val tradePrice:Double?, @Json(name="high_price") val highPrice:Double?, @Json(name="low_price") val lowPrice:Double?, @Json(name="candle_acc_trade_volume") val volume:Double?, @Json(name="timestamp") val timestamp:Long?)

data class OrderChanceDto(@Json(name="bid_fee") val bidFee:String?, @Json(name="ask_fee") val askFee:String?, @Json(name="market") val market: ChanceMarketDto?, @Json(name="bid_account") val bidAccount: AccountDto?, @Json(name="ask_account") val askAccount: AccountDto?)
data class ChanceMarketDto(@Json(name="id") val id:String?, @Json(name="state") val state:String?, @Json(name="bid") val bid: ConstraintDto?, @Json(name="ask") val ask: ConstraintDto?, @Json(name="bid_types") val bidTypes:List<String>?, @Json(name="ask_types") val askTypes:List<String>?)
data class ConstraintDto(@Json(name="currency") val currency:String?, @Json(name="price_unit") val priceUnit:String?, @Json(name="min_total") val minTotal:String?)
data class AccountDto(@Json(name="currency") val currency:String?, @Json(name="balance") val balance:String?, @Json(name="locked") val locked:String?, @Json(name="avg_buy_price") val avgBuyPrice:String?)
data class OrderRequestDto(@Json(name="market") val market:String, @Json(name="side") val side:String, @Json(name="order_type") val orderType:String, @Json(name="price") val price:String? = null, @Json(name="volume") val volume:String? = null, @Json(name="client_order_id") val clientOrderId:String = UUID.randomUUID().toString().take(36))
data class OrderResponseDto(@Json(name="order_id") val orderId:String?, @Json(name="market") val market:String?, @Json(name="side") val side:String?, @Json(name="order_type") val orderType:String?, @Json(name="created_at") val createdAt:String?)

@Entity(tableName="trades") data class TradeEntity(@PrimaryKey val id:String = UUID.randomUUID().toString(), val time:Long, val market:String, val side:String, val amount:Double, val quantity:Double, val avgPrice:Double, val fee:Double, val realizedPnl:Double, val pnlRate:Double, val reason:String, val mode:String = "PAPER")
@Entity(tableName="orders") data class OrderEntity(@PrimaryKey val clientOrderId:String, val exchangeOrderId:String?, val market:String, val side:String, val amount:Double, val quantity:Double, val state:String, val updatedAt:Long, val mode:String = "PAPER")
@Entity(tableName="positions") data class PositionEntity(@PrimaryKey val market:String, val quantity:Double, val avgPrice:Double, val highestPrice:Double, val openedAt:Long, val updatedAt:Long, val mode:String = "PAPER")
@Entity(tableName="balance_snapshots") data class BalanceSnapshotEntity(@PrimaryKey val time:Long, val totalValue:Double, val krw:Double, val coinValue:Double)
@Entity(tableName="daily_performance") data class DailyPerformanceEntity(@PrimaryKey val yyyymmdd:String, val startValue:Double, val endValue:Double, val realizedPnl:Double, val maxDrawdown:Double)
@Entity(tableName="strategy_signals") data class StrategySignalEntity(@PrimaryKey val id:String = UUID.randomUUID().toString(), val time:Long, val market:String, val score:Double, val reason:String)
@Entity(tableName="app_events") data class AppEventEntity(@PrimaryKey val id:String = UUID.randomUUID().toString(), val time:Long, val level:String, val message:String)
@Entity(tableName="settings") data class SettingEntity(@PrimaryKey val key:String, val value:String)

@Entity(tableName="ai_training_samples") data class AiTrainingSampleEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val market: String,
    val time: Long,
    val featuresJson: String,
    val strategyScore: Double,
    val aiScoreAtCapture: Double,
    val buyTradeId: String?,
    val outcomeLabel: Double? = null,
    val realizedPnlRate: Double? = null,
    val resolvedAt: Long? = null,
    val marketRegime: String = "UNKNOWN",
    val source: String = LearningDataSource.PAPER.name,
    val futureReturn30s: Double? = null,
    val futureReturn1m: Double? = null,
    val futureReturn3m: Double? = null,
    val futureReturn5m: Double? = null,
    val futureReturn15m: Double? = null,
    val futureReturn30m: Double? = null,
    val futureReturn60m: Double? = null,
    val mfePercent: Double? = null,
    val maePercent: Double? = null,
    val immediateDrawdown: Boolean = false,
    val stopHit: Boolean = false,
    val takeProfitHit: Boolean = false,
    val goodEntry: Boolean = false,
    val chaseEntry: Boolean = false,
    val profitableAfterCost: Boolean = false,
    val netEdgeRealized: Double? = null,
    val hardExample: Boolean = false,
    val lookaheadSafe: Boolean = true
)

@Entity(tableName="crash_events") data class CrashEventEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val startedAt: Long,
    val endedAt: Long? = null,
    val minHealthScore: Double,
    val regimeAtStart: String,
    val averageChangeRatePercent: Double,
    val breadthPositive: Double,
    val reasons: String,
    val holdingCountAtStart: Int,
    val totalValueAtStart: Double,
    val resolvedTotalValue: Double? = null
)

@Entity(tableName="market_regime_history") data class MarketRegimeEntity(
    @PrimaryKey val time: Long,
    val regime: String,
    val breadthPositive: Double,
    val averageChangeRatePercent: Double,
    val sampleCount: Int,
    val confidence: Double = 0.0,
    val trendStrength: Double = 0.0,
    val volatilityLevel: String = "UNKNOWN",
    val shortRegime: String = "UNKNOWN",
    val midRegime: String = "UNKNOWN",
    val longRegime: String = "UNKNOWN",
    val durationMinutes: Long = 0L
)

@Entity(tableName="strategy_recommendations") data class StrategyRecommendationEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val createdAt: Long,
    val stage: String,
    val reason: String,
    val scoreThresholdDelta: Double,
    val stopLossDelta: Double,
    val takeProfitDelta: Double,
    val trailingStopDelta: Double,
    val maxPositionsDelta: Int,
    val baselineSampleCount: Int,
    val baselineWinRate: Double,
    val baselineProfitFactor: Double,
    val baselineMaxDrawdown: Double,
    val baselineExpectedReturn: Double,
    val backtestSampleCount: Int = 0,
    val backtestWinRate: Double = 0.0,
    val backtestProfitFactor: Double = 0.0,
    val paperTrialSampleCount: Int = 0,
    val paperTrialWinRate: Double = 0.0,
    val paperTrialProfitFactor: Double = 0.0,
    val resolvedAt: Long? = null
)

@Entity(tableName="regime_strategy_performance")
data class RegimeStrategyPerformanceEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val strategy: String,
    val regime: String,
    val sampleCount: Int,
    val winRate: Double,
    val profitFactor: Double,
    val expectancy: Double,
    val maxDrawdownPercent: Double,
    val averagePnlRate: Double,
    val averageHoldingMinutes: Double,
    val averageMfe: Double,
    val averageMae: Double,
    val riskAdjustedReturn: Double,
    val updatedAt: Long
)

@Entity(tableName="regime_transitions")
data class RegimeTransitionEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val fromRegime: String,
    val toRegime: String,
    val time: Long,
    val confidence: Double,
    val healthScore: Double,
    val return5m: Double? = null,
    val return15m: Double? = null,
    val return30m: Double? = null,
    val return60m: Double? = null,
    val return180m: Double? = null,
    val evaluatedAt: Long? = null
)

@Entity(tableName="strategy_promotions")
data class StrategyPromotionEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val champion: String,
    val challenger: String,
    val status: String,
    val reason: String,
    val championScore: Double,
    val challengerScore: Double,
    val sampleCount: Int,
    val walkForwardPassed: Boolean,
    val createdAt: Long
)

@Entity(tableName="confidence_calibration")
data class ConfidenceCalibrationEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val regime: String,
    val confidenceBand: String,
    val predictedConfidence: Double,
    val correct: Boolean,
    val createdAt: Long
)

@Entity(tableName="strategy_model_versions") data class StrategyModelVersionEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val generation: Int,
    val source: String,
    val createdAt: Long,
    val trainingSamples: Int,
    val validationSamples: Int,
    val validationAccuracy: Double,
    val baselineAccuracy: Double,
    val adopted: Boolean,
    val reason: String,
    val modelJson: String
)

@Entity(tableName="opportunity_decisions")
data class OpportunityDecisionEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val time: Long,
    val heldMarket: String,
    val candidateMarket: String?,
    val keepScore: Double,
    val rotateScore: Double,
    val doNothingScore: Double,
    val action: String,
    val reason: String,
    val strategyVersion: Long
)

@Entity(tableName="post_entry_trackers")
data class PostEntryTrackerEntity(
    @PrimaryKey val buyTradeId: String,
    val market: String,
    val entryTime: Long,
    val entryPrice: Double,
    val strategyScore: Double,
    val marketHealthAtEntry: Double,
    val regimeAtEntry: String,
    val strategyVersion: Long,
    val volumeChangePercent: Double
)

@Entity(
    tableName = "post_entry_snapshots",
    primaryKeys = ["buyTradeId", "horizonMinutes"]
)
data class PostEntrySnapshotEntity(
    val buyTradeId: String,
    val market: String,
    val capturedAt: Long,
    val horizonMinutes: Int,
    val entryPrice: Double,
    val currentPrice: Double,
    val changePercent: Double,
    val mfePercent: Double,
    val maePercent: Double,
    val strategyScore: Double,
    val marketHealth: Double,
    val volumeChangePercent: Double,
    val marketRegime: String,
    val strategyVersion: Long
)

@Entity(tableName="shadow_portfolios")
data class ShadowPortfolioEntity(
    @PrimaryKey val strategy: String,
    val initialValue: Double,
    val cash: Double,
    val equity: Double,
    val positionsJson: String,
    val tradeCount: Int = 0,
    val winCount: Int = 0,
    val lossCount: Int = 0,
    val grossProfit: Double = 0.0,
    val grossLoss: Double = 0.0,
    val realizedPnl: Double = 0.0,
    val peakEquity: Double = initialValue,
    val maxDrawdownPercent: Double = 0.0,
    val consecutiveLosses: Int = 0,
    val totalHoldingMinutes: Long = 0L,
    val updatedAt: Long = 0L
) {
    val returnPercent: Double
        get() = if (initialValue > 0.0) (equity / initialValue - 1.0) * 100.0 else 0.0
    val winRate: Double
        get() = if (tradeCount > 0) winCount.toDouble() / tradeCount else 0.0
    val profitFactor: Double
        get() = if (grossLoss > 0.0) grossProfit / grossLoss else if (grossProfit > 0.0) Double.POSITIVE_INFINITY else 0.0
    val expectedReturnPercent: Double
        get() = if (tradeCount > 0) realizedPnl / tradeCount else 0.0
}

@Entity(tableName="shadow_trades")
data class ShadowTradeEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val strategy: String,
    val time: Long,
    val market: String,
    val side: String,
    val amount: Double,
    val quantity: Double,
    val price: Double,
    val pnlRate: Double,
    val holdingMinutes: Long,
    val reason: String,
    val regime: String = "UNKNOWN"
)

@Entity(tableName="missed_opportunities")
data class MissedOpportunityEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val capturedAt: Long,
    val market: String,
    val reason: String,
    val strategyScore: Double,
    val aiScore: Double,
    val entryPrice: Double,
    val marketHealth: Double,
    val marketRegime: String,
    val strategyVersion: Long,
    val status: String = "PENDING",
    val maxReturnPercent: Double = 0.0,
    val minReturnPercent: Double = 0.0,
    val resolvedAt: Long? = null
)

@Entity(
    tableName = "missed_opportunity_snapshots",
    primaryKeys = ["opportunityId", "horizonMinutes"]
)
data class MissedOpportunitySnapshotEntity(
    val opportunityId: String,
    val market: String,
    val capturedAt: Long,
    val horizonMinutes: Int,
    val entryPrice: Double,
    val currentPrice: Double,
    val changePercent: Double,
    val strategyScore: Double,
    val marketHealth: Double,
    val marketRegime: String
)


data class RegimeTransitionView(val from: String, val to: String, val time: Long, val confidence: Double)


@Entity(tableName="news_events")
data class NewsEventEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val source: String,
    val sourceTier: String,
    val publishedAt: Long,
    val receivedAt: Long,
    val url: String,
    val title: String,
    val summary: String,
    val fingerprint: String,
    val symbols: String,
    val eventType: String,
    val sentiment: Double,
    val confidence: Double,
    val expectedImpact: Double,
    val urgency: Double,
    val scope: String,
    val horizon: String,
    val status: String,
    val impactScore: Double
)

@Entity(tableName="news_reactions", primaryKeys=["eventId", "horizonMinutes"])
data class NewsReactionEntity(
    val eventId: String,
    val market: String,
    val capturedAt: Long,
    val horizonMinutes: Int,
    val referencePrice: Double,
    val currentPrice: Double,
    val changePercent: Double,
    val volumeChangePercent: Double,
    val spreadPercent: Double,
    val volatilityPercent: Double,
    val marketRegime: String,
    val marketHealth: Double,
    val mfePercent: Double,
    val maePercent: Double,
    val predictionCorrect: Boolean
)

@Entity(tableName="news_predictions")
data class NewsPredictionEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val eventId: String,
    val market: String,
    val predictedDirection: Double,
    val expectedImpact: Double,
    val actualReturnPercent: Double? = null,
    val predictionError: Double? = null,
    val resolvedAt: Long? = null
)

@Entity(tableName="news_model_registry")
data class NewsModelRegistryEntity(
    @PrimaryKey val modelVersion: String,
    val status: String,
    val createdAt: Long,
    val trainingSampleCount: Int,
    val trainingWindow: String,
    val validationWindow: String,
    val featureNames: String,
    val performanceJson: String,
    val mdd: Double,
    val profitFactor: Double,
    val expectancy: Double
)

@Entity(tableName="news_drift_events")
data class NewsDriftEventEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val createdAt: Long,
    val metric: String,
    val recentValue: Double,
    val longTermValue: Double,
    val message: String,
    val status: String
)

@Entity(tableName="research_hypotheses")
data class ResearchHypothesisEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val createdAt: Long,
    val hypothesis: String,
    val status: String,
    val evidenceJson: String,
    val result: String = ""
)


@Entity(tableName="market_reentry_guards")
data class MarketReentryGuardEntity(
    @PrimaryKey val market: String,
    val lossStreak: Int,
    val cooldownUntil: Long,
    val status: String,
    val lastExitReason: String,
    val exitScore: Double,
    val exitTime: Long,
    val signalResetRequired: Boolean,
    val scoreResetObserved: Boolean
)

@Entity(tableName="liquidity_scan_diagnostics")
data class LiquidityScanEntity(
    @PrimaryKey val time: Long,
    val total: Int,
    val pass: Int,
    val rejectLowTradeValue: Int,
    val p10: Double,
    val p25: Double,
    val p50: Double,
    val p75: Double,
    val p90: Double,
    val requiredKrw: Double,
    val percentileThreshold: Double,
    val overblockingWarning: Boolean
)

@Entity(tableName="execution_quality_snapshots")
data class ExecutionQualitySnapshotEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val time: Long,
    val market: String,
    val side: String,
    val decisionPrice: Double,
    val quotedPrice: Double,
    val simulatedAverageFillPrice: Double,
    val feeKrw: Double,
    val spreadCostPercent: Double,
    val slippageCostPercent: Double,
    val marketImpactCostPercent: Double,
    val grossReturnPercent: Double = 0.0,
    val netReturnPercent: Double = 0.0,
    val executionQualityScore: Double
)

@Entity(tableName="net_edge_decisions")
data class NetEdgeDecisionEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val time: Long,
    val market: String,
    val signalScore: Double,
    val grossExpectedEdge: Double,
    val executionCost: Double,
    val netExpectedEdge: Double,
    val confidence: Double,
    val sampleCount: Int,
    val allowed: Boolean,
    val reason: String
)

@Entity(tableName="portfolio_risk_snapshots")
data class PortfolioRiskSnapshotEntity(
    @PrimaryKey val time: Long,
    val heatLevel: String,
    val totalOpenRiskPercent: Double,
    val portfolioExposurePercent: Double,
    val correlatedExposurePercent: Double,
    val clusterName: String,
    val worstCaseLossPercent: Double,
    val tailRiskScore: Double,
    val var95Percent: Double,
    val expectedShortfall95Percent: Double
)

@Entity(tableName="data_quality_events")
data class DataQualityEventEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val time: Long,
    val market: String,
    val score: Double,
    val status: String,
    val reasons: String
)

@Entity(tableName="capital_growth_snapshots")
data class CapitalGrowthSnapshotEntity(
    @PrimaryKey val time: Long,
    val totalEquity: Double,
    val peakEquity: Double,
    val protectedReserve: Double,
    val tradingCapital: Double,
    val capitalState: String,
    val growthConfidence: Double,
    val riskBudgetKrw: Double,
    val positionSizeMultiplier: Double,
    val reinvestmentRatio: Double,
    val drawdownFromPeakPercent: Double,
    val ruinRisk: String,
    val tradingActive: Boolean,
    val reason: String
)

@Entity(tableName="post_exit_trackers")
data class PostExitTrackerEntity(
    @PrimaryKey val sellTradeId: String,
    val market: String,
    val sellTime: Long,
    val entryPrice: Double,
    val exitPrice: Double,
    val exitReason: String,
    val pnlRate: Double,
    val mfePercent: Double,
    val maePercent: Double,
    val entryScore: Double,
    val aiScore: Double,
    val netEdge: Double,
    val regime: String,
    val marketHealth: Double
)

@Entity(tableName="post_exit_snapshots", primaryKeys=["sellTradeId", "horizonMinutes"])
data class PostExitSnapshotEntity(
    val sellTradeId: String,
    val market: String,
    val capturedAt: Long,
    val horizonMinutes: Int,
    val exitPrice: Double,
    val currentPrice: Double,
    val changeFromExitPercent: Double,
    val priceFromEntryPercent: Double
)

@Entity(tableName="loss_root_cause_records")
data class LossRootCauseRecordEntity(
    @PrimaryKey val tradeId: String,
    val market: String,
    val exitTime: Long,
    val rootCause: String,
    val stopQuality: String,
    val trailingQuality: String,
    val mfePercent: Double,
    val maePercent: Double,
    val pnlRate: Double,
    val exitReason: String,
    val postExit30mReturn: Double? = null,
    val postExit60mReturn: Double? = null
)

@Entity(tableName="counterfactual_exit_snapshots")
data class CounterfactualExitSnapshotEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val tradeId: String,
    val strategyType: String,
    val simulatedPnlRate: Double,
    val simulatedExitReason: String,
    val simulatedHoldingMinutes: Long,
    val capturedAt: Long
)




===== END FILE: app/src/main/java/com/example/bithumbtrader/Models.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/NetProfitAfterCost.kt =====
package com.example.bithumbtrader

/**
 * Net Profit After All Costs — extends existing Short Edge / Net Edge cost inputs.
 * Does NOT replace PaperTradingMath fills or ScalpingExecutionEngine; reuses the same
 * fee/spread/slippage percent units (0.25 = 0.25%).
 *
 * Round-trip fee = buy fee + sell fee (both included; historical Short Edge used one-way only).
 * Spread and market impact counted once per round trip (not doubled).
 */
data class RoundTripCostBreakdown(
    val buyFeePercent: Double,
    val sellFeePercent: Double,
    val buySlippagePercent: Double,
    val sellSlippagePercent: Double,
    val spreadPercent: Double,
    val marketImpactPercent: Double,
    val safetyMargin: Double,
    val totalCostBeforeMarginPercent: Double,
    val expectedRoundTripCostPercent: Double
) {
    val buyFeeIncluded: Boolean get() = buyFeePercent > 0.0
    val sellFeeIncluded: Boolean get() = sellFeePercent > 0.0
}

data class NetProfitAfterCostDecision(
    val allowed: Boolean,
    val reasonCode: String,
    val reason: String,
    val expectedGrossProfitKrw: Double,
    val expectedGrossMovePercent: Double,
    val expectedRoundTripCostKrw: Double,
    val expectedRoundTripCostPercent: Double,
    val expectedNetProfitKrw: Double,
    val expectedNetProfitPercent: Double,
    val costToGrossProfitRatio: Double,
    val costCoverageMultiple: Double,
    val breakEvenPrice: Double,
    val requiredNetProfitKrw: Double,
    val cost: RoundTripCostBreakdown
)

data class TradingCostLedgerSnapshot(
    val tradeCount: Int,
    val grossPnlKrw: Double,
    val totalFeesKrw: Double,
    val netPnlKrw: Double,
    val tradingCostSharePercent: Double,
    val feeDragPercent: Double,
    val grossWinNetLossCount: Int,
    val status: String,
    val overtradingCostDrag: Boolean,
    val warning: String
)

object NetProfitAfterCostEngine {
    /** Same unit as ScalpingExecutionEngine.FEE_PERCENT / NetEdgeGateEngine feePercent. */
    const val ONE_WAY_FEE_PERCENT = 0.25
    const val DEFAULT_ONE_WAY_SLIP_PERCENT = 0.10
    const val ROUND_TRIP_FEE_PERCENT = ONE_WAY_FEE_PERCENT * 2.0

    fun roundTripCost(
        oneWayFeePercent: Double = ONE_WAY_FEE_PERCENT,
        spreadPercent: Double,
        oneWaySlippagePercent: Double = DEFAULT_ONE_WAY_SLIP_PERCENT,
        marketImpactPercent: Double = 0.0,
        safetyMargin: Double = 1.35,
        includeBuyFee: Boolean = true,
        includeSellFee: Boolean = true
    ): RoundTripCostBreakdown {
        val buyFee = if (includeBuyFee) oneWayFeePercent.coerceAtLeast(0.0) else 0.0
        val sellFee = if (includeSellFee) oneWayFeePercent.coerceAtLeast(0.0) else 0.0
        val buySlip = oneWaySlippagePercent.coerceAtLeast(0.0)
        val sellSlip = oneWaySlippagePercent.coerceAtLeast(0.0)
        val spread = spreadPercent.coerceAtLeast(0.0)
        val impact = marketImpactPercent.coerceAtLeast(0.0)
        val before = buyFee + sellFee + buySlip + sellSlip + spread + impact
        val margin = safetyMargin.coerceAtLeast(1.0)
        return RoundTripCostBreakdown(
            buyFeePercent = buyFee,
            sellFeePercent = sellFee,
            buySlippagePercent = buySlip,
            sellSlippagePercent = sellSlip,
            spreadPercent = spread,
            marketImpactPercent = impact,
            safetyMargin = margin,
            totalCostBeforeMarginPercent = before,
            expectedRoundTripCostPercent = before * margin
        )
    }

    fun requiredNetProfitKrw(
        plannedOrderKrw: Double,
        absoluteMinimumNetProfitKrw: Double,
        minimumNetProfitPercentOfOrder: Double
    ): Double {
        val pctFloor = plannedOrderKrw.coerceAtLeast(0.0) * minimumNetProfitPercentOfOrder.coerceAtLeast(0.0) / 100.0
        return maxOf(absoluteMinimumNetProfitKrw.coerceAtLeast(0.0), pctFloor)
    }

    fun evaluate(
        entryPrice: Double,
        plannedCapitalKrw: Double,
        expectedGrossMovePercent: Double,
        spreadPercent: Double,
        oneWaySlippagePercent: Double = DEFAULT_ONE_WAY_SLIP_PERCENT,
        marketImpactPercent: Double = 0.0,
        safetyMargin: Double = 1.35,
        oneWayFeePercent: Double = ONE_WAY_FEE_PERCENT,
        absoluteMinimumNetProfitKrw: Double = 30.0,
        minimumNetProfitPercentOfOrder: Double = 0.05,
        minimumCostCoverageMultiple: Double = 1.5
    ): NetProfitAfterCostDecision {
        val capital = plannedCapitalKrw.takeIf { it.isFinite() && it > 0.0 } ?: 0.0
        val grossMove = expectedGrossMovePercent.takeIf { it.isFinite() } ?: 0.0
        val cost = roundTripCost(
            oneWayFeePercent = oneWayFeePercent,
            spreadPercent = spreadPercent,
            oneWaySlippagePercent = oneWaySlippagePercent,
            marketImpactPercent = marketImpactPercent,
            safetyMargin = safetyMargin
        )
        val grossKrw = capital * grossMove / 100.0
        val costKrw = capital * cost.expectedRoundTripCostPercent / 100.0
        val netKrw = grossKrw - costKrw
        val netPct = if (capital > 0.0) netKrw / capital * 100.0 else 0.0
        val ratio = if (grossKrw > 0.0) costKrw / grossKrw else if (costKrw > 0.0) Double.POSITIVE_INFINITY else 0.0
        val coverage = when {
            costKrw <= 0.0 && grossKrw > 0.0 -> Double.POSITIVE_INFINITY
            costKrw <= 0.0 -> 0.0
            else -> grossKrw / costKrw
        }
        val breakEven = if (entryPrice.isFinite() && entryPrice > 0.0) {
            entryPrice * (1.0 + cost.expectedRoundTripCostPercent / 100.0)
        } else 0.0
        val required = requiredNetProfitKrw(capital, absoluteMinimumNetProfitKrw, minimumNetProfitPercentOfOrder)
        val minCoverage = minimumCostCoverageMultiple.coerceAtLeast(1.0)

        val (allowed, code, reason) = when {
            !grossMove.isFinite() || capital <= 0.0 || entryPrice <= 0.0 ->
                Triple(false, "NET_PROFIT_DATA_INSUFFICIENT", "NET_PROFIT_DATA_INSUFFICIENT (진입가/자본/기대수익 부족)")
            netKrw <= 0.0 ->
                Triple(
                    false,
                    "NET_PROFIT_TOO_SMALL",
                    "NET_PROFIT_TOO_SMALL (예상 순이익 ${"%.0f".format(netKrw)}원 ≤ 0 · Gross ${"%.0f".format(grossKrw)} · Cost ${"%.0f".format(costKrw)})"
                )
            coverage < minCoverage ->
                Triple(
                    false,
                    "COST_COVERAGE_TOO_LOW",
                    "COST_COVERAGE_TOO_LOW (coverage ${"%.2f".format(coverage)}x < ${"%.2f".format(minCoverage)}x)"
                )
            grossKrw > 0.0 && ratio > 0.67 ->
                Triple(
                    false,
                    "COST_TO_PROFIT_TOO_HIGH",
                    "COST_TO_PROFIT_TOO_HIGH (비용비중 ${"%.1f".format(ratio * 100.0)}% > 67%)"
                )
            netKrw < required ->
                Triple(
                    false,
                    "NET_PROFIT_TOO_SMALL",
                    "NET_PROFIT_TOO_SMALL (순이익 ${"%.0f".format(netKrw)}원 < 최소 ${"%.0f".format(required)}원)"
                )
            else ->
                Triple(
                    true,
                    "NET_PROFIT_PASS",
                    "NET_PROFIT_PASS (순이익 ${"%.0f".format(netKrw)}원 · coverage ${"%.2f".format(coverage)}x · 비용비중 ${"%.1f".format(ratio * 100.0)}%)"
                )
        }

        return NetProfitAfterCostDecision(
            allowed = allowed,
            reasonCode = code,
            reason = reason,
            expectedGrossProfitKrw = grossKrw,
            expectedGrossMovePercent = grossMove,
            expectedRoundTripCostKrw = costKrw,
            expectedRoundTripCostPercent = cost.expectedRoundTripCostPercent,
            expectedNetProfitKrw = netKrw,
            expectedNetProfitPercent = netPct,
            costToGrossProfitRatio = if (ratio.isFinite()) ratio else 99.0,
            costCoverageMultiple = if (coverage.isFinite()) coverage else 0.0,
            breakEvenPrice = breakEven,
            requiredNetProfitKrw = required,
            cost = cost
        )
    }

    /**
     * Reconstruct approx gross from completed SELL trades (realized net + fees on legs).
     * Uses TradeEntity.fee (per fill) — does not invent costs.
     */
    fun ledgerFromTrades(
        trades: List<TradeEntity>,
        windowMs: Long = Long.MAX_VALUE,
        nowMs: Long = System.currentTimeMillis()
    ): TradingCostLedgerSnapshot {
        val recent = trades.filter { windowMs == Long.MAX_VALUE || nowMs - it.time <= windowMs }
        val sells = recent.filter { it.side.equals("SELL", ignoreCase = true) }
        val totalFees = recent.sumOf { it.fee.takeIf { f -> f.isFinite() } ?: 0.0 }
        val netPnl = sells.sumOf { it.realizedPnl.takeIf { p -> p.isFinite() } ?: 0.0 }
        // Approx gross = net + all fees in window (buy+sell legs).
        val grossPnl = netPnl + totalFees
        val grossWinNetLoss = sells.count { sell ->
            val approxGross = sell.realizedPnl + sell.fee
            approxGross > 0.0 && sell.realizedPnl <= 0.0
        }
        val costShare = if (grossPnl > 0.0) totalFees / grossPnl * 100.0 else if (totalFees > 0.0) 100.0 else 0.0
        val feeDrag = if (grossPnl > 0.0) totalFees / grossPnl * 100.0 else 0.0
        val overtrading = sells.isNotEmpty() && grossPnl > 0.0 && netPnl <= 0.0
        val status = when {
            recent.isEmpty() || sells.isEmpty() -> "INSUFFICIENT_DATA"
            overtrading -> "OVERTRADING"
            feeDrag >= 40.0 && netPnl > 0.0 -> "FEE_DRAGGED"
            netPnl > 0.0 -> "PROFITABLE_AFTER_COST"
            netPnl < 0.0 && grossPnl > 0.0 -> "FEE_DRAGGED"
            netPnl < 0.0 -> "FEE_DRAGGED"
            else -> "INSUFFICIENT_DATA"
        }
        val warning = when {
            overtrading -> "OVERTRADING_COST_DRAG"
            feeDrag >= 40.0 -> "HIGH_FEE_DRAG"
            else -> "NORMAL"
        }
        return TradingCostLedgerSnapshot(
            tradeCount = sells.size,
            grossPnlKrw = grossPnl,
            totalFeesKrw = totalFees,
            netPnlKrw = netPnl,
            tradingCostSharePercent = costShare,
            feeDragPercent = feeDrag,
            grossWinNetLossCount = grossWinNetLoss,
            status = status,
            overtradingCostDrag = overtrading,
            warning = warning
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/NetProfitAfterCost.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/NewsIntelligence.kt =====
package com.example.bithumbtrader

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Locale
import kotlin.math.abs

interface NewsDataProvider {
    suspend fun fetch(since: Long): List<NewsRawItem>
}

data class NewsRawItem(
    val source: String,
    val sourceTier: NewsSourceTier,
    val publishedAt: Long,
    val url: String,
    val title: String,
    val summary: String = ""
)

enum class NewsSourceTier { TIER_A, TIER_B, TIER_C, TIER_D }
enum class NewsEventStatus { NEW, CONFIRMED, DUPLICATE, STALE, UNVERIFIED }
enum class NewsEventType {
    LISTING, DELISTING, SECURITY_INCIDENT, HACK, REGULATION_POSITIVE, REGULATION_NEGATIVE,
    MACRO_POSITIVE, MACRO_NEGATIVE, NETWORK_UPGRADE, NETWORK_FAILURE, TOKEN_UNLOCK,
    SUPPLY_CHANGE, PARTNERSHIP, LEGAL, EXCHANGE_NOTICE, ETF, LIQUIDITY_EVENT,
    STABLECOIN_RISK, RUMOR, OTHER
}

enum class NewsScope { COIN, SECTOR, MARKET }
enum class NewsHorizon { MINUTES, HOURS, DAYS }
enum class NewsEngineStatus { ONLINE, DEGRADED, OFFLINE }
enum class NewsRiskLevel { LOW, MEDIUM, HIGH, CRITICAL }

data class NewsEventModel(
    val id: String,
    val source: String,
    val sourceTier: NewsSourceTier,
    val publishedAt: Long,
    val receivedAt: Long,
    val url: String,
    val title: String,
    val summary: String,
    val fingerprint: String,
    val symbols: List<String>,
    val eventType: NewsEventType,
    val sentiment: Double,
    val confidence: Double,
    val expectedImpact: Double,
    val urgency: Double,
    val scope: NewsScope,
    val horizon: NewsHorizon,
    val status: NewsEventStatus,
    val impactScore: Double
)

data class NewsReactionStats(
    val sampleCount: Int = 0,
    val averageReturnByHorizon: Map<Int, Double> = emptyMap(),
    val averageMfe: Double = 0.0,
    val averageMae: Double = 0.0,
    val predictionAccuracy: Double = 0.0
)

data class NewsLearningStats(
    val eventTypeAccuracy: Map<String, Double> = emptyMap(),
    val sourceAccuracy: Map<String, Double> = emptyMap(),
    val coinAccuracy: Map<String, Double> = emptyMap(),
    val horizonAccuracy: Map<String, Double> = emptyMap()
)

data class NewsRiskState(
    val level: NewsRiskLevel = NewsRiskLevel.LOW,
    val newEntryBlocked: Boolean = false,
    val message: String = "뉴스 위험 없음",
    val criticalEventCount: Int = 0
)

data class NewsDashboardItem(val title: String, val source: String, val symbols: String, val eventType: String, val impactScore: Double, val confidence: Double, val status: String, val receivedAt: Long)
data class NewsResearchState(
    val engineStatus: NewsEngineStatus = NewsEngineStatus.OFFLINE,
    val lastCheckAt: Long = 0L,
    val lastMessage: String = "뉴스 수집 대기",
    val recentEvents: List<NewsDashboardItem> = emptyList(),
    val risk: NewsRiskState = NewsRiskState(),
    val reactionStats: NewsReactionStats = NewsReactionStats(),
    val learningStats: NewsLearningStats = NewsLearningStats(),
    val modelVersion: String = "AI_NEWS_000",
    val modelStatus: String = "COLLECT",
    val driftWarning: Boolean = false,
    val driftMessage: String = "정상",
    val hypothesis: String = ""
)

object NewsFingerprint {
    fun create(item: NewsRawItem, eventType: NewsEventType, symbols: List<String>): String {
        val normalized = normalize(item.title)
        val hour = item.publishedAt / 3_600_000L
        return "$normalized|${eventType.name}|${symbols.sorted().joinToString(",")}|$hour"
    }

    fun normalize(text: String): String = text.lowercase(Locale.KOREA)
        .replace(Regex("https?://\\S+"), "")
        .replace(Regex("[^\\p{L}\\p{N} ]"), " ")
        .replace(Regex("\\s+"), " ")
        .trim()
}

object NewsDeduplicator {
    fun isDuplicate(candidate: NewsEventModel, existing: List<NewsEventEntity>): Boolean = existing.any {
        it.fingerprint == candidate.fingerprint ||
            (it.eventType == candidate.eventType.name && abs(it.publishedAt - candidate.publishedAt) <= 3_600_000L && similarity(it.title, candidate.title) >= 0.8)
    }

    private fun similarity(left: String, right: String): Double {
        val a = NewsFingerprint.normalize(left).split(" ").filter { it.length > 1 }.toSet()
        val b = NewsFingerprint.normalize(right).split(" ").filter { it.length > 1 }.toSet()
        if (a.isEmpty() || b.isEmpty()) return 0.0
        return a.intersect(b).size.toDouble() / a.union(b).size
    }
}

object NewsCoinMappingEngine {
    private val aliases = mapOf(
        "bitcoin" to "KRW-BTC", "btc" to "KRW-BTC", "비트코인" to "KRW-BTC",
        "ethereum" to "KRW-ETH", "eth" to "KRW-ETH", "이더리움" to "KRW-ETH",
        "xrp" to "KRW-XRP", "ripple" to "KRW-XRP", "리플" to "KRW-XRP",
        "solana" to "KRW-SOL", "sol" to "KRW-SOL", "솔라나" to "KRW-SOL",
        "dogecoin" to "KRW-DOGE", "doge" to "KRW-DOGE", "도지코인" to "KRW-DOGE",
        "cardano" to "KRW-ADA", "ada" to "KRW-ADA", "에이다" to "KRW-ADA",
        "polkadot" to "KRW-DOT", "dot" to "KRW-DOT", "폴카닷" to "KRW-DOT",
        "avalanche" to "KRW-AVAX", "avax" to "KRW-AVAX", "아발란체" to "KRW-AVAX",
        "chainlink" to "KRW-LINK", "link" to "KRW-LINK", "체인링크" to "KRW-LINK"
    )
    private val marketWideWords = listOf("market", "crypto market", "bitcoin etf", "fomc", "cpi", "금리", "규제", "가상자산 시장", "시장 전체")

    fun map(title: String, summary: String, availableMarkets: Set<String>): Pair<List<String>, NewsScope> {
        val text = "$title $summary".lowercase(Locale.KOREA)
        val symbols = aliases.filterKeys { text.contains(it) }.values.distinct().filter { availableMarkets.isEmpty() || it in availableMarkets }
        return if (symbols.isEmpty() && marketWideWords.any(text::contains)) availableMarkets.toList() to NewsScope.MARKET
        else symbols to if (symbols.size > 1) NewsScope.SECTOR else NewsScope.COIN
    }
}

object NewsEventClassifier {
    fun classify(title: String, summary: String): NewsEventType {
        val text = "$title $summary".lowercase(Locale.KOREA)
        return when {
            listOf("hack", "hacked", "exploit", "해킹", "익스플로잇").any(text::contains) -> NewsEventType.HACK
            listOf("security incident", "보안 사고", "취약점").any(text::contains) -> NewsEventType.SECURITY_INCIDENT
            listOf("delist", "delisting", "상장폐지", "거래지원 종료").any(text::contains) -> NewsEventType.DELISTING
            listOf("listing", "listed", "상장").any(text::contains) -> NewsEventType.LISTING
            listOf("halt", "suspend", "입출금 정지", "거래 중단").any(text::contains) -> NewsEventType.EXCHANGE_NOTICE
            listOf("etf").any(text::contains) -> NewsEventType.ETF
            listOf("unlock", "token unlock", "언락").any(text::contains) -> NewsEventType.TOKEN_UNLOCK
            listOf("mainnet", "upgrade", "업그레이드", "메인넷").any(text::contains) -> NewsEventType.NETWORK_UPGRADE
            listOf("outage", "network failure", "장애", "네트워크 중단").any(text::contains) -> NewsEventType.NETWORK_FAILURE
            listOf("fomc", "cpi", "interest rate", "금리", "인플레이션").any(text::contains) -> NewsEventType.MACRO_NEGATIVE
            listOf("regulation", "법안", "규제", "승인").any(text::contains) -> if (listOf("ban", "금지", "제재").any(text::contains)) NewsEventType.REGULATION_NEGATIVE else NewsEventType.REGULATION_POSITIVE
            listOf("partnership", "협력", "파트너십").any(text::contains) -> NewsEventType.PARTNERSHIP
            listOf("rumor", "rumour", "소문", "미확인").any(text::contains) -> NewsEventType.RUMOR
            else -> NewsEventType.OTHER
        }
    }

    fun analyze(type: NewsEventType, title: String, summary: String, tier: NewsSourceTier): Triple<Double, Double, Double> {
        val text = "$title $summary".lowercase(Locale.KOREA)
        val structuralSentiment = when (type) {
            NewsEventType.HACK, NewsEventType.SECURITY_INCIDENT, NewsEventType.DELISTING, NewsEventType.NETWORK_FAILURE, NewsEventType.REGULATION_NEGATIVE, NewsEventType.MACRO_NEGATIVE, NewsEventType.STABLECOIN_RISK -> -0.9
            NewsEventType.LISTING, NewsEventType.PARTNERSHIP, NewsEventType.NETWORK_UPGRADE, NewsEventType.REGULATION_POSITIVE, NewsEventType.MACRO_POSITIVE, NewsEventType.ETF -> 0.7
            NewsEventType.TOKEN_UNLOCK, NewsEventType.SUPPLY_CHANGE -> -0.35
            NewsEventType.RUMOR -> 0.0
            else -> if (listOf("rise", "surge", "positive", "상승", "호재").any(text::contains)) 0.35 else if (listOf("fall", "drop", "negative", "하락", "악재").any(text::contains)) -0.35 else 0.0
        }
        val confidence = when (tier) { NewsSourceTier.TIER_A -> 0.95; NewsSourceTier.TIER_B -> 0.8; NewsSourceTier.TIER_C -> 0.6; NewsSourceTier.TIER_D -> 0.25 }
        val impact = when (type) {
            NewsEventType.HACK, NewsEventType.SECURITY_INCIDENT, NewsEventType.DELISTING, NewsEventType.STABLECOIN_RISK -> 95.0
            NewsEventType.EXCHANGE_NOTICE, NewsEventType.NETWORK_FAILURE, NewsEventType.REGULATION_NEGATIVE -> 85.0
            NewsEventType.ETF, NewsEventType.MACRO_NEGATIVE, NewsEventType.MACRO_POSITIVE -> 75.0
            else -> 50.0
        }
        return Triple(structuralSentiment, confidence * 100.0, impact)
    }
}

object NewsImpactEngine {
    fun score(tier: NewsSourceTier, sentiment: Double, confidence: Double, expectedImpact: Double, urgency: Double, publishedAt: Long, now: Long): Double {
        val trust = when (tier) { NewsSourceTier.TIER_A -> 1.0; NewsSourceTier.TIER_B -> 0.8; NewsSourceTier.TIER_C -> 0.55; NewsSourceTier.TIER_D -> 0.2 }
        val freshness = (1.0 - ((now - publishedAt).coerceAtLeast(0L) / 86_400_000.0)).coerceIn(0.0, 1.0)
        return ((abs(sentiment) * 25.0 + confidence * 0.25 + expectedImpact * 0.25 + urgency * 0.15) * trust * (0.5 + freshness * 0.5)).coerceIn(0.0, 100.0)
    }
}

object NewsRiskEngine {
    private val criticalTypes = setOf(NewsEventType.HACK, NewsEventType.SECURITY_INCIDENT, NewsEventType.DELISTING, NewsEventType.STABLECOIN_RISK, NewsEventType.NETWORK_FAILURE)
    fun evaluate(events: List<NewsEventModel>): NewsRiskState {
        val critical = events.count { it.eventType in criticalTypes && it.status == NewsEventStatus.CONFIRMED && it.impactScore >= 70.0 }
        val high = events.count { it.impactScore >= 70.0 && it.sentiment < 0.0 && it.status != NewsEventStatus.DUPLICATE }
        return when {
            critical > 0 -> NewsRiskState(NewsRiskLevel.CRITICAL, true, "확인된 중대 뉴스 ${critical}건 — 신규 진입 차단", critical)
            high >= 2 -> NewsRiskState(NewsRiskLevel.HIGH, true, "고위험 악재 ${high}건 — 신규 진입 보류 추천", 0)
            high == 1 -> NewsRiskState(NewsRiskLevel.MEDIUM, false, "고위험 뉴스 1건 — 포지션 점검 추천", 0)
            else -> NewsRiskState()
        }
    }
}

object NewsReactionEngine {
    fun stats(reactions: List<NewsReactionEntity>): NewsReactionStats {
        if (reactions.isEmpty()) return NewsReactionStats()
        val grouped = reactions.groupBy { it.horizonMinutes }
        return NewsReactionStats(
            sampleCount = reactions.map { it.eventId }.distinct().size,
            averageReturnByHorizon = grouped.mapValues { (_, rows) -> rows.map { it.changePercent }.average() },
            averageMfe = reactions.map { it.mfePercent }.average(),
            averageMae = reactions.map { it.maePercent }.average(),
            predictionAccuracy = reactions.count { it.predictionCorrect }.toDouble() / reactions.size
        )
    }

    fun learningStats(events: List<NewsEventEntity>, reactions: List<NewsReactionEntity>): NewsLearningStats {
        fun accuracy(rows: List<NewsReactionEntity>) = if (rows.isEmpty()) 0.0 else rows.count { it.predictionCorrect }.toDouble() / rows.size
        val eventMap = events.associateBy { it.id }
        val joined = reactions.mapNotNull { reaction -> eventMap[reaction.eventId]?.let { it to reaction } }
        return NewsLearningStats(
            eventTypeAccuracy = joined.groupBy { it.first.eventType }.mapValues { (_, rows) -> accuracy(rows.map { it.second }) },
            sourceAccuracy = joined.groupBy { it.first.source }.mapValues { (_, rows) -> accuracy(rows.map { it.second }) },
            coinAccuracy = joined.flatMap { (event, reaction) -> event.symbols.split(",").filter { it.isNotBlank() }.map { it to reaction } }.groupBy { it.first }.mapValues { (_, rows) -> accuracy(rows.map { it.second }) },
            horizonAccuracy = reactions.groupBy { it.horizonMinutes.toString() }.mapValues { (_, rows) -> accuracy(rows) }
        )
    }
}

object ConceptDriftDetector {
    fun detect(recent: List<Double>, longTerm: List<Double>, minSamples: Int = 20): Pair<Boolean, String> {
        if (recent.size < minSamples || longTerm.size < minSamples) return false to "표본 부족"
        val recentAverage = recent.average()
        val longAverage = longTerm.average()
        val recentLoss = recent.count { it < 0 }.toDouble() / recent.size
        val longLoss = longTerm.count { it < 0 }.toDouble() / longTerm.size
        val drift = abs(recentAverage - longAverage) >= 1.0 || abs(recentLoss - longLoss) >= 0.15
        return drift to if (drift) "최근 성과가 장기 분포와 달라짐" else "정상"
    }
}

object ResearchHypothesisEngine {
    fun generate(stats: NewsReactionStats, drift: Pair<Boolean, String>): String? = when {
        stats.sampleCount >= 20 && (stats.averageReturnByHorizon[5] ?: 0.0) < 0.0 -> "뉴스 발생 후 5분 대기 진입이 즉시 진입보다 유리한가?"
        drift.first -> "최근 시장에서 기존 뉴스 영향 패턴이 약화되었는가?"
        stats.sampleCount >= 20 && stats.predictionAccuracy < 0.55 -> "뉴스 Impact 80 이상 신호의 과신을 줄이면 예측력이 개선되는가?"
        else -> null
    }
}

class RssNewsDataProvider(
    private val feeds: List<Pair<String, NewsSourceTier>> = DEFAULT_FEEDS,
    private val client: OkHttpClient = OkHttpClient.Builder().connectTimeout(java.time.Duration.ofSeconds(6)).readTimeout(java.time.Duration.ofSeconds(8)).build()
) : NewsDataProvider {
    override suspend fun fetch(since: Long): List<NewsRawItem> {
        val output = mutableListOf<NewsRawItem>()
        feeds.forEach { (url, tier) ->
            try {
                val request = Request.Builder().url(url).get().build()
                client.newCall(request).execute().use { response ->
                    if (!response.isSuccessful) return@use
                    val xml = response.body?.string().orEmpty()
                    output += NewsRssParser.parse(xml, url, tier).filter { it.publishedAt >= since }
                }
            } catch (e: CancellationException) { throw e } catch (_: Exception) { /* one source may be offline */ }
        }
        return output
    }

    companion object {
        // 공개 RSS만 사용한다. 거래 루프와 분리되고, 출처 신뢰도는 TIER_B/C로 보수적으로 취급한다.
        val DEFAULT_FEEDS = listOf(
            "https://www.coindesk.com/arc/outboundfeeds/rss/" to NewsSourceTier.TIER_B,
            "https://www.theblock.co/rss.xml" to NewsSourceTier.TIER_B
        )
    }
}

object NewsRssParser {
    fun parse(xml: String, sourceUrl: String, tier: NewsSourceTier): List<NewsRawItem> = Regex("<item\\b[^>]*>([\\s\\S]*?)</item>", RegexOption.IGNORE_CASE).findAll(xml).mapNotNull { match ->
        val body = match.groupValues[1]
        val title = tag(body, "title") ?: return@mapNotNull null
        val link = tag(body, "link") ?: sourceUrl
        val description = tag(body, "description").orEmpty()
        val date = tag(body, "pubDate")?.let { parseDate(it) } ?: System.currentTimeMillis()
        NewsRawItem(sourceUrl, tier, date, decode(link), decode(title).trim(), decode(description).replace(Regex("<[^>]+>"), " ").trim())
    }.toList()

    private fun tag(body: String, name: String): String? = Regex("<$name(?:\\s[^>]*)?>([\\s\\S]*?)</$name>", RegexOption.IGNORE_CASE).find(body)?.groupValues?.get(1)
    private fun decode(value: String) = URLDecoder.decode(value.replace("&amp;", "&").replace("&quot;", "\\\""), StandardCharsets.UTF_8.name())
    private fun parseDate(value: String): Long = runCatching { java.time.ZonedDateTime.parse(value, java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() }.getOrDefault(System.currentTimeMillis())
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/NewsIntelligence.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/NoTradeDiagnostics.kt =====
package com.example.bithumbtrader

import kotlin.math.roundToInt

enum class ScanStage {
    IDLE,
    LOADING_MARKETS,
    TICKER,
    POSITION_FAST_LANE,
    FAST_SCAN,
    DEEP_SCAN,
    ENTRY_TIMING,
    SCALPING_AI,
    LIQUIDITY,
    NET_EDGE,
    RISK,
    ORDER,
    POST_PROCESSING,
    COMPLETE
}

enum class EngineHealthClass {
    ENGINE_STALLED,
    ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED,
    ENGINE_RUNNING_WITH_TRADES,
    ENGINE_STOPPED,
    INSUFFICIENT_DATA
}

data class ScanHeartbeatSnapshot(
    val scanSequence: Long = 0L,
    val scanStartedAt: Long = 0L,
    val scanCompletedAt: Long = 0L,
    val scanDurationMs: Long = 0L,
    val lastSuccessfulScanAt: Long = 0L,
    val nextExpectedScanAt: Long = 0L,
    val scanSuccessCount: Long = 0L,
    val scanFailureCount: Long = 0L,
    val consecutiveScanFailures: Int = 0,
    val currentStage: String = ScanStage.IDLE.name,
    val stageStartedAt: Long = 0L,
    val stageElapsedMs: Long = 0L,
    val stallWarning: Boolean = false,
    val stallMessage: String = "",
    val watchingMarkets: Int = 0,
    val fastCandidates: Int = 0,
    val deepCandidates: Int = 0,
    val finalCandidates: Int = 0,
    val buyReady: Int = 0,
    val actualBuysThisScan: Int = 0,
    val sellEvaluationsThisScan: Int = 0,
    val actualSellsThisScan: Int = 0,
    val lastJudgedMarket: String = "-",
    val lastBlockGate: String = "-",
    val lastBlockReason: String = "-"
)

data class GateFunnelSnapshot(
    val time: Long = 0L,
    val totalKrw: Int = 0,
    val fastCandidates: Int = 0,
    val deepCandidates: Int = 0,
    val strategyPass: Int = 0,
    val chasePass: Int = 0,
    val timingPass: Int = 0,
    val scalpPass: Int = 0,
    val shortEdgePass: Int = 0,
    val liquidityPass: Int = 0,
    val heatPass: Int = 0,
    val netEdgePass: Int = 0,
    val riskPass: Int = 0,
    val reentryPass: Int = 0,
    val buyReady: Int = 0,
    val actualBuys: Int = 0,
    val rejectCounts: Map<String, Int> = emptyMap()
)

data class NoTradeDiagnosisSnapshot(
    val healthClass: String = EngineHealthClass.INSUFFICIENT_DATA.name,
    val systemFailure: Boolean = false,
    val allCandidatesBlocked: Boolean = false,
    val systemStalled: Boolean = false,
    val summary: String = "NO_RUNTIME_DATA",
    val topBlockers: List<Pair<String, Double>> = emptyList(),
    val scansLastHour: Int = 0,
    val marketsAnalyzedLastHour: Int = 0,
    val buyReadyLastHour: Int = 0,
    val actualBuysLastHour: Int = 0,
    val rejectCountsLastHour: Map<String, Int> = emptyMap(),
    val scalpAvoidRate: Double = 0.0,
    val shortEdgePassRate: Double = 0.0,
    val liquidityPassRate: Double = 0.0,
    val netEdgePassRate: Double = 0.0,
    val strategyPassRate: Double = 0.0,
    val aiPassRate: Double = 0.0,
    val entryTimingPassRate: Double = 0.0,
    val riskPassRate: Double = 0.0,
    val shortEdgeDistribution: String = "NO_DATA",
    val strategyScoreDistribution: String = "NO_DATA",
    val aiScoreDistribution: String = "NO_DATA"
)

/**
 * 무거래 진단 헬퍼. 임계값을 바꾸지 않고 Scan/Gate 관측만 한다.
 */
object NoTradeDiagnostics {
    const val SCAN_INTERVAL_MS = 30_000L
    const val STALL_THRESHOLD_MS = 90_000L
    private const val HOUR_MS = 60 * 60_000L

    fun classifyRejectGate(failureReason: String, status: CandidateStatus = CandidateStatus.REJECTED): String {
        val r = failureReason.uppercase()
        return when {
            r.contains("DATA_QUALITY") || r.contains("데이터 품질") -> "DATA_QUALITY"
            r.contains("급락") || r.contains("CRASH") || r.contains("HEALTH") && r.contains("차단") -> "MARKET_HEALTH"
            r.contains("점수 부족") && !r.contains("AI") -> "STRATEGY_SCORE"
            r.contains("AI SCORE") || r.contains("AI 점수") -> "AI"
            r.contains("CHASE") || r.contains("EXTREME_CHASE") || r.contains("추격") -> "CHASE"
            r.contains("ENTRY_TIMING") || r.contains("HIGH_STRATEGY_SCORE_BAD_ENTRY") ||
                r.contains("WAIT_PULLBACK") && status == CandidateStatus.WAIT_PULLBACK && r.contains("TIMING") -> "ENTRY_TIMING"
            r.contains("SCALP_AVOID") || (r.contains("SCALP_") && r.contains("AVOID")) -> "SCALP_AVOID"
            r.contains("SCALP_EDGE") || r.contains("SCALP_NO_EDGE") || r.contains("SCALP_EDGE_TOO_SMALL") ||
                r.contains("NO_EDGE") -> "SCALP_EDGE_TOO_SMALL"
            r.contains("SCALP_WAIT_PULLBACK") || status == CandidateStatus.WAIT_PULLBACK && r.contains("SCALP") -> "WAIT_PULLBACK"
            r.contains("SCALP_WAIT_RETEST") || status == CandidateStatus.WAIT_RETEST -> "WAIT_RETEST"
            r.contains("SCALP_WAIT_REACCELERATION") || status == CandidateStatus.WAIT_MOMENTUM -> "WAIT_REACCELERATION"
            r.contains("SCALP_") -> "SCALP_OTHER"
            r.contains("LIQUIDITY") || r.contains("거래대금") || r.contains("LOW_24H") -> "LIQUIDITY"
            r.contains("HEAT") || r.contains("PORTFOLIO") -> "PORTFOLIO_HEAT"
            r.contains("NET_EDGE") || r.contains("순기대") || r.contains("REJECTED_NET_EDGE") -> "NET_EDGE"
            r.contains("수익 보호") || r.contains("PROFIT_PROTECTION") || r.contains("PROFIT_LOCKED") -> "PROFIT_PROTECTION"
            r.contains("PAPER_SHADOW") || r.contains("PAPER_DEFENSE") || r.contains("PAPER_RISK") -> "PAPER_RISK"
            r.contains("COOLDOWN") && r.contains("손절") || r.contains("LOSS") && r.contains("COOLDOWN") -> "LOSS_COOLDOWN"
            r.contains("PROFIT_REENTRY") || r.contains("REENTRY") && r.contains("PROFIT") -> "PROFIT_REENTRY"
            r.contains("이미 보유") || r.contains("주문 진행") || r.contains("DUPLICATE") -> "DUPLICATE"
            r.contains("SIGNAL") && r.contains("EXPIR") || r.contains("TTL") || r.contains("만료") -> "SIGNAL_EXPIRED"
            r.contains("PRICE_MOVED_AWAY") -> "PRICE_MOVED_AWAY"
            r.contains("ENTRY_WINDOW") -> "ENTRY_WINDOW_CLOSED"
            r.contains("킬 스위치") || r.contains("KILL") -> "RISK"
            r.contains("스프레드") || r.contains("잔액") || r.contains("현금") || r.contains("최대 보유") ||
                r.contains("시세 지연") || r.contains("API") || r.contains("주문금액") -> "RISK"
            r.contains("거래량 부족") -> "VOLUME"
            status.name.startsWith("WAIT_") -> status.name
            failureReason.isBlank() -> "UNKNOWN"
            else -> failureReason.take(48).ifBlank { "UNKNOWN" }
        }
    }

    fun buildFunnel(
        totalKrw: Int,
        fastCandidates: Int,
        deepCandidates: Int,
        signals: List<StrategySignalModel>,
        scoreThreshold: Double,
        aiMinScore: Double,
        chaseRejectScore: Double,
        minEntryTiming: Double,
        minShortEdge: Double,
        actualBuys: Int,
        now: Long = System.currentTimeMillis()
    ): GateFunnelSnapshot {
        val rejectCounts = linkedMapOf<String, Int>()
        fun bump(gate: String) { rejectCounts[gate] = (rejectCounts[gate] ?: 0) + 1 }

        var strategyPass = 0
        var chasePass = 0
        var timingPass = 0
        var scalpPass = 0
        var shortEdgePass = 0
        var liquidityPass = 0
        var heatPass = 0
        var netEdgePass = 0
        var riskPass = 0
        var reentryPass = 0
        var buyReady = 0

        signals.forEach { s ->
            val strategyOk = s.score >= scoreThreshold
            if (strategyOk) strategyPass++ else bump("STRATEGY_SCORE")
            val chaseOk = s.chaseEntryScore < chaseRejectScore
            if (strategyOk && chaseOk) chasePass++ else if (strategyOk) bump("CHASE")
            val timingOk = s.entryTimingScore >= minEntryTiming
            if (strategyOk && chaseOk && timingOk) timingPass++ else if (strategyOk && chaseOk) bump("ENTRY_TIMING")
            val scalpOk = s.scalpEntryAllowed || s.scalpExecutionState == ScalpingExecutionState.ENTER_NOW.name
            if (strategyOk && chaseOk && timingOk && scalpOk) scalpPass++ else if (strategyOk && chaseOk && timingOk) {
                bump(when (s.scalpExecutionState) {
                    ScalpingExecutionState.AVOID.name -> "SCALP_AVOID"
                    ScalpingExecutionState.NO_EDGE.name -> "SCALP_EDGE_TOO_SMALL"
                    ScalpingExecutionState.CHASE_RISK.name -> "CHASE"
                    ScalpingExecutionState.TOO_LATE.name -> "PRICE_MOVED_AWAY"
                    ScalpingExecutionState.WAIT_PULLBACK.name -> "WAIT_PULLBACK"
                    ScalpingExecutionState.WAIT_RETEST.name -> "WAIT_RETEST"
                    ScalpingExecutionState.WAIT_REACCELERATION.name -> "WAIT_REACCELERATION"
                    ScalpingExecutionState.DATA_INSUFFICIENT.name -> "DATA_QUALITY"
                    ScalpingExecutionState.WARMING_UP.name -> "WAIT_REACCELERATION"
                    else -> "SCALP_OTHER"
                })
            }
            val edgeOk = s.shortHorizonNetEdge >= minShortEdge
            if (strategyOk && chaseOk && timingOk && scalpOk && edgeOk) shortEdgePass++ else if (strategyOk && chaseOk && timingOk && scalpOk) bump("SCALP_EDGE_TOO_SMALL")
            if (s.liquidityPassed || s.liquidityReady && s.liquidityPassed) liquidityPass++
            if (!s.liquidityReady || !s.liquidityPassed) {
                if (s.status == CandidateStatus.REJECTED) bump("LIQUIDITY")
            } else {
                // counted above
            }
            if (s.netEdgePassed) netEdgePass++
            if (s.status == CandidateStatus.BUY_READY) {
                buyReady++
                heatPass++
                riskPass++
                reentryPass++
            } else if (s.status == CandidateStatus.REJECTED || s.status.name.startsWith("WAIT_")) {
                bump(classifyRejectGate(s.failureReason, s.status))
            }
            if (s.aiScore >= aiMinScore) { /* observed for rates */ }
        }

        // Prefer explicit status-based reject tallies for TOP blocker (cleaner than double counting)
        val statusRejects = linkedMapOf<String, Int>()
        signals.filter { it.status == CandidateStatus.REJECTED || it.status.name.startsWith("WAIT_") }.forEach { s ->
            val gate = classifyRejectGate(s.failureReason, s.status)
            statusRejects[gate] = (statusRejects[gate] ?: 0) + 1
        }

        return GateFunnelSnapshot(
            time = now,
            totalKrw = totalKrw,
            fastCandidates = fastCandidates,
            deepCandidates = deepCandidates,
            strategyPass = strategyPass,
            chasePass = chasePass,
            timingPass = timingPass,
            scalpPass = scalpPass,
            shortEdgePass = shortEdgePass,
            liquidityPass = signals.count { it.liquidityReady && it.liquidityPassed },
            heatPass = heatPass,
            netEdgePass = signals.count { it.netEdgePassed },
            riskPass = riskPass,
            reentryPass = reentryPass,
            buyReady = buyReady,
            actualBuys = actualBuys,
            rejectCounts = statusRejects.ifEmpty { rejectCounts }
        )
    }

    fun stallWarning(
        engineRunning: Boolean,
        lastSuccessfulScanAt: Long,
        now: Long = System.currentTimeMillis(),
        thresholdMs: Long = STALL_THRESHOLD_MS
    ): Pair<Boolean, String> {
        if (!engineRunning || lastSuccessfulScanAt <= 0L) return false to ""
        val age = now - lastSuccessfulScanAt
        return if (age >= thresholdMs) {
            true to "ENGINE_STALL_WARNING: 마지막 정상 Scan ${(age / 1000)}초 전 (기준 ${thresholdMs / 1000}초)"
        } else false to ""
    }

    fun healthClass(
        engineStatus: EngineStatus,
        stalled: Boolean,
        scansLastHour: Int,
        buyReadyLastHour: Int,
        actualBuysLastHour: Int
    ): EngineHealthClass = when {
        engineStatus == EngineStatus.STOPPED || engineStatus == EngineStatus.STOPPING -> EngineHealthClass.ENGINE_STOPPED
        stalled -> EngineHealthClass.ENGINE_STALLED
        scansLastHour <= 0 && engineStatus == EngineStatus.RUNNING -> EngineHealthClass.INSUFFICIENT_DATA
        actualBuysLastHour > 0 -> EngineHealthClass.ENGINE_RUNNING_WITH_TRADES
        scansLastHour > 0 && buyReadyLastHour == 0 -> EngineHealthClass.ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED
        scansLastHour > 0 -> EngineHealthClass.ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED
        else -> EngineHealthClass.INSUFFICIENT_DATA
    }

    fun topBlockers(rejectCounts: Map<String, Int>, limit: Int = 5): List<Pair<String, Double>> {
        val total = rejectCounts.values.sum().coerceAtLeast(1)
        return rejectCounts.entries
            .sortedByDescending { it.value }
            .take(limit)
            .map { it.key to (it.value.toDouble() / total * 100.0) }
    }

    fun diagnose(
        engineStatus: EngineStatus,
        heartbeat: ScanHeartbeatSnapshot,
        hourFunnels: List<GateFunnelSnapshot>,
        now: Long = System.currentTimeMillis()
    ): NoTradeDiagnosisSnapshot {
        val recent = hourFunnels.filter { now - it.time <= HOUR_MS }
        val scans = recent.size
        val markets = recent.sumOf { it.deepCandidates }
        val buyReady = recent.sumOf { it.buyReady }
        val buys = recent.sumOf { it.actualBuys }
        val mergedRejects = linkedMapOf<String, Int>()
        recent.forEach { funnel ->
            funnel.rejectCounts.forEach { (k, v) -> mergedRejects[k] = (mergedRejects[k] ?: 0) + v }
        }
        val blockers = topBlockers(mergedRejects)
        val stalled = heartbeat.stallWarning
        val health = healthClass(engineStatus, stalled, scans, buyReady, buys)
        val deep = recent.sumOf { it.deepCandidates }.coerceAtLeast(1)
        val strategyPass = recent.sumOf { it.strategyPass }.toDouble() / deep
        val timingPass = recent.sumOf { it.timingPass }.toDouble() / deep
        val scalpEdge = recent.sumOf { it.shortEdgePass }.toDouble() / deep
        val liquidity = recent.sumOf { it.liquidityPass }.toDouble() / deep
        val netEdge = recent.sumOf { it.netEdgePass }.toDouble() / deep
        val risk = recent.sumOf { it.riskPass }.toDouble() / deep
        val scalpAvoid = mergedRejects["SCALP_AVOID"] ?: 0
        val rejectTotal = mergedRejects.values.sum().coerceAtLeast(1)

        val summary = buildString {
            append("Engine: ${engineStatus.name}\n")
            append("Scans(1h): $scans\n")
            append("Deep candidates(1h): $markets\n")
            append("BUY_READY(1h): $buyReady\n")
            append("BUY(1h): $buys\n")
            if (blockers.isNotEmpty()) {
                append("Main blockers:\n")
                blockers.forEachIndexed { i, (name, pct) -> append("${i + 1}. $name ${"%.0f".format(pct)}%\n") }
            } else {
                append("Main blockers: NO_DATA\n")
            }
            append("System failure: ${if (stalled) "YES" else "NO"}\n")
            append(
                when (health) {
                    EngineHealthClass.ENGINE_STALLED -> "Scan engine stalled"
                    EngineHealthClass.ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED -> "Market opportunity insufficient / gates blocking (LIKELY)"
                    EngineHealthClass.ENGINE_RUNNING_WITH_TRADES -> "Trades occurring"
                    EngineHealthClass.ENGINE_STOPPED -> "Engine stopped"
                    EngineHealthClass.INSUFFICIENT_DATA -> "NO_RUNTIME_DATA"
                }
            )
        }

        return NoTradeDiagnosisSnapshot(
            healthClass = health.name,
            systemFailure = stalled,
            allCandidatesBlocked = health == EngineHealthClass.ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED,
            systemStalled = stalled,
            summary = summary,
            topBlockers = blockers,
            scansLastHour = scans,
            marketsAnalyzedLastHour = markets,
            buyReadyLastHour = buyReady,
            actualBuysLastHour = buys,
            rejectCountsLastHour = mergedRejects,
            scalpAvoidRate = scalpAvoid.toDouble() / rejectTotal,
            shortEdgePassRate = scalpEdge,
            liquidityPassRate = liquidity,
            netEdgePassRate = netEdge,
            strategyPassRate = strategyPass,
            aiPassRate = 0.0, // filled by caller with signal samples when available
            entryTimingPassRate = timingPass,
            riskPassRate = risk
        )
    }

    fun distributionLabel(values: List<Double>, threshold: Double? = null): String {
        val clean = values.filter { it.isFinite() }.sorted()
        if (clean.isEmpty()) return "NO_DATA"
        fun p(q: Double): Double {
            val idx = ((clean.size - 1) * q).roundToInt().coerceIn(0, clean.lastIndex)
            return clean[idx]
        }
        val base = "min=${"%.2f".format(clean.first())} P10=${"%.2f".format(p(0.10))} P25=${"%.2f".format(p(0.25))} P50=${"%.2f".format(p(0.50))} P75=${"%.2f".format(p(0.75))} P90=${"%.2f".format(p(0.90))} max=${"%.2f".format(clean.last())}"
        return if (threshold == null) base else "$base | threshold=${"%.2f".format(threshold)}"
    }

    fun percentUnitLooksLikeFractionNotPercent(value: Double, maxExpectedPercent: Double = 20.0): Boolean =
        value.isFinite() && value > 0.0 && value < 0.05 && maxExpectedPercent >= 1.0

    fun selectPaperBuyCandidate(currentReadySignals: List<StrategySignalModel>): StrategySignalModel? =
        currentReadySignals.maxByOrNull { it.score }

    fun stageElapsed(stageStartedAt: Long, now: Long = System.currentTimeMillis()): Long =
        if (stageStartedAt <= 0L) 0L else (now - stageStartedAt).coerceAtLeast(0L)
}

/** 최근 1시간 funnel 롤링 버퍼 (메모리만, Room 스키마 변경 없음). */
class HourlyGateFunnelTracker(private val retainMs: Long = 60 * 60_000L) {
    private val samples = ArrayDeque<GateFunnelSnapshot>()

    @Synchronized
    fun record(snapshot: GateFunnelSnapshot) {
        samples.addLast(snapshot)
        trim(snapshot.time)
    }

    @Synchronized
    fun snapshots(now: Long = System.currentTimeMillis()): List<GateFunnelSnapshot> {
        trim(now)
        return samples.toList()
    }

    private fun trim(now: Long) {
        while (samples.isNotEmpty() && now - samples.first().time > retainMs) samples.removeFirst()
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/NoTradeDiagnostics.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/PaperLossAutopsy.kt =====
package com.example.bithumbtrader

import kotlin.math.abs
import kotlin.math.max

/**
 * PAPER loss autopsy: decompose equity drawdown into KRW-attributed causes.
 *
 * Accounting rule (no double-count):
 * - Slippage/spread already in executionPrice → recorded in breakdown fields only,
 *   NOT subtracted again from Net PnL.
 * - Buy fee is paid at entry (cash). Economic Net = sellNet − buyCashSpent.
 * - Legacy realized that excluded buy fee → ACCOUNTING_MISMATCH / BUY_FEE_NOT_IN_REALIZED.
 */
enum class PrimaryLossCause {
    PRICE_LOSS,
    BUY_FEE,
    SELL_FEE,
    BUY_SLIPPAGE,
    SELL_SLIPPAGE,
    SPREAD_COST,
    MARKET_IMPACT,
    STOP_LOSS_LOSS,
    TRAILING_EXIT_LOSS,
    EARLY_EXIT_OPPORTUNITY_LOSS,
    REENTRY_LOSS,
    SHORT_HOLD_LOSS,
    DUPLICATE_ENTRY_LOSS,
    OVERTRADING_COST,
    LATE_ENTRY_LOSS,
    BAD_SIGNAL_LOSS,
    BAD_TIMING_LOSS,
    FEE_DRAG_LOSS,
    LIQUIDITY_LOSS,
    CHASE_ENTRY_LOSS,
    OTHER,
    NONE
}

data class TradeLossBreakdown(
    val exchange: String = ExchangeId.BITHUMB.name,
    val market: String,
    val tradeId: String,
    val buyTradeId: String? = null,
    val entryTime: Long,
    val exitTime: Long,
    val entryPrice: Double,
    val exitPrice: Double,
    val quantity: Double,
    val buyAmountKrw: Double,
    val sellAmountKrw: Double,
    val grossPnlKrw: Double,
    val netPnlKrw: Double,
    val buyFeeKrw: Double,
    val sellFeeKrw: Double,
    val buySlippageKrw: Double,
    val sellSlippageKrw: Double,
    val spreadCostKrw: Double = 0.0,
    val marketImpactKrw: Double = 0.0,
    val priceMovePnlKrw: Double,
    val holdingSeconds: Long,
    val stopLossTriggered: Boolean,
    val takeProfitTriggered: Boolean,
    val trailingTriggered: Boolean,
    val exitReason: String,
    val mfePercent: Double? = null,
    val maePercent: Double? = null,
    val futureReturn5m: Double? = null,
    val futureReturn15m: Double? = null,
    val futureReturn30m: Double? = null,
    val futureReturn60m: Double? = null,
    val entryStrategyScore: Double? = null,
    val entryTimingScore: Double? = null,
    val entryChaseScore: Double? = null,
    val scoreConflictType: String? = null,
    val primaryLossCause: PrimaryLossCause = PrimaryLossCause.NONE,
    val secondaryLossCauses: List<PrimaryLossCause> = emptyList(),
    val lossCauseConfidence: Double = 0.0,
    val lossContributionKrw: Double = 0.0
)

data class LossCauseBucket(
    val cause: PrimaryLossCause,
    val krw: Double,
    val percentOfTotalLoss: Double,
    val tradeCount: Int
)

data class PaperAccountingAudit(
    val initialCapital: Double,
    val cash: Double,
    val positionValue: Double,
    val currentEquity: Double,
    val realizedPnl: Double,
    val unrealizedPnl: Double,
    val economicRealizedPnl: Double,
    val initPlusPnl: Double,
    val initPlusEconomicPnl: Double,
    val cashPlusPositions: Double,
    val mismatchKrw: Double,
    val economicMismatchKrw: Double,
    val accountingMismatch: Boolean,
    val accountingDifferenceKrw: Double = 0.0,
    val accountingStatus: String = "OK",
    val attributionGapKrw: Double = 0.0,
    val attributionStatus: String = "OK",
    val buyFeesInWindow: Double,
    val sellFeesInWindow: Double,
    val note: String
)

data class PeakEquityAudit(
    val peakEquity: Double = 0.0,
    val peakTimestamp: Long = 0L,
    val peakValid: Boolean = true,
    val peakSource: String = "LEDGER",
    val peakErrorCause: String = "",
    val ledgerPeakEquity: Double = 0.0,
    val overridePeakEquity: Double? = null,
    val returnFromInitialPercent: Double = 0.0,
    val drawdownFromPeakPercent: Double = 0.0,
    val peakToCurrentLossKrw: Double = 0.0
)

data class PaperEquityPoint(
    val timestamp: Long,
    val cash: Double,
    val equity: Double,
    val realizedPnl: Double,
    val fees: Double
)

data class PaperLossWindowStats(
    val tradeLimit: Int,
    val closedRoundTrips: Int,
    val netPnlKrw: Double,
    val feesKrw: Double,
    val stopLossTotalLossKrw: Double,
    val topCause: PrimaryLossCause = PrimaryLossCause.NONE,
    val topCauseKrw: Double = 0.0
)

data class PaperLossAutopsyReport(
    val exchange: String = ExchangeId.BITHUMB.name,
    val windowTradeCount: Int = 0,
    val closedRoundTrips: Int = 0,
    val winCount: Int = 0,
    val lossCount: Int = 0,
    val grossProfitKrw: Double = 0.0,
    val grossLossKrw: Double = 0.0,
    val netProfitKrw: Double = 0.0,
    val netLossKrw: Double = 0.0,
    val feesKrw: Double = 0.0,
    val estimatedSlippageKrw: Double = 0.0,
    val stopLossCount: Int = 0,
    val stopLossTotalLossKrw: Double = 0.0,
    val trailingCount: Int = 0,
    val trailingTotalLossKrw: Double = 0.0,
    val takeProfitCount: Int = 0,
    val reentryChainLossKrw: Double = 0.0,
    val reentryChainCount: Int = 0,
    val feeDragLossCount: Int = 0,
    val feeDragLossKrw: Double = 0.0,
    val lateEntryLossKrw: Double = 0.0,
    val badSignalLossKrw: Double = 0.0,
    val badTimingLossKrw: Double = 0.0,
    val overtradingAlert: Boolean = false,
    val avgHoldingSeconds: Double = 0.0,
    val tradesPerHour: Double = 0.0,
    val drawdownPercent: Double = 0.0,
    val peakEquity: Double = 0.0,
    val currentEquity: Double = 0.0,
    val netExpectancyKrw: Double = 0.0,
    val profitFactor: Double = 0.0,
    val accounting: PaperAccountingAudit = PaperAccountingAudit(
        initialCapital = 0.0,
        cash = 0.0,
        positionValue = 0.0,
        currentEquity = 0.0,
        realizedPnl = 0.0,
        unrealizedPnl = 0.0,
        economicRealizedPnl = 0.0,
        initPlusPnl = 0.0,
        initPlusEconomicPnl = 0.0,
        cashPlusPositions = 0.0,
        mismatchKrw = 0.0,
        economicMismatchKrw = 0.0,
        accountingMismatch = false,
        buyFeesInWindow = 0.0,
        sellFeesInWindow = 0.0,
        note = ""
    ),
    val topCauses: List<LossCauseBucket> = emptyList(),
    val contributingFactors: List<LossCauseBucket> = emptyList(),
    val primaryAttributedLossSum: Double = 0.0,
    val windows: List<PaperLossWindowStats> = emptyList(),
    val equityCurve: List<PaperEquityPoint> = emptyList(),
    val breakdowns: List<TradeLossBreakdown> = emptyList(),
    val peakAudit: PeakEquityAudit = PeakEquityAudit(),
    val protectionHint: String = "NORMAL",
    val sampleTooSmall: Boolean = true
)

object PaperLossAutopsyEngine {
    private const val DEFAULT_FEE_RATE = 0.0025
    private const val DEFAULT_SLIP_RATE = 0.001

    data class RoundTrip(
        val buy: TradeEntity,
        val sell: TradeEntity,
        val exchange: String
    )

    /** Server stores sell.amount as net; legacy Android stored gross — detect both. */
    fun sellProceedsNet(sell: TradeEntity): Double {
        val grossAtPx = sell.quantity * sell.avgPrice
        val asNet = grossAtPx - sell.fee
        return when {
            abs(sell.amount - asNet) <= max(1.0, abs(asNet) * 0.002) -> sell.amount
            abs(sell.amount - grossAtPx) <= max(1.0, abs(grossAtPx) * 0.002) -> sell.amount - sell.fee
            else -> sell.amount - sell.fee.coerceAtLeast(0.0)
        }
    }

    fun pairRoundTrips(trades: List<TradeEntity>, exchange: String = ExchangeId.BITHUMB.name): List<RoundTrip> {
        val chronological = trades.sortedBy { it.time }
        val books = mutableMapOf<String, ArrayDeque<TradeEntity>>()
        val out = mutableListOf<RoundTrip>()
        for (t in chronological) {
            val side = t.side.uppercase()
            if (side == "BUY") {
                books.getOrPut(t.market) { ArrayDeque() }.addLast(t)
            } else if (side == "SELL") {
                val buy = books[t.market]?.removeFirstOrNull() ?: continue
                out += RoundTrip(buy, t, exchange)
            }
        }
        return out
    }

    fun economicRealizedFromTrades(trades: List<TradeEntity>): Double =
        pairRoundTrips(trades).sumOf { sellProceedsNet(it.sell) - it.buy.amount }

    fun rebuildEquityCurve(trades: List<TradeEntity>, initialCapital: Double): List<PaperEquityPoint> {
        var cash = initialCapital
        var fees = 0.0
        var realized = 0.0
        val open = mutableMapOf<String, Pair<Double, Double>>() // qty, avg
        val books = mutableMapOf<String, ArrayDeque<TradeEntity>>()
        val points = mutableListOf(
            PaperEquityPoint(0L, cash, cash, 0.0, 0.0)
        )
        for (t in trades.sortedBy { it.time }) {
            when (t.side.uppercase()) {
                "BUY" -> {
                    cash -= t.amount
                    fees += t.fee
                    books.getOrPut(t.market) { ArrayDeque() }.addLast(t)
                    val (q, a) = open[t.market] ?: (0.0 to 0.0)
                    val nq = q + t.quantity
                    val na = if (nq > 0) (a * q + t.avgPrice * t.quantity) / nq else 0.0
                    open[t.market] = nq to na
                }
                "SELL" -> {
                    val buy = books[t.market]?.removeFirstOrNull()
                    val net = sellProceedsNet(t)
                    cash += net
                    fees += t.fee
                    if (buy != null) realized += net - buy.amount
                    open.remove(t.market)
                }
            }
            val posValue = open.values.sumOf { (q, a) -> q * a }
            points += PaperEquityPoint(
                timestamp = t.time,
                cash = cash,
                equity = cash + posValue,
                realizedPnl = realized,
                fees = fees
            )
        }
        return points
    }

    fun auditAccounting(
        initialCapital: Double,
        cash: Double,
        positionValue: Double,
        realizedPnl: Double,
        unrealizedPnl: Double,
        economicRealizedPnl: Double,
        buyFeesInWindow: Double,
        sellFeesInWindow: Double,
        tradeWindowIncomplete: Boolean = false
    ): PaperAccountingAudit {
        val equity = cash + positionValue
        val initPlus = initialCapital + realizedPnl + unrealizedPnl
        val initPlusEcon = initialCapital + economicRealizedPnl + unrealizedPnl
        val mismatch = initPlus - equity
        val econMismatch = initPlusEcon - equity
        val tol = max(1.0, initialCapital * 1e-6)
        val accountingDiff = mismatch
        val ledgerBad = abs(accountingDiff) > tol
        // Partial trade window vs full-account equity is attribution gap, not ledger mismatch.
        val attributionGap = if (tradeWindowIncomplete && abs(econMismatch) > tol && !ledgerBad) econMismatch else 0.0
        val attributionBad = abs(attributionGap) > tol
        val accountingMismatchFlag = ledgerBad
        val accountingStatus = if (ledgerBad) "MISMATCH" else "OK"
        val attributionStatus = when {
            attributionBad -> "GAP"
            tradeWindowIncomplete -> "WINDOW_PARTIAL"
            abs(econMismatch) > tol -> "GAP"
            else -> "OK"
        }
        val note = when {
            !ledgerBad && !attributionBad && abs(econMismatch) <= tol ->
                "ACCOUNTING OK · cash+positions == equity · init+economic aligned"
            !ledgerBad && attributionBad ->
                "ATTRIBUTION_GAP (partial trade window vs full equity) econΔ=${"%.0f".format(attributionGap)} — not ledger mismatch"
            abs(econMismatch) <= tol && ledgerBad ->
                "ACCOUNTING_MISMATCH≈BUY_FEE_NOT_IN_REALIZED (legacy meta); economic ledger OK"
            abs(mismatch - buyFeesInWindow) < max(50.0, buyFeesInWindow * 0.15) ->
                "ACCOUNTING_MISMATCH≈BUY_FEE_NOT_IN_REALIZED (legacy realized excludes entry fees)"
            else -> "ACCOUNTING_MISMATCH (investigate cash/realized ledger)"
        }
        return PaperAccountingAudit(
            initialCapital = initialCapital,
            cash = cash,
            positionValue = positionValue,
            currentEquity = equity,
            realizedPnl = realizedPnl,
            unrealizedPnl = unrealizedPnl,
            economicRealizedPnl = economicRealizedPnl,
            initPlusPnl = initPlus,
            initPlusEconomicPnl = initPlusEcon,
            cashPlusPositions = equity,
            mismatchKrw = if (abs(mismatch) < 0.5) 0.0 else mismatch,
            economicMismatchKrw = if (abs(econMismatch) < 0.5) 0.0 else econMismatch,
            accountingMismatch = accountingMismatchFlag,
            accountingDifferenceKrw = if (abs(accountingDiff) < 0.5) 0.0 else accountingDiff,
            accountingStatus = accountingStatus,
            attributionGapKrw = if (abs(attributionGap) < 0.5) 0.0 else attributionGap,
            attributionStatus = attributionStatus,
            buyFeesInWindow = buyFeesInWindow,
            sellFeesInWindow = sellFeesInWindow,
            note = note
        )
    }

    fun breakdownRoundTrip(
        rt: RoundTrip,
        feeRate: Double = DEFAULT_FEE_RATE,
        slipRate: Double = DEFAULT_SLIP_RATE,
        mfePercent: Double? = null,
        maePercent: Double? = null,
        future5m: Double? = null,
        future15m: Double? = null,
        future30m: Double? = null,
        future60m: Double? = null,
        entryStrategyScore: Double? = null,
        entryTimingScore: Double? = null,
        entryChaseScore: Double? = null
    ): TradeLossBreakdown {
        val buy = rt.buy
        val sell = rt.sell
        val qty = sell.quantity
        val buyFee = buy.fee
        val sellFee = sell.fee
        val buyAmount = buy.amount
        val sellNet = sellProceedsNet(sell)
        val buyGross = qty * buy.avgPrice
        val sellGross = qty * sell.avgPrice
        val priceMove = sellGross - buyGross
        val netEconomic = sellNet - buyAmount
        val buySlipEst = buyGross * slipRate / (1.0 + slipRate).coerceAtLeast(1e-9)
        val sellSlipEst = sellGross * slipRate / (1.0 - slipRate).coerceAtLeast(1e-9)

        val stop = sell.reason.contains("STOP LOSS", ignoreCase = true) &&
            !sell.reason.contains("TRAILING", ignoreCase = true)
        val trail = sell.reason.contains("TRAILING", ignoreCase = true)
        val tp = sell.reason.contains("TAKE PROFIT", ignoreCase = true)

        val scoreConflict = when {
            entryStrategyScore != null && entryStrategyScore >= 90.0 &&
                entryTimingScore != null && entryTimingScore <= 50.0 -> "STRATEGY_HIGH_TIMING_LOW"
            entryStrategyScore != null && entryStrategyScore >= 80.0 &&
                entryChaseScore != null && entryChaseScore >= 70.0 -> "STRATEGY_HIGH_CHASE_RISK"
            else -> null
        }

        val holdSec = ((sell.time - buy.time) / 1000L).coerceAtLeast(0L)
        val (primary, secondary, conf) = classifyCause(
            netEconomic = netEconomic,
            priceMove = priceMove,
            buyFee = buyFee,
            sellFee = sellFee,
            stop = stop,
            trail = trail,
            tp = tp,
            mfe = mfePercent,
            mae = maePercent,
            f5 = future5m,
            f15 = future15m,
            f30 = future30m,
            f60 = future60m,
            reason = sell.reason,
            chaseScore = entryChaseScore,
            scoreConflict = scoreConflict,
            holdSec = holdSec
        )

        return TradeLossBreakdown(
            exchange = rt.exchange,
            market = sell.market,
            tradeId = sell.id,
            buyTradeId = buy.id,
            entryTime = buy.time,
            exitTime = sell.time,
            entryPrice = buy.avgPrice,
            exitPrice = sell.avgPrice,
            quantity = qty,
            buyAmountKrw = buyAmount,
            sellAmountKrw = sellNet,
            grossPnlKrw = priceMove,
            netPnlKrw = netEconomic,
            buyFeeKrw = buyFee,
            sellFeeKrw = sellFee,
            buySlippageKrw = buySlipEst,
            sellSlippageKrw = sellSlipEst,
            priceMovePnlKrw = priceMove,
            holdingSeconds = holdSec,
            stopLossTriggered = stop,
            takeProfitTriggered = tp,
            trailingTriggered = trail,
            exitReason = sell.reason,
            mfePercent = mfePercent,
            maePercent = maePercent,
            futureReturn5m = future5m,
            futureReturn15m = future15m,
            futureReturn30m = future30m,
            futureReturn60m = future60m,
            entryStrategyScore = entryStrategyScore,
            entryTimingScore = entryTimingScore,
            entryChaseScore = entryChaseScore,
            scoreConflictType = scoreConflict,
            primaryLossCause = primary,
            secondaryLossCauses = secondary,
            lossCauseConfidence = conf,
            lossContributionKrw = if (netEconomic < 0.0) netEconomic else 0.0
        )
    }

    private fun classifyCause(
        netEconomic: Double,
        priceMove: Double,
        buyFee: Double,
        sellFee: Double,
        stop: Boolean,
        trail: Boolean,
        tp: Boolean,
        mfe: Double?,
        mae: Double?,
        f5: Double?,
        f15: Double?,
        f30: Double?,
        f60: Double?,
        reason: String,
        chaseScore: Double?,
        scoreConflict: String?,
        holdSec: Long = 0L
    ): Triple<PrimaryLossCause, List<PrimaryLossCause>, Double> {
        if (netEconomic >= 0.0) return Triple(PrimaryLossCause.NONE, emptyList(), 1.0)
        val secondary = mutableListOf<PrimaryLossCause>()
        val fees = buyFee + sellFee
        if (priceMove > 0.0 && netEconomic <= 0.0) {
            return Triple(PrimaryLossCause.FEE_DRAG_LOSS, listOf(PrimaryLossCause.BUY_FEE, PrimaryLossCause.SELL_FEE), 0.9)
        }
        if (fees >= abs(netEconomic) * 0.6 && abs(priceMove) < fees) {
            secondary += PrimaryLossCause.OVERTRADING_COST
        }
        if (chaseScore != null && chaseScore >= 70.0) {
            secondary += PrimaryLossCause.CHASE_ENTRY_LOSS
        }
        if (scoreConflict == "STRATEGY_HIGH_TIMING_LOW") {
            secondary += PrimaryLossCause.LATE_ENTRY_LOSS
        }
        val futures = listOfNotNull(f5, f15, f30, f60)
        val allDown = futures.isNotEmpty() && futures.all { it < 0.0 }
        val laterUp = listOfNotNull(f15, f30, f60).any { it >= 1.5 }
        when {
            stop && laterUp -> {
                secondary += PrimaryLossCause.STOP_LOSS_LOSS
                return Triple(PrimaryLossCause.EARLY_EXIT_OPPORTUNITY_LOSS, secondary, 0.75)
            }
            trail && laterUp -> {
                return Triple(PrimaryLossCause.EARLY_EXIT_OPPORTUNITY_LOSS, listOf(PrimaryLossCause.TRAILING_EXIT_LOSS), 0.7)
            }
            allDown && (mfe == null || mfe < 0.5) ->
                return Triple(PrimaryLossCause.BAD_SIGNAL_LOSS, listOf(PrimaryLossCause.PRICE_LOSS) + secondary, 0.8)
            !allDown && laterUp && (mae != null && mae < -1.0) ->
                return Triple(PrimaryLossCause.BAD_TIMING_LOSS, listOf(PrimaryLossCause.LATE_ENTRY_LOSS) + secondary, 0.7)
            chaseScore != null && chaseScore >= 75.0 ->
                return Triple(PrimaryLossCause.CHASE_ENTRY_LOSS, listOf(PrimaryLossCause.LATE_ENTRY_LOSS) + secondary, 0.75)
            stop && holdSec < 180 ->
                return Triple(PrimaryLossCause.SHORT_HOLD_LOSS, listOf(PrimaryLossCause.STOP_LOSS_LOSS, PrimaryLossCause.PRICE_LOSS) + secondary, 0.85)
            trail && holdSec < 180 ->
                return Triple(PrimaryLossCause.SHORT_HOLD_LOSS, listOf(PrimaryLossCause.TRAILING_EXIT_LOSS, PrimaryLossCause.PRICE_LOSS) + secondary, 0.8)
            stop -> return Triple(PrimaryLossCause.STOP_LOSS_LOSS, listOf(PrimaryLossCause.PRICE_LOSS) + secondary, 0.85)
            trail -> return Triple(PrimaryLossCause.TRAILING_EXIT_LOSS, listOf(PrimaryLossCause.PRICE_LOSS) + secondary, 0.8)
            reason.contains("CHASE", ignoreCase = true) ->
                return Triple(PrimaryLossCause.CHASE_ENTRY_LOSS, listOf(PrimaryLossCause.LATE_ENTRY_LOSS), 0.7)
            else -> return Triple(PrimaryLossCause.PRICE_LOSS, secondary.ifEmpty { listOf(PrimaryLossCause.OTHER) }, 0.55)
        }
    }

    private fun windowStats(breakdowns: List<TradeLossBreakdown>, limit: Int): PaperLossWindowStats {
        val slice = breakdowns.takeLast(limit)
        val losses = slice.filter { it.netPnlKrw < 0.0 }
        val stopLoss = losses.filter { it.stopLossTriggered }.sumOf { it.netPnlKrw }
        val causeMap = mutableMapOf<PrimaryLossCause, Double>()
        for (b in losses) {
            causeMap[b.primaryLossCause] = (causeMap[b.primaryLossCause] ?: 0.0) + b.netPnlKrw
        }
        val top = causeMap.entries.minByOrNull { it.value }
        return PaperLossWindowStats(
            tradeLimit = limit,
            closedRoundTrips = slice.size,
            netPnlKrw = slice.sumOf { it.netPnlKrw },
            feesKrw = slice.sumOf { it.buyFeeKrw + it.sellFeeKrw },
            stopLossTotalLossKrw = stopLoss,
            topCause = top?.key ?: PrimaryLossCause.NONE,
            topCauseKrw = top?.value ?: 0.0
        )
    }

    fun analyze(
        trades: List<TradeEntity>,
        initialCapital: Double,
        cash: Double,
        positionValue: Double,
        realizedPnlReported: Double,
        unrealizedPnl: Double,
        exchange: String = ExchangeId.BITHUMB.name,
        feeRate: Double = DEFAULT_FEE_RATE,
        slipRate: Double = DEFAULT_SLIP_RATE,
        enrichment: Map<String, TradeEnrichment> = emptyMap(),
        peakEquityOverride: Double? = null
    ): PaperLossAutopsyReport {
        val rounds = pairRoundTrips(trades, exchange)
        val breakdowns = rounds.map { rt ->
            val en = enrichment[rt.sell.id] ?: enrichment[rt.buy.id]
            breakdownRoundTrip(
                rt = rt,
                feeRate = feeRate,
                slipRate = slipRate,
                mfePercent = en?.mfePercent,
                maePercent = en?.maePercent,
                future5m = en?.futureReturn5m,
                future15m = en?.futureReturn15m,
                future30m = en?.futureReturn30m,
                future60m = en?.futureReturn60m,
                entryStrategyScore = en?.entryStrategyScore,
                entryTimingScore = en?.entryTimingScore,
                entryChaseScore = en?.entryChaseScore
            )
        }
        val attributed = applyExclusivePrimaryAttribution(breakdowns)
        val buyFees = attributed.sumOf { it.buyFeeKrw }
        val sellFees = attributed.sumOf { it.sellFeeKrw }
        val economicRealized = attributed.sumOf { it.netPnlKrw }
        val equity = cash + positionValue
        val expectedFlatEquity = initialCapital + economicRealized + unrealizedPnl
        val tradeWindowIncomplete =
            abs(expectedFlatEquity - equity) > max(500.0, initialCapital * 0.005) &&
                abs((initialCapital + realizedPnlReported + unrealizedPnl) - equity) <= max(1.0, initialCapital * 1e-6)
        val accounting = auditAccounting(
            initialCapital = initialCapital,
            cash = cash,
            positionValue = positionValue,
            realizedPnl = realizedPnlReported,
            unrealizedPnl = unrealizedPnl,
            economicRealizedPnl = economicRealized,
            buyFeesInWindow = buyFees,
            sellFeesInWindow = sellFees,
            tradeWindowIncomplete = tradeWindowIncomplete
        )
        val wins = attributed.filter { it.netPnlKrw > 0.0 }
        val losses = attributed.filter { it.netPnlKrw < 0.0 }
        val stopLosses = attributed.filter { it.stopLossTriggered && it.netPnlKrw < 0.0 }
        val trails = attributed.filter { it.trailingTriggered && it.netPnlKrw < 0.0 }
        val feeDrag = attributed.filter { it.primaryLossCause == PrimaryLossCause.FEE_DRAG_LOSS }

        var reentryLoss = 0.0
        var reentryCount = 0
        for (b in losses) {
            if (b.primaryLossCause == PrimaryLossCause.REENTRY_LOSS) {
                reentryCount++
                reentryLoss += b.netPnlKrw
            }
        }

        val primaryLossAbs = losses.sumOf { abs(it.lossContributionKrw) }.coerceAtLeast(1e-9)
        val causeKrw = mutableMapOf<PrimaryLossCause, Pair<Double, Int>>()
        for (b in losses) {
            val c = b.primaryLossCause
            if (c == PrimaryLossCause.NONE) continue
            val prev = causeKrw[c] ?: (0.0 to 0)
            causeKrw[c] = (prev.first + b.lossContributionKrw) to (prev.second + 1)
        }
        val primaryAttributedLossSum = causeKrw.values.sumOf { it.first }

        val top = causeKrw.entries
            .filter { it.value.first < 0.0 }
            .sortedBy { it.value.first }
            .take(8)
            .map { (cause, v) ->
                LossCauseBucket(
                    cause = cause,
                    krw = v.first,
                    percentOfTotalLoss = abs(v.first) / primaryLossAbs * 100.0,
                    tradeCount = v.second
                )
            }

        val factorKrw = mutableMapOf<PrimaryLossCause, Pair<Double, Int>>()
        fun addFactor(c: PrimaryLossCause, krw: Double) {
            if (krw >= 0.0) return
            val prev = factorKrw[c] ?: (0.0 to 0)
            factorKrw[c] = (prev.first + krw) to (prev.second + 1)
        }
        for (b in losses) {
            for (s in b.secondaryLossCauses) addFactor(s, b.netPnlKrw)
            if (b.holdingSeconds < 180) addFactor(PrimaryLossCause.SHORT_HOLD_LOSS, b.netPnlKrw)
            if (b.stopLossTriggered) addFactor(PrimaryLossCause.STOP_LOSS_LOSS, b.netPnlKrw)
            if (b.trailingTriggered) addFactor(PrimaryLossCause.TRAILING_EXIT_LOSS, b.netPnlKrw)
        }
        addFactor(PrimaryLossCause.BUY_FEE, -buyFees)
        addFactor(PrimaryLossCause.SELL_FEE, -sellFees)
        val contributing = factorKrw.entries
            .sortedBy { it.value.first }
            .take(8)
            .map { (cause, v) ->
                LossCauseBucket(
                    cause = cause,
                    krw = v.first,
                    percentOfTotalLoss = abs(v.first) / primaryLossAbs * 100.0,
                    tradeCount = v.second
                )
            }

        val spanMs = if (attributed.size >= 2) {
            (attributed.maxOf { it.exitTime } - attributed.minOf { it.entryTime }).coerceAtLeast(1L)
        } else 3_600_000L
        val hours = spanMs / 3_600_000.0
        val tph = if (hours > 0) attributed.size / hours else 0.0
        val avgHold = if (attributed.isNotEmpty()) attributed.map { it.holdingSeconds }.average() else 0.0
        val overtrading = tph >= 8.0 && avgHold < 180.0 && (buyFees + sellFees) >= abs(losses.sumOf { it.netPnlKrw }) * 0.25

        val equityCurve = rebuildEquityCurve(trades, initialCapital)
        val ledgerPeakPoint = equityCurve.maxByOrNull { it.equity }
        val ledgerPeak = ledgerPeakPoint?.equity ?: initialCapital
        val overridePeak = peakEquityOverride
        val overrideInflated = overridePeak != null &&
            overridePeak > ledgerPeak + max(5_000.0, ledgerPeak * 0.05)
        val peakValid = !overrideInflated
        val peakEq = if (peakValid && overridePeak != null) max(ledgerPeak, overridePeak) else max(initialCapital, ledgerPeak)
        val peakTs = ledgerPeakPoint?.timestamp ?: 0L
        val dd = if (peakEq > 0) (equity - peakEq) / peakEq * 100.0 else 0.0
        val retInitial = if (initialCapital > 0) (equity - initialCapital) / initialCapital * 100.0 else 0.0
        val peakAudit = PeakEquityAudit(
            peakEquity = peakEq,
            peakTimestamp = peakTs,
            peakValid = peakValid,
            peakSource = if (peakValid && overridePeak != null && overridePeak >= ledgerPeak) "OVERRIDE_OK" else "LEDGER",
            peakErrorCause = if (!peakValid)
                "OVERRIDE_INFLATED_VS_LEDGER override=${overridePeak?.toLong()} ledgerPeak=${ledgerPeak.toLong()}"
            else "",
            ledgerPeakEquity = ledgerPeak,
            overridePeakEquity = overridePeak,
            returnFromInitialPercent = retInitial,
            drawdownFromPeakPercent = dd,
            peakToCurrentLossKrw = peakEq - equity
        )

        val grossWin = wins.sumOf { it.netPnlKrw }
        val grossLossAbs = abs(losses.sumOf { it.netPnlKrw })
        val pf = when {
            grossLossAbs <= 1e-9 && grossWin > 0 -> Double.POSITIVE_INFINITY
            grossLossAbs <= 1e-9 -> 0.0
            else -> grossWin / grossLossAbs
        }
        val netExpectancy = if (attributed.isNotEmpty()) attributed.map { it.netPnlKrw }.average() else 0.0

        val sampleSmall = attributed.size < 15
        val protection = when {
            sampleSmall -> "LOW_SAMPLE"
            accounting.accountingStatus == "MISMATCH" && abs(accounting.accountingDifferenceKrw) > 1000 ->
                "ACCOUNTING_MISMATCH_REVIEW"
            overtrading -> "OVERTRADING_CAUTION"
            netExpectancy < -150.0 && attributed.size >= 20 && pf < 1.0 -> "NET_EXPECTANCY_DEFENSE"
            pf < 0.85 && attributed.size >= 25 -> "PROFIT_FACTOR_CAUTION"
            stopLosses.sumOf { it.netPnlKrw } < -5000.0 -> "STOP_LOSS_DOMINANT_CAUTION"
            abs(dd) >= 8.0 -> "DRAWDOWN_DEFENSE"
            else -> "NORMAL"
        }

        val ordered = attributed.sortedBy { it.exitTime }
        val windows = listOf(20, 50, 100).map { windowStats(ordered, it) }

        return PaperLossAutopsyReport(
            exchange = exchange,
            windowTradeCount = trades.size,
            closedRoundTrips = attributed.size,
            winCount = wins.size,
            lossCount = losses.size,
            grossProfitKrw = wins.sumOf { it.grossPnlKrw.coerceAtLeast(0.0) },
            grossLossKrw = losses.sumOf { it.grossPnlKrw.coerceAtMost(0.0) },
            netProfitKrw = wins.sumOf { it.netPnlKrw },
            netLossKrw = losses.sumOf { it.netPnlKrw },
            feesKrw = buyFees + sellFees,
            estimatedSlippageKrw = attributed.sumOf { it.buySlippageKrw + it.sellSlippageKrw },
            stopLossCount = stopLosses.size,
            stopLossTotalLossKrw = stopLosses.sumOf { it.netPnlKrw },
            trailingCount = trails.size,
            trailingTotalLossKrw = trails.sumOf { it.netPnlKrw },
            takeProfitCount = attributed.count { it.takeProfitTriggered },
            reentryChainLossKrw = reentryLoss,
            reentryChainCount = reentryCount,
            feeDragLossCount = feeDrag.size,
            feeDragLossKrw = feeDrag.sumOf { it.netPnlKrw },
            lateEntryLossKrw = losses.filter { it.primaryLossCause == PrimaryLossCause.LATE_ENTRY_LOSS || it.primaryLossCause == PrimaryLossCause.CHASE_ENTRY_LOSS }.sumOf { it.netPnlKrw },
            badSignalLossKrw = losses.filter { it.primaryLossCause == PrimaryLossCause.BAD_SIGNAL_LOSS }.sumOf { it.netPnlKrw },
            badTimingLossKrw = losses.filter { it.primaryLossCause == PrimaryLossCause.BAD_TIMING_LOSS }.sumOf { it.netPnlKrw },
            overtradingAlert = overtrading,
            avgHoldingSeconds = avgHold,
            tradesPerHour = tph,
            drawdownPercent = dd,
            peakEquity = peakEq,
            currentEquity = equity,
            netExpectancyKrw = netExpectancy,
            profitFactor = pf,
            accounting = accounting,
            topCauses = top,
            contributingFactors = contributing,
            primaryAttributedLossSum = primaryAttributedLossSum,
            windows = windows,
            equityCurve = equityCurve.takeLast(120),
            breakdowns = ordered,
            peakAudit = peakAudit,
            protectionHint = protection,
            sampleTooSmall = sampleSmall
        )
    }

    /** Promote consecutive same-market losses to exclusive REENTRY_LOSS primary. */
    fun applyExclusivePrimaryAttribution(breakdowns: List<TradeLossBreakdown>): List<TradeLossBreakdown> {
        if (breakdowns.isEmpty()) return breakdowns
        val byMarket = breakdowns.groupBy { it.market }
        val out = mutableListOf<TradeLossBreakdown>()
        for ((_, list) in byMarket) {
            val ordered = list.sortedBy { it.exitTime }
            var streak = 0
            for (b in ordered) {
                if (b.netPnlKrw < 0.0) {
                    streak++
                    if (streak >= 2 && b.primaryLossCause != PrimaryLossCause.REENTRY_LOSS) {
                        val prev = b.primaryLossCause
                        val secondary = (listOf(prev) + b.secondaryLossCauses)
                            .filter { it != PrimaryLossCause.NONE && it != PrimaryLossCause.REENTRY_LOSS }
                            .distinct()
                        out += b.copy(
                            primaryLossCause = PrimaryLossCause.REENTRY_LOSS,
                            secondaryLossCauses = secondary,
                            lossContributionKrw = b.netPnlKrw
                        )
                    } else {
                        out += b.copy(lossContributionKrw = b.netPnlKrw)
                    }
                } else {
                    streak = 0
                    out += b.copy(lossContributionKrw = 0.0)
                }
            }
        }
        return out.sortedBy { it.exitTime }
    }

    data class TradeEnrichment(
        val mfePercent: Double? = null,
        val maePercent: Double? = null,
        val futureReturn5m: Double? = null,
        val futureReturn15m: Double? = null,
        val futureReturn30m: Double? = null,
        val futureReturn60m: Double? = null,
        val entryStrategyScore: Double? = null,
        val entryTimingScore: Double? = null,
        val entryChaseScore: Double? = null
    )
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/PaperLossAutopsy.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/PaperRiskEngine.kt =====
package com.example.bithumbtrader

enum class PaperRiskState { NORMAL, PAPER_CAUTION, PAPER_DEFENSE, PAPER_SHADOW_MODE }

data class PaperRiskSnapshot(
    val state: PaperRiskState = PaperRiskState.NORMAL,
    val lossStreak: Int = 0,
    val positionSizeMultiplier: Double = 1.0,
    val thresholdOffset: Double = 0.0,
    val tradingActive: Boolean = true,
    val shadowCentric: Boolean = false,
    val isDailyLossWarning: Boolean = false,
    val reason: String = "정상",
    val autopsyProtectionHint: String = "NORMAL",
    val remainingRiskBudgetMultiplier: Double = 1.0
)

object PaperRiskEngine {
    fun evaluate(
        mode: TradeMode,
        lossStreak: Int,
        dailyLossLocked: Boolean,
        recentStats: PerformanceStats,
        health: MarketHealthScore,
        regime: MarketRegimeSnapshot,
        autopsyHint: String = "NORMAL",
        autopsySampleTooSmall: Boolean = true
    ): PaperRiskSnapshot {
        if (mode == TradeMode.LIVE) {
            return PaperRiskSnapshot(
                state = PaperRiskState.NORMAL,
                lossStreak = lossStreak,
                positionSizeMultiplier = 1.0,
                thresholdOffset = 0.0,
                tradingActive = !dailyLossLocked,
                shadowCentric = false,
                isDailyLossWarning = false,
                reason = if (dailyLossLocked) "LIVE_DAILY_LOSS_LOCK (실제 거래 차단)" else "LIVE 정상",
                autopsyProtectionHint = autopsyHint,
                remainingRiskBudgetMultiplier = 1.0
            )
        }

        // Recovery check when conditions improve
        val isRecovered = recentStats.expectedReturnPercent > 0.0 &&
            recentStats.winRate >= 0.45 &&
            health.level == MarketHealthLevel.HEALTHY &&
            regime.regime != MarketRegime.CRASH &&
            regime.regime != MarketRegime.STRONG_BEAR

        val streakState = when {
            lossStreak >= 8 -> if (isRecovered) PaperRiskState.PAPER_DEFENSE else PaperRiskState.PAPER_SHADOW_MODE
            lossStreak >= 5 -> if (isRecovered) PaperRiskState.PAPER_CAUTION else PaperRiskState.PAPER_DEFENSE
            lossStreak >= 3 -> if (isRecovered) PaperRiskState.NORMAL else PaperRiskState.PAPER_CAUTION
            else -> PaperRiskState.NORMAL
        }

        // Cause-based autopsy overlay — never permanent lock; never blanket stop widen.
        val autopsyState = if (autopsySampleTooSmall) PaperRiskState.NORMAL else when (autopsyHint) {
            "NET_EXPECTANCY_DEFENSE", "DRAWDOWN_DEFENSE" -> PaperRiskState.PAPER_DEFENSE
            "OVERTRADING_CAUTION", "PROFIT_FACTOR_CAUTION", "STOP_LOSS_DOMINANT_CAUTION" ->
                PaperRiskState.PAPER_CAUTION
            "ACCOUNTING_MISMATCH_REVIEW" -> PaperRiskState.PAPER_CAUTION
            else -> PaperRiskState.NORMAL
        }

        val state = maxSeverity(streakState, autopsyState)

        val multiplier = when (state) {
            PaperRiskState.PAPER_SHADOW_MODE -> 0.1
            PaperRiskState.PAPER_DEFENSE -> 0.3
            PaperRiskState.PAPER_CAUTION -> 0.7
            PaperRiskState.NORMAL -> 1.0
        }

        val thresholdOffset = when (state) {
            PaperRiskState.PAPER_SHADOW_MODE -> 10.0
            PaperRiskState.PAPER_DEFENSE -> 5.0
            PaperRiskState.PAPER_CAUTION -> 2.0
            PaperRiskState.NORMAL -> 0.0
        }

        val riskBudgetMult = when (state) {
            PaperRiskState.PAPER_SHADOW_MODE -> 0.25
            PaperRiskState.PAPER_DEFENSE -> 0.45
            PaperRiskState.PAPER_CAUTION -> 0.75
            PaperRiskState.NORMAL -> 1.0
        }

        val reason = when {
            dailyLossLocked -> "PAPER_DAILY_LOSS_WARNING: 모의 거래/학습/통계 수집 계속 (Risk 축소)"
            state == PaperRiskState.PAPER_SHADOW_MODE -> "PAPER_SHADOW_MODE: 8연속 손실로 체결 최소화, 분석/학습 지속"
            autopsyState.ordinal >= streakState.ordinal && autopsyHint != "NORMAL" && autopsyHint != "LOW_SAMPLE" ->
                "PAPER_${state.name}: autopsy=$autopsyHint (원인별 보호 · 영구 lock 없음)"
            state == PaperRiskState.PAPER_DEFENSE -> "PAPER_DEFENSE: 5연속 손실로 포지션 0.3배 및 진입 기준 강화"
            state == PaperRiskState.PAPER_CAUTION -> "PAPER_CAUTION: 3연속 손실로 포지션 0.7배 축소"
            else -> "PAPER_NORMAL: 정상 거래 활성"
        }

        return PaperRiskSnapshot(
            state = state,
            lossStreak = lossStreak,
            positionSizeMultiplier = multiplier,
            thresholdOffset = thresholdOffset,
            tradingActive = true,
            shadowCentric = (state == PaperRiskState.PAPER_SHADOW_MODE),
            isDailyLossWarning = dailyLossLocked,
            reason = reason,
            autopsyProtectionHint = autopsyHint,
            remainingRiskBudgetMultiplier = riskBudgetMult
        )
    }

    private fun maxSeverity(a: PaperRiskState, b: PaperRiskState): PaperRiskState =
        if (a.ordinal >= b.ordinal) a else b
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/PaperRiskEngine.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ProfitProtection.kt =====
package com.example.bithumbtrader

import kotlin.math.abs

/** 수익 목표 도달 자체가 아니라, 고점 수익을 반납할 때만 방어한다. */
enum class ProfitProtectionState { NORMAL, PROFIT_RUNNING, PROFIT_CAUTION, PROFIT_DEFENSE, PROFIT_LOCKED }
enum class ProfitVelocityState { UNKNOWN, ACCELERATING, DECELERATING, FLAT }

data class ProfitVelocity(
    val state: ProfitVelocityState = ProfitVelocityState.UNKNOWN,
    val returnPercent: Double = 0.0,
    val slopePercentPerMinute: Double = 0.0,
    val sampleCount: Int = 0
)

data class ProfitProtectionSnapshot(
    val dayStartEquity: Double = 0.0,
    val currentEquity: Double = 0.0,
    val dayPeakEquity: Double = 0.0,
    val dailyReturnPercent: Double = 0.0,
    val peakReturnPercent: Double = 0.0,
    val drawdownFromPeakPercent: Double = 0.0,
    val givebackPercentPoints: Double = 0.0,
    val state: ProfitProtectionState = ProfitProtectionState.NORMAL,
    val level: Int = 0,
    val positionSizeMultiplier: Double = 1.0,
    val newEntryAllowed: Boolean = true,
    val reason: String = ""
)

data class ProfitProtectionSettings(
    val level1Percent: Double,
    val level2Percent: Double,
    val level3Percent: Double,
    val level4Percent: Double,
    val cautionGivebackPercentPoints: Double,
    val defenseGivebackPercentPoints: Double,
    val lockedGivebackPercentPoints: Double,
    val cautionMultiplier: Double,
    val defenseMultiplier: Double,
    val lockedEntryAllowed: Boolean
)

object ProfitProtectionEngine {
    fun evaluate(start: Double, peak: Double, current: Double, settings: ProfitProtectionSettings, health: MarketHealthScore, consecutiveLosses: Int): ProfitProtectionSnapshot {
        if (!start.isFinite() || start <= 0.0 || !peak.isFinite() || !current.isFinite()) return ProfitProtectionSnapshot(reason = "자산 데이터 부족")
        val daily = (current / start - 1.0) * 100.0
        val peakReturn = (peak / start - 1.0) * 100.0
        val giveback = (peakReturn - daily).coerceAtLeast(0.0)
        val level = when {
            peakReturn >= settings.level4Percent -> 4
            peakReturn >= settings.level3Percent -> 3
            peakReturn >= settings.level2Percent -> 2
            peakReturn >= settings.level1Percent -> 1
            else -> 0
        }
        val state = when {
            peakReturn >= settings.level4Percent && giveback >= settings.lockedGivebackPercentPoints -> ProfitProtectionState.PROFIT_LOCKED
            peakReturn >= settings.level3Percent && giveback >= settings.defenseGivebackPercentPoints -> ProfitProtectionState.PROFIT_DEFENSE
            peakReturn >= settings.level2Percent && giveback >= settings.cautionGivebackPercentPoints -> ProfitProtectionState.PROFIT_CAUTION
            daily > 0.0 -> ProfitProtectionState.PROFIT_RUNNING
            else -> ProfitProtectionState.NORMAL
        }
        val streakMultiplier = when { consecutiveLosses >= 5 -> 0.5; consecutiveLosses >= 3 -> 0.7; else -> 1.0 }
        val multiplier = when (state) {
            ProfitProtectionState.PROFIT_DEFENSE -> settings.defenseMultiplier
            ProfitProtectionState.PROFIT_LOCKED -> 0.0
            ProfitProtectionState.PROFIT_CAUTION -> settings.cautionMultiplier
            else -> 1.0
        }.coerceIn(0.0, 1.0).let { minOf(it, streakMultiplier) }
        val entryAllowed = state != ProfitProtectionState.PROFIT_LOCKED || settings.lockedEntryAllowed
        val reason = when (state) {
            ProfitProtectionState.PROFIT_RUNNING -> "수익 추세 유지, 목표 도달만으로 거래 중지하지 않음"
            ProfitProtectionState.PROFIT_CAUTION -> "고점 수익 ${"%.2f".format(peakReturn)}% 대비 ${"%.2f".format(giveback)}%p 반납 — 비중 축소"
            ProfitProtectionState.PROFIT_DEFENSE -> "고점 수익 반납 확대 — 신규 비중 ${"%.0f".format(multiplier * 100)}% 방어"
            ProfitProtectionState.PROFIT_LOCKED -> "고점 수익 반납 한도 초과 — 신규 진입 잠금"
            ProfitProtectionState.NORMAL -> if (consecutiveLosses >= 3) "연속 손실로 보수적 비중 적용" else "정상 수익 보호 상태"
        }
        val healthSuffix = if (health.level != MarketHealthLevel.HEALTHY) " / Market Health ${health.score.toInt()}" else ""
        return ProfitProtectionSnapshot(start, current, peak, daily, peakReturn, if (peak > 0) (current / peak - 1.0) * 100.0 else 0.0, giveback, state, level, multiplier, entryAllowed, reason + healthSuffix)
    }
}

object ProfitVelocityEngine {
    fun evaluate(snapshots: List<BalanceSnapshotEntity>, windowMinutes: Long = 30L): ProfitVelocity {
        val rows = snapshots.sortedBy { it.time }.takeLast(20)
        if (rows.size < 2) return ProfitVelocity()
        val window = rows.filter { rows.last().time - it.time <= windowMinutes * 60_000L }
        if (window.size < 2) return ProfitVelocity()
        val first = window.first()
        val last = window.last()
        val returnPercent = if (first.totalValue > 0) (last.totalValue / first.totalValue - 1.0) * 100.0 else 0.0
        val slope = returnPercent / ((last.time - first.time).coerceAtLeast(1L) / 60_000.0)
        val prior = if (window.size >= 4) window.dropLast(window.size / 2) else emptyList()
        val priorReturn = if (prior.size >= 2 && prior.first().totalValue > 0) (prior.last().totalValue / prior.first().totalValue - 1.0) * 100.0 else 0.0
        val state = when { prior.isEmpty() || abs(returnPercent - priorReturn) < 0.05 -> ProfitVelocityState.FLAT; returnPercent > priorReturn -> ProfitVelocityState.ACCELERATING; else -> ProfitVelocityState.DECELERATING }
        return ProfitVelocity(state, returnPercent, slope, window.size)
    }
}

data class ProfitCounterfactualResult(val continueReturn: Double, val cautionReturn: Double, val defenseReturn: Double, val fixedTargetReturn: Double, val recommendation: String)
object ProfitCounterfactualEngine {
    fun compare(continueReturns: List<Double>, cautionReturns: List<Double>, defenseReturns: List<Double>, fixedTargetReturns: List<Double>): ProfitCounterfactualResult {
        fun averageOrZero(values: List<Double>) = if (values.isEmpty()) 0.0 else values.average()
        val values = listOf(averageOrZero(continueReturns), averageOrZero(cautionReturns), averageOrZero(defenseReturns), averageOrZero(fixedTargetReturns))
        val best = values.indices.maxByOrNull { values[it] } ?: 0
        return ProfitCounterfactualResult(values[0], values[1], values[2], values[3], listOf("CONTINUE", "CAUTION", "DEFENSE", "FIXED_3_PERCENT_STOP")[best])
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ProfitProtection.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ReentryGuard.kt =====
package com.example.bithumbtrader

import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

enum class MarketGuardStatus { NORMAL, COOLDOWN, EXTENDED_COOLDOWN, TEMP_BLOCKED }

data class MarketReentryState(
    val market: String,
    val lossStreak: Int = 0,
    val cooldownUntil: Long = 0L,
    val status: MarketGuardStatus = MarketGuardStatus.NORMAL,
    val lastExitReason: String = "",
    val exitScore: Double = 0.0,
    val exitTime: Long = 0L,
    val signalResetRequired: Boolean = false,
    val scoreResetObserved: Boolean = false
)

data class ReentryDecision(
    val allowed: Boolean,
    val reasonCode: String,
    val detail: String,
    val remainingCooldownMs: Long = 0L,
    val lossStreak: Int = 0
)

object ReentryGuardPolicy {
    fun evaluate(
        state: MarketReentryState?,
        candidateSignal: StrategySignalModel,
        holding: Boolean,
        inflight: Boolean,
        now: Long = System.currentTimeMillis()
    ): ReentryDecision {
        if (holding) return ReentryDecision(false, "ALREADY_HOLDING", "ALREADY_HOLDING 현재 보유 중")
        if (inflight) return ReentryDecision(false, "BUY_IN_FLIGHT", "BUY_IN_FLIGHT 주문 처리 중")
        if (state == null) return ReentryDecision(true, "ALLOWED", "진입 가능")

        if (state.status == MarketGuardStatus.TEMP_BLOCKED && now < state.cooldownUntil) {
            val remaining = (state.cooldownUntil - now).coerceAtLeast(0L)
            return ReentryDecision(false, "TEMP_BLOCKED", "TEMP_BLOCKED ${state.market} 연속 손실 ${state.lossStreak}회로 일시 차단 (${remaining / 60_000}분 남음)", remaining, state.lossStreak)
        }

        if (now < state.cooldownUntil) {
            val remaining = state.cooldownUntil - now
            val code = when (state.lastExitReason) {
                "STOP LOSS", "STOP_LOSS" -> "STOP_LOSS_COOLDOWN"
                "TRAILING STOP", "TRAILING_STOP" -> "TRAILING_STOP_COOLDOWN"
                "CRASH", "CRASH_EXIT" -> "CRASH_EXIT_COOLDOWN"
                else -> "REENTRY_COOLDOWN"
            }
            return ReentryDecision(false, code, "$code ${state.market} 쿨다운 중 (${remaining / 1000}초 남음, lossStreak=${state.lossStreak})", remaining, state.lossStreak)
        }

        if (state.signalResetRequired && !state.scoreResetObserved) {
            val resetDrop = candidateSignal.score <= maxOf(30.0, state.exitScore - 12.0)
            if (!resetDrop) {
                return ReentryDecision(false, "WAITING_FOR_NEW_SIGNAL", "WAITING_FOR_NEW_SIGNAL 손절 이전 신호 잔류(exitScore=${state.exitScore.toInt()}, current=${candidateSignal.score.toInt()})", 0L, state.lossStreak)
            }
        }

        return ReentryDecision(true, "ALLOWED", "진입 가능", 0L, state.lossStreak)
    }

    fun onExit(
        previous: MarketReentryState?,
        market: String,
        exitReason: String,
        pnlRate: Double,
        exitScore: Double,
        stopLossCooldownMs: Long,
        trailingCooldownMs: Long,
        now: Long = System.currentTimeMillis()
    ): MarketReentryState {
        val isLoss = pnlRate < 0.0 || exitReason.contains("STOP") || exitReason.contains("CRASH")
        val lossStreak = if (isLoss) (previous?.lossStreak ?: 0) + 1 else 0
        val baseDuration = when {
            exitReason.contains("STOP LOSS") || exitReason.contains("STOP_LOSS") -> stopLossCooldownMs
            exitReason.contains("TRAILING") -> trailingCooldownMs
            exitReason.contains("CRASH") -> stopLossCooldownMs * 2
            else -> if (isLoss) stopLossCooldownMs else 0L
        }
        val duration = when {
            lossStreak >= 3 -> maxOf(baseDuration * 4, 3 * 3_600_000L)
            lossStreak == 2 -> baseDuration * 2
            else -> baseDuration
        }
        val status = when {
            lossStreak >= 3 -> MarketGuardStatus.TEMP_BLOCKED
            lossStreak == 2 -> MarketGuardStatus.EXTENDED_COOLDOWN
            duration > 0L -> MarketGuardStatus.COOLDOWN
            else -> MarketGuardStatus.NORMAL
        }
        return MarketReentryState(
            market = market,
            lossStreak = lossStreak,
            cooldownUntil = now + duration,
            status = status,
            lastExitReason = exitReason,
            exitScore = exitScore,
            exitTime = now,
            signalResetRequired = isLoss,
            scoreResetObserved = false
        )
    }
}

class MarketReentryCoordinator {
    private val marketLocks = ConcurrentHashMap<String, Mutex>()
    private val states = ConcurrentHashMap<String, MarketReentryState>()

    fun lockFor(market: String): Mutex = marketLocks.computeIfAbsent(market) { Mutex() }

    fun get(market: String): MarketReentryState? = states[market]

    fun updateScore(market: String, currentScore: Double) {
        val current = states[market] ?: return
        if (current.signalResetRequired && !current.scoreResetObserved) {
            if (currentScore <= maxOf(30.0, current.exitScore - 12.0)) {
                states[market] = current.copy(scoreResetObserved = true)
            }
        }
    }

    fun recordExit(
        market: String,
        exitReason: String,
        pnlRate: Double,
        exitScore: Double,
        stopLossCooldownMs: Long,
        trailingCooldownMs: Long,
        now: Long = System.currentTimeMillis()
    ): MarketReentryState {
        val next = ReentryGuardPolicy.onExit(
            previous = states[market],
            market = market,
            exitReason = exitReason,
            pnlRate = pnlRate,
            exitScore = exitScore,
            stopLossCooldownMs = stopLossCooldownMs,
            trailingCooldownMs = trailingCooldownMs,
            now = now
        )
        states[market] = next
        return next
    }

    fun restore(all: List<MarketReentryState>) {
        all.forEach { states[it.market] = it }
    }

    fun snapshot(): List<MarketReentryState> = states.values.toList()

    fun clear() {
        states.clear()
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ReentryGuard.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/RegimeStrategySets.kt =====
package com.example.bithumbtrader

import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

/**
 * 국면별 전략 파라미터 세트.
 * - Strategy Score 계산식은 변경하지 않는다.
 * - PAPER: 설정이 켜져 있으면 effectiveSettings에 국면 세트를 자동 적용한다.
 * - LIVE: 추천/표시만 하고 자동 적용하지 않는다.
 * - dailyMaxLoss / Kill Switch / Crash Protection 하드 가드는 절대 완화하지 않는다.
 */
data class RegimeStrategyParameterSet(
    val setId: String,
    val regime: MarketRegime,
    val displayName: String,
    val scoreThresholdDelta: Double = 0.0,
    val aiMinScoreDelta: Double = 0.0,
    val stopLossTightenFactor: Double = 1.0,
    val takeProfitFactor: Double = 1.0,
    val trailingStopFactor: Double = 1.0,
    val maxPositionsDelta: Int = 0,
    val orderSizeMultiplier: Double = 1.0,
    val chaseRejectScoreDelta: Double = 0.0,
    val minimumEntryTimingScoreDelta: Double = 0.0,
    val noNewEntries: Boolean = false,
    val reason: String = ""
)

data class RegimeStrategySetDecision(
    val regime: MarketRegime,
    val setId: String,
    val displayName: String,
    val applied: Boolean,
    val recommendOnly: Boolean,
    val noNewEntries: Boolean,
    val reason: String,
    val scoreThreshold: Double,
    val aiMinScore: Double,
    val stopLossPercent: Double,
    val takeProfitPercent: Double,
    val trailingStopPercent: Double,
    val maxPositions: Int,
    val maxOrderPercent: Double,
    val chaseRejectScore: Double,
    val minimumEntryTimingScore: Double,
    val scoreThresholdDelta: Double = 0.0,
    val orderSizeMultiplier: Double = 1.0
)

data class RegimeStrategySetState(
    val active: RegimeStrategySetDecision = RegimeStrategySetDecision(
        regime = MarketRegime.UNKNOWN,
        setId = "BASELINE",
        displayName = "기준선",
        applied = false,
        recommendOnly = true,
        noNewEntries = false,
        reason = "국면 세트 대기",
        scoreThreshold = 75.0,
        aiMinScore = 55.0,
        stopLossPercent = -2.5,
        takeProfitPercent = 6.0,
        trailingStopPercent = 2.5,
        maxPositions = 3,
        maxOrderPercent = 20.0,
        chaseRejectScore = 80.0,
        minimumEntryTimingScore = 45.0
    ),
    val catalog: List<RegimeStrategyParameterSet> = RegimeStrategySetCatalog.defaults(),
    val accuracy: RegimeAccuracyStats = RegimeAccuracyStats(),
    val shadow: RegimeSetShadowComparison = RegimeSetShadowComparison(),
    val lastSwitchedAt: Long = 0L,
    val previousSetId: String = "BASELINE"
)

data class RegimeAccuracyStats(
    val sampleCount: Int = 0,
    val correctCount: Int = 0,
    val accuracyRate: Double = 0.0,
    val byRegime: Map<String, Double> = emptyMap(),
    val status: String = "INSUFFICIENT_SAMPLE",
    val message: String = "국면 정확도 표본 부족"
)

data class RegimeAccuracyObservation(
    val predicted: MarketRegime,
    val confidence: Double,
    val forwardAverageReturnPercent: Double,
    val horizonMinutes: Int = 60
)

data class RegimeSetShadowComparison(
    val baselineExpectedReturn: Double = 0.0,
    val adaptiveExpectedReturn: Double = 0.0,
    val baselineMdd: Double = 0.0,
    val adaptiveMdd: Double = 0.0,
    val sampleCount: Int = 0,
    val winner: String = "INSUFFICIENT_SAMPLE",
    val reason: String = "국면 세트 Shadow 표본 부족"
)

@Entity(tableName = "regime_strategy_set_snapshots")
data class RegimeStrategySetSnapshotEntity(
    @PrimaryKey val id: String,
    val regime: String,
    val setId: String,
    val applied: Boolean,
    val recommendOnly: Boolean,
    val noNewEntries: Boolean,
    val scoreThreshold: Double,
    val stopLossPercent: Double,
    val takeProfitPercent: Double,
    val trailingStopPercent: Double,
    val maxPositions: Int,
    val maxOrderPercent: Double,
    val reason: String,
    val createdAt: Long
)

@Entity(tableName = "regime_accuracy_samples")
data class RegimeAccuracySampleEntity(
    @PrimaryKey val id: String,
    val predictedRegime: String,
    val confidence: Double,
    val forwardAverageReturnPercent: Double,
    val horizonMinutes: Int,
    val correct: Boolean,
    val createdAt: Long
)

object RegimeStrategySetCatalog {
    fun defaults(): List<RegimeStrategyParameterSet> = listOf(
        RegimeStrategyParameterSet(
            setId = "SET_STRONG_BULL",
            regime = MarketRegime.STRONG_BULL,
            displayName = "강세장 공격",
            scoreThresholdDelta = -4.0,
            aiMinScoreDelta = -3.0,
            stopLossTightenFactor = 1.05,
            takeProfitFactor = 1.15,
            trailingStopFactor = 1.1,
            maxPositionsDelta = 1,
            orderSizeMultiplier = 1.05,
            chaseRejectScoreDelta = 2.0,
            minimumEntryTimingScoreDelta = -3.0,
            reason = "강한 상승 국면에서 완만한 진입 완화 + 익절 여유"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_BULL",
            regime = MarketRegime.BULL,
            displayName = "상승장 표준",
            scoreThresholdDelta = -2.0,
            aiMinScoreDelta = -2.0,
            stopLossTightenFactor = 1.0,
            takeProfitFactor = 1.05,
            trailingStopFactor = 1.0,
            maxPositionsDelta = 0,
            orderSizeMultiplier = 1.0,
            chaseRejectScoreDelta = 0.0,
            minimumEntryTimingScoreDelta = -2.0,
            reason = "상승 국면 기준 세트"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_SIDEWAYS",
            regime = MarketRegime.SIDEWAYS,
            displayName = "횡보 보수",
            scoreThresholdDelta = 3.0,
            aiMinScoreDelta = 3.0,
            stopLossTightenFactor = 0.9,
            takeProfitFactor = 0.85,
            trailingStopFactor = 0.9,
            maxPositionsDelta = 0,
            orderSizeMultiplier = 0.85,
            chaseRejectScoreDelta = -2.0,
            minimumEntryTimingScoreDelta = 3.0,
            reason = "횡보에서는 진입 기준을 높이고 포지션을 축소"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_HIGH_VOL",
            regime = MarketRegime.HIGH_VOLATILITY,
            displayName = "고변동 방어",
            scoreThresholdDelta = 6.0,
            aiMinScoreDelta = 5.0,
            stopLossTightenFactor = 0.75,
            takeProfitFactor = 0.9,
            trailingStopFactor = 0.75,
            maxPositionsDelta = -1,
            orderSizeMultiplier = 0.6,
            chaseRejectScoreDelta = -5.0,
            minimumEntryTimingScoreDelta = 8.0,
            reason = "고변동성에서는 손절을 타이트하게, 주문비중 축소"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_WEAK_BEAR",
            regime = MarketRegime.WEAK_BEAR,
            displayName = "약세 축소",
            scoreThresholdDelta = 5.0,
            aiMinScoreDelta = 5.0,
            stopLossTightenFactor = 0.8,
            takeProfitFactor = 0.8,
            trailingStopFactor = 0.8,
            maxPositionsDelta = -1,
            orderSizeMultiplier = 0.55,
            chaseRejectScoreDelta = -4.0,
            minimumEntryTimingScoreDelta = 6.0,
            reason = "약세 초입 — 신규 진입 축소"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_BEAR",
            regime = MarketRegime.BEAR,
            displayName = "하락장 방어",
            scoreThresholdDelta = 8.0,
            aiMinScoreDelta = 8.0,
            stopLossTightenFactor = 0.7,
            takeProfitFactor = 0.7,
            trailingStopFactor = 0.7,
            maxPositionsDelta = -1,
            orderSizeMultiplier = 0.4,
            chaseRejectScoreDelta = -6.0,
            minimumEntryTimingScoreDelta = 10.0,
            reason = "하락장 — 높은 진입 기준 + 타이트한 손절"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_STRONG_BEAR",
            regime = MarketRegime.STRONG_BEAR,
            displayName = "강한 하락 방어",
            scoreThresholdDelta = 12.0,
            aiMinScoreDelta = 10.0,
            stopLossTightenFactor = 0.6,
            takeProfitFactor = 0.65,
            trailingStopFactor = 0.6,
            maxPositionsDelta = -2,
            orderSizeMultiplier = 0.25,
            chaseRejectScoreDelta = -8.0,
            minimumEntryTimingScoreDelta = 12.0,
            reason = "강한 하락 — 거의 신규 진입 금지에 가깝게 축소"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_CRASH",
            regime = MarketRegime.CRASH,
            displayName = "급락 차단",
            scoreThresholdDelta = 25.0,
            aiMinScoreDelta = 20.0,
            stopLossTightenFactor = 0.5,
            takeProfitFactor = 0.5,
            trailingStopFactor = 0.5,
            maxPositionsDelta = -50,
            orderSizeMultiplier = 0.0,
            chaseRejectScoreDelta = -20.0,
            minimumEntryTimingScoreDelta = 30.0,
            noNewEntries = true,
            reason = "급락 — 신규 진입 금지 세트"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_RECOVERY",
            regime = MarketRegime.RECOVERY,
            displayName = "회복 신중",
            scoreThresholdDelta = 4.0,
            aiMinScoreDelta = 4.0,
            stopLossTightenFactor = 0.85,
            takeProfitFactor = 0.9,
            trailingStopFactor = 0.85,
            maxPositionsDelta = 0,
            orderSizeMultiplier = 0.7,
            chaseRejectScoreDelta = -3.0,
            minimumEntryTimingScoreDelta = 5.0,
            reason = "급락 이후 회복 — 신중 재진입"
        ),
        RegimeStrategyParameterSet(
            setId = "SET_UNKNOWN",
            regime = MarketRegime.UNKNOWN,
            displayName = "미확인 기준선",
            scoreThresholdDelta = 2.0,
            aiMinScoreDelta = 2.0,
            stopLossTightenFactor = 0.95,
            takeProfitFactor = 1.0,
            trailingStopFactor = 0.95,
            maxPositionsDelta = 0,
            orderSizeMultiplier = 0.9,
            chaseRejectScoreDelta = 0.0,
            minimumEntryTimingScoreDelta = 2.0,
            reason = "국면 미확인 — 기준선 유지 + 소폭 보수"
        )
    )

    fun forRegime(regime: MarketRegime): RegimeStrategyParameterSet =
        defaults().firstOrNull { it.regime == regime } ?: defaults().first { it.regime == MarketRegime.UNKNOWN }
}

object RegimeStrategySetSafety {
    /** Bull 완화 상한: scoreThreshold는 기준선 대비 최대 5점까지만 낮출 수 있다. */
    const val MAX_SCORE_RELAXATION = 5.0
    const val MAX_ORDER_MULTIPLIER = 1.1
    const val MIN_ORDER_MULTIPLIER = 0.0

    fun clampCandidate(base: TradingSettings, candidate: TradingSettings): TradingSettings {
        val minScore = (base.scoreThreshold - MAX_SCORE_RELAXATION).coerceAtLeast(0.0)
        val maxPositionsCeiling = (base.maxPositions + 1).coerceAtMost(50)
        return candidate.copy(
            scoreThreshold = candidate.scoreThreshold.coerceIn(minScore, 100.0),
            aiMinScore = candidate.aiMinScore.coerceIn(0.0, 100.0),
            // 손절은 기준선보다 완화(더 넓게)되지 않도록 제한. 타이트닝만 허용.
            stopLossPercent = candidate.stopLossPercent.coerceIn(base.stopLossPercent, -0.1),
            takeProfitPercent = candidate.takeProfitPercent.coerceIn(0.1, max(base.takeProfitPercent * 1.25, base.takeProfitPercent)),
            trailingStopPercent = candidate.trailingStopPercent.coerceIn(0.1, max(base.trailingStopPercent * 1.25, base.trailingStopPercent)),
            maxPositions = candidate.maxPositions.coerceIn(1, maxPositionsCeiling),
            maxOrderPercent = candidate.maxOrderPercent.coerceIn(0.0, base.maxOrderPercent * MAX_ORDER_MULTIPLIER),
            maxAssetPercentPerCoin = candidate.maxAssetPercentPerCoin.coerceIn(0.0, base.maxAssetPercentPerCoin * MAX_ORDER_MULTIPLIER),
            // 하드 리스크 한도는 절대 변경하지 않는다.
            dailyMaxLossPercent = base.dailyMaxLossPercent,
            maxConsecutiveLosses = base.maxConsecutiveLosses,
            chaseRejectScore = candidate.chaseRejectScore.coerceIn(50.0, 100.0),
            minimumEntryTimingScore = candidate.minimumEntryTimingScore.coerceIn(0.0, 100.0)
        )
    }
}

object RegimeStrategySetEngine {
    fun resolveSet(regime: MarketRegime): RegimeStrategyParameterSet = RegimeStrategySetCatalog.forRegime(regime)

    fun buildDecision(
        base: TradingSettings,
        regime: MarketRegimeSnapshot,
        mode: TradeMode,
        enabled: Boolean,
        paperApplyEnabled: Boolean
    ): RegimeStrategySetDecision {
        val set = resolveSet(regime.regime)
        val raw = applySetRaw(base, set)
        val clamped = RegimeStrategySetSafety.clampCandidate(base, raw)
        val shouldApply = enabled && paperApplyEnabled && mode == TradeMode.PAPER
        val effective = if (shouldApply) clamped else base
        return RegimeStrategySetDecision(
            regime = regime.regime,
            setId = set.setId,
            displayName = set.displayName,
            applied = shouldApply,
            recommendOnly = !shouldApply,
            noNewEntries = set.noNewEntries && shouldApply,
            reason = when {
                !enabled -> "국면 전략 세트 비활성 — 기준선 유지"
                mode != TradeMode.PAPER -> "LIVE는 추천만 — ${set.displayName} (${set.reason})"
                !paperApplyEnabled -> "Paper 자동적용 OFF — ${set.displayName} 추천만"
                else -> "PAPER 자동적용: ${set.displayName} · ${set.reason}"
            },
            scoreThreshold = effective.scoreThreshold,
            aiMinScore = effective.aiMinScore,
            stopLossPercent = effective.stopLossPercent,
            takeProfitPercent = effective.takeProfitPercent,
            trailingStopPercent = effective.trailingStopPercent,
            maxPositions = effective.maxPositions,
            maxOrderPercent = effective.maxOrderPercent,
            chaseRejectScore = effective.chaseRejectScore,
            minimumEntryTimingScore = effective.minimumEntryTimingScore,
            scoreThresholdDelta = effective.scoreThreshold - base.scoreThreshold,
            orderSizeMultiplier = if (base.maxOrderPercent <= 0.0) 1.0 else effective.maxOrderPercent / base.maxOrderPercent
        )
    }

    fun applyOverlay(
        base: TradingSettings,
        regime: MarketRegimeSnapshot,
        mode: TradeMode,
        enabled: Boolean,
        paperApplyEnabled: Boolean
    ): TradingSettings {
        val decision = buildDecision(base, regime, mode, enabled, paperApplyEnabled)
        if (!decision.applied) return base
        val set = resolveSet(regime.regime)
        val raw = applySetRaw(base, set)
        return RegimeStrategySetSafety.clampCandidate(base, raw).let { clamped ->
            if (decision.noNewEntries) {
                clamped.copy(scoreThreshold = 100.0, maxOrderPercent = 0.0, maxPositions = 1)
            } else clamped
        }
    }

    private fun applySetRaw(base: TradingSettings, set: RegimeStrategyParameterSet): TradingSettings {
        val multiplier = set.orderSizeMultiplier.coerceIn(
            RegimeStrategySetSafety.MIN_ORDER_MULTIPLIER,
            RegimeStrategySetSafety.MAX_ORDER_MULTIPLIER
        )
        // stopLossTightenFactor < 1.0 → 손절을 0에 가깝게(타이트). factor는 |stop|에 곱한다.
        val stopMagnitude = abs(base.stopLossPercent) * set.stopLossTightenFactor
        val stop = -stopMagnitude.coerceAtLeast(0.1)
        return base.copy(
            scoreThreshold = (base.scoreThreshold + set.scoreThresholdDelta).coerceIn(0.0, 100.0),
            aiMinScore = (base.aiMinScore + set.aiMinScoreDelta).coerceIn(0.0, 100.0),
            stopLossPercent = stop.coerceIn(-80.0, -0.1),
            takeProfitPercent = (base.takeProfitPercent * set.takeProfitFactor).coerceIn(0.1, 300.0),
            trailingStopPercent = (base.trailingStopPercent * set.trailingStopFactor).coerceIn(0.1, 80.0),
            maxPositions = (base.maxPositions + set.maxPositionsDelta).coerceIn(1, 50),
            maxOrderPercent = (base.maxOrderPercent * multiplier).coerceIn(0.0, 100.0),
            maxAssetPercentPerCoin = (base.maxAssetPercentPerCoin * multiplier).coerceIn(0.0, 100.0),
            chaseRejectScore = (base.chaseRejectScore + set.chaseRejectScoreDelta).coerceIn(50.0, 100.0),
            minimumEntryTimingScore = (base.minimumEntryTimingScore + set.minimumEntryTimingScoreDelta).coerceIn(0.0, 100.0)
        )
    }
}

/**
 * 국면 분류기 정확도 검증.
 * 예측 국면과 이후 시장 평균 수익률의 방향 일치 여부를 사후 평가한다.
 * Look-ahead: forward return은 예측 시점 이후에만 사용한다.
 */
object RegimeAccuracyValidator {
    const val MIN_SAMPLE = 20

    fun isDirectionallyCorrect(predicted: MarketRegime, forwardAverageReturnPercent: Double): Boolean = when (predicted) {
        MarketRegime.STRONG_BULL, MarketRegime.BULL, MarketRegime.RECOVERY -> forwardAverageReturnPercent > 0.15
        MarketRegime.WEAK_BEAR, MarketRegime.BEAR, MarketRegime.STRONG_BEAR, MarketRegime.CRASH -> forwardAverageReturnPercent < -0.15
        MarketRegime.SIDEWAYS -> abs(forwardAverageReturnPercent) <= 1.0
        MarketRegime.HIGH_VOLATILITY -> abs(forwardAverageReturnPercent) >= 0.8
        MarketRegime.UNKNOWN -> false
    }

    fun summarize(observations: List<RegimeAccuracyObservation>): RegimeAccuracyStats {
        if (observations.isEmpty()) return RegimeAccuracyStats()
        val evaluated = observations.map {
            it to isDirectionallyCorrect(it.predicted, it.forwardAverageReturnPercent)
        }
        val correct = evaluated.count { it.second }
        val byRegime = evaluated.groupBy { it.first.predicted.name }.mapValues { (_, rows) ->
            rows.count { it.second }.toDouble() / rows.size
        }
        val rate = correct.toDouble() / evaluated.size
        val status = when {
            evaluated.size < MIN_SAMPLE -> "INSUFFICIENT_SAMPLE"
            rate >= 0.6 -> "ACCEPTABLE"
            rate >= 0.45 -> "WEAK"
            else -> "POOR"
        }
        return RegimeAccuracyStats(
            sampleCount = evaluated.size,
            correctCount = correct,
            accuracyRate = rate,
            byRegime = byRegime,
            status = status,
            message = when (status) {
                "INSUFFICIENT_SAMPLE" -> "표본 ${evaluated.size}/${MIN_SAMPLE} — 정확도 판정 보류"
                "ACCEPTABLE" -> "국면 방향 적중률 ${"%.0f".format(rate * 100)}% — 수용 가능"
                "WEAK" -> "국면 방향 적중률 ${"%.0f".format(rate * 100)}% — 약함, 세트 자동 강화만 유지"
                else -> "국면 방향 적중률 ${"%.0f".format(rate * 100)}% — 낮음, 보수 세트 우선"
            }
        )
    }
}

object RegimeSetShadowEngine {
    const val MIN_SAMPLE = 15

    fun compare(baselinePnls: List<Double>, adaptivePnls: List<Double>): RegimeSetShadowComparison {
        val base = StrategyPerformanceEngine.fromPnlRates(baselinePnls)
        val adaptive = StrategyPerformanceEngine.fromPnlRates(adaptivePnls)
        val samples = min(base.sampleCount, adaptive.sampleCount)
        if (samples < MIN_SAMPLE) {
            return RegimeSetShadowComparison(
                baselineExpectedReturn = base.expectedReturnPercent,
                adaptiveExpectedReturn = adaptive.expectedReturnPercent,
                baselineMdd = base.maxDrawdownPercent,
                adaptiveMdd = adaptive.maxDrawdownPercent,
                sampleCount = samples,
                winner = "INSUFFICIENT_SAMPLE",
                reason = "국면 세트 Shadow 표본 부족 ($samples/$MIN_SAMPLE)"
            )
        }
        val baseScore = base.expectedReturnPercent - abs(base.maxDrawdownPercent) * 0.5
        val adaptiveScore = adaptive.expectedReturnPercent - abs(adaptive.maxDrawdownPercent) * 0.5
        val winner = when {
            adaptiveScore > baseScore + 0.05 -> "REGIME_ADAPTIVE"
            baseScore > adaptiveScore + 0.05 -> "FIXED_BASELINE"
            else -> "TIE"
        }
        return RegimeSetShadowComparison(
            baselineExpectedReturn = base.expectedReturnPercent,
            adaptiveExpectedReturn = adaptive.expectedReturnPercent,
            baselineMdd = base.maxDrawdownPercent,
            adaptiveMdd = adaptive.maxDrawdownPercent,
            sampleCount = samples,
            winner = winner,
            reason = when (winner) {
                "REGIME_ADAPTIVE" -> "국면 적응 세트가 위험조정 기대수익 우위 — 추천 유지(자동 OTA 없음)"
                "FIXED_BASELINE" -> "고정 기준선이 우위 — 국면 세트 자동적용 재검토 권고"
                else -> "차이 미미 — 현상 유지"
            }
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/RegimeStrategySets.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/RemoteAiBrain.kt =====
package com.example.bithumbtrader

import com.squareup.moshi.Json
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs

enum class AiBrainLinkStatus { ONLINE, DEGRADED, OFFLINE, DISABLED }
enum class AiDecisionSource { LOCAL, HETZNER, SHADOW_COMPARE }

data class RemoteWsHealth(
    @Json(name = "connectionState") val connectionState: String? = null,
    @Json(name = "lastMessageAt") val lastMessageAt: Long? = null,
    @Json(name = "messageCount") val messageCount: Long? = null,
    @Json(name = "marketCount") val marketCount: Int? = null
)

data class RemoteAiBrainHealth(
    @Json(name = "service") val service: String? = null,
    @Json(name = "status") val status: String? = null,
    @Json(name = "serverTime") val serverTime: Long? = null,
    @Json(name = "uptimeMs") val uptimeMs: Long? = null,
    @Json(name = "modelVersion") val modelVersion: String? = null,
    @Json(name = "strategyVersion") val strategyVersion: String? = null,
    @Json(name = "apiVersion") val apiVersion: String? = null,
    @Json(name = "lastDecisionAt") val lastDecisionAt: Long? = null,
    @Json(name = "marketCount") val marketCount: Int? = null,
    @Json(name = "microBufferReadyMarkets") val microBufferReadyMarkets: Int? = null,
    @Json(name = "executionDataReadyMarketCount") val executionDataReadyMarketCount: Int? = null,
    @Json(name = "learningStatus") val learningStatus: String? = null,
    @Json(name = "decisionTtlMs") val decisionTtlMs: Long? = null,
    @Json(name = "lastComputeMs") val lastComputeMs: Double? = null,
    @Json(name = "bithumbWs") val bithumbWs: RemoteWsHealth? = null,
    @Json(name = "upbitWs") val upbitWs: RemoteWsHealth? = null,
    @Json(name = "bybitWs") val bybitWs: RemoteWsHealth? = null,
    @Json(name = "bithumbStatus") val bithumbStatus: String? = null,
    @Json(name = "upbitStatus") val upbitStatus: String? = null
)

data class RemoteTradingDecision(
    @Json(name = "decisionId") val decisionId: String? = null,
    @Json(name = "exchange") val exchange: String? = null,
    @Json(name = "positionKey") val positionKey: String? = null,
    @Json(name = "serverTimestamp") val serverTimestamp: Long? = null,
    @Json(name = "expiresAt") val expiresAt: Long? = null,
    @Json(name = "market") val market: String? = null,
    @Json(name = "decision") val decision: String? = null,
    @Json(name = "strategyScore") val strategyScore: Double? = null,
    @Json(name = "aiScore") val aiScore: Double? = null,
    @Json(name = "aiPositive") val aiPositive: Boolean? = null,
    @Json(name = "aiConfidence") val aiConfidence: Double? = null,
    @Json(name = "executionScore") val executionScore: Double? = null,
    @Json(name = "executionConfidence") val executionConfidence: Double? = null,
    @Json(name = "executionState") val executionState: String? = null,
    @Json(name = "entryTimingScore") val entryTimingScore: Double? = null,
    @Json(name = "entryTimingState") val entryTimingState: String? = null,
    @Json(name = "chaseScore") val chaseScore: Double? = null,
    @Json(name = "chaseState") val chaseState: String? = null,
    @Json(name = "shortEdge") val shortEdge: Double? = null,
    @Json(name = "signalPrice") val signalPrice: Double? = null,
    @Json(name = "signalCreatedAt") val signalCreatedAt: Long? = null,
    @Json(name = "signalExpiresAt") val signalExpiresAt: Long? = null,
    @Json(name = "reasonCodes") val reasonCodes: List<String>? = null,
    @Json(name = "modelVersion") val modelVersion: String? = null,
    @Json(name = "strategyVersion") val strategyVersion: String? = null,
    @Json(name = "apiVersion") val apiVersion: String? = null,
    @Json(name = "dataQuality") val dataQuality: String? = null,
    @Json(name = "executionDataQuality") val executionDataQuality: String? = null,
    @Json(name = "serverComputeMs") val serverComputeMs: Double? = null,
    @Json(name = "grossExpectedEdge") val grossExpectedEdge: Double? = null,
    @Json(name = "expectedExecutionCost") val expectedExecutionCost: Double? = null,
    @Json(name = "netExpectedEdge") val netExpectedEdge: Double? = null,
    @Json(name = "liquidityPassed") val liquidityPassed: Boolean? = null,
    @Json(name = "liquidityRank") val liquidityRank: Int? = null,
    @Json(name = "liquidityTotal") val liquidityTotal: Int? = null,
    @Json(name = "liquidityPercentile") val liquidityPercentile: Double? = null,
    @Json(name = "expectedGrossProfitKrw") val expectedGrossProfitKrw: Double? = null,
    @Json(name = "expectedRoundTripCostKrw") val expectedRoundTripCostKrw: Double? = null,
    @Json(name = "expectedRoundTripCostPercent") val expectedRoundTripCostPercent: Double? = null,
    @Json(name = "expectedNetProfitKrw") val expectedNetProfitKrw: Double? = null,
    @Json(name = "expectedNetProfitPercent") val expectedNetProfitPercent: Double? = null,
    @Json(name = "costToGrossProfitRatio") val costToGrossProfitRatio: Double? = null,
    @Json(name = "costCoverageMultiple") val costCoverageMultiple: Double? = null,
    @Json(name = "breakEvenPrice") val breakEvenPrice: Double? = null
)

data class RemoteDashboardCandidate(
    @Json(name = "exchange") val exchange: String? = null,
    @Json(name = "positionKey") val positionKey: String? = null,
    @Json(name = "market") val market: String? = null,
    @Json(name = "price") val price: Double? = null,
    @Json(name = "strategyScore") val strategyScore: Double? = null,
    @Json(name = "aiScore") val aiScore: Double? = null,
    @Json(name = "aiConfidence") val aiConfidence: Double? = null,
    @Json(name = "aiPositive") val aiPositive: Boolean? = null,
    @Json(name = "entryTimingScore") val entryTimingScore: Double? = null,
    @Json(name = "entryTimingState") val entryTimingState: String? = null,
    @Json(name = "chaseScore") val chaseScore: Double? = null,
    @Json(name = "chaseState") val chaseState: String? = null,
    @Json(name = "executionScore") val executionScore: Double? = null,
    @Json(name = "executionConfidence") val executionConfidence: Double? = null,
    @Json(name = "executionState") val executionState: String? = null,
    @Json(name = "shortEdge") val shortEdge: Double? = null,
    @Json(name = "grossExpectedEdge") val grossExpectedEdge: Double? = null,
    @Json(name = "executionCost") val executionCost: Double? = null,
    @Json(name = "netExpectedEdge") val netExpectedEdge: Double? = null,
    @Json(name = "liquidityPassed") val liquidityPassed: Boolean? = null,
    @Json(name = "liquidityRank") val liquidityRank: Int? = null,
    @Json(name = "liquidityTotal") val liquidityTotal: Int? = null,
    @Json(name = "liquidityPercentile") val liquidityPercentile: Double? = null,
    @Json(name = "dataQuality") val dataQuality: String? = null,
    @Json(name = "executionDataQuality") val executionDataQuality: String? = null,
    @Json(name = "derivativesState") val derivativesState: Double? = null,
    @Json(name = "newsRisk") val newsRisk: Double? = null,
    @Json(name = "decision") val decision: String? = null,
    @Json(name = "decisionId") val decisionId: String? = null,
    @Json(name = "reasonCodes") val reasonCodes: List<String>? = null,
    @Json(name = "signalCreatedAt") val signalCreatedAt: Long? = null,
    @Json(name = "signalExpiresAt") val signalExpiresAt: Long? = null,
    @Json(name = "serverTimestamp") val serverTimestamp: Long? = null,
    @Json(name = "modelVersion") val modelVersion: String? = null,
    @Json(name = "strategyVersion") val strategyVersion: String? = null,
    @Json(name = "apiVersion") val apiVersion: String? = null,
    @Json(name = "microSampleCount") val microSampleCount: Int? = null,
    @Json(name = "expectedGrossProfitKrw") val expectedGrossProfitKrw: Double? = null,
    @Json(name = "expectedRoundTripCostKrw") val expectedRoundTripCostKrw: Double? = null,
    @Json(name = "expectedRoundTripCostPercent") val expectedRoundTripCostPercent: Double? = null,
    @Json(name = "expectedNetProfitKrw") val expectedNetProfitKrw: Double? = null,
    @Json(name = "expectedNetProfitPercent") val expectedNetProfitPercent: Double? = null,
    @Json(name = "costToGrossProfitRatio") val costToGrossProfitRatio: Double? = null,
    @Json(name = "costCoverageMultiple") val costCoverageMultiple: Double? = null,
    @Json(name = "breakEvenPrice") val breakEvenPrice: Double? = null
)

data class RemoteDashboardSnapshot(
    @Json(name = "exchange") val exchange: String? = null,
    @Json(name = "serverTimestamp") val serverTimestamp: Long? = null,
    @Json(name = "serverHealth") val serverHealth: String? = null,
    @Json(name = "health") val health: RemoteAiBrainHealth? = null,
    @Json(name = "marketCount") val marketCount: Int? = null,
    @Json(name = "marketRegime") val marketRegime: String? = null,
    @Json(name = "marketHealth") val marketHealth: Double? = null,
    @Json(name = "fastScanCount") val fastScanCount: Int? = null,
    @Json(name = "deepScanCount") val deepScanCount: Int? = null,
    @Json(name = "microBufferReadyMarkets") val microBufferReadyMarkets: Int? = null,
    @Json(name = "modelVersion") val modelVersion: String? = null,
    @Json(name = "strategyVersion") val strategyVersion: String? = null,
    @Json(name = "apiVersion") val apiVersion: String? = null,
    @Json(name = "learningStatus") val learningStatus: String? = null,
    @Json(name = "autonomousLearning") val autonomousLearning: RemoteAutonomousLearning? = null,
    @Json(name = "recentLearning") val recentLearning: RemoteRecentLearning? = null,
    @Json(name = "candidates") val candidates: List<RemoteDashboardCandidate>? = null,
    @Json(name = "serverComputeMs") val serverComputeMs: Double? = null,
    @Json(name = "paper") val paper: RemotePaperState? = null,
    @Json(name = "recentTrades") val recentTrades: List<RemotePaperTrade>? = null,
    @Json(name = "tradeCount") val tradeCount: Int? = null
)

data class RemoteAutonomousLearning(
    @Json(name = "mode") val mode: String? = null,
    @Json(name = "brainState") val brainState: String? = null,
    @Json(name = "layer") val layer: Int? = null,
    @Json(name = "layerStatus") val layerStatus: String? = null,
    @Json(name = "activeModel") val activeModel: String? = null,
    @Json(name = "activeModelHash") val activeModelHash: String? = null,
    @Json(name = "lastLearningAt") val lastLearningAt: Long? = null,
    @Json(name = "lastResearchAt") val lastResearchAt: Long? = null,
    @Json(name = "samplesTotal") val samplesTotal: Int? = null,
    @Json(name = "samplesSinceLastLearning") val samplesSinceLastLearning: Int? = null,
    @Json(name = "realSampleCount") val realSampleCount: Int? = null,
    @Json(name = "realShadowSampleCount") val realShadowSampleCount: Int? = null,
    @Json(name = "paperSampleCount") val paperSampleCount: Int? = null,
    @Json(name = "invalidSampleCount") val invalidSampleCount: Int? = null,
    @Json(name = "syntheticSampleCount") val syntheticSampleCount: Int? = null,
    @Json(name = "realLearningCycleCount") val realLearningCycleCount: Int? = null,
    @Json(name = "syntheticCycleCount") val syntheticCycleCount: Int? = null,
    @Json(name = "championVersion") val championVersion: String? = null,
    @Json(name = "challengerVersion") val challengerVersion: String? = null,
    @Json(name = "candidateModel") val candidateModel: String? = null,
    @Json(name = "shadowStatus") val shadowStatus: String? = null,
    @Json(name = "oosStatus") val oosStatus: String? = null,
    @Json(name = "predictionChangeRate") val predictionChangeRate: Double? = null,
    @Json(name = "learningStatus") val learningStatus: String? = null,
    @Json(name = "learningHealth") val learningHealth: String? = null,
    @Json(name = "learningProofSource") val learningProofSource: String? = null,
    @Json(name = "modelStatus") val modelStatus: String? = null,
    @Json(name = "productionEvidence") val productionEvidence: String? = null,
    @Json(name = "isLearning") val isLearning: Boolean? = null,
    @Json(name = "isImproving") val isImproving: String? = null,
    @Json(name = "paperBuyState") val paperBuyState: String? = null,
    @Json(name = "liveTrading") val liveTrading: Boolean? = null
)

data class RemoteRecentLearning(
    @Json(name = "problem") val problem: String? = null,
    @Json(name = "hypothesis") val hypothesis: String? = null,
    @Json(name = "candidate") val candidate: String? = null,
    @Json(name = "status") val status: String? = null,
    @Json(name = "promotionDecision") val promotionDecision: String? = null,
    @Json(name = "learningCycleId") val learningCycleId: String? = null,
    @Json(name = "badge") val badge: String? = null,
    @Json(name = "dataBadge") val dataBadge: String? = null,
    @Json(name = "message") val message: String? = null,
    @Json(name = "productionEvidence") val productionEvidence: String? = null
)

data class RemotePaperPosition(
    @Json(name = "exchange") val exchange: String? = null,
    @Json(name = "positionKey") val positionKey: String? = null,
    @Json(name = "market") val market: String? = null,
    @Json(name = "quantity") val quantity: Double? = null,
    @Json(name = "avgPrice") val avgPrice: Double? = null,
    @Json(name = "highestPrice") val highestPrice: Double? = null,
    @Json(name = "openedAt") val openedAt: Long? = null,
    @Json(name = "updatedAt") val updatedAt: Long? = null,
    @Json(name = "markPrice") val markPrice: Double? = null,
    @Json(name = "unrealizedPnl") val unrealizedPnl: Double? = null,
    @Json(name = "pnlRate") val pnlRate: Double? = null
)

data class RemotePaperState(
    @Json(name = "exchange") val exchange: String? = null,
    @Json(name = "paperAuto") val paperAuto: Boolean? = null,
    @Json(name = "cash") val cash: Double? = null,
    @Json(name = "initialCash") val initialCash: Double? = null,
    @Json(name = "coinValue") val coinValue: Double? = null,
    @Json(name = "totalValue") val totalValue: Double? = null,
    @Json(name = "realizedPnl") val realizedPnl: Double? = null,
    @Json(name = "unrealizedPnl") val unrealizedPnl: Double? = null,
    @Json(name = "totalPnl") val totalPnl: Double? = null,
    @Json(name = "totalPnlRate") val totalPnlRate: Double? = null,
    @Json(name = "drawdownPercent") val drawdownPercent: Double? = null,
    @Json(name = "drawdownKrw") val drawdownKrw: Double? = null,
    @Json(name = "positionCount") val positionCount: Int? = null,
    @Json(name = "positions") val positions: List<RemotePaperPosition>? = null,
    @Json(name = "recentTrades") val recentTrades: List<RemotePaperTrade>? = null,
    @Json(name = "tradeCount") val tradeCount: Int? = null,
    @Json(name = "newBuyPaused") val newBuyPaused: Boolean? = null,
    @Json(name = "paperBuyResumeMode") val paperBuyResumeMode: String? = null,
    @Json(name = "paperBuyBlockReason") val paperBuyBlockReason: String? = null,
    @Json(name = "pauseReason") val pauseReason: String? = null,
    @Json(name = "topLossCauses") val topLossCauses: List<RemoteTopLossCause>? = null,
    @Json(name = "updatedAt") val updatedAt: Long? = null,
    @Json(name = "lastTickAt") val lastTickAt: Long? = null,
    @Json(name = "tickCount") val tickCount: Int? = null,
    @Json(name = "sourceOfTruth") val sourceOfTruth: String? = null,
    @Json(name = "androidIndependent") val androidIndependent: Boolean? = null
)

data class RemoteTopLossCause(
    @Json(name = "cause") val cause: String? = null,
    @Json(name = "krw") val krw: Double? = null,
    @Json(name = "percentOfLosses") val percentOfLosses: Double? = null
)

data class RemotePaperAutoResponse(
    @Json(name = "accepted") val accepted: Boolean? = null,
    @Json(name = "paper") val paper: RemotePaperState? = null
)

data class RemotePaperTrade(
    @Json(name = "id") val id: String? = null,
    @Json(name = "exchange") val exchange: String? = null,
    @Json(name = "positionKey") val positionKey: String? = null,
    @Json(name = "time") val time: Long? = null,
    @Json(name = "market") val market: String? = null,
    @Json(name = "side") val side: String? = null,
    @Json(name = "amount") val amount: Double? = null,
    @Json(name = "quantity") val quantity: Double? = null,
    @Json(name = "avgPrice") val avgPrice: Double? = null,
    @Json(name = "fee") val fee: Double? = null,
    @Json(name = "realizedPnl") val realizedPnl: Double? = null,
    @Json(name = "pnlRate") val pnlRate: Double? = null,
    @Json(name = "reason") val reason: String? = null,
    @Json(name = "decisionId") val decisionId: String? = null
)

data class RemotePaperTradesResponse(
    @Json(name = "trades") val trades: List<RemotePaperTrade>? = null,
    @Json(name = "serverTimestamp") val serverTimestamp: Long? = null,
    @Json(name = "exchange") val exchange: String? = null
)

/** Lenient parser: Moshi first, then org.json fallback so one bad field never blanks the whole history. */
object RemotePaperTradesParser {
    fun parse(body: String): RemotePaperTradesResponse {
        if (body.isBlank()) error("empty paper trades body")
        runCatching {
            Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
                .adapter(RemotePaperTradesResponse::class.java)
                .fromJson(body)
        }.getOrNull()?.let { moshiParsed ->
            if (moshiParsed.trades != null) return moshiParsed
        }
        val root = org.json.JSONObject(body)
        val arr = root.optJSONArray("trades") ?: org.json.JSONArray()
        val trades = mutableListOf<RemotePaperTrade>()
        for (i in 0 until arr.length()) {
            val o = arr.optJSONObject(i) ?: continue
            val market = o.optString("market", "")
            val side = o.optString("side", "")
            if (market.isBlank() || side.isBlank()) continue
            val decisionId = if (o.isNull("decisionId")) null else o.optString("decisionId", "").ifBlank { null }
            trades += RemotePaperTrade(
                id = o.optString("id", "").ifBlank { null },
                exchange = o.optString("exchange", "").ifBlank { null },
                positionKey = o.optString("positionKey", "").ifBlank { null },
                time = o.optLong("time", 0L).takeIf { it > 0L },
                market = market,
                side = side,
                amount = o.optDouble("amount", Double.NaN).takeIf { !it.isNaN() },
                quantity = o.optDouble("quantity", Double.NaN).takeIf { !it.isNaN() },
                avgPrice = o.optDouble("avgPrice", Double.NaN).takeIf { !it.isNaN() },
                fee = o.optDouble("fee", Double.NaN).takeIf { !it.isNaN() },
                realizedPnl = o.optDouble("realizedPnl", Double.NaN).takeIf { !it.isNaN() },
                pnlRate = o.optDouble("pnlRate", Double.NaN).takeIf { !it.isNaN() },
                reason = o.optString("reason", "").ifBlank { null },
                decisionId = decisionId
            )
        }
        return RemotePaperTradesResponse(
            trades = trades,
            serverTimestamp = root.optLong("serverTimestamp", 0L).takeIf { it > 0L },
            exchange = root.optString("exchange", "").ifBlank { null }
        )
    }
}

data class DeviceVerifyRequest(
    val deviceSessionId: String,
    val appVersion: String,
    val timestamp: Long,
    val event: String,
    val decision: String,
    val serverStateTimestamp: Long? = null,
    val reason: String? = null,
    val expected: Map<String, Any?>? = null,
    val actual: Map<String, Any?>? = null
)

data class DeviceVerifyResponse(
    @Json(name = "accepted") val accepted: Boolean? = null,
    @Json(name = "receivedAt") val receivedAt: Long? = null,
    @Json(name = "event") val event: String? = null,
    @Json(name = "decision") val decision: String? = null
)

data class AiDecisionBundle(
    val prediction: AiPrediction,
    val source: AiDecisionSource,
    val remote: RemoteTradingDecision? = null,
    val localShadow: AiPrediction? = null,
    val linkStatus: AiBrainLinkStatus = AiBrainLinkStatus.DISABLED,
    val networkLatencyMs: Long? = null,
    val decisionAgeMs: Long? = null,
    val rejectReason: String? = null
)

interface AiDecisionProvider {
    suspend fun decide(
        market: String,
        features: List<Double>,
        nowMs: Long = System.currentTimeMillis()
    ): AiDecisionBundle
}

class LocalAiDecisionProvider(
    private val modelProvider: () -> OnDeviceAiModel
) : AiDecisionProvider {
    override suspend fun decide(market: String, features: List<Double>, nowMs: Long): AiDecisionBundle {
        val prediction = modelProvider().predict(features)
        return AiDecisionBundle(
            prediction = prediction,
            source = AiDecisionSource.LOCAL,
            localShadow = prediction,
            linkStatus = AiBrainLinkStatus.DISABLED
        )
    }
}

class HetznerAiBrainClient(
    private val baseUrl: String = "https://riderapp.duckdns.org",
    private val tokenProvider: () -> String,
    private val client: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(Duration.ofSeconds(5))
        .readTimeout(Duration.ofSeconds(8))
        .callTimeout(Duration.ofSeconds(10))
        .build()
) {
    private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
    private val healthAdapter = moshi.adapter(RemoteAiBrainHealth::class.java)
    private val decisionAdapter = moshi.adapter(RemoteTradingDecision::class.java)
    private val dashboardAdapter = moshi.adapter(RemoteDashboardSnapshot::class.java)
    private val paperStateAdapter = moshi.adapter(RemotePaperState::class.java)
    private val paperAutoAdapter = moshi.adapter(RemotePaperAutoResponse::class.java)
    private val paperTradesAdapter = moshi.adapter(RemotePaperTradesResponse::class.java)
    private val deviceVerifyResAdapter = moshi.adapter(DeviceVerifyResponse::class.java)

    data class Timed<T>(val value: T, val networkLatencyMs: Long, val receivedAtMs: Long)

    fun health(): Timed<RemoteAiBrainHealth> {
        val started = System.currentTimeMillis()
        val request = Request.Builder().url("$baseUrl/api/trading/v1/health").get().build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            if (!response.isSuccessful) error("AI Brain health HTTP ${response.code}")
            val parsed = healthAdapter.fromJson(body) ?: error("AI Brain health empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun decision(market: String): Timed<RemoteTradingDecision> {
        // PHASE6 hard stop: Android must use /dashboard, never per-market /decision floods.
        error("DECISION_HTTP_DISABLED_USE_DASHBOARD market=$market")
    }

    fun dashboard(limit: Int = 15, refresh: Boolean = false): Timed<RemoteDashboardSnapshot> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/dashboard?limit=$limit&refresh=$refresh")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .get()
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "dashboard")
            val parsed = dashboardAdapter.fromJson(body) ?: error("AI Brain dashboard empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun upbitHealth(): Timed<RemoteAiBrainHealth> {
        val started = System.currentTimeMillis()
        val request = Request.Builder().url("$baseUrl/api/trading/v1/upbit/health").get().build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            if (!response.isSuccessful) error("Upbit AI Brain health HTTP ${response.code}")
            val parsed = healthAdapter.fromJson(body) ?: error("Upbit AI Brain health empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun upbitDashboard(limit: Int = 15, refresh: Boolean = false): Timed<RemoteDashboardSnapshot> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/upbit/dashboard?limit=$limit&refresh=$refresh")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .get()
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "upbit/dashboard")
            val parsed = dashboardAdapter.fromJson(body) ?: error("Upbit AI Brain dashboard empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun paperState(): Timed<RemotePaperState> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/paper/state")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .get()
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "paper/state")
            val parsed = paperStateAdapter.fromJson(body) ?: error("AI Brain paper state empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun upbitPaperState(): Timed<RemotePaperState> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/upbit/paper/state")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .get()
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "upbit/paper/state")
            val parsed = paperStateAdapter.fromJson(body) ?: error("Upbit paper state empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun paperTrades(limit: Int = 100): Timed<RemotePaperTradesResponse> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val safeLimit = limit.coerceIn(1, 200)
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/paper/trades?limit=$safeLimit")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .get()
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "paper/trades")
            val parsed = RemotePaperTradesParser.parse(body)
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun upbitPaperTrades(limit: Int = 100): Timed<RemotePaperTradesResponse> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val safeLimit = limit.coerceIn(1, 200)
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/upbit/paper/trades?limit=$safeLimit")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .get()
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "upbit/paper/trades")
            val parsed = RemotePaperTradesParser.parse(body)
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun setPaperAuto(enabled: Boolean, source: String = "ANDROID"): Timed<RemotePaperAutoResponse> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val json = """{"enabled":$enabled,"source":"$source"}"""
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/paper/auto")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .post(json.toRequestBody("application/json".toMediaType()))
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "paper/auto")
            val parsed = paperAutoAdapter.fromJson(body) ?: error("AI Brain paper auto empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun setUpbitPaperAuto(enabled: Boolean, source: String = "ANDROID"): Timed<RemotePaperAutoResponse> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val json = """{"enabled":$enabled,"source":"$source"}"""
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/upbit/paper/auto")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .post(json.toRequestBody("application/json".toMediaType()))
            .build()
        client.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "upbit/paper/auto")
            val parsed = paperAutoAdapter.fromJson(body) ?: error("Upbit paper auto empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    /**
     * PHASE6 diagnostic upload. Short timeout — caller must swallow failures.
     * Does not place orders / change paper auto.
     */
    fun postDeviceVerify(payload: DeviceVerifyRequest): Timed<DeviceVerifyResponse> {
        val token = ***REDACTED***
        val started = System.currentTimeMillis()
        val root = org.json.JSONObject()
        root.put("deviceSessionId", payload.deviceSessionId)
        root.put("appVersion", payload.appVersion)
        root.put("timestamp", payload.timestamp)
        root.put("event", payload.event)
        root.put("decision", payload.decision)
        if (payload.serverStateTimestamp != null) root.put("serverStateTimestamp", payload.serverStateTimestamp)
        if (payload.reason != null) root.put("reason", payload.reason)
        if (payload.expected != null) root.put("expected", jsonValue(payload.expected))
        if (payload.actual != null) root.put("actual", jsonValue(payload.actual))
        val diagClient = client.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .readTimeout(Duration.ofSeconds(3))
            .callTimeout(Duration.ofSeconds(4))
            .build()
        val request = Request.Builder()
            .url("$baseUrl/api/trading/v1/diagnostics/device-verify")
            .addHeader("Authorization", "Bearer $token")
            .addHeader("X-Api-Token", token)
            .post(root.toString().toRequestBody("application/json".toMediaType()))
            .build()
        diagClient.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            throwIfAuthHttp(response.code, body, "diagnostics/device-verify")
            val parsed = deviceVerifyResAdapter.fromJson(body) ?: error("device-verify empty")
            val ended = System.currentTimeMillis()
            return Timed(parsed, ended - started, ended)
        }
    }

    fun baseUrlForLog(): String = baseUrl

    private fun requireToken(): String {
        val token = ***REDACTED***
        if (token.isBlank()) error("AUTH_TOKEN_MISSING")
        return token
    }

    private fun throwIfAuthHttp(code: Int, body: String, api: String) {
        when (code) {
            401 -> error("HTTP_401 api=$api")
            403 -> error("HTTP_403 api=$api")
            in 200..299 -> return
            else -> error("PAPER_STATE_REQUEST_FAILED api=$api HTTP $code: ${body.take(120)}")
        }
    }

    private fun jsonValue(value: Any?): Any = when (value) {
        null -> org.json.JSONObject.NULL
        is Map<*, *> -> org.json.JSONObject().also { obj ->
            value.forEach { (k, v) -> if (k != null) obj.put(k.toString(), jsonValue(v)) }
        }
        is List<*> -> org.json.JSONArray().also { arr -> value.forEach { arr.put(jsonValue(it)) } }
        is Number, is Boolean, is String -> value
        else -> value.toString()
    }
}

object RemoteDecisionPolicy {
    const val DEFAULT_MAX_AGE_MS = 90_000L
    const val DEFAULT_MAX_LATENCY_MS = 2_500L
    const val DEFAULT_MAX_CLOCK_SKEW_MS = 5_000L
    const val COMPAT_API_VERSION = "v1"

    fun linkStatus(health: RemoteAiBrainHealth?, error: Boolean): AiBrainLinkStatus = when {
        error || health == null -> AiBrainLinkStatus.OFFLINE
        health.status.equals("ONLINE", ignoreCase = true) -> AiBrainLinkStatus.ONLINE
        health.status.equals("DEGRADED", ignoreCase = true) -> AiBrainLinkStatus.DEGRADED
        else -> AiBrainLinkStatus.DEGRADED
    }

    fun isExpired(decision: RemoteTradingDecision, nowMs: Long, maxAgeMs: Long = DEFAULT_MAX_AGE_MS): Boolean {
        val created = decision.serverTimestamp ?: decision.signalCreatedAt ?: return true
        val expires = decision.expiresAt ?: decision.signalExpiresAt ?: (created + maxAgeMs)
        val age = nowMs - created
        if (age > maxAgeMs + DEFAULT_MAX_CLOCK_SKEW_MS) return true
        return nowMs > expires + DEFAULT_MAX_CLOCK_SKEW_MS
    }

    fun priceMovedAway(signalPrice: Double?, currentPrice: Double, atrPercent: Double, multiple: Double): Boolean {
        if (signalPrice == null || signalPrice <= 0.0 || currentPrice <= 0.0 || atrPercent <= 0.0) return false
        val movePct = abs(currentPrice - signalPrice) / currentPrice * 100.0
        return movePct >= atrPercent * multiple
    }

    fun toPrediction(decision: RemoteTradingDecision): AiPrediction {
        val score = decision.aiScore
        if (score == null || !score.isFinite()) {
            return AiPrediction(0.0, false, "REMOTE AI 데이터 부족")
        }
        val positive = decision.aiPositive ?: (score >= 55.0)
        val label = if (positive) "REMOTE AI: 매수 참고" else "REMOTE AI: 매수 비추천"
        return AiPrediction(score.coerceIn(0.0, 100.0), positive, label)
    }

    fun apiCompatible(decision: RemoteTradingDecision?): Boolean {
        val v = decision?.apiVersion ?: return true
        return v == COMPAT_API_VERSION || v.startsWith("v1")
    }
}

class HetznerAiDecisionProvider(
    private val client: HetznerAiBrainClient,
    private val local: LocalAiDecisionProvider,
    private val settingsProvider: () -> TradingSettings
) : AiDecisionProvider {
    @Volatile var lastDashboard: RemoteDashboardSnapshot? = null
    @Volatile var lastHealth: RemoteAiBrainHealth? = null
    @Volatile var lastLinkStatus: AiBrainLinkStatus = AiBrainLinkStatus.DISABLED
    @Volatile var lastNetworkLatencyMs: Long? = null
    @Volatile var lastError: String? = null
    @Volatile private var lastLocalShadowAtMs: Long = 0L
    private val usedDecisionIds = ConcurrentHashMap.newKeySet<String>()

    fun markDecisionUsed(decisionId: String): Boolean = usedDecisionIds.add(decisionId)

    fun fetchDashboard(limit: Int = 15, refresh: Boolean = false): HetznerAiBrainClient.Timed<RemoteDashboardSnapshot> {
        val timed = client.dashboard(limit, refresh)
        lastDashboard = timed.value
        lastNetworkLatencyMs = timed.networkLatencyMs
        lastHealth = timed.value.health ?: lastHealth
        lastLinkStatus = RemoteDecisionPolicy.linkStatus(
            timed.value.health ?: RemoteAiBrainHealth(status = timed.value.serverHealth),
            error = false
        )
        lastError = null
        return timed
    }

    fun fetchUpbitDashboard(limit: Int = 15, refresh: Boolean = false): HetznerAiBrainClient.Timed<RemoteDashboardSnapshot> {
        val timed = client.upbitDashboard(limit, refresh)
        lastDashboard = timed.value
        lastNetworkLatencyMs = timed.networkLatencyMs
        lastHealth = timed.value.health ?: lastHealth
        lastLinkStatus = RemoteDecisionPolicy.linkStatus(
            timed.value.health ?: RemoteAiBrainHealth(status = timed.value.serverHealth),
            error = false
        )
        lastError = null
        return timed
    }

    fun fetchDashboardFor(exchange: ExchangeId, limit: Int = 15, refresh: Boolean = false) =
        if (exchange == ExchangeId.UPBIT) fetchUpbitDashboard(limit, refresh) else fetchDashboard(limit, refresh)

    fun fetchPaperState(): HetznerAiBrainClient.Timed<RemotePaperState> = client.paperState()

    fun fetchUpbitPaperState(): HetznerAiBrainClient.Timed<RemotePaperState> = client.upbitPaperState()

    fun fetchPaperStateFor(exchange: ExchangeId) =
        if (exchange == ExchangeId.UPBIT) fetchUpbitPaperState() else fetchPaperState()

    fun fetchPaperTrades(limit: Int = 100): HetznerAiBrainClient.Timed<RemotePaperTradesResponse> =
        client.paperTrades(limit)

    fun fetchUpbitPaperTrades(limit: Int = 100): HetznerAiBrainClient.Timed<RemotePaperTradesResponse> =
        client.upbitPaperTrades(limit)

    fun fetchPaperTradesFor(exchange: ExchangeId, limit: Int = 100) =
        if (exchange == ExchangeId.UPBIT) fetchUpbitPaperTrades(limit) else fetchPaperTrades(limit)

    fun setPaperAuto(enabled: Boolean, source: String = "ANDROID"): HetznerAiBrainClient.Timed<RemotePaperAutoResponse> =
        client.setPaperAuto(enabled, source)

    fun setUpbitPaperAuto(enabled: Boolean, source: String = "ANDROID"): HetznerAiBrainClient.Timed<RemotePaperAutoResponse> =
        client.setUpbitPaperAuto(enabled, source)

    fun setPaperAutoFor(exchange: ExchangeId, enabled: Boolean, source: String = "ANDROID") =
        if (exchange == ExchangeId.UPBIT) setUpbitPaperAuto(enabled, source) else setPaperAuto(enabled, source)

    /** Trading-neutral diagnostic upload; failures must be ignored by callers. */
    fun postDeviceVerify(payload: DeviceVerifyRequest): HetznerAiBrainClient.Timed<DeviceVerifyResponse> =
        client.postDeviceVerify(payload)

    private fun shouldRunLocalShadow(settings: TradingSettings, nowMs: Long): Boolean {
        if (!settings.localAiShadowEnabled) return false
        // Phase1/2 (primary OFF): shadow every decide call for comparison.
        if (!settings.remoteAiPrimary) return true
        // Phase3 primary: throttle local shadow to localShadowIntervalMinutes.
        val intervalMs = settings.localShadowIntervalMinutes.coerceIn(1, 180) * 60_000L
        if (nowMs - lastLocalShadowAtMs >= intervalMs) {
            lastLocalShadowAtMs = nowMs
            return true
        }
        return false
    }

    private fun skippedLocalBundle(): AiDecisionBundle = AiDecisionBundle(
        prediction = AiPrediction(0.0, false, "LOCAL_SHADOW_THROTTLED"),
        source = AiDecisionSource.LOCAL,
        linkStatus = AiBrainLinkStatus.DISABLED
    )

    override suspend fun decide(market: String, features: List<Double>, nowMs: Long): AiDecisionBundle {
        val settings = settingsProvider()
        if (!settings.remoteAiEnabled) {
            lastLinkStatus = AiBrainLinkStatus.DISABLED
            return local.decide(market, features, nowMs)
        }
        val runLocalShadow = shouldRunLocalShadow(settings, nowMs)
        val localBundle = if (runLocalShadow) local.decide(market, features, nowMs) else skippedLocalBundle()
        // PHASE6 hard stop: never POST /decision per market. Server Primary uses /dashboard only.
        // ROOT CAUSE: Primary OFF still called client.decision() for every DEEP market → phone floods.
        lastError = "DECISION_HTTP_DISABLED_USE_DASHBOARD"
        if (settings.remoteAiPrimary) {
            lastLinkStatus = AiBrainLinkStatus.DEGRADED
            return AiDecisionBundle(
                prediction = AiPrediction(0.0, false, "USE_SERVER_DASHBOARD"),
                source = AiDecisionSource.HETZNER,
                localShadow = if (runLocalShadow) localBundle.prediction else null,
                linkStatus = AiBrainLinkStatus.DEGRADED,
                rejectReason = "DECISION_HTTP_DISABLED_USE_DASHBOARD"
            )
        }
        return localBundle.copy(
            source = if (settings.localAiShadowEnabled) AiDecisionSource.SHADOW_COMPARE else AiDecisionSource.LOCAL,
            linkStatus = lastLinkStatus.takeIf { it != AiBrainLinkStatus.DISABLED } ?: AiBrainLinkStatus.ONLINE,
            rejectReason = "DECISION_HTTP_DISABLED_USE_DASHBOARD"
        )
    }

    fun refreshHealth(): AiBrainLinkStatus {
        val settings = settingsProvider()
        if (!settings.remoteAiEnabled) {
            lastLinkStatus = AiBrainLinkStatus.DISABLED
            return lastLinkStatus
        }
        return try {
            val timed = client.health()
            lastHealth = timed.value
            lastNetworkLatencyMs = timed.networkLatencyMs
            lastError = null
            lastLinkStatus = RemoteDecisionPolicy.linkStatus(timed.value, error = false)
            lastLinkStatus
        } catch (t: Throwable) {
            lastError = t.message
            lastLinkStatus = AiBrainLinkStatus.OFFLINE
            lastLinkStatus
        }
    }

    fun refreshUpbitHealthStatus(): String {
        return try {
            val timed = client.upbitHealth()
            timed.value.status ?: "DEGRADED"
        } catch (_: Throwable) {
            "OFFLINE"
        }
    }
}

class CompositeAiDecisionProvider(
    private val remote: HetznerAiDecisionProvider,
    private val local: LocalAiDecisionProvider,
    private val settingsProvider: () -> TradingSettings
) : AiDecisionProvider {
    override suspend fun decide(market: String, features: List<Double>, nowMs: Long): AiDecisionBundle {
        val settings = settingsProvider()
        return if (settings.remoteAiEnabled) remote.decide(market, features, nowMs)
        else local.decide(market, features, nowMs)
    }

    fun remoteProvider(): HetznerAiDecisionProvider = remote
}

object AuthFailureCodes {
    fun fromThrowable(t: Throwable): String {
        val m = t.message.orEmpty()
        return when {
            m.contains("AUTH_TOKEN_MISSING") -> "AUTH_TOKEN_MISSING"
            m.contains("HTTP_401") -> "HTTP_401"
            m.contains("HTTP_403") -> "HTTP_403"
            m.contains("WRONG_BASE_URL") || m.contains("UnknownHost") || m.contains("Failed to connect") -> "WRONG_BASE_URL"
            m.contains("PAPER_STATE_REQUEST_FAILED") || m.contains("paper/state") -> "PAPER_STATE_REQUEST_FAILED"
            m.contains("dashboard") -> "DASHBOARD_REQUEST_FAILED"
            else -> "AUTH_OR_NETWORK_ERROR"
        }
    }
}

object ServerBuyGate {
    /**
     * When remote AI is primary, server offline/degraded must FAIL CLOSED for new BUY.
     * Local AI must NOT auto-execute as fallback.
     * Position exits are independent and unaffected.
     */
    fun blockNewBuy(
        settings: TradingSettings,
        linkStatus: AiBrainLinkStatus,
        remoteDecision: RemoteTradingDecision?,
        nowMs: Long = System.currentTimeMillis()
    ): Pair<Boolean, String> {
        if (!settings.remoteAiEnabled || !settings.remoteAiPrimary) return false to ""
        when (linkStatus) {
            AiBrainLinkStatus.OFFLINE -> return true to "SERVER_OFFLINE"
            AiBrainLinkStatus.DEGRADED -> return true to "SERVER_DEGRADED"
            AiBrainLinkStatus.DISABLED -> return true to "SERVER_DISABLED_WHILE_PRIMARY"
            AiBrainLinkStatus.ONLINE -> Unit
        }
        if (remoteDecision == null) return true to "SERVER_DECISION_MISSING"
        if (RemoteDecisionPolicy.isExpired(remoteDecision, nowMs, settings.serverDecisionMaxAgeMillis)) {
            return true to "SERVER_SIGNAL_EXPIRED"
        }
        if (remoteDecision.decision != "BUY") return true to "SERVER_NOT_BUY:${remoteDecision.decision ?: "NULL"}"
        return false to ""
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/RemoteAiBrain.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ScalpingExecution.kt =====
package com.example.bithumbtrader

import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max

data class MicroMarketSample(
    val time: Long,
    val price: Double,
    val volume: Double
)

data class MicroFlowMetrics(
    val return10s: Double = 0.0,
    val return30s: Double = 0.0,
    val return1m: Double = 0.0,
    val return3m: Double = 0.0,
    val return5m: Double = 0.0,
    val volume10s: Double = 0.0,
    val volume30s: Double = 0.0,
    val volume1m: Double = 0.0,
    val tradeIntensity: Double = 0.0
)

data class OrderbookPressureMetrics(
    val buyPressure: Double,
    val sellPressure: Double,
    val imbalance: Double,
    val depthChangePercent: Double,
    val stable: Boolean
)

enum class ScalpingExecutionState {
    ENTER_NOW, WAIT, WAIT_PULLBACK, WAIT_RETEST, WAIT_REACCELERATION, TOO_LATE, CHASE_RISK, NO_EDGE, AVOID,
    DATA_INSUFFICIENT, WARMING_UP
}

enum class MicroMomentumState { ACCELERATING, STABLE, DECELERATING, REVERSING, UNKNOWN }
enum class MicroVolatilityRegime { CALM, NORMAL, ACTIVE, EXTREME, CHAOTIC }
enum class ScalpingMarketState { SCALP_READY, WATCH, WAIT, CHASE, EXHAUSTED, TOXIC, COOLDOWN }
enum class ScalpingModelStatus { TRAINING, VALIDATING, SHADOW, PAPER, CHALLENGER, CHAMPION, REJECTED }
enum class ExecutionDataStatus { GOOD, WARMING_UP, DEGRADED, MISSING }

data class ExecutionDataAssessment(
    val status: ExecutionDataStatus,
    val gaps: List<String>,
    val microSampleCount: Int,
    val orderbookPresent: Boolean,
    val usedCandleProxy: Boolean,
    val shortEdgeReliable: Boolean
) {
    val summary: String
        get() = if (gaps.isEmpty()) status.name else "$status: ${gaps.joinToString(",")}"
}

data class ScalpingExecutionInput(
    val market: String,
    val currentPrice: Double,
    val return10s: Double = 0.0,
    val return30s: Double = 0.0,
    val return1m: Double = 0.0,
    val return3m: Double = 0.0,
    val return5m: Double = 0.0,
    val volume10s: Double = 0.0,
    val volume30s: Double = 0.0,
    val volume1m: Double = 0.0,
    val volumeAcceleration: Double = 0.0,
    val bidAskSpread: Double = 99.0,
    val orderbookImbalance: Double = 0.5,
    val bidDepth: Double = 0.0,
    val askDepth: Double = 0.0,
    val depthChangePercent: Double = 0.0,
    val orderbookStable: Boolean = true,
    val tradeIntensity: Double = 0.0,
    val buyPressure: Double = 0.5,
    val sellPressure: Double = 0.5,
    val atrPercent: Double = 0.0,
    val shortVolatilityPercent: Double = 0.0,
    val momentum: Double = 0.0,
    val momentumSlope: Double = 0.0,
    val momentumAcceleration: Double = 0.0,
    val rsi: Double = 50.0,
    val emaDistancePercent: Double = 0.0,
    val breakoutDistancePercent: Double = 0.0,
    val pullbackState: PullbackState = PullbackState.NONE,
    val retestState: BreakoutRetestState = BreakoutRetestState.NONE,
    val strategyScore: Double = 0.0,
    val aiScore: Double = 0.0,
    val marketRegime: MarketRegime = MarketRegime.UNKNOWN,
    val marketHealth: Double = 100.0,
    val netEdge: Double = 0.0,
    val chaseScore: Double = 0.0,
    val entryTimingScore: Double = 50.0,
    val dataQualityGood: Boolean = true,
    val currentSignalPrice: Double = currentPrice,
    val derivativesAvailable: Boolean = false,
    val derivativesSentiment: Double = 50.0,
    val derivativesRisk: Double = 50.0,
    val openInterestChange5m: Double? = null,
    val derivativesFundingState: FundingState = FundingState.NEUTRAL,
    val derivativesPositioningState: DerivativesPositioningState = DerivativesPositioningState.DATA_UNAVAILABLE,
    val shortSqueezeScore: Double = 0.0,
    val longSqueezeRisk: Double = 0.0,
    val spotFuturesDivergence: SpotFuturesDivergence = SpotFuturesDivergence.NONE,
    val globalLeadState: GlobalLeadState = GlobalLeadState.NO_CLEAR_LEAD,
    val microSampleCount: Int = 0,
    val orderbookPresent: Boolean = true,
    val usedCandleProxyForMicro: Boolean = false,
    val tickerAgeMs: Long = 0L,
    val orderbookAgeMs: Long = 0L
)

data class ScalpingExecutionDecision(
    val executionScore: Double,
    val executionConfidence: Double,
    val state: ScalpingExecutionState,
    val marketState: ScalpingMarketState,
    val momentumState: MicroMomentumState,
    val volatilityRegime: MicroVolatilityRegime,
    val shortNetEdge: Double,
    val expectedMove30s: Double,
    val expectedMove1m: Double,
    val expectedMove3m: Double,
    val expectedMove5m: Double,
    val recommendedHorizonSeconds: Int,
    val orderbookStable: Boolean,
    val reasonCodes: List<String>,
    val entryWindowOpen: Boolean,
    val priceMovedAway: Boolean,
    val shortEdgeBreakdown: ShortEdgeCostBreakdown = ShortEdgeCostBreakdown(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0),
    val executionDataStatus: ExecutionDataStatus = ExecutionDataStatus.GOOD,
    val executionDataGaps: List<String> = emptyList(),
    val shortEdgeReliable: Boolean = true
) {
    val allowed: Boolean get() = state == ScalpingExecutionState.ENTER_NOW && entryWindowOpen && shortEdgeReliable
}

object ScalpingExecutionEngine {
    private const val FEE_PERCENT = 0.25
    private const val DEFAULT_SLIPPAGE_PERCENT = 0.10
    const val MIN_MICRO_SAMPLES_ENTER = 8
    const val MIN_MICRO_SAMPLES_WARMUP = 3
    const val MAX_TICKER_AGE_MS = 30_000L
    const val MAX_ORDERBOOK_AGE_MS = 5_000L

    fun assessExecutionData(input: ScalpingExecutionInput): ExecutionDataAssessment {
        val gaps = mutableListOf<String>()
        if (input.currentPrice <= 0.0 || !input.currentPrice.isFinite()) gaps += "INVALID_PRICE"
        if (!input.orderbookPresent || input.bidDepth <= 0.0 || input.askDepth <= 0.0) gaps += "MISSING_ORDERBOOK"
        if (input.bidAskSpread >= 90.0) gaps += "MISSING_SPREAD_DATA"
        if (input.tickerAgeMs > MAX_TICKER_AGE_MS) gaps += "STALE_TICKER"
        if (input.orderbookPresent && input.orderbookAgeMs > MAX_ORDERBOOK_AGE_MS) gaps += "STALE_ORDERBOOK"
        if (input.usedCandleProxyForMicro) gaps += "MISSING_MICRO_PRICE_HISTORY"
        if (input.microSampleCount <= 0) gaps += "INSUFFICIENT_MICRO_SAMPLES"
        else if (input.microSampleCount < MIN_MICRO_SAMPLES_ENTER) gaps += "INSUFFICIENT_MICRO_SAMPLES"
        if (input.tradeIntensity <= 0.0 && input.microSampleCount < MIN_MICRO_SAMPLES_ENTER) gaps += "INSUFFICIENT_TRADE_INTENSITY"
        if (!input.orderbookStable && input.depthChangePercent <= -99.0) gaps += "INSUFFICIENT_ORDERBOOK_DEPTH"

        val warming = input.microSampleCount in 1 until MIN_MICRO_SAMPLES_ENTER &&
            !gaps.any { it in setOf("MISSING_ORDERBOOK", "INVALID_PRICE", "MISSING_SPREAD_DATA") }
        val missing = gaps.any { it.startsWith("MISSING_") || it == "INVALID_PRICE" }
        val status = when {
            gaps.isEmpty() -> ExecutionDataStatus.GOOD
            warming && input.microSampleCount >= MIN_MICRO_SAMPLES_WARMUP && !missing -> ExecutionDataStatus.WARMING_UP
            missing -> ExecutionDataStatus.MISSING
            else -> ExecutionDataStatus.DEGRADED
        }
        val shortEdgeReliable = status == ExecutionDataStatus.GOOD && !input.usedCandleProxyForMicro
        return ExecutionDataAssessment(status, gaps.distinct(), input.microSampleCount, input.orderbookPresent, input.usedCandleProxyForMicro, shortEdgeReliable)
    }

    fun microFlow(samples: List<MicroMarketSample>, currentPrice: Double, now: Long = System.currentTimeMillis()): MicroFlowMetrics {
        val valid = samples.sortedBy { it.time }.filter { it.price > 0.0 && it.time <= now }
        if (valid.isEmpty() || currentPrice <= 0.0) return MicroFlowMetrics()
        fun change(windowMs: Long): Double {
            val base = valid.lastOrNull { it.time <= now - windowMs }?.price ?: return 0.0
            return (currentPrice / base - 1.0) * 100.0
        }
        fun volume(windowMs: Long): Double = valid.filter { it.time >= now - windowMs }.sumOf { it.volume.coerceAtLeast(0.0) }
        return MicroFlowMetrics(
            return10s = change(10_000L),
            return30s = change(30_000L),
            return1m = change(60_000L),
            return3m = change(180_000L),
            return5m = change(300_000L),
            volume10s = volume(10_000L),
            volume30s = volume(30_000L),
            volume1m = volume(60_000L),
            tradeIntensity = valid.count { it.time >= now - 60_000L }.toDouble()
        )
    }

    fun orderbookPressure(current: OrderbookModel?, previous: OrderbookModel?): OrderbookPressureMetrics {
        if (current == null || current.bidPrice <= 0.0 || current.askPrice <= 0.0 ||
            current.bidSize < 0.0 || current.askSize < 0.0
        ) return OrderbookPressureMetrics(0.0, 1.0, 0.0, -100.0, false)
        val total = current.bidSize + current.askSize
        val imbalance = if (total > 0.0) current.bidSize / total else 0.5
        val previousDepth = previous?.let { it.bidSize + it.askSize }?.takeIf { it > 0.0 }
        val currentDepth = total
        val depthChange = if (previousDepth != null) (currentDepth / previousDepth - 1.0) * 100.0 else 0.0
        val disappearance = previous != null && previous.bidSize > 0.0 && current.bidSize / previous.bidSize < 0.30
        val spread = (current.askPrice - current.bidPrice) / current.askPrice * 100.0
        return OrderbookPressureMetrics(
            buyPressure = imbalance,
            sellPressure = 1.0 - imbalance,
            imbalance = imbalance,
            depthChangePercent = depthChange,
            stable = !disappearance && spread.isFinite() && spread >= 0.0
        )
    }

    fun momentumState(
        return30s: Double,
        return1m: Double,
        return3m: Double,
        momentumSlope: Double
    ): MicroMomentumState {
        val first = return30s
        val second = return1m - return30s
        val third = return3m - return1m
        return when {
            first > 0.0 && second > 0.0 && third >= -0.05 && momentumSlope > -0.05 -> MicroMomentumState.ACCELERATING
            first > 0.0 && second > -0.10 && third > -0.20 -> MicroMomentumState.STABLE
            return3m > 0.0 && (second < -0.20 || momentumSlope < -0.15) -> MicroMomentumState.DECELERATING
            return30s < 0.0 && return1m < 0.0 && return3m < 0.0 -> MicroMomentumState.REVERSING
            else -> MicroMomentumState.UNKNOWN
        }
    }

    fun volatilityRegime(shortVolatilityPercent: Double, atrPercent: Double): MicroVolatilityRegime {
        val value = max(shortVolatilityPercent, atrPercent)
        return when {
            !value.isFinite() || value > 8.0 -> MicroVolatilityRegime.CHAOTIC
            value > 5.0 -> MicroVolatilityRegime.EXTREME
            value > 2.5 -> MicroVolatilityRegime.ACTIVE
            value > 0.8 -> MicroVolatilityRegime.NORMAL
            else -> MicroVolatilityRegime.CALM
        }
    }

    fun evaluate(
        input: ScalpingExecutionInput,
        minShortNetEdgePercent: Double = 0.15,
        safetyMargin: Double = 1.35,
        minimumExecutionScore: Double = 60.0,
        maximumSpreadPercent: Double = 0.7,
        priceMovedAwayAtrMultiple: Double = 1.5
    ): ScalpingExecutionDecision {
        val dataAssessment = assessExecutionData(input)
        val momentum = momentumState(input.return30s, input.return1m, input.return3m, input.momentumSlope)
        val volatility = volatilityRegime(input.shortVolatilityPercent, input.atrPercent)
        val marketImpact = if (input.orderbookImbalance < 0.35) 0.15 else 0.0
        val accelerationProjection = input.momentumAcceleration.coerceIn(-2.0, 2.0)
        val expected30 = input.return30s + accelerationProjection * 0.25
        val expected1m = input.return1m + accelerationProjection * 0.35
        val expected3m = input.return3m + accelerationProjection * 0.50
        val expected5m = input.return5m + accelerationProjection * 0.65
        val shortEdgeBreakdown = EntryUrgencyAudit.shortEdgeBreakdown(
            expectedMove30s = expected30,
            expectedMove1m = expected1m,
            expectedMove3m = expected3m,
            expectedMove5m = expected5m,
            feePercent = FEE_PERCENT,
            spreadPercent = input.bidAskSpread.coerceAtLeast(0.0),
            slippagePercent = DEFAULT_SLIPPAGE_PERCENT,
            marketImpactPercent = marketImpact,
            safetyMargin = safetyMargin
        )
        // Candle-proxy micro returns can inflate Short Edge — keep value for diagnostics but mark unreliable.
        val shortEdge = if (dataAssessment.shortEdgeReliable) shortEdgeBreakdown.finalShortEdgePercent
        else shortEdgeBreakdown.finalShortEdgePercent
        val priceMovedAway = input.currentSignalPrice > 0.0 && input.atrPercent > 0.0 &&
            abs(input.currentPrice - input.currentSignalPrice) / input.currentPrice * 100.0 >= input.atrPercent * priceMovedAwayAtrMultiple

        val reasons = mutableListOf<String>()
        reasons += dataAssessment.gaps
        if (input.buyPressure > input.sellPressure && input.depthChangePercent >= -5.0) reasons += "BUY_PRESSURE_RISING"
        if (input.bidAskSpread in 0.0..maximumSpreadPercent) reasons += "SPREAD_GOOD"
        if (momentum == MicroMomentumState.ACCELERATING) reasons += "MOMENTUM_ACCELERATING"
        if (momentum == MicroMomentumState.DECELERATING) reasons += "MOMENTUM_DECELERATING"
        if (momentum == MicroMomentumState.REVERSING) reasons += "MOMENTUM_REVERSING"
        if (input.pullbackState in setOf(PullbackState.PULLBACK_STABILIZING, PullbackState.REACCELERATION, PullbackState.ENTRY_READY)) reasons += "PULLBACK_CONFIRMED"
        if (input.retestState == BreakoutRetestState.RETEST_CONFIRMED) reasons += "RETEST_CONFIRMED"
        if (input.emaDistancePercent < 2.0 && input.chaseScore < 50.0) reasons += "NOT_OVEREXTENDED"
        if (input.orderbookImbalance >= 0.55) reasons += "ORDERBOOK_BUY_SUPPORT"
        if (input.derivativesAvailable && input.derivativesSentiment >= 65.0) reasons += "DERIVATIVES_BULLISH"
        if (input.derivativesAvailable && input.derivativesPositioningState == DerivativesPositioningState.CROWDED_LONG) reasons += "CROWDED_LONG"
        if (input.derivativesAvailable && input.longSqueezeRisk >= 65.0) reasons += "LONG_SQUEEZE_RISK"
        if (input.derivativesAvailable && input.spotFuturesDivergence == SpotFuturesDivergence.DIVERGENCE) reasons += "SPOT_FUTURES_DIVERGENCE"
        if (input.depthChangePercent < -30.0 || !input.orderbookStable) reasons += "ORDERBOOK_UNSTABLE"
        if (volatility == MicroVolatilityRegime.CHAOTIC) reasons += "MICRO_VOLATILITY_CHAOTIC"
        if (priceMovedAway) reasons += "PRICE_MOVED_AWAY"
        if (priceMovedAway) reasons += "ENTRY_WINDOW_CLOSED"
        if (momentum == MicroMomentumState.REVERSING || input.sellPressure > input.buyPressure + 0.20) reasons += "SETUP_INVALIDATED"
        if (shortEdge < minShortNetEdgePercent) reasons += "SCALP_EDGE_TOO_SMALL"
        if (!dataAssessment.shortEdgeReliable) reasons += "SHORT_EDGE_UNRELIABLE_PARTIAL_DATA"

        var score = 50.0
        score += when (momentum) {
            MicroMomentumState.ACCELERATING -> 20.0
            MicroMomentumState.STABLE -> 7.0
            MicroMomentumState.DECELERATING -> -14.0
            MicroMomentumState.REVERSING -> -25.0
            MicroMomentumState.UNKNOWN -> 0.0
        }
        score += ((input.buyPressure - input.sellPressure) * 30.0).coerceIn(-15.0, 15.0)
        score += ((input.orderbookImbalance - 0.5) * 24.0).coerceIn(-12.0, 12.0)
        score += (input.entryTimingScore - 50.0) * 0.35
        score += (input.netEdge.coerceIn(-5.0, 5.0) * 2.0)
        if (input.derivativesAvailable) {
            score += (input.derivativesSentiment - 50.0) * 0.12
            score -= input.derivativesRisk * 0.12
            score += if (input.globalLeadState == GlobalLeadState.BYBIT_LEADING && input.derivativesSentiment > 55.0) 4.0 else 0.0
        }
        score -= input.chaseScore.coerceAtLeast(0.0) * 0.30
        score -= when (volatility) {
            MicroVolatilityRegime.CALM -> 0.0
            MicroVolatilityRegime.NORMAL -> 0.0
            MicroVolatilityRegime.ACTIVE -> 4.0
            MicroVolatilityRegime.EXTREME -> 12.0
            MicroVolatilityRegime.CHAOTIC -> 30.0
        }
        if (!input.orderbookStable) score -= 12.0
        if (priceMovedAway) score -= 18.0
        if (!dataAssessment.shortEdgeReliable) score -= 15.0
        score = score.coerceIn(0.0, 100.0)

        // Confidence = 판단 확신도(모델/컨텍스트), 데이터 품질과 별개. 데이터 부족 시 하향.
        var confidence = 52.0
        confidence += if (input.tradeIntensity > 0.0) 8.0 else 0.0
        confidence += if (input.orderbookStable) 10.0 else -20.0
        confidence += if (input.marketRegime != MarketRegime.UNKNOWN) 5.0 else -5.0
        confidence += ((input.marketHealth - 70.0) * 0.15).coerceIn(-12.0, 5.0)
        confidence += if (input.aiScore >= 70.0) 5.0 else 0.0
        confidence += if (input.derivativesAvailable) 8.0 else -5.0
        confidence -= when (dataAssessment.status) {
            ExecutionDataStatus.GOOD -> 0.0
            ExecutionDataStatus.WARMING_UP -> 12.0
            ExecutionDataStatus.DEGRADED -> 20.0
            ExecutionDataStatus.MISSING -> 35.0
        }
        confidence = confidence.coerceIn(0.0, 100.0)

        val hardAvoid = !input.dataQualityGood || input.marketHealth < 40.0 ||
            input.bidAskSpread > maximumSpreadPercent * 2.0 ||
            input.bidDepth <= 0.0 || input.askDepth <= 0.0 ||
            volatility == MicroVolatilityRegime.CHAOTIC
        val trueChase = input.chaseScore >= 90.0 ||
            (momentum == MicroMomentumState.DECELERATING && input.chaseScore >= 70.0) ||
            (input.derivativesAvailable && input.longSqueezeRisk >= 80.0 && input.chaseScore >= 70.0)
        if (trueChase) reasons += "CHASE_RISK_CONFIRMED"

        val state = when {
            dataAssessment.status == ExecutionDataStatus.WARMING_UP -> ScalpingExecutionState.WARMING_UP
            dataAssessment.status == ExecutionDataStatus.MISSING -> ScalpingExecutionState.DATA_INSUFFICIENT
            hardAvoid -> ScalpingExecutionState.AVOID
            trueChase -> ScalpingExecutionState.CHASE_RISK
            input.derivativesAvailable && input.derivativesRisk >= 85.0 -> ScalpingExecutionState.AVOID
            !dataAssessment.shortEdgeReliable -> ScalpingExecutionState.DATA_INSUFFICIENT
            input.netEdge <= 0.0 || shortEdge < minShortNetEdgePercent -> ScalpingExecutionState.NO_EDGE
            priceMovedAway || input.entryTimingScore < 35.0 -> ScalpingExecutionState.TOO_LATE
            input.pullbackState == PullbackState.PULLBACK -> ScalpingExecutionState.WAIT_PULLBACK
            input.retestState == BreakoutRetestState.WAITING_RETEST -> ScalpingExecutionState.WAIT_RETEST
            momentum == MicroMomentumState.DECELERATING -> ScalpingExecutionState.WAIT_REACCELERATION
            score >= minimumExecutionScore && confidence >= 55.0 &&
                momentum in setOf(MicroMomentumState.ACCELERATING, MicroMomentumState.STABLE) -> ScalpingExecutionState.ENTER_NOW
            else -> ScalpingExecutionState.WAIT
        }
        when (state) {
            ScalpingExecutionState.CHASE_RISK -> if ("CHASE_RISK_CONFIRMED" !in reasons) reasons += "CHASE_RISK_CONFIRMED"
            ScalpingExecutionState.WARMING_UP -> reasons += "WARMING_UP_EXECUTION_DATA"
            ScalpingExecutionState.DATA_INSUFFICIENT -> if (dataAssessment.gaps.isEmpty()) reasons += "UNKNOWN_EXECUTION_DATA_GAP"
            ScalpingExecutionState.WAIT -> if (reasons.none { it.startsWith("MOMENTUM") || it.startsWith("WAIT") || it.contains("SCORE") }) reasons += "EXECUTION_WAIT_CONDITIONS"
            else -> Unit
        }
        // 절대 빈 reason을 EXECUTION_DATA_INSUFFICIENT로 위장하지 않음
        if (reasons.isEmpty()) reasons += "STATE_${state.name}"

        val marketState = when (state) {
            ScalpingExecutionState.ENTER_NOW -> ScalpingMarketState.SCALP_READY
            ScalpingExecutionState.WAIT, ScalpingExecutionState.WAIT_PULLBACK,
            ScalpingExecutionState.WAIT_RETEST, ScalpingExecutionState.WAIT_REACCELERATION,
            ScalpingExecutionState.WARMING_UP, ScalpingExecutionState.DATA_INSUFFICIENT -> ScalpingMarketState.WAIT
            ScalpingExecutionState.CHASE_RISK -> ScalpingMarketState.CHASE
            ScalpingExecutionState.TOO_LATE -> ScalpingMarketState.EXHAUSTED
            ScalpingExecutionState.NO_EDGE -> ScalpingMarketState.WATCH
            ScalpingExecutionState.AVOID -> if (input.marketHealth < 40.0) ScalpingMarketState.TOXIC else ScalpingMarketState.COOLDOWN
        }
        val horizon = when {
            expected30 >= expected1m && expected30 >= expected3m -> 30
            expected1m >= expected3m -> 60
            expected3m >= expected5m -> 180
            else -> 300
        }
        return ScalpingExecutionDecision(
            executionScore = score,
            executionConfidence = confidence,
            state = state,
            marketState = marketState,
            momentumState = momentum,
            volatilityRegime = volatility,
            shortNetEdge = shortEdge,
            expectedMove30s = expected30,
            expectedMove1m = expected1m,
            expectedMove3m = expected3m,
            expectedMove5m = expected5m,
            recommendedHorizonSeconds = horizon,
            orderbookStable = input.orderbookStable,
            reasonCodes = reasons.distinct(),
            entryWindowOpen = state == ScalpingExecutionState.ENTER_NOW && !priceMovedAway && dataAssessment.shortEdgeReliable,
            priceMovedAway = priceMovedAway,
            shortEdgeBreakdown = shortEdgeBreakdown,
            executionDataStatus = dataAssessment.status,
            executionDataGaps = dataAssessment.gaps,
            shortEdgeReliable = dataAssessment.shortEdgeReliable
        )
    }
}

@Entity(tableName = "scalping_execution_diagnostics")
data class ScalpingExecutionDiagnosticEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val market: String,
    val time: Long,
    val tradeId: String? = null,
    val entryPrice: Double,
    val strategyScore: Double,
    val aiScore: Double,
    val entryTimingScore: Double,
    val chaseScore: Double,
    val executionScore: Double,
    val executionConfidence: Double,
    val state: String,
    val marketState: String,
    val momentumState: String,
    val volatilityRegime: String,
    val return30s: Double,
    val return1m: Double,
    val return3m: Double,
    val return5m: Double,
    val return15m: Double? = null,
    val volume10s: Double = 0.0,
    val volume30s: Double = 0.0,
    val volume1m: Double = 0.0,
    val volumeAcceleration: Double,
    val momentum: Double = 0.0,
    val momentumSlope: Double = 0.0,
    val momentumAcceleration: Double = 0.0,
    val rsi: Double = 50.0,
    val emaDistancePercent: Double = 0.0,
    val breakoutDistancePercent: Double = 0.0,
    val pullbackState: String = PullbackState.NONE.name,
    val retestState: String = BreakoutRetestState.NONE.name,
    val marketRegime: String = MarketRegime.UNKNOWN.name,
    val marketHealth: Double = 100.0,
    val spreadPercent: Double,
    val orderbookImbalance: Double,
    val depthChangePercent: Double,
    val shortNetEdge: Double,
    val recommendedHorizonSeconds: Int,
    val executionCostPercent: Double,
    val first5mReturn: Double? = null,
    val mfePercent: Double? = null,
    val maePercent: Double? = null,
    val exitReason: String? = null,
    val netPnl: Double? = null,
    val outcome: String? = null,
    val decision: String,
    val reasonCodes: String
)

data class ScalpingResearchStats(
    val totalSignals: Int = 0,
    val entered: Int = 0,
    val rejected: Int = 0,
    val waited: Int = 0,
    val goodEntry: Int = 0,
    val falseEntry: Int = 0,
    val goodReject: Int = 0,
    val falseReject: Int = 0,
    val averageExecutionScore: Double = 0.0,
    val averageConfidence: Double = 0.0,
    val shortNetEdgeAverage: Double = 0.0,
    val calibration: String = "INSUFFICIENT_SAMPLE",
    val modelVersion: String = "SCALP_EXEC_v1",
    val modelStatus: ScalpingModelStatus = ScalpingModelStatus.SHADOW,
    val shadowTrades: Int = 0,
    val shadowWinRate: Double = 0.0,
    val shadowProfitFactor: Double = 0.0,
    val shadowExpectancy: Double = 0.0,
    val shadowMdd: Double = 0.0,
    val shadowNetReturn: Double = 0.0,
    val shadowFees: Double = 0.0,
    val shadowSlippage: Double = 0.0
)

object ScalpingSignalPolicy {
    fun isFresh(createdAt: Long, now: Long, ttlMillis: Long): Boolean =
        createdAt > 0L && now >= createdAt && now - createdAt <= ttlMillis

    fun priceMovedAway(currentPrice: Double, signalPrice: Double, atrPercent: Double, atrMultiple: Double): Boolean =
        currentPrice <= 0.0 || signalPrice <= 0.0 ||
            (atrPercent > 0.0 && abs(currentPrice / signalPrice - 1.0) * 100.0 >= atrPercent * atrMultiple)
}

data class ScalpingValidationResult(
    val passed: Boolean,
    val reason: String
)

object ScalpingValidationPolicy {
    fun validate(
        sampleCount: Int,
        winRate: Double,
        profitFactor: Double,
        expectancyPercent: Double,
        maxDrawdownPercent: Double,
        minimumSamples: Int = 30
    ): ScalpingValidationResult {
        if (sampleCount < minimumSamples) return ScalpingValidationResult(false, "INSUFFICIENT_SAMPLE")
        if (winRate >= 0.85 && expectancyPercent <= 0.0) return ScalpingValidationResult(false, "HIGH_WIN_RATE_LOW_EXPECTANCY")
        if (profitFactor < 1.10 || expectancyPercent <= 0.0) return ScalpingValidationResult(false, "RISK_ADJUSTED_EDGE_INSUFFICIENT")
        if (maxDrawdownPercent <= -15.0) return ScalpingValidationResult(false, "MDD_TOO_HIGH")
        return ScalpingValidationResult(true, "SHADOW_VALIDATION_PASS")
    }
}

@Entity(tableName = "scalping_execution_models")
data class ScalpingExecutionModelEntity(
    @PrimaryKey val modelVersion: String = "SCALP_EXEC_v1",
    val status: String = ScalpingModelStatus.SHADOW.name,
    val createdAt: Long = System.currentTimeMillis(),
    val sampleCount: Int = 0,
    val validationProfitFactor: Double = 0.0,
    val validationExpectancy: Double = 0.0,
    val validationMdd: Double = 0.0,
    val reason: String = "연구 데이터 수집 중"
)

@Entity(tableName = "scalping_shadow_accounts")
data class ScalpingShadowAccountEntity(
    @PrimaryKey val accountId: String = "SCALPING_EXECUTION",
    val initialValue: Double = 100_000.0,
    val cash: Double = initialValue,
    val equity: Double = initialValue,
    val positionMarket: String? = null,
    val positionPrice: Double = 0.0,
    val positionAmount: Double = 0.0,
    val positionOpenedAt: Long = 0L,
    val tradeCount: Int = 0,
    val winCount: Int = 0,
    val grossProfit: Double = 0.0,
    val grossLoss: Double = 0.0,
    val realizedPnl: Double = 0.0,
    val peakEquity: Double = initialValue,
    val maxDrawdownPercent: Double = 0.0,
    val totalFees: Double = 0.0,
    val totalSlippage: Double = 0.0,
    val totalHoldingSeconds: Long = 0L,
    val updatedAt: Long = 0L
)

object ScalpingShadowEngine {
    private const val FEE_PERCENT = 0.25
    private const val SLIPPAGE_PERCENT = 0.10

    fun initial(initialValue: Double): ScalpingShadowAccountEntity =
        ScalpingShadowAccountEntity(initialValue = initialValue, cash = initialValue, equity = initialValue, peakEquity = initialValue)

    fun step(
        account: ScalpingShadowAccountEntity,
        signal: StrategySignalModel?,
        now: Long,
        minimumHoldingSeconds: Long = 30L,
        maximumHoldingSeconds: Long = 5 * 60L
    ): ScalpingShadowAccountEntity {
        if (signal == null || signal.currentPrice <= 0.0) return account
        var cash = account.cash
        var tradeCount = account.tradeCount
        var winCount = account.winCount
        var grossProfit = account.grossProfit
        var grossLoss = account.grossLoss
        var realized = account.realizedPnl
        var totalFees = account.totalFees
        var totalSlippage = account.totalSlippage
        var holdingSeconds = account.totalHoldingSeconds
        var market = account.positionMarket
        var positionPrice = account.positionPrice
        var positionAmount = account.positionAmount
        var openedAt = account.positionOpenedAt

        if (market != null && market == signal.market) {
            val elapsed = ((now - openedAt).coerceAtLeast(0L) / 1000L)
            val shouldExit = elapsed >= maximumHoldingSeconds ||
                (elapsed >= minimumHoldingSeconds && (
                    signal.microMomentumState in setOf(MicroMomentumState.DECELERATING.name, MicroMomentumState.REVERSING.name) ||
                        signal.scalpExecutionState in setOf(ScalpingExecutionState.CHASE_RISK.name, ScalpingExecutionState.TOO_LATE.name)
                    ))
            if (shouldExit) {
                val gross = positionAmount * signal.currentPrice
                val fee = gross * FEE_PERCENT / 100.0
                val slippage = gross * SLIPPAGE_PERCENT / 100.0
                val cost = positionAmount * positionPrice
                val pnl = gross - fee - slippage - cost
                val pnlRate = if (cost > 0.0) pnl / cost * 100.0 else 0.0
                cash += gross - fee - slippage
                tradeCount++
                if (pnlRate > 0.0) {
                    winCount++
                    grossProfit += pnlRate
                } else {
                    grossLoss += abs(pnlRate)
                }
                realized += pnl
                totalFees += fee
                totalSlippage += slippage
                holdingSeconds += elapsed
                market = null
                positionPrice = 0.0
                positionAmount = 0.0
                openedAt = 0L
            }
        }
        if (market == null && signal.scalpEntryAllowed && signal.status == CandidateStatus.BUY_READY) {
            val amount = (cash * 0.20).coerceAtMost(cash)
            if (amount > 1_000.0) {
                cash -= amount
                market = signal.market
                positionPrice = signal.currentPrice * (1.0 + SLIPPAGE_PERCENT / 100.0)
                positionAmount = (amount * (1.0 - FEE_PERCENT / 100.0)) / positionPrice
                openedAt = now
            }
        }
        val marked = if (market == signal.market) positionAmount * signal.currentPrice else 0.0
        val equity = cash + marked
        val peak = maxOf(account.peakEquity, equity)
        val drawdown = if (peak > 0.0) (equity / peak - 1.0) * 100.0 else 0.0
        return account.copy(
            cash = cash,
            equity = equity,
            positionMarket = market,
            positionPrice = positionPrice,
            positionAmount = positionAmount,
            positionOpenedAt = openedAt,
            tradeCount = tradeCount,
            winCount = winCount,
            grossProfit = grossProfit,
            grossLoss = grossLoss,
            realizedPnl = realized,
            peakEquity = peak,
            maxDrawdownPercent = minOf(account.maxDrawdownPercent, drawdown),
            totalFees = totalFees,
            totalSlippage = totalSlippage,
            totalHoldingSeconds = holdingSeconds,
            updatedAt = now
        )
    }
}

object ScalpingResearchAnalytics {
    fun summarize(rows: List<ScalpingExecutionDiagnosticEntity>, shadow: ScalpingShadowAccountEntity? = null): ScalpingResearchStats {
        if (rows.isEmpty()) return ScalpingResearchStats(
            shadowTrades = shadow?.tradeCount ?: 0,
            shadowWinRate = if (shadow != null && shadow.tradeCount > 0) shadow.winCount.toDouble() / shadow.tradeCount else 0.0,
            shadowProfitFactor = if (shadow != null && shadow.grossLoss > 0.0) shadow.grossProfit / shadow.grossLoss else 0.0,
            shadowExpectancy = if (shadow != null && shadow.tradeCount > 0) shadow.realizedPnl / shadow.tradeCount else 0.0,
            shadowMdd = shadow?.maxDrawdownPercent ?: 0.0,
            shadowNetReturn = if (shadow != null && shadow.initialValue > 0.0) (shadow.equity / shadow.initialValue - 1.0) * 100.0 else 0.0,
            shadowFees = shadow?.totalFees ?: 0.0,
            shadowSlippage = shadow?.totalSlippage ?: 0.0
        )
        val entered = rows.filter { it.tradeId != null }
        val rejected = rows.count { it.tradeId == null && it.state !in setOf(ScalpingExecutionState.ENTER_NOW.name) }
        val waited = rows.count { it.state in setOf(
            ScalpingExecutionState.WAIT.name,
            ScalpingExecutionState.WAIT_PULLBACK.name,
            ScalpingExecutionState.WAIT_RETEST.name,
            ScalpingExecutionState.WAIT_REACCELERATION.name
        ) }
        val outcomes = rows.mapNotNull { it.outcome }
        val confident = entered.filter { it.executionConfidence >= 80.0 }
        val confidentWins = confident.count { (it.first5mReturn ?: it.netPnl ?: 0.0) > 0.0 }
        val validation = ScalpingValidationPolicy.validate(
            sampleCount = entered.size,
            winRate = if (entered.isEmpty()) 0.0 else entered.count { (it.first5mReturn ?: it.netPnl ?: 0.0) > 0.0 }.toDouble() / entered.size,
            profitFactor = StrategyPerformanceEngine.fromPnlRates(entered.mapNotNull { it.first5mReturn ?: it.netPnl }).profitFactor,
            expectancyPercent = StrategyPerformanceEngine.fromPnlRates(entered.mapNotNull { it.first5mReturn ?: it.netPnl }).expectedReturnPercent,
            maxDrawdownPercent = StrategyPerformanceEngine.fromPnlRates(entered.mapNotNull { it.first5mReturn ?: it.netPnl }).maxDrawdownPercent
        )
        return ScalpingResearchStats(
            totalSignals = rows.size,
            entered = entered.size,
            rejected = rejected,
            waited = waited,
            goodEntry = outcomes.count { it == "GOOD_ENTRY" },
            falseEntry = outcomes.count { it == "FALSE_ENTRY" },
            goodReject = outcomes.count { it == "GOOD_REJECT" },
            falseReject = outcomes.count { it == "FALSE_REJECT" },
            averageExecutionScore = rows.map { it.executionScore }.average(),
            averageConfidence = rows.map { it.executionConfidence }.average(),
            shortNetEdgeAverage = rows.map { it.shortNetEdge }.average(),
            calibration = when {
                confident.size < 20 -> "INSUFFICIENT_SAMPLE"
                confidentWins.toDouble() / confident.size < 0.55 -> "OVERCONFIDENT"
                else -> "CALIBRATED"
            },
            modelStatus = if (validation.passed) ScalpingModelStatus.CHALLENGER else ScalpingModelStatus.SHADOW,
            shadowTrades = shadow?.tradeCount ?: 0,
            shadowWinRate = if (shadow != null && shadow.tradeCount > 0) shadow.winCount.toDouble() / shadow.tradeCount else 0.0,
            shadowProfitFactor = if (shadow != null && shadow.grossLoss > 0.0) shadow.grossProfit / shadow.grossLoss else 0.0,
            shadowExpectancy = if (shadow != null && shadow.tradeCount > 0) shadow.realizedPnl / shadow.tradeCount else 0.0,
            shadowMdd = shadow?.maxDrawdownPercent ?: 0.0,
            shadowNetReturn = if (shadow != null && shadow.initialValue > 0.0) (shadow.equity / shadow.initialValue - 1.0) * 100.0 else 0.0,
            shadowFees = shadow?.totalFees ?: 0.0,
            shadowSlippage = shadow?.totalSlippage ?: 0.0
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ScalpingExecution.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/SecureCredentialStore.kt =====

package com.example.bithumbtrader

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureCredentialStore(context: Context) {
    private val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
    private val prefs = EncryptedSharedPreferences.create(context, "bithumb_secure_credentials", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
    fun save(accessKey:***REDACTED*** secretKey:***REDACTED*** prefs.edit().putString("access", accessKey.trim()).putString("secret", secretKey.trim()).apply() }
    fun accessKey(): String = prefs.getString("access", "").orEmpty()
    fun secretKey(): String = prefs.getString("secret", "").orEmpty()
    fun hasKeys(): Boolean = accessKey().isNotBlank() && secretKey().isNotBlank()
    fun saveTradingAiToken(token: ***REDACTED*** prefs.edit().putString("trading_ai_token", token.trim()).apply() }
    fun tradingAiToken(): String = prefs.getString("trading_ai_token", "").orEmpty()
    fun hasTradingAiToken(): Boolean = tradingAiToken().isNotBlank()
    fun clear(){ prefs.edit().clear().apply() }
}
fun maskSecret(value:String): String = if(value.length <= 6) "***" else value.take(3)+"***"+value.takeLast(3)

===== END FILE: app/src/main/java/com/example/bithumbtrader/SecureCredentialStore.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/ServerPrimaryCoordinator.kt =====
package com.example.bithumbtrader

/**
 * SERVER PRIMARY 모드에서 Android는 분석 엔진을 재실행하지 않고
 * 서버 Snapshot → 안전검증 → 실행만 수행한다.
 */
object ServerPrimaryCoordinator {
    /**
     * PATH GATE (PHASE6 hard fix): Trading token + Remote AI + dashboard => always SERVER PRIMARY.
     * Do NOT require [TradingSettings.remoteAiPrimary] — persisted Primary OFF caused LOCAL /decision floods.
     */
    fun shouldEnterServerPrimaryPath(settings: TradingSettings, tokenReady: Boolean): Boolean =
        settings.remoteAiEnabled && settings.remoteDashboardEnabled && tokenReady

    fun shouldUseServerPrimary(settings: TradingSettings, linkStatus: AiBrainLinkStatus): Boolean =
        settings.remoteAiEnabled && settings.remoteDashboardEnabled &&
            linkStatus == AiBrainLinkStatus.ONLINE

    fun toRemoteDecision(c: RemoteDashboardCandidate): RemoteTradingDecision = RemoteTradingDecision(
        decisionId = c.decisionId,
        exchange = c.exchange,
        positionKey = c.positionKey,
        serverTimestamp = c.serverTimestamp,
        expiresAt = c.signalExpiresAt,
        market = c.market,
        decision = c.decision,
        strategyScore = c.strategyScore,
        aiScore = c.aiScore,
        aiPositive = c.aiPositive,
        aiConfidence = c.aiConfidence,
        executionScore = c.executionScore,
        executionConfidence = c.executionConfidence,
        executionState = c.executionState,
        entryTimingScore = c.entryTimingScore,
        entryTimingState = c.entryTimingState,
        chaseScore = c.chaseScore,
        chaseState = c.chaseState,
        shortEdge = c.shortEdge,
        signalPrice = c.price,
        signalCreatedAt = c.signalCreatedAt,
        signalExpiresAt = c.signalExpiresAt,
        reasonCodes = c.reasonCodes,
        modelVersion = c.modelVersion,
        strategyVersion = c.strategyVersion,
        apiVersion = c.apiVersion,
        dataQuality = c.dataQuality,
        executionDataQuality = c.executionDataQuality,
        grossExpectedEdge = c.grossExpectedEdge,
        expectedExecutionCost = c.executionCost,
        netExpectedEdge = c.netExpectedEdge,
        liquidityPassed = c.liquidityPassed,
        liquidityRank = c.liquidityRank,
        liquidityTotal = c.liquidityTotal,
        liquidityPercentile = c.liquidityPercentile,
        expectedGrossProfitKrw = c.expectedGrossProfitKrw,
        expectedRoundTripCostKrw = c.expectedRoundTripCostKrw,
        expectedRoundTripCostPercent = c.expectedRoundTripCostPercent,
        expectedNetProfitKrw = c.expectedNetProfitKrw,
        expectedNetProfitPercent = c.expectedNetProfitPercent,
        costToGrossProfitRatio = c.costToGrossProfitRatio,
        costCoverageMultiple = c.costCoverageMultiple,
        breakEvenPrice = c.breakEvenPrice
    )

    fun toSignal(c: RemoteDashboardCandidate, nowMs: Long = System.currentTimeMillis()): StrategySignalModel {
        val decision = (c.decision ?: "WAIT").uppercase()
        val status = when (decision) {
            "BUY" -> CandidateStatus.BUY_READY
            "AVOID" -> CandidateStatus.REJECTED
            else -> CandidateStatus.WAIT_RECONFIRMATION
        }
        val reasons = c.reasonCodes.orEmpty()
        return StrategySignalModel(
            market = c.market.orEmpty(),
            score = c.strategyScore ?: 0.0,
            reason = "SERVER_SNAPSHOT:${reasons.joinToString(",")}",
            timestamp = c.signalCreatedAt ?: c.serverTimestamp ?: nowMs,
            currentPrice = c.price ?: 0.0,
            aiScore = c.aiScore ?: 0.0,
            aiLabel = if (c.aiPositive == true) "REMOTE AI: 매수 참고" else "REMOTE AI: 매수 비추천",
            status = status,
            failureReason = if (status == CandidateStatus.BUY_READY) "서버 BUY 대기(Android 안전검증)" else reasons.joinToString(",").ifBlank { decision },
            entryTimingScore = c.entryTimingScore ?: 50.0,
            chaseEntryScore = c.chaseScore ?: 0.0,
            entryTimingState = c.entryTimingState ?: EntryTimingState.NORMAL.name,
            entryQualityClassification = "SERVER",
            scalpExecutionScore = c.executionScore ?: 0.0,
            scalpExecutionConfidence = c.executionConfidence ?: 0.0,
            scalpExecutionState = c.executionState ?: ScalpingExecutionState.WAIT.name,
            shortHorizonNetEdge = c.shortEdge ?: 0.0,
            shortEdgeGrossMovePercent = c.grossExpectedEdge ?: 0.0,
            shortEdgeFeePercent = 0.25,
            shortEdgeSpreadPercent = 0.0,
            shortEdgeSlippagePercent = 0.10,
            shortEdgeImpactPercent = 0.0,
            shortEdgeSafetyMargin = 1.35,
            shortEdgeCostBreakdownText = "SERVER",
            shortEdgeReliable = c.executionDataQuality.equals("AVAILABLE", ignoreCase = true) ||
                c.executionDataQuality.equals("GOOD", ignoreCase = true),
            scalpReasonCodes = reasons,
            scalpEntryAllowed = decision == "BUY",
            scalpEntryWindowOpen = decision == "BUY",
            executionDataStatus = c.executionDataQuality ?: ExecutionDataStatus.DEGRADED.name,
            executionDataGaps = if (
                c.executionDataQuality.equals("AVAILABLE", ignoreCase = true) ||
                    c.executionDataQuality.equals("GOOD", ignoreCase = true)
            ) emptyList() else listOf(c.executionDataQuality ?: "UNKNOWN"),
            microSampleCount = c.microSampleCount ?: 0,
            grossExpectedEdge = c.grossExpectedEdge ?: 0.0,
            expectedExecutionCost = c.executionCost ?: 0.0,
            netExpectedEdge = c.netExpectedEdge ?: (c.shortEdge ?: 0.0),
            netEdgePassed = (c.netExpectedEdge ?: c.shortEdge ?: -1.0) > 0.0,
            liquidityPassed = c.liquidityPassed == true,
            liquidityReady = c.liquidityPassed != null,
            liquidityRank = c.liquidityRank ?: 0,
            liquidityTotal = c.liquidityTotal ?: 0,
            liquidityPercentile = c.liquidityPercentile ?: 0.0,
            dataQualityStatus = c.dataQuality ?: "GOOD",
            expectedGrossProfitKrw = c.expectedGrossProfitKrw ?: 0.0,
            expectedRoundTripCostKrw = c.expectedRoundTripCostKrw ?: 0.0,
            expectedRoundTripCostPercent = c.expectedRoundTripCostPercent ?: 0.0,
            expectedNetProfitKrw = c.expectedNetProfitKrw ?: 0.0,
            expectedNetProfitPercent = c.expectedNetProfitPercent ?: 0.0,
            costToGrossProfitRatio = c.costToGrossProfitRatio ?: 0.0,
            costCoverageMultiple = c.costCoverageMultiple ?: 0.0,
            breakEvenPrice = c.breakEvenPrice ?: 0.0,
            netProfitAfterCostPassed = c.expectedNetProfitKrw == null || c.expectedNetProfitKrw > 0.0,
            netProfitAfterCostReason = when {
                c.expectedNetProfitKrw == null -> ""
                c.expectedNetProfitKrw > 0.0 -> "SERVER_NET_PROFIT_PASS"
                else -> "REMOTE_NET_PROFIT_INVALID"
            }
        )
    }

    /** 차단 사유를 고정 코드로 변환 (로그/디버깅용). 전략 임계값은 변경하지 않음. */
    fun blockReasonCode(
        decision: String?,
        status: CandidateStatus,
        failureReason: String
    ): String? {
        if (status == CandidateStatus.BUY_READY) return null
        val d = (decision ?: "").uppercase()
        if (d.isNotBlank() && d != "BUY") return "SIGNAL_NOT_BUY"
        val r = failureReason
        return when {
            r.contains("SERVER_STALE") || r.contains("SERVER_SIGNAL_EXPIRED") || r.contains("EXPIRED") -> "SERVER_STALE"
            r.contains("SERVER_OFFLINE") || r.contains("SERVER_DEGRADED") || r.contains("SERVER_DISABLED") ||
                r.contains("SERVER_DASHBOARD") || r.contains("SERVER_BUY_BLOCKED") -> "SERVER_OFF"
            r.contains("DUPLICATE") -> "DUPLICATE_SIGNAL"
            r.contains("PRICE_MOVED") -> "PRICE_MOVED_AWAY"
            r.contains("AI Score") || r.contains("점수 부족") -> "SCORE_LOW"
            r.contains("최대 보유") || r.contains("HARD_EMERGENCY") -> "POSITION_LIMIT"
            r.contains("PORTFOLIO_HEAT") || r.contains("CORRELATED_PORTFOLIO") -> "PORTFOLIO_HEAT"
            r.contains("CASH_RESERVE") || r.contains("현금비중") -> "CASH_RESERVE"
            r.contains("MINIMUM_VIABLE_ORDER") -> "MINIMUM_VIABLE_ORDER"
            r.contains("쿨다운") || r.contains("Cooldown") || r.contains("Cooldown") -> "COOLDOWN"
            r.contains("보유 중") || r.contains("이미 보유") -> "ALREADY_HOLDING"
            r.contains("주문 중") || r.contains("주문 진행") -> "IN_FLIGHT_ORDER"
            r.contains("티커") || r.contains("시세 지연") -> "TICKER_STALE"
            r.contains("킬") || r.contains("급락") || r.contains("수익 보호") || r.contains("뉴스") ||
                r.contains("스프레드") || r.contains("주문금액") || r.contains("현금") ||
                r.contains("잔액") || r.contains("API") || r.contains("모드") ||
                r.contains("일일 손실") || r.contains("연속 손실") -> "RISK_BLOCK"
            else -> "RISK_BLOCK"
        }
    }

    fun flowLogLine(
        stage: String,
        market: String = "-",
        decision: String = "-",
        score: Double? = null,
        reason: String = "-",
        extra: String = ""
    ): String {
        val scorePart = score?.let { " score=${"%.1f".format(it)}" } ?: ""
        val extraPart = if (extra.isBlank()) "" else " $extra"
        return "FLOW $stage market=$market decision=$decision$scorePart reason=$reason$extraPart"
    }

    /**
     * PHASE5: SERVER STATE -> ANDROID UI one-way mirror.
     * Does not recalculate cash/PnL/positions locally — copies server values as-is.
     */
    fun applyServerPaperMirror(
        base: DashboardState,
        paper: RemotePaperState,
        serverStatus: String = "ONLINE"
    ): DashboardState {
        val cash = paper.cash ?: 0.0
        val coin = paper.coinValue ?: 0.0
        val total = paper.totalValue ?: (cash + coin)
        val realized = paper.realizedPnl ?: 0.0
        val unrealized = paper.unrealizedPnl ?: 0.0
        val totalPnl = paper.totalPnl ?: (realized + unrealized)
        val totalPnlRate = paper.totalPnlRate
            ?: run {
                val initial = paper.initialCash ?: base.settings.paperInitialKrw
                if (initial > 0.0) totalPnl / initial * 100.0 else 0.0
            }
        val positions = paper.positions.orEmpty().filter { (it.quantity ?: 0.0) > 0.0 }
        val paperAuto = paper.paperAuto == true
        val heldModels = positions.map {
            PositionModel(
                market = it.market.orEmpty(),
                quantity = it.quantity ?: 0.0,
                avgPrice = it.avgPrice ?: 0.0,
                highestPrice = it.highestPrice ?: it.avgPrice ?: 0.0,
                openedAt = it.openedAt ?: 0L
            )
        }
        val heat = PortfolioHeatEngine.evaluate(heldModels, total, base.settings.stopLossPercent)
        var capacity = DynamicPortfolioCapacityEngine.evaluateSnapshot(
            settings = base.settings,
            positions = heldModels,
            totalEquityKrw = total,
            availableCashKrw = cash,
            heat = heat
        )
        val embeddedEntities = toTradeEntities(paper.recentTrades.orEmpty())
        val tradesForAutopsy = when {
            base.serverPaperTrades.size >= embeddedEntities.size && base.serverPaperTrades.isNotEmpty() ->
                base.serverPaperTrades
            embeddedEntities.isNotEmpty() -> embeddedEntities
            else -> base.serverPaperTrades
        }
        val autopsy = PaperLossAutopsyEngine.analyze(
            trades = tradesForAutopsy,
            initialCapital = paper.initialCash ?: base.settings.paperInitialKrw,
            cash = cash,
            positionValue = coin,
            realizedPnlReported = realized,
            unrealizedPnl = unrealized,
            exchange = (paper.exchange ?: ExchangeId.BITHUMB.name).uppercase()
        )
        val paperRisk = PaperRiskEngine.evaluate(
            mode = base.mode,
            lossStreak = base.consecutiveLossCount,
            dailyLossLocked = base.dailyLossLocked,
            recentStats = base.overallPerformance,
            health = base.marketHealth,
            regime = base.currentRegime,
            autopsyHint = autopsy.protectionHint,
            autopsySampleTooSmall = autopsy.sampleTooSmall
        )
        if (paperRisk.remainingRiskBudgetMultiplier < 1.0) {
            capacity = capacity.copy(
                remainingRiskBudgetPercent = capacity.remainingRiskBudgetPercent * paperRisk.remainingRiskBudgetMultiplier,
                remainingRiskBudgetKrw = capacity.remainingRiskBudgetKrw * paperRisk.remainingRiskBudgetMultiplier,
                detail = capacity.detail + " · autopsyRisk×${"%.2f".format(paperRisk.remainingRiskBudgetMultiplier)}"
            )
        }
        val embeddedTrades = paper.recentTrades.orEmpty()
        val withPaper = base.copy(
            engineStatus = if (paperAuto) EngineStatus.RUNNING else EngineStatus.STOPPED,
            krwBalance = cash,
            coinValue = coin,
            totalValue = total,
            realizedPnl = realized,
            unrealizedPnl = unrealized,
            cumulativePnl = totalPnl,
            cumulativePnlRate = totalPnlRate,
            todayPnl = totalPnl,
            todayPnlRate = totalPnlRate,
            holdingCount = paper.positionCount ?: positions.size,
            portfolioHeat = heat,
            portfolioCapacity = capacity,
            paperRisk = paperRisk,
            paperLossAutopsy = autopsy,
            serverPaperAuto = paperAuto,
            serverPaperCash = cash,
            serverPaperTotalValue = total,
            serverPaperRealizedPnl = realized,
            serverPaperUnrealizedPnl = unrealized,
            serverPaperPositionCount = paper.positionCount ?: positions.size,
            serverPaperTickCount = paper.tickCount ?: 0,
            serverPaperIndependent = paper.androidIndependent == true,
            serverPaperInitialCash = paper.initialCash ?: base.settings.paperInitialKrw,
            serverPaperCoinValue = coin,
            serverPaperTotalPnl = totalPnl,
            serverPaperTotalPnlRate = totalPnlRate,
            serverPaperUpdatedAt = paper.updatedAt ?: 0L,
            serverPaperLastTickAt = paper.lastTickAt ?: 0L,
            serverPaperPositions = positions,
            serverPaperNewBuyPaused = paper.newBuyPaused == true ||
                (paper.paperBuyResumeMode?.uppercase()?.contains("PAUSE") == true) ||
                (paper.paperBuyResumeMode?.uppercase()?.contains("SHADOW") == true),
            serverPaperBuyResumeMode = paper.paperBuyResumeMode
                ?: if (paper.newBuyPaused == true) "PAUSED_DIAGNOSTIC" else "NORMAL",
            serverPaperPauseReason = paper.pauseReason.orEmpty(),
            serverPaperTopLossCauses = paper.topLossCauses.orEmpty(),
            serverStatusLabel = serverStatus,
            serverPaperUiSynced = true,
            androidAnalysisMode = "SERVER_PRIMARY_VIEWER"
        )
        // Prefer embedded recentTrades from paper/state so history is not empty when /paper/trades is skipped.
        return if (embeddedTrades.isNotEmpty()) withServerPaperTrades(withPaper, embeddedTrades) else withPaper
    }

    /** Map server /paper/trades rows into Room-compatible TradeEntity for UI (display only). */
    fun toTradeEntities(remote: List<RemotePaperTrade>): List<TradeEntity> =
        remote.mapNotNull { t ->
            val market = t.market?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
            val side = (t.side ?: "").uppercase().ifBlank { return@mapNotNull null }
            val exchange = (t.exchange ?: ExchangeId.BITHUMB.name).uppercase()
            TradeEntity(
                id = t.id?.takeIf { it.isNotBlank() } ?: "server-${t.time}-$exchange-$market-$side",
                time = t.time ?: 0L,
                market = market,
                side = side,
                amount = t.amount ?: 0.0,
                quantity = t.quantity ?: 0.0,
                avgPrice = t.avgPrice ?: 0.0,
                fee = t.fee ?: 0.0,
                realizedPnl = t.realizedPnl ?: 0.0,
                pnlRate = t.pnlRate ?: 0.0,
                reason = "[$exchange] " + t.reason.orEmpty().ifBlank { t.decisionId.orEmpty() },
                mode = "PAPER"
            )
        }.sortedByDescending { it.time }

    /**
     * 거래내역 탭 SoT 선택.
     * Primary 설정이 켜져 있거나 서버 원장이 이미 있으면 로컬 Room(0건)을 보여주지 않는다.
     */
    fun useServerPaperTradeLedger(state: DashboardState): Boolean {
        val settingsPrimary = state.settings.remoteAiEnabled && state.settings.remoteAiPrimary
        return settingsPrimary ||
            state.androidAnalysisMode == "SERVER_PRIMARY_VIEWER" ||
            state.serverPaperUiSynced ||
            state.serverPaperTrades.isNotEmpty()
    }

    fun withServerPaperTrades(base: DashboardState, remote: List<RemotePaperTrade>): DashboardState {
        val trades = toTradeEntities(remote)
        val ledger = NetProfitAfterCostEngine.ledgerFromTrades(trades)
        val performance = StrategyPerformanceEngine.fromTrades(trades)
        val autopsy = PaperLossAutopsyEngine.analyze(
            trades = trades,
            initialCapital = base.serverPaperInitialCash.takeIf { it > 0.0 } ?: base.settings.paperInitialKrw,
            cash = base.krwBalance,
            positionValue = base.coinValue,
            realizedPnlReported = base.serverPaperRealizedPnl.takeIf { it != 0.0 } ?: base.realizedPnl,
            unrealizedPnl = base.unrealizedPnl,
            exchange = base.selectedExchange
        )
        val paperRisk = PaperRiskEngine.evaluate(
            mode = base.mode,
            lossStreak = base.consecutiveLossCount,
            dailyLossLocked = base.dailyLossLocked,
            recentStats = performance,
            health = base.marketHealth,
            regime = base.currentRegime,
            autopsyHint = autopsy.protectionHint,
            autopsySampleTooSmall = autopsy.sampleTooSmall
        )
        return base.copy(
            serverPaperTrades = trades,
            tradingCostLedger = ledger,
            overallPerformance = performance,
            paperLossAutopsy = autopsy,
            paperRisk = paperRisk,
            serverPaperTradesSyncStatus = if (trades.isEmpty()) "EMPTY" else "OK",
            serverPaperTradesSyncError = "",
            serverPaperTradesSyncedAt = System.currentTimeMillis()
        )
    }

    /** Compare UI mirror vs server paper SoT (same timestamp snapshot). */
    fun uiMatchesServerPaper(ui: DashboardState, paper: RemotePaperState, cashEps: Double = 1e-6): Boolean {
        if (!ui.serverPaperUiSynced) return false
        if (ui.serverPaperAuto != (paper.paperAuto == true)) return false
        if (kotlin.math.abs(ui.krwBalance - (paper.cash ?: 0.0)) > cashEps) return false
        if (kotlin.math.abs(ui.realizedPnl - (paper.realizedPnl ?: 0.0)) > cashEps) return false
        if (kotlin.math.abs(ui.unrealizedPnl - (paper.unrealizedPnl ?: 0.0)) > cashEps) return false
        val serverPos = paper.positions.orEmpty().filter { (it.quantity ?: 0.0) > 0.0 }
            .sortedBy { it.market.orEmpty() }
        val uiPos = ui.serverPaperPositions.sortedBy { it.market.orEmpty() }
        if (uiPos.size != serverPos.size) return false
        serverPos.zip(uiPos).forEach { (s, u) ->
            if (s.market != u.market) return false
            if (kotlin.math.abs((s.quantity ?: 0.0) - (u.quantity ?: 0.0)) > 1e-9) return false
            if (kotlin.math.abs((s.avgPrice ?: 0.0) - (u.avgPrice ?: 0.0)) > cashEps) return false
        }
        return true
    }

    /** Compact expected/actual maps for PHASE6 diagnostic upload. */
    fun paperExpectedSnapshot(paper: RemotePaperState): Map<String, Any?> {
        val positions = paper.positions.orEmpty().filter { (it.quantity ?: 0.0) > 0.0 }
            .sortedBy { it.market.orEmpty() }
            .map {
                mapOf(
                    "market" to it.market,
                    "quantity" to it.quantity,
                    "avgPrice" to it.avgPrice
                )
            }
        return mapOf(
            "cash" to paper.cash,
            "realizedPnl" to paper.realizedPnl,
            "unrealizedPnl" to paper.unrealizedPnl,
            "paperAuto" to (paper.paperAuto == true),
            "positionCount" to (paper.positionCount ?: positions.size),
            "positions" to positions,
            "serverStateTimestamp" to (paper.lastTickAt ?: paper.updatedAt)
        )
    }

    fun paperActualUiSnapshot(ui: DashboardState): Map<String, Any?> {
        val positions = ui.serverPaperPositions.filter { (it.quantity ?: 0.0) > 0.0 }
            .sortedBy { it.market.orEmpty() }
            .map {
                mapOf(
                    "market" to it.market,
                    "quantity" to it.quantity,
                    "avgPrice" to it.avgPrice
                )
            }
        return mapOf(
            "cash" to ui.krwBalance,
            "realizedPnl" to ui.realizedPnl,
            "unrealizedPnl" to ui.unrealizedPnl,
            "paperAuto" to ui.serverPaperAuto,
            "positionCount" to ui.holdingCount,
            "positions" to positions,
            "serverStateTimestamp" to (ui.serverPaperLastTickAt.takeIf { it > 0L } ?: ui.serverPaperUpdatedAt)
        )
    }

    /**
     * Strategy/AI/Scalp 재계산 없이 Android 안전 게이트만 적용.
     * @param sessionStartedAtMs 앱/엔진 세션 시작 시각. 이보다 오래된 서버 BUY는 재실행 금지.
     */
    fun applySafetyOnly(
        signal: StrategySignalModel,
        remote: RemoteTradingDecision,
        ticker: TickerModel?,
        orderbook: OrderbookModel?,
        holdings: Set<String>,
        inFlight: Set<String>,
        settings: TradingSettings,
        state: DashboardState,
        risk: RiskManager,
        nowMs: Long,
        sessionStartedAtMs: Long,
        markUsed: (String) -> Boolean
    ): StrategySignalModel {
        val price = ticker?.tradePrice ?: signal.currentPrice
        val estimated = minOf(
            state.totalValue * settings.maxOrderPercent / 100.0,
            state.totalValue * settings.maxAssetPercentPerCoin / 100.0
        )
        val signalCreated = remote.signalCreatedAt ?: remote.serverTimestamp ?: 0L
        val (blocked, blockReason) = ServerBuyGate.blockNewBuy(settings, AiBrainLinkStatus.ONLINE, remote, nowMs)
        val statusReason = when {
            blocked -> CandidateStatus.WAIT_RECONFIRMATION to "SERVER_BUY_BLOCKED: $blockReason"
            holdings.contains(signal.market) -> CandidateStatus.HOLDING to "보유 중"
            inFlight.contains(signal.market) -> CandidateStatus.ORDERING to "주문 중"
            ticker == null -> CandidateStatus.REJECTED to "실시간 티커 없음"
            signalCreated > 0L && signalCreated < sessionStartedAtMs - 5_000L ->
                CandidateStatus.REJECTED to "SERVER_STALE_ON_APP_RESTART"
            RemoteDecisionPolicy.isExpired(remote, nowMs, settings.serverDecisionMaxAgeMillis) ->
                CandidateStatus.REJECTED to "SERVER_SIGNAL_EXPIRED"
            RemoteDecisionPolicy.priceMovedAway(
                remote.signalPrice,
                price,
                signal.entryAtrPercent.takeIf { it > 0.0 } ?: 0.5,
                settings.scalpingPriceMovedAwayAtrMultiple
            ) -> CandidateStatus.REJECTED to "SERVER_PRICE_MOVED_AWAY"
            remote.decisionId != null && !markUsed(remote.decisionId) ->
                CandidateStatus.REJECTED to "SERVER_DECISION_DUPLICATE"
            (remote.aiScore ?: signal.aiScore) < settings.aiMinScore ->
                CandidateStatus.REJECTED to "AI Score 부족(서버값)"
            signal.score < settings.scoreThreshold ->
                CandidateStatus.REJECTED to "점수 부족"
            else -> {
                val riskResult = risk.canBuy(
                    settings,
                    state.copy(lastTickerAt = ticker.timestamp),
                    signal.copy(currentPrice = price, estimatedInvestment = estimated),
                    orderbook,
                    false,
                    false,
                    estimated
                )
                if (riskResult.first) CandidateStatus.BUY_READY to "매수 예정(REMOTE_PRIMARY)"
                else CandidateStatus.REJECTED to riskResult.second
            }
        }
        return signal.copy(
            currentPrice = price,
            estimatedInvestment = estimated,
            investmentRatio = if (state.totalValue > 0) estimated / state.totalValue * 100.0 else 0.0,
            status = statusReason.first,
            failureReason = statusReason.second,
            scalpEntryAllowed = statusReason.first == CandidateStatus.BUY_READY,
            scalpEntryWindowOpen = statusReason.first == CandidateStatus.BUY_READY
        )
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/ServerPrimaryCoordinator.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/SmartReentry.kt =====
package com.example.bithumbtrader

import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

enum class ProfitReentryStatus {
    NONE, PROFIT_EXIT_COOLDOWN, WAIT_NEW_SETUP, WAIT_PULLBACK, WAIT_STABILIZATION,
    WAIT_REACCELERATION, REENTRY_READY, REENTRY_REJECTED_CHASE, BLOCKED_UNTIL_NEW_WAVE
}

enum class NewWaveState { SAME_WAVE, PULLBACK, BASE_FORMING, NEW_WAVE_CANDIDATE, NEW_WAVE_CONFIRMED }
enum class ReentryShadowPolicy { IMMEDIATE_REENTRY, TIME_COOLDOWN_ONLY, WAIT_PULLBACK, WAIT_REACCELERATION, NEW_WAVE_ONLY, NO_REENTRY }

data class ProfitExitAnchor(
    val market: String,
    val exitPrice: Double,
    val exitTime: Long,
    val entryPrice: Double,
    val peakPrice: Double,
    val realizedProfit: Double,
    val realizedProfitPercent: Double,
    val exitReason: String,
    val strategyScoreAtExit: Double,
    val aiScoreAtExit: Double,
    val regimeAtExit: String,
    val marketHealthAtExit: Double,
    val consumedSignalId: String
)

data class SmartReentryState(
    val market: String,
    val status: ProfitReentryStatus = ProfitReentryStatus.NONE,
    val waveState: NewWaveState = NewWaveState.SAME_WAVE,
    val anchor: ProfitExitAnchor? = null,
    val cooldownUntil: Long = 0L,
    val scoreResetObserved: Boolean = false,
    val newSignalValid: Boolean = false,
    val lastSignalId: String = "",
    val lastSignalTime: Long = 0L,
    val reentryQualityScore: Double = 0.0,
    val sameWaveProbability: Double = 1.0,
    val newWaveConfidence: Double = 0.0,
    val marketSessionRealizedProfit: Double = 0.0,
    val recentCycleProfit: Double = 0.0,
    val reentryRiskAmount: Double = 0.0,
    val profitGivenBackAmount: Double = 0.0,
    val reentryLossChain: Int = 0,
    val wasReentry: Boolean = false,
    val roundTrips: Int = 0,
    val fees: Double = 0.0,
    val slippage: Double = 0.0
)

data class SmartReentryDecision(
    val allowed: Boolean,
    val status: ProfitReentryStatus,
    val waveState: NewWaveState,
    val qualityScore: Double,
    val sameWaveProbability: Double,
    val newWaveConfidence: Double,
    val reasonCodes: List<String>,
    val nextState: SmartReentryState
)

object SmartReentryEngine {
    fun evaluate(
        state: SmartReentryState?,
        signal: StrategySignalModel,
        now: Long = System.currentTimeMillis(),
        cooldownMinutes: Int = 10,
        scoreResetDrop: Double = 12.0,
        minimumQuality: Double = 62.0,
        maxRiskPercentOfProfit: Double = 100.0
    ): SmartReentryDecision {
        val current = state ?: return SmartReentryDecision(
            allowed = true, status = ProfitReentryStatus.NONE, waveState = NewWaveState.NEW_WAVE_CONFIRMED,
            qualityScore = 100.0, sameWaveProbability = 0.0, newWaveConfidence = 100.0,
            reasonCodes = listOf("NO_PREVIOUS_PROFIT_EXIT"), nextState = SmartReentryState(signal.market)
        )
        if (current.status == ProfitReentryStatus.NONE) {
            return SmartReentryDecision(
                true, ProfitReentryStatus.NONE, current.waveState, 100.0, 0.0, 100.0,
                listOf("NO_ACTIVE_PROFIT_REENTRY_GUARD"), current.copy(lastSignalId = signal.signalId, lastSignalTime = signal.timestamp)
            )
        }
        val anchor = current.anchor ?: return SmartReentryDecision(
            true, ProfitReentryStatus.NONE, NewWaveState.NEW_WAVE_CONFIRMED, 100.0, 0.0, 100.0,
            listOf("NO_ACTIVE_PROFIT_ANCHOR"), current
        )
        val reason = mutableListOf<String>()
        val elapsedMinutes = ((now - anchor.exitTime).coerceAtLeast(0L) / 60_000L).toDouble()
        val priceVsExit = if (anchor.exitPrice > 0.0) (signal.currentPrice / anchor.exitPrice - 1.0) * 100.0 else 0.0
        val atr = signal.entryAtrPercent.coerceAtLeast(0.1)
        val pullbackDepth = if (priceVsExit < 0.0) abs(priceVsExit) / atr else 0.0
        val scoreReset = signal.score <= maxOf(30.0, anchor.strategyScoreAtExit - max(scoreResetDrop, signal.volatilityPercent))
        val newSignal = signal.signalId.isNotBlank() && signal.signalId != anchor.consumedSignalId && signal.timestamp > anchor.exitTime
        val stabilized = signal.pullbackState in setOf(
            PullbackState.PULLBACK_STABILIZING.name, PullbackState.REACCELERATION.name, PullbackState.ENTRY_READY.name
        )
        val reaccelerating = signal.pullbackState in setOf(PullbackState.REACCELERATION.name, PullbackState.ENTRY_READY.name) &&
            signal.microMomentumState == MicroMomentumState.ACCELERATING.name
        val sameWave = priceVsExit >= 0.0
        val waveState = when {
            sameWave -> NewWaveState.SAME_WAVE
            reaccelerating && newSignal -> NewWaveState.NEW_WAVE_CONFIRMED
            stabilized -> NewWaveState.BASE_FORMING
            pullbackDepth > 0.25 -> NewWaveState.PULLBACK
            newSignal -> NewWaveState.NEW_WAVE_CANDIDATE
            else -> NewWaveState.SAME_WAVE
        }
        val quality = (
            (elapsedMinutes.coerceAtMost(30.0) / 30.0 * 15.0) +
                (pullbackDepth.coerceIn(0.0, 2.0) / 2.0 * 20.0) +
                (if (stabilized) 15.0 else 0.0) +
                (if (reaccelerating) 25.0 else 0.0) +
                (if (newSignal) 15.0 else 0.0) +
                ((signal.entryTimingScore - 50.0) * 0.20).coerceIn(-10.0, 10.0) +
                ((signal.scalpExecutionScore - 50.0) * 0.15).coerceIn(-7.5, 7.5) +
                (if (signal.chaseEntryScore < 35.0) 10.0 else -signal.chaseEntryScore * 0.10)
            ).coerceIn(0.0, 100.0)
        val sameWaveProbability = when {
            sameWave -> 0.90
            priceVsExit > -atr -> 0.70
            else -> 0.35
        }
        val newWaveConfidence = (quality * 0.65 + if (waveState == NewWaveState.NEW_WAVE_CONFIRMED) 35.0 else 0.0).coerceIn(0.0, 100.0)
        val riskAmount = signal.estimatedInvestment.takeIf { it > 0.0 } ?: 0.0
        val profitRisk = if (anchor.realizedProfit > 0.0) riskAmount / anchor.realizedProfit * 100.0 else Double.POSITIVE_INFINITY
        val blocked = now < current.cooldownUntil
        when {
            blocked -> reason += "PROFIT_EXIT_COOLDOWN"
            !newSignal -> reason += "OLD_SIGNAL_REENTRY_BLOCKED"
            sameWave -> reason += "REENTRY_ABOVE_EXIT_CHASE"
            !scoreReset -> reason += "SCORE_RESET_REQUIRED"
            !stabilized -> reason += "PULLBACK_STABILIZATION_REQUIRED"
            !reaccelerating -> reason += "REACCELERATION_REQUIRED"
            signal.chaseEntryScore >= 70.0 -> reason += "PROFIT_REENTRY_CHASE"
            signal.shortHorizonNetEdge < 0.0 -> reason += "NO_REENTRY_EDGE"
            profitRisk > maxRiskPercentOfProfit -> reason += "PROFIT_GIVEBACK_RISK"
        }
        val allowed = !blocked && newSignal && !sameWave && scoreReset && stabilized && reaccelerating &&
            signal.chaseEntryScore < 70.0 && signal.shortHorizonNetEdge >= 0.0 && quality >= minimumQuality &&
            profitRisk <= maxRiskPercentOfProfit
        val status = when {
            allowed -> ProfitReentryStatus.REENTRY_READY
            blocked -> ProfitReentryStatus.PROFIT_EXIT_COOLDOWN
            sameWave || signal.chaseEntryScore >= 70.0 -> ProfitReentryStatus.REENTRY_REJECTED_CHASE
            !stabilized -> ProfitReentryStatus.WAIT_PULLBACK
            !reaccelerating -> ProfitReentryStatus.WAIT_REACCELERATION
            else -> ProfitReentryStatus.WAIT_NEW_SETUP
        }
        return SmartReentryDecision(
            allowed, status, waveState, quality, sameWaveProbability, newWaveConfidence,
            reason.ifEmpty { listOf("REENTRY_READY") },
            current.copy(
                status = status, waveState = waveState, scoreResetObserved = scoreReset,
                newSignalValid = newSignal, lastSignalId = signal.signalId, lastSignalTime = signal.timestamp,
                reentryQualityScore = quality, sameWaveProbability = sameWaveProbability, newWaveConfidence = newWaveConfidence,
                reentryRiskAmount = riskAmount
            )
        )
    }

    fun onProfitExit(
        previous: SmartReentryState?,
        anchor: ProfitExitAnchor,
        cooldownMinutes: Int,
        marketSessionProfit: Double = anchor.realizedProfit
    ): SmartReentryState = SmartReentryState(
        market = anchor.market,
        status = ProfitReentryStatus.PROFIT_EXIT_COOLDOWN,
        waveState = NewWaveState.SAME_WAVE,
        anchor = anchor,
        cooldownUntil = anchor.exitTime + cooldownMinutes * 60_000L,
        marketSessionRealizedProfit = marketSessionProfit,
        recentCycleProfit = anchor.realizedProfit,
        reentryLossChain = previous?.reentryLossChain ?: 0,
        roundTrips = (previous?.roundTrips ?: 0) + 1,
        fees = previous?.fees ?: 0.0,
        slippage = previous?.slippage ?: 0.0
    )

    fun afterReentryBuy(state: SmartReentryState, entrySignalId: String): SmartReentryState =
        state.copy(status = ProfitReentryStatus.NONE, waveState = NewWaveState.NEW_WAVE_CONFIRMED, wasReentry = true, lastSignalId = entrySignalId)

    fun onReentryLoss(state: SmartReentryState, lossAmount: Double, now: Long, chainWindowMinutes: Int): SmartReentryState {
        val recent = state.anchor?.let { now - it.exitTime <= chainWindowMinutes * 60_000L } == true
        val givenBack = if (recent) min(state.recentCycleProfit.coerceAtLeast(0.0), abs(lossAmount)) else 0.0
        return state.copy(
            status = if (recent) ProfitReentryStatus.BLOCKED_UNTIL_NEW_WAVE else state.status,
            reentryLossChain = if (recent) state.reentryLossChain + 1 else state.reentryLossChain,
            profitGivenBackAmount = state.profitGivenBackAmount + givenBack
        )
    }
}

data class ReentryChurnStats(
    val roundTrips: Int = 0,
    val fees: Double = 0.0,
    val slippage: Double = 0.0,
    val grossPnl: Double = 0.0,
    val netPnl: Double = 0.0,
    val warning: String = "NORMAL"
)

object TradeChurnDetector {
    fun analyze(trades: List<TradeEntity>, windowMinutes: Int = 60): ReentryChurnStats {
        val rows = trades.sortedBy { it.time }
        val roundTrips = rows.zipWithNext().count { it.first.side == "SELL" && it.second.side == "BUY" }
        val fees = rows.sumOf { it.fee }
        val gross = rows.filter { it.side == "SELL" }.sumOf { it.realizedPnl }
        val net = gross - fees
        return ReentryChurnStats(
            roundTrips = roundTrips,
            fees = fees,
            slippage = 0.0,
            grossPnl = gross,
            netPnl = net,
            warning = if (roundTrips >= 3 && net < gross) "OVERTRADING_COST_WARNING" else "NORMAL"
        )
    }
}

data class ReentryShadowMetrics(val policy: ReentryShadowPolicy, val trades: Int, val winRate: Double, val profitFactor: Double, val expectancy: Double, val mdd: Double, val netReturn: Double)

object ReentryShadowEngine {
    fun compare(prices: List<Double>, horizon: Int = 5): List<ReentryShadowMetrics> {
        val clean = prices.filter { it.isFinite() && it > 0.0 }
        if (clean.size <= horizon + 4) return ReentryShadowPolicy.values().map { ReentryShadowMetrics(it, 0, 0.0, 0.0, 0.0, 0.0, 0.0) }
        return ReentryShadowPolicy.values().map { policy ->
            val delay = when (policy) {
                ReentryShadowPolicy.IMMEDIATE_REENTRY -> 0
                ReentryShadowPolicy.TIME_COOLDOWN_ONLY -> 1
                ReentryShadowPolicy.WAIT_PULLBACK -> 2
                ReentryShadowPolicy.WAIT_REACCELERATION -> 3
                ReentryShadowPolicy.NEW_WAVE_ONLY -> 4
                ReentryShadowPolicy.NO_REENTRY -> -1
            }
            val returns = if (delay < 0) emptyList() else (0..clean.size - horizon - delay - 1).map {
                (clean[it + delay + horizon] / clean[it + delay] - 1.0) * 100.0
            }
            val stats = StrategyPerformanceEngine.fromPnlRates(returns)
            ReentryShadowMetrics(policy, stats.sampleCount, stats.winRate, stats.profitFactor, stats.expectedReturnPercent, stats.maxDrawdownPercent, returns.sum())
        }
    }
}

@Entity(tableName = "smart_reentry_states")
data class SmartReentryStateEntity(
    @PrimaryKey val market: String,
    val status: String,
    val waveState: String,
    val exitPrice: Double,
    val exitTime: Long,
    val entryPrice: Double,
    val peakPrice: Double,
    val realizedProfit: Double,
    val realizedProfitPercent: Double,
    val exitReason: String,
    val strategyScoreAtExit: Double,
    val aiScoreAtExit: Double,
    val regimeAtExit: String,
    val marketHealthAtExit: Double,
    val consumedSignalId: String,
    val cooldownUntil: Long,
    val scoreResetObserved: Boolean,
    val newSignalValid: Boolean,
    val lastSignalId: String,
    val lastSignalTime: Long,
    val reentryQualityScore: Double,
    val sameWaveProbability: Double,
    val newWaveConfidence: Double,
    val marketSessionRealizedProfit: Double,
    val recentCycleProfit: Double,
    val reentryRiskAmount: Double,
    val profitGivenBackAmount: Double,
    val reentryLossChain: Int,
    val wasReentry: Boolean,
    val roundTrips: Int,
    val fees: Double,
    val slippage: Double
)

fun SmartReentryState.toEntity() = SmartReentryStateEntity(
    market, status.name, waveState.name, anchor?.exitPrice ?: 0.0, anchor?.exitTime ?: 0L,
    anchor?.entryPrice ?: 0.0, anchor?.peakPrice ?: 0.0, anchor?.realizedProfit ?: 0.0,
    anchor?.realizedProfitPercent ?: 0.0, anchor?.exitReason.orEmpty(), anchor?.strategyScoreAtExit ?: 0.0,
    anchor?.aiScoreAtExit ?: 0.0, anchor?.regimeAtExit.orEmpty(), anchor?.marketHealthAtExit ?: 0.0,
    anchor?.consumedSignalId.orEmpty(), cooldownUntil, scoreResetObserved, newSignalValid, lastSignalId, lastSignalTime,
    reentryQualityScore, sameWaveProbability, newWaveConfidence, marketSessionRealizedProfit, recentCycleProfit,
    reentryRiskAmount, profitGivenBackAmount, reentryLossChain, wasReentry, roundTrips, fees, slippage
)

fun SmartReentryStateEntity.toModel() = SmartReentryState(
    market = market,
    status = runCatching { ProfitReentryStatus.valueOf(status) }.getOrDefault(ProfitReentryStatus.NONE),
    waveState = runCatching { NewWaveState.valueOf(waveState) }.getOrDefault(NewWaveState.SAME_WAVE),
    anchor = exitTime.takeIf { it > 0L }?.let {
        ProfitExitAnchor(market, exitPrice, exitTime, entryPrice, peakPrice, realizedProfit, realizedProfitPercent, exitReason, strategyScoreAtExit, aiScoreAtExit, regimeAtExit, marketHealthAtExit, consumedSignalId)
    },
    cooldownUntil = cooldownUntil, scoreResetObserved = scoreResetObserved, newSignalValid = newSignalValid,
    lastSignalId = lastSignalId, lastSignalTime = lastSignalTime, reentryQualityScore = reentryQualityScore,
    sameWaveProbability = sameWaveProbability, newWaveConfidence = newWaveConfidence,
    marketSessionRealizedProfit = marketSessionRealizedProfit, recentCycleProfit = recentCycleProfit,
    reentryRiskAmount = reentryRiskAmount, profitGivenBackAmount = profitGivenBackAmount,
    reentryLossChain = reentryLossChain, wasReentry = wasReentry, roundTrips = roundTrips, fees = fees, slippage = slippage
)

@Entity(tableName = "smart_reentry_attempts")
data class SmartReentryAttemptEntity(
    @PrimaryKey val id: String = UUID.randomUUID().toString(),
    val market: String,
    val time: Long,
    val signalId: String,
    val previousExitPrice: Double,
    val previousExitTime: Long,
    val signalPrice: Double,
    val priceVsExitPercent: Double,
    val reentryQualityScore: Double,
    val sameWaveProbability: Double,
    val newWaveConfidence: Double,
    val state: String,
    val decision: String,
    val reasonCodes: String,
    val previousProfit: Double,
    val isReentry: Boolean,
    val resultPnl: Double? = null,
    val outcome: String? = null
)

data class SmartReentryResearchStats(
    val attempts: Int = 0,
    val reentries: Int = 0,
    val reentryWins: Int = 0,
    val reentryLosses: Int = 0,
    val averageReentryPnl: Double = 0.0,
    val profitGivenBack: Double = 0.0,
    val sameWaveCount: Int = 0,
    val churn: ReentryChurnStats = ReentryChurnStats(),
    val warning: String = "DATA_INSUFFICIENT"
)

object SmartReentryAnalytics {
    fun summarize(attempts: List<SmartReentryAttemptEntity>, states: List<SmartReentryState>, trades: List<TradeEntity>): SmartReentryResearchStats {
        val reentries = attempts.filter { it.isReentry }
        val completed = reentries.filter { it.resultPnl != null }
        val wins = completed.count { it.resultPnl!! > 0.0 }
        val losses = completed.count { it.resultPnl!! < 0.0 }
        return SmartReentryResearchStats(
            attempts = attempts.size, reentries = reentries.size, reentryWins = wins, reentryLosses = losses,
            averageReentryPnl = completed.map { it.resultPnl!! }.averageOrZero(),
            profitGivenBack = states.sumOf { it.profitGivenBackAmount },
            sameWaveCount = attempts.count { it.state == NewWaveState.SAME_WAVE.name },
            churn = TradeChurnDetector.analyze(trades),
            warning = if (reentries.size < 20) "DATA_INSUFFICIENT" else if (wins + losses > 0 && wins.toDouble() / (wins + losses) < 0.40) "REENTRY_CHAIN_RISK" else "NORMAL"
        )
    }

    private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
}

class SmartReentryCoordinator {
    private val states = java.util.concurrent.ConcurrentHashMap<String, SmartReentryState>()

    fun get(market: String): SmartReentryState? = states[market]

    fun observe(market: String, decision: SmartReentryDecision): SmartReentryState {
        states[market] = decision.nextState
        return decision.nextState
    }

    fun recordProfitExit(
        anchor: ProfitExitAnchor,
        cooldownMinutes: Int
    ): SmartReentryState {
        val next = SmartReentryEngine.onProfitExit(states[anchor.market], anchor, cooldownMinutes)
        states[anchor.market] = next
        return next
    }

    fun recordReentryBuy(market: String, signalId: String): SmartReentryState? =
        states[market]?.let { SmartReentryEngine.afterReentryBuy(it, signalId).also { next -> states[market] = next } }

    fun recordLoss(market: String, lossAmount: Double, now: Long, chainWindowMinutes: Int): SmartReentryState? =
        states[market]?.let { SmartReentryEngine.onReentryLoss(it, lossAmount, now, chainWindowMinutes).also { next -> states[market] = next } }

    fun restore(statesToRestore: List<SmartReentryState>) {
        statesToRestore.forEach { states[it.market] = it }
    }

    fun snapshot(): List<SmartReentryState> = states.values.toList()

    fun clear() {
        states.clear()
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/SmartReentry.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/SpeedOptimization.kt =====
package com.example.bithumbtrader

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.ln

/** 중앙 API 호출 간격 제어. 캐시 미스가 몰려도 endpoint별 폭주를 막는다. */
class CentralApiRateLimiter(private val minimumIntervalMs: Long = 120L) {
    private val mutex = Mutex()
    private val lastRequestAt = mutableMapOf<String, Long>()
    private val callCounts = ConcurrentHashMap<String, Int>()
    private var totalWaitMs = 0L

    suspend fun acquire(endpoint: String) {
        mutex.withLock {
            val now = System.currentTimeMillis()
            val wait = (minimumIntervalMs - (now - (lastRequestAt[endpoint] ?: 0L))).coerceAtLeast(0L)
            if (wait > 0) { totalWaitMs += wait; delay(wait) }
            lastRequestAt[endpoint] = System.currentTimeMillis()
            callCounts[endpoint] = (callCounts[endpoint] ?: 0) + 1
        }
    }

    fun totalCalls(): Int = callCounts.values.sum()
    fun totalWaitMs(): Long = totalWaitMs
}

data class CacheMetrics(val hits: Int = 0, val misses: Int = 0) {
    val hitRate: Double get() = if (hits + misses == 0) 0.0 else hits.toDouble() / (hits + misses)
}

private data class TimedValue<T>(val value: T, val storedAt: Long)

class MarketDataSharedCache(
    private val tickerTtlMs: Long = 2_000L,
    private val orderbookTtlMs: Long = 1_000L,
    private val candleTtlMs: Long = 55_000L,
    private val marketListTtlMs: Long = 10 * 60_000L
) {
    private val tickers = ConcurrentHashMap<String, TimedValue<TickerModel>>()
    private val orderbooks = ConcurrentHashMap<String, TimedValue<OrderbookModel>>()
    private val candles = ConcurrentHashMap<String, TimedValue<List<CandleModel>>>()
    @Volatile private var markets: TimedValue<List<MarketModel>>? = null
    @Volatile private var tickerHits = 0
    @Volatile private var tickerMisses = 0
    @Volatile private var orderbookHits = 0
    @Volatile private var orderbookMisses = 0
    @Volatile private var candleHits = 0
    @Volatile private var candleMisses = 0
    @Volatile private var marketHits = 0
    @Volatile private var marketMisses = 0

    fun ticker(market: String, now: Long = System.currentTimeMillis()): TickerModel? = tickers[market]?.takeIf { now - it.storedAt <= tickerTtlMs }?.also { tickerHits++ }?.value ?: run { tickerMisses++; null }
    fun putTicker(value: TickerModel, now: Long = System.currentTimeMillis()) { tickers[value.market] = TimedValue(value, now) }
    fun orderbook(market: String, now: Long = System.currentTimeMillis()): OrderbookModel? = orderbooks[market]?.takeIf { now - it.storedAt <= orderbookTtlMs }?.also { orderbookHits++ }?.value ?: run { orderbookMisses++; null }
    fun putOrderbook(value: OrderbookModel, now: Long = System.currentTimeMillis()) { orderbooks[value.market] = TimedValue(value, now) }
    fun candle(market: String, unit: Int = 5, now: Long = System.currentTimeMillis()): List<CandleModel>? =
        candles["$unit:$market"]?.takeIf { now - it.storedAt <= candleTtlMs }?.also { candleHits++ }?.value ?: run { candleMisses++; null }
    fun putCandle(market: String, unit: Int = 5, value: List<CandleModel>, now: Long = System.currentTimeMillis()) { candles["$unit:$market"] = TimedValue(value, now) }
    fun marketList(now: Long = System.currentTimeMillis()): List<MarketModel>? = markets?.takeIf { now - it.storedAt <= marketListTtlMs }?.also { marketHits++ }?.value ?: run { marketMisses++; null }
    fun putMarketList(value: List<MarketModel>, now: Long = System.currentTimeMillis()) { markets = TimedValue(value, now) }
    fun metrics(): Map<String, CacheMetrics> = mapOf("Ticker" to CacheMetrics(tickerHits, tickerMisses), "Orderbook" to CacheMetrics(orderbookHits, orderbookMisses), "Candle" to CacheMetrics(candleHits, candleMisses), "Market" to CacheMetrics(marketHits, marketMisses))
}

/** 기존 Provider를 감싸 모든 엔진이 같은 TTL 캐시와 RateLimiter를 공유하게 한다. */
class CachedMarketDataProvider(
    private val delegate: MarketDataProvider,
    val cache: MarketDataSharedCache = MarketDataSharedCache(),
    private val rateLimiter: CentralApiRateLimiter = CentralApiRateLimiter()
) : MarketDataProvider {
    override suspend fun loadKrwMarkets(): List<MarketModel> = cache.marketList() ?: delegate.loadKrwMarkets().also { cache.putMarketList(it) }
    override suspend fun ticker(markets: List<String>): List<TickerModel> {
        val cached = markets.mapNotNull { cache.ticker(it) }.associateBy { it.market }
        val missing = markets.filterNot { cached.containsKey(it) }
        val fresh = if (missing.isEmpty()) emptyList() else { rateLimiter.acquire("ticker"); delegate.ticker(missing).also { it.forEach(cache::putTicker) } }
        return (cached.values + fresh).distinctBy { it.market }
    }
    override suspend fun orderbook(markets: List<String>): List<OrderbookModel> {
        val cached = markets.mapNotNull { cache.orderbook(it) }.associateBy { it.market }
        val missing = markets.filterNot { cached.containsKey(it) }
        val fresh = if (missing.isEmpty()) emptyList() else { rateLimiter.acquire("orderbook"); delegate.orderbook(missing).also { it.forEach(cache::putOrderbook) } }
        return (cached.values + fresh).distinctBy { it.market }
    }
    override suspend fun candles(market: String, unit: Int): List<CandleModel> =
        cache.candle(market, unit) ?: run { rateLimiter.acquire("candle-$unit"); delegate.candles(market, unit).also { cache.putCandle(market, unit, it) } }
    fun optimizationStats(): Triple<Int, Long, Double> {
        val metrics = cache.metrics().values
        val hits = metrics.sumOf { it.hits }
        val misses = metrics.sumOf { it.misses }
        val hitRate = if (hits + misses == 0) 0.0 else hits.toDouble() / (hits + misses)
        return Triple(rateLimiter.totalCalls(), rateLimiter.totalWaitMs(), hitRate)
    }
}

data class FastScanCandidate(val market: String, val fastScore: Double, val priority: Int, val detectedAt: Long = 0L)
data class FastScanResult(val candidates: List<FastScanCandidate>, val eligibleMarketCount: Int)

object FastScanEngine {
    fun scan(markets: List<MarketModel>, tickers: Map<String, TickerModel>, rotation: List<String> = emptyList(), now: Long = System.currentTimeMillis()): FastScanResult {
        val desired = (markets.size * 0.25).toInt().coerceIn(20, 50).coerceAtMost(markets.size)
        val scored = markets.mapNotNull { market ->
            val ticker = tickers[market.market] ?: return@mapNotNull null
            val score = (ticker.signedChangeRate * 100.0).coerceIn(-20.0, 20.0) + ln(1.0 + ticker.accTradePrice24h.coerceAtLeast(0.0)) * 0.8 + ln(1.0 + ticker.tradeVolume.coerceAtLeast(0.0)) * 1.5
            FastScanCandidate(market.market, score, if (ticker.signedChangeRate > 0.01 || ticker.tradeVolume > 0.0) 2 else 4, now)
        }.sortedByDescending { it.fastScore }
        val rotationCandidates = rotation.mapNotNull { code -> scored.firstOrNull { it.market == code } }
        val selected = (rotationCandidates + scored).distinctBy { it.market }.take(desired)
        return FastScanResult(selected, scored.size)
    }
}

suspend fun <T, R> limitedParallelMap(values: Iterable<T>, concurrency: Int = 4, block: suspend (T) -> R): List<R> = coroutineScope {
    val semaphore = Semaphore(concurrency.coerceAtLeast(1))
    values.map { value -> async { semaphore.withPermit { block(value) } } }.awaitAll()
}

data class ScanPerformanceSnapshot(
    val totalScanMs: Long = 0L,
    val fastScanMs: Long = 0L,
    val deepScanMs: Long = 0L,
    val executionScanMs: Long = 0L,
    val positionFastLaneMs: Long = 0L,
    val marketDataMs: Long = 0L,
    val indicatorMs: Long = 0L,
    val aiMs: Long = 0L,
    val newsMs: Long = 0L,
    val dbMs: Long = 0L,
    val apiCallCount: Int = 0,
    val cacheHitRate: Double = 0.0,
    val websocketUpdates: Int = 0,
    val rateLimitWaitMs: Long = 0L
)

data class CandidateLatencyStats(val samples: Int = 0, val averageMs: Long = 0L, val p50Ms: Long = 0L, val p95Ms: Long = 0L)

object ScanPerformanceProfiler {
    fun latency(values: List<Long>): CandidateLatencyStats {
        if (values.isEmpty()) return CandidateLatencyStats()
        val sorted = values.sorted()
        fun percentile(percent: Double): Long = sorted[((sorted.size - 1) * percent).toInt().coerceIn(0, sorted.lastIndex)]
        return CandidateLatencyStats(sorted.size, values.average().toLong(), percentile(0.50), percentile(0.95))
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/SpeedOptimization.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/StrategyIntelligence.kt =====
package com.example.bithumbtrader

import kotlin.math.abs

/**
 * 승률 / Profit Factor / MDD / 거래당 기대수익률.
 * PAPER, LIVE 모두 동일한 realizedPnl/pnlRate 스키마(TradeEntity)를 사용하므로
 * 모드에 상관없이 동일한 계산 로직이 재사용된다.
 *
 * pnlRate / realizedPnl 은 Paper 체결 기준 **비용 차감 후 Net** 이다
 * (매수 수수료·슬리피지는 평균가에, 매도 수수료·슬리피지는 실현손익에 반영).
 */
data class PerformanceStats(
    val sampleCount: Int = 0,
    val winRate: Double = 0.0,
    val profitFactor: Double = 0.0,
    val maxDrawdownPercent: Double = 0.0,
    val expectedReturnPercent: Double = 0.0
)

object StrategyPerformanceEngine {
    fun fromPnlRates(pnlRates: List<Double>): PerformanceStats {
        val clean = pnlRates.filter { it.isFinite() }
        if (clean.isEmpty()) return PerformanceStats()
        val wins = clean.count { it > 0.0 }
        val grossProfit = clean.filter { it > 0.0 }.sum()
        val grossLoss = abs(clean.filter { it < 0.0 }.sum())
        val profitFactor = when {
            grossLoss <= 0.0 && grossProfit > 0.0 -> Double.POSITIVE_INFINITY
            grossLoss <= 0.0 -> 0.0
            else -> grossProfit / grossLoss
        }
        var cumulative = 0.0
        var peak = 0.0
        var maxDrawdown = 0.0
        clean.forEach { rate ->
            cumulative += rate
            if (cumulative > peak) peak = cumulative
            val drawdown = cumulative - peak
            if (drawdown < maxDrawdown) maxDrawdown = drawdown
        }
        return PerformanceStats(
            sampleCount = clean.size,
            winRate = wins.toDouble() / clean.size,
            profitFactor = profitFactor,
            maxDrawdownPercent = maxDrawdown,
            expectedReturnPercent = clean.average()
        )
    }

    /** Trade 원장(TradeEntity, SELL 행)에서 직접 계산 — PAPER/LIVE 동일 스키마로 재사용 가능 */
    fun fromTrades(trades: List<TradeEntity>): PerformanceStats =
        fromPnlRates(trades.filter { it.side == "SELL" }.map { it.pnlRate })
}

enum class RecommendationStage {
    NONE,
    BACKTESTING,
    BACKTEST_REJECTED,
    PAPER_TRIAL,
    PAPER_TRIAL_REJECTED,
    AWAITING_LIVE_VALIDATION,
    LIVE_VALIDATION_REJECTED,
    OTA_READY
}

/**
 * 전략을 직접 바꾸지 않고 "제안"만 하는 값 객체.
 * apply() 는 순수 함수로 델타를 기존 설정에 얹어 후보 설정을 만들 뿐, 그 자체로는 아무것도 바꾸지 않는다.
 */
data class RecommendationProposal(
    val reason: String,
    val scoreThresholdDelta: Double = 0.0,
    val stopLossDelta: Double = 0.0,
    val takeProfitDelta: Double = 0.0,
    val trailingStopDelta: Double = 0.0,
    val maxPositionsDelta: Int = 0
) {
    fun apply(settings: TradingSettings): TradingSettings = settings.copy(
        scoreThreshold = (settings.scoreThreshold + scoreThresholdDelta).coerceIn(0.0, 100.0),
        stopLossPercent = (settings.stopLossPercent + stopLossDelta).coerceIn(-80.0, -0.1),
        takeProfitPercent = (settings.takeProfitPercent + takeProfitDelta).coerceAtLeast(0.1),
        trailingStopPercent = (settings.trailingStopPercent + trailingStopDelta).coerceAtLeast(0.1),
        maxPositions = (settings.maxPositions + maxPositionsDelta).coerceIn(1, 50)
    )
}

data class RecommendationRuntimeState(
    val id: String,
    val stage: RecommendationStage,
    val reason: String,
    val proposal: RecommendationProposal,
    val baseline: PerformanceStats,
    val backtest: PerformanceStats? = null,
    val paperTrial: PerformanceStats? = null,
    val trialStartedAt: Long = 0L,
    val trialSamplesRequired: Int = 15,
    val trialSamplesCollected: Int = 0,
    val createdAt: Long = System.currentTimeMillis()
)

data class RecommendationHistoryItem(
    val id: String,
    val createdAt: Long,
    val stage: String,
    val reason: String,
    val baselineWinRate: Double,
    val baselineProfitFactor: Double,
    val backtestWinRate: Double,
    val backtestProfitFactor: Double,
    val paperTrialWinRate: Double,
    val paperTrialProfitFactor: Double
)

/**
 * 누적된 성과 통계만으로 "추천"을 만드는 규칙 기반 엔진.
 * 데이터가 부족하면 아무것도 제안하지 않고(null), 임계값을 넘는 뚜렷한 신호가 있을 때만
 * 설명 가능한 이유와 함께 작은 폭의 파라미터 조정을 제안한다. 전략을 직접 바꾸지 않는다.
 */
object StrategyRecommendationEngine {
    const val MIN_SAMPLES = 30
    const val MIN_VALIDATION_SAMPLES = 15
    private const val WIN_RATE_TOLERANCE = 0.02
    private const val PROFIT_FACTOR_TOLERANCE = 0.05

    fun propose(stats: PerformanceStats): RecommendationProposal? {
        if (stats.sampleCount < MIN_SAMPLES) return null
        return when {
            stats.winRate < 0.4 && stats.profitFactor < 1.0 -> RecommendationProposal(
                reason = "승률 ${"%.1f".format(stats.winRate * 100)}%, Profit Factor ${"%.2f".format(stats.profitFactor)}로 부진 " +
                    "— 진입 기준 강화(Score +5) 및 손절 타이트닝(+0.5%p) 제안",
                scoreThresholdDelta = 5.0,
                stopLossDelta = 0.5
            )
            stats.maxDrawdownPercent <= -15.0 -> RecommendationProposal(
                reason = "누적 최대 낙폭 ${"%.1f".format(stats.maxDrawdownPercent)}%로 과도 " +
                    "— 동시 보유 종목 축소(-1) 및 Trailing Stop 강화(-0.5%p) 제안",
                maxPositionsDelta = -1,
                trailingStopDelta = -0.5
            )
            stats.winRate > 0.6 && stats.profitFactor > 1.5 && stats.expectedReturnPercent > 0.0 -> RecommendationProposal(
                reason = "승률 ${"%.1f".format(stats.winRate * 100)}%, Profit Factor ${"%.2f".format(stats.profitFactor)}로 안정적 " +
                    "— 진입 기준 소폭 완화(Score -3)로 기회 확대 제안",
                scoreThresholdDelta = -3.0
            )
            else -> null
        }
    }

    /**
     * 후보 성과가 기준 대비 "실제로" 나아졌는지 상대평가한다.
     * 최소 표본 수를 만족해야 하고, 승률/Profit Factor 어느 한쪽도 허용 오차 이상으로 나빠지지 않으면서
     * 둘 중 하나 이상은 명확히 개선되어야 통과한다.
     */
    fun isImprovement(candidate: PerformanceStats, baseline: PerformanceStats, minSamples: Int = MIN_VALIDATION_SAMPLES): Boolean {
        if (candidate.sampleCount < minSamples) return false
        val winRateNotWorse = candidate.winRate >= baseline.winRate - WIN_RATE_TOLERANCE
        val profitFactorNotWorse = candidate.profitFactor >= baseline.profitFactor - PROFIT_FACTOR_TOLERANCE
        val meaningfullyBetter = candidate.winRate > baseline.winRate + WIN_RATE_TOLERANCE ||
            candidate.profitFactor > baseline.profitFactor + PROFIT_FACTOR_TOLERANCE
        return winRateNotWorse && profitFactorNotWorse && meaningfullyBetter
    }
}

/** 시장 국면 — 상승장 / 횡보장 / 하락장. 표본이 부족하면 UNKNOWN. */
enum class MarketRegime { STRONG_BULL, BULL, SIDEWAYS, HIGH_VOLATILITY, WEAK_BEAR, STRONG_BEAR, CRASH, RECOVERY, BEAR, UNKNOWN }

data class MarketRegimeSnapshot(
    val regime: MarketRegime = MarketRegime.UNKNOWN,
    val breadthPositive: Double = 0.0,
    val averageChangeRatePercent: Double = 0.0,
    val sampleCount: Int = 0,
    val computedAt: Long = 0L,
    val confidence: Double = 0.0,
    val trendStrength: Double = 0.0,
    val volatilityLevel: String = "UNKNOWN",
    val shortRegime: MarketRegime = MarketRegime.UNKNOWN,
    val midRegime: MarketRegime = MarketRegime.UNKNOWN,
    val longRegime: MarketRegime = MarketRegime.UNKNOWN,
    val durationMinutes: Long = 0L
)

/**
 * 이미 매 스캔 사이클마다 받아오는 티커의 signedChangeRate 값들(24시간 등락률)을
 * 롤링 윈도우로 모아 시장 전체의 국면을 판정한다. 새로운 외부 데이터 소스나
 * 추가 API 호출 없이, 기존에 받아오던 데이터만 재활용한다.
 */
object MarketRegimeClassifier {
    const val MIN_SAMPLES = 20
    private const val BULL_AVG_THRESHOLD = 1.5
    private const val BULL_BREADTH_THRESHOLD = 0.6
    private const val BEAR_AVG_THRESHOLD = -1.5
    private const val BEAR_BREADTH_THRESHOLD = 0.4

    fun classify(changeRates: List<Double>): MarketRegimeSnapshot {
        val clean = changeRates.filter { it.isFinite() }
        if (clean.size < MIN_SAMPLES) return MarketRegimeSnapshot(sampleCount = clean.size, computedAt = System.currentTimeMillis())
        val positive = clean.count { it > 0.0 }
        val breadth = positive.toDouble() / clean.size
        val averagePercent = clean.average() * 100.0
        val regime = when {
            averagePercent > BULL_AVG_THRESHOLD && breadth > BULL_BREADTH_THRESHOLD -> MarketRegime.BULL
            averagePercent < BEAR_AVG_THRESHOLD && breadth < BEAR_BREADTH_THRESHOLD -> MarketRegime.BEAR
            else -> MarketRegime.SIDEWAYS
        }
        return MarketRegimeSnapshot(regime, breadth, averagePercent, clean.size, System.currentTimeMillis(), confidence = confidenceFor(clean, regime))
    }

    fun classifyMultiTimeframe(
        shortRates: List<Double>,
        midRates: List<Double>,
        longRates: List<Double>,
        healthScore: Double,
        previous: MarketRegime = MarketRegime.UNKNOWN
    ): MarketRegimeSnapshot {
        val short = classify(shortRates)
        val mid = classify(midRates)
        val long = classify(longRates)
        val all = (shortRates + midRates + longRates).filter { it.isFinite() }
        if (all.size < MIN_SAMPLES) return MarketRegimeSnapshot(sampleCount = all.size, computedAt = System.currentTimeMillis(), shortRegime = short.regime, midRegime = mid.regime, longRegime = long.regime)
        val avg = all.average() * 100.0
        val breadth = all.count { it > 0.0 }.toDouble() / all.size
        val volatility = kotlin.math.sqrt(all.map { (it - all.average()).let { d -> d * d } }.average()) * 100.0
        val agreement = listOf(short.regime, mid.regime, long.regime).count { it == short.regime }.toDouble() / 3.0
        val baseConfidence = (0.55 + agreement * 0.35 + (all.size.toDouble() / 300.0).coerceAtMost(0.1)).coerceIn(0.0, 0.98)
        val regime = when {
            healthScore < 40.0 || (avg <= -5.0 && breadth <= 0.2) -> MarketRegime.CRASH
            previous == MarketRegime.CRASH && avg > 0.5 && healthScore >= 50.0 -> MarketRegime.RECOVERY
            volatility >= 4.0 -> MarketRegime.HIGH_VOLATILITY
            avg >= 3.0 && breadth >= 0.7 && short.regime in setOf(MarketRegime.BULL, MarketRegime.STRONG_BULL) -> MarketRegime.STRONG_BULL
            avg > 1.0 && breadth > 0.55 -> MarketRegime.BULL
            avg <= -3.0 && breadth <= 0.3 -> MarketRegime.STRONG_BEAR
            avg < -1.0 && breadth < 0.45 -> MarketRegime.WEAK_BEAR
            else -> MarketRegime.SIDEWAYS
        }
        val trendStrength = (abs(avg) * 12.0 + agreement * 35.0).coerceIn(0.0, 100.0)
        return MarketRegimeSnapshot(regime, breadth, avg, all.size, System.currentTimeMillis(), baseConfidence, trendStrength,
            when { volatility >= 4.0 -> "HIGH"; volatility >= 2.0 -> "MEDIUM"; else -> "LOW" }, short.regime, mid.regime, long.regime)
    }

    private fun confidenceFor(values: List<Double>, regime: MarketRegime): Double {
        if (values.isEmpty()) return 0.0
        val direction = when (regime) { MarketRegime.BULL, MarketRegime.STRONG_BULL -> values.count { it > 0 }.toDouble() / values.size; MarketRegime.BEAR, MarketRegime.WEAK_BEAR, MarketRegime.STRONG_BEAR -> values.count { it < 0 }.toDouble() / values.size; else -> 0.5 }
        return (0.5 + abs(direction - 0.5)).coerceIn(0.0, 0.98)
    }
}

/**
 * 후보 품질(전략 Score + AI Score)에 따라 투자 비중을 동적으로 조절한다.
 * 기존 maxOrderPercent/maxAssetPercentPerCoin 한도를 절대 넘지 않고, 그 한도 "이하"로만
 * 축소하는 방향으로만 동작하므로 기존 리스크 상한보다 더 위험해지지 않는다.
 */
object PortfolioAllocationEngine {
    private const val MIN_ALLOCATION_FACTOR = 0.5
    private const val MAX_ALLOCATION_FACTOR = 1.0

    fun allocationFactor(score: Double, scoreThreshold: Double, aiScore: Double): Double {
        val qualityFactor = if (scoreThreshold >= 100.0) 0.0
            else ((score - scoreThreshold) / (100.0 - scoreThreshold)).coerceIn(0.0, 1.0)
        val aiFactor = (aiScore / 100.0).coerceIn(0.0, 1.0)
        val combined = (qualityFactor + aiFactor) / 2.0
        return (MIN_ALLOCATION_FACTOR + (MAX_ALLOCATION_FACTOR - MIN_ALLOCATION_FACTOR) * combined)
            .coerceIn(MIN_ALLOCATION_FACTOR, MAX_ALLOCATION_FACTOR)
    }
}

enum class MarketHealthLevel { HEALTHY, CAUTION, CRASH }

data class MarketHealthScore(
    val score: Double = 100.0,
    val level: MarketHealthLevel = MarketHealthLevel.HEALTHY,
    val reasons: List<String> = emptyList(),
    val computedAt: Long = 0L
)

data class CrashHistoryItem(
    val id: String,
    val startedAt: Long,
    val endedAt: Long?,
    val minHealthScore: Double,
    val regimeAtStart: String,
    val reasons: String,
    val totalValueAtStart: Double,
    val resolvedTotalValue: Double?
)

/**
 * Market Health Score(0~100) — 이미 계산된 MarketRegimeClassifier 결과와
 * AnomalyDetector가 찾은 이상 징후 수, 시세 지연/ API 오류 상태를 그대로 재사용해
 * 시장 전반의 "건강도"를 하나의 점수로 합성한다. 새로운 판정 데이터를 추가로
 * 수집하지 않고 이미 있는 신호들만 조합한다(중복 계산 없음).
 */
object MarketHealthEngine {
    private const val CRASH_THRESHOLD = 40.0
    private const val CAUTION_THRESHOLD = 70.0
    private const val CRASH_AVG_CHANGE_PERCENT = -5.0
    private const val CRASH_BREADTH = 0.2

    fun evaluate(regime: MarketRegimeSnapshot, anomalyCount: Int, tickerStale: Boolean, apiError: Boolean): MarketHealthScore {
        var score = 100.0
        val reasons = mutableListOf<String>()
        when (regime.regime) {
            MarketRegime.STRONG_BEAR, MarketRegime.BEAR, MarketRegime.WEAK_BEAR -> { score -= 30.0; reasons += "하락장 국면" }
            MarketRegime.CRASH -> { score -= 45.0; reasons += "급락 국면" }
            MarketRegime.HIGH_VOLATILITY -> { score -= 20.0; reasons += "고변동성 국면" }
            MarketRegime.SIDEWAYS -> score -= 5.0
            MarketRegime.BULL, MarketRegime.STRONG_BULL, MarketRegime.RECOVERY -> {}
            MarketRegime.UNKNOWN -> { score -= 10.0; reasons += "국면 판정 표본 부족" }
        }
        if (regime.sampleCount >= MarketRegimeClassifier.MIN_SAMPLES &&
            regime.averageChangeRatePercent <= CRASH_AVG_CHANGE_PERCENT &&
            regime.breadthPositive <= CRASH_BREADTH
        ) {
            score -= 40.0
            reasons += "급락 신호(평균 ${"%.1f".format(regime.averageChangeRatePercent)}%, 상승비중 ${"%.0f".format(regime.breadthPositive * 100)}%)"
        }
        if (anomalyCount > 0) {
            score -= (anomalyCount * 8.0)
            reasons += "이상 징후 ${anomalyCount}건 감지"
        }
        if (tickerStale) { score -= 15.0; reasons += "시세 갱신 지연" }
        if (apiError) { score -= 15.0; reasons += "API 오류 지속" }
        val clamped = score.coerceIn(0.0, 100.0)
        val level = when {
            clamped < CRASH_THRESHOLD -> MarketHealthLevel.CRASH
            clamped < CAUTION_THRESHOLD -> MarketHealthLevel.CAUTION
            else -> MarketHealthLevel.HEALTHY
        }
        return MarketHealthScore(clamped, level, reasons, System.currentTimeMillis())
    }
}

/**
 * Market Health Score에 따라 계좌 전체의 노출도(동시 보유 종목 수 / 주문 비중 상한)를
 * 축소하는 승수. PortfolioAllocationEngine(후보 품질 기반, 거래 1건 단위)과는 별개로
 * 시장 전체 상태(계좌 단위)를 반영하는 것이라 서로 중복되지 않고 곱해져서 함께 적용된다.
 * 1.0을 넘지 않으므로 기존에 설정한 한도보다 더 위험해지는 방향으로는 절대 작동하지 않는다.
 */
object ExposureControlEngine {
    fun exposureFactor(healthScore: Double): Double = when {
        healthScore >= 80.0 -> 1.0
        healthScore >= 60.0 -> 0.8
        healthScore >= 40.0 -> 0.6
        healthScore >= 20.0 -> 0.4
        else -> 0.3
    }
}

/**
 * 이미 쌓이고 있는 app_events 로그 위에서 반복되는 실패/이상 패턴만 감시하는
 * 순수 판정 로직. 새로운 로깅 체계를 만들지 않고 기존 로그를 재사용한다.
 */
object AnomalyDetector {
    const val REPEATED_FAILURE_THRESHOLD = 3

    fun detect(recentMessages: List<String>, state: DashboardState): List<String> {
        val anomalies = mutableListOf<String>()
        val failureCount = recentMessages.count {
            it.contains("PAPER BUY 실패") || it.contains("PAPER SELL 실패") || it.contains("킬 스위치 청산 실패")
        }
        if (failureCount >= REPEATED_FAILURE_THRESHOLD) {
            anomalies += "최근 주문 실패 ${failureCount}건 반복 감지 — 잔액/최소주문금액/네트워크 상태 점검 필요"
        }
        val otaFailureCount = recentMessages.count { it.contains("OTA 실패") || it.contains("OTA 실패/롤백") }
        if (otaFailureCount >= REPEATED_FAILURE_THRESHOLD) {
            anomalies += "OTA 갱신 실패 ${otaFailureCount}건 반복 감지 — 서버 연결 상태 점검 필요"
        }
        if (state.apiStatus == ConnectionStatus.ERROR) {
            anomalies += "API 연결 오류 상태 지속 중"
        }
        if (state.lastTickerAt > 0 && System.currentTimeMillis() - state.lastTickerAt > state.settings.staleTickerMillis * 3) {
            anomalies += "시세 갱신이 기준 시간의 3배 이상 지연됨 (${state.settings.staleTickerMillis * 3 / 1000}초)"
        }
        return anomalies
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/StrategyIntelligence.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/TraderApp.kt =====

package com.example.bithumbtrader

import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

class TraderApp: Application() {
    lateinit var database: AppDatabase
    lateinit var repository: TradingRepository
    private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    override fun onCreate() {
        super.onCreate()
        database=AppDatabase.get(this)
        val settingsStore = TradingSettingsStore(this)
        val restoredSettings = settingsStore.load()
        val cachedMarketData = CachedMarketDataProvider(BithumbMarketDataProvider(ApiFactory.publicApi()))
        val derivativesProvider = BybitDerivativesDataProvider(
            api = BybitApiFactory.publicApi(),
            liquidationStream = BybitLiquidationStream(ApiFactory.wsClient())
        )
        repository=TradingRepository(
            marketData = cachedMarketData,
            dao = database.dao(),
            initialAiModel = OnDeviceAiModel.fromAsset(this),
            tickerStream = BithumbTickerWebSocket(ApiFactory.wsClient()),
            initialSettings = restoredSettings,
            settingsStore = settingsStore,
            newsProvider = RssNewsDataProvider(),
            derivativesProvider = derivativesProvider,
            tradingAiTokenProvider = { SecureCredentialStore(this).tradingAiToken() }
        )
        appScope.launch {
            // PHASE6 hotfix: seed encrypted store from build-time property if empty (not source hardcode).
            val store = SecureCredentialStore(this@TraderApp)
            val seeded = BuildConfig.SEEDED_TRADING_AI_TOKEN.trim()
            if (!store.hasTradingAiToken() && seeded.isNotBlank()) {
                store.saveTradingAiToken(seeded)
                repository.log("AUTH_CLIENT_READY seeded from buildConfig token=***REDACTED*** baseUrl=${BuildConfig.DEFAULT_REMOTE_AI_BASE_URL}")
            } else if (!store.hasTradingAiToken()) {
                repository.log("AUTH_TOKEN_MISSING — Settings에서 Trading API Token 저장 필요 (health만 가능, paper/state 불가)")
            } else {
                repository.log("AUTH_CLIENT_READY token=***REDACTED*** baseUrl=${restoredSettings.remoteAiBaseUrl}")
            }
            // PHASE6: persisted Primary OFF kept devices on LOCAL /decision. Token ready → force Primary ON.
            val settingsNow = repository.state.value.settings
            if (settingsNow.remoteAiEnabled && !settingsNow.remoteAiPrimary && store.hasTradingAiToken()) {
                repository.updateSettings(settingsNow.copy(remoteAiPrimary = true))
                repository.log("SERVER PRIMARY force-ON on app start (token ready) app=${BuildConfig.VERSION_NAME}")
            }
            repository.log("저장된 거래 설정 복원 완료 (PAPER 초기자금 ${restoredSettings.paperInitialKrw.toLong()} KRW) app=${BuildConfig.VERSION_NAME}/${BuildConfig.VERSION_CODE}")
            repository.initializeLearningContinuity(
                appVersion = BuildConfig.VERSION_NAME,
                appVersionCode = BuildConfig.VERSION_CODE,
                schemaVersion = AppDatabase.SCHEMA_VERSION
            )
            // PHASE5: Primary ON이면 Local 세션 대신 Hetzner paper SoT로 UI 복원 (주문 발생 없음).
            val primaryNow = repository.state.value.settings.let { it.remoteAiEnabled && it.remoteAiPrimary }
            if (primaryNow) {
                runCatching { repository.restoreServerPaperOnAppStart(source = "APP_START") }
                    .onFailure { repository.log("APP_REOPEN_RESTORE error: ${it.message}") }
            }
            repository.checkStrategyOta(force = true)
            repository.checkAiModelOta(force = true)
            repository.checkNews(force = true)
        }
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/TraderApp.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/TradingCore.kt =====

package com.example.bithumbtrader

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.math.*
import java.util.concurrent.ConcurrentHashMap

object Indicators {
    fun ema(values: List<Double>, period: Int): Double { val clean = values.filter { it.isFinite() && it > 0 }; if (clean.isEmpty()) return 0.0; val k = 2.0/(period+1); var e = clean.first(); clean.drop(1).forEach { e = it*k + e*(1-k) }; return e.safe() }
    fun rsi(values: List<Double>, period:Int=14): Double { if (values.size <= period) return 50.0; var gain=0.0; var loss=0.0; values.takeLast(period+1).zipWithNext().forEach{ val d=it.second-it.first; if(d>=0) gain+=d else loss-=d }; if(loss==0.0) return 100.0; return (100.0 - 100.0/(1.0+gain/loss)).coerceIn(0.0,100.0) }
    fun macd(values: List<Double>): Double = (ema(values,12) - ema(values,26)).safe()
    fun volatility(values: List<Double>): Double { if(values.size<2) return 0.0; val returns=values.zipWithNext().mapNotNull{ if(it.first>0) ((it.second-it.first)/it.first).takeIf(Double::isFinite) else null }; if(returns.isEmpty()) return 0.0; val avg=returns.average(); return sqrt(returns.sumOf{(it-avg).pow(2)}/returns.size).safe() }
    fun Double.safe() = if (isFinite()) this else 0.0
}

class StrategyEngine {
    fun score(market:String, ticker:TickerModel?, orderbook:OrderbookModel?, candles:List<CandleModel>): StrategySignalModel {
        val closes = candles.sortedBy { it.timestamp }.map { it.close }.filter { it.isFinite() && it > 0 }
        val price = ticker?.tradePrice ?: closes.lastOrNull() ?: 0.0
        if (price <= 0 || ticker == null || orderbook == null || closes.size < 20) return StrategySignalModel(market, 0.0, "데이터 부족")
        val ema5=Indicators.ema(closes,5); val ema20=Indicators.ema(closes,20); val ema60=Indicators.ema(closes,60)
        val rsi=Indicators.rsi(closes,14); val macd=Indicators.macd(closes); val vol=Indicators.volatility(closes)
        val spread = if(orderbook.askPrice>0) ((orderbook.askPrice-orderbook.bidPrice)/orderbook.askPrice*100.0).coerceAtLeast(0.0) else 99.0
        val imbalance = if(orderbook.askSize+orderbook.bidSize>0) orderbook.bidSize/(orderbook.askSize+orderbook.bidSize) else 0.5
        val momentum = if(closes.size>=6 && closes[closes.size-6] > 0) (price/closes[closes.size-6]-1.0)*100.0 else 0.0
        val breakout = if(closes.takeLast(60).maxOrNull()?.let { price >= it } == true) 8.0 else 0.0
        var score = 0.0
        score += if(ema5>ema20) 14 else 0; score += if(ema20>ema60) 10 else 0
        score += when { rsi in 45.0..70.0 -> 14.0; rsi in 35.0..80.0 -> 8.0; else -> 2.0 }
        score += (macd / price * 10_000.0).coerceIn(-5.0,10.0) + 5.0
        score += (ticker.accTradePrice24h / 5_000_000_000.0 * 12.0).coerceIn(0.0,12.0)
        score += momentum.coerceIn(-8.0,12.0) + 8.0
        score += (10.0 - vol*500.0).coerceIn(0.0,10.0)
        score += breakout
        score += (10.0 - spread*8.0).coerceIn(0.0,10.0)
        score += ((imbalance-0.5)*20.0).coerceIn(-5.0,5.0) + 5.0
        return StrategySignalModel(
            market = market,
            score = score.coerceIn(0.0, 100.0),
            reason = "EMA/RSI/MACD/거래대금/모멘텀/스프레드 점수",
            momentumPercent = momentum,
            volatilityPercent = vol * 100.0
        )
    }
}

class RiskManager {
    fun canBuy(settings:TradingSettings, state:DashboardState, signal:StrategySignalModel, orderbook:OrderbookModel?, holding:Boolean, orderInFlight:Boolean, orderAmount:Double): Pair<Boolean,String> {
        if(state.mode != TradeMode.PAPER && state.mode != TradeMode.LIVE) return false to "모드 오류"
        if(state.killSwitchEngaged) return false to "킬 스위치 발동 중"
        if(state.mode == TradeMode.LIVE && state.consecutiveLossLocked) return false to "연속 손실 한도 도달 (LIVE 차단)"
        if(state.mode == TradeMode.LIVE && state.dailyLossLocked) return false to "일일 손실 한도 도달 (LIVE 차단)"
        if(state.crashProtectionEngaged) return false to "시장 급락 보호 중(신규 진입 차단)"
        if(state.cooldownActive) return false to "쿨다운 모드(급락 이후 대기 중)"
        if(!state.profitProtection.newEntryAllowed) return false to "수익 보호 잠금: 고점 수익 반납"
        if(state.newsResearch.risk.newEntryBlocked) return false to "뉴스 위험 차단: ${state.newsResearch.risk.message}"
        if(state.apiStatus == ConnectionStatus.ERROR) return false to "API 오류"
        if(System.currentTimeMillis()-state.lastTickerAt > settings.staleTickerMillis) return false to "시세 지연"
        if(holding) return false to "이미 보유"
        if(orderInFlight) return false to "주문 진행 중"
        // Dynamic capacity (heat/cash/min-order/hard-cap) replaces fixed maxPositions=3.
        val positionsProxy = List(state.holdingCount.coerceAtLeast(0)) { index ->
            // Approximate open risk using equal-split of coin value when detailed list unavailable.
            val per = if (state.holdingCount > 0) state.coinValue / state.holdingCount else 0.0
            PositionModel("HELD-$index", if (per > 0) 1.0 else 0.0, per, per, 0L)
        }
        val serverPositions = state.serverPaperPositions.filter { (it.quantity ?: 0.0) > 0.0 }.map {
            PositionModel(it.market.orEmpty(), it.quantity ?: 0.0, it.avgPrice ?: 0.0, it.highestPrice ?: it.avgPrice ?: 0.0, it.openedAt ?: 0L)
        }
        val positionsForCap = serverPositions.ifEmpty { positionsProxy }
        val netEvaluated = signal.expectedGrossProfitKrw != 0.0 ||
            signal.expectedRoundTripCostKrw != 0.0 ||
            signal.netProfitAfterCostReason.isNotBlank()
        val netOk = !settings.netProfitAfterCostGateEnabled ||
            !netEvaluated ||
            signal.netProfitAfterCostPassed ||
            signal.expectedNetProfitKrw > 0.0
        val capacity = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = settings,
            positions = positionsForCap,
            totalEquityKrw = state.totalValue,
            availableCashKrw = state.krwBalance,
            candidateOrderKrw = orderAmount,
            candidateMarket = signal.market,
            heat = state.portfolioHeat,
            netProfitAfterCostPassed = netOk
        )
        if (!capacity.allowed) return false to capacity.reason
        if(signal.score < settings.scoreThreshold) return false to "점수 부족"
        val spread = if(orderbook!=null && orderbook.askPrice>0) (orderbook.askPrice-orderbook.bidPrice)/orderbook.askPrice*100 else 99.0
        if(spread > settings.maxSpreadPercent) return false to "스프레드 초과"
        val maxOrder = state.totalValue * settings.maxOrderPercent / 100.0
        val maxCoin = state.totalValue * settings.maxAssetPercentPerCoin / 100.0
        val minCash = state.totalValue * settings.minKrwCashPercent / 100.0
        if(orderAmount > maxOrder || orderAmount > maxCoin) return false to "주문금액 한도 초과"
        if(state.krwBalance - orderAmount < minCash) return false to "현금비중 부족"
        if(orderAmount <= 0 || state.krwBalance < orderAmount) return false to "잔액 부족"
        return true to "OK"
    }
    fun shouldSell(settings:TradingSettings, position:PositionModel, price:Double, signal:StrategySignalModel): Pair<Boolean,String> {
        if(position.avgPrice <= 0 || price <= 0) return false to "가격 오류"
        val pnl = (price/position.avgPrice - 1.0)*100.0
        val highest = maxOf(position.highestPrice, price)
        val peakPnl = (highest/position.avgPrice - 1.0)*100.0
        val trailing = (price/highest - 1.0)*100.0
        // Arm trailing only after peak unrealized reaches Profit Protection L1 (default 1%).
        // Prevents entry-noise highs from firing TRAILING STOP while still net-negative (HOOK pattern).
        val armMin = settings.trailingArmMinProfitPercent.takeIf { it > 0.0 }
            ?: settings.profitProtectionLevel1Percent
        val trailingArmed = peakPnl >= armMin
        // Net-aware estimate: avgPrice already embeds buy fee/slip; subtract sell fee+slip only.
        val feeRate = settings.paperFeeRate.coerceAtLeast(0.0)
        val slip = settings.paperSlippageRate.coerceAtLeast(0.0)
        val estSellPx = price * (1.0 - slip)
        val estGross = estSellPx * position.quantity
        val estSellFee = estGross * feeRate
        val costBasis = position.avgPrice * position.quantity
        val estNetKrw = estGross - estSellFee - costBasis
        return when {
            pnl <= settings.stopLossPercent -> true to "STOP LOSS"
            pnl >= settings.takeProfitPercent -> {
                // Do not take profit on nominal % alone when estimated net after sell cost is <= 0.
                if (estNetKrw <= 0.0) false to "HOLD_NET_TP_INSUFFICIENT"
                else true to "TAKE PROFIT"
            }
            trailingArmed && trailing <= -settings.trailingStopPercent -> {
                // Still protect via trailing; annotate when nominal profit is wiped by costs.
                if (pnl > 0.0 && estNetKrw <= 0.0) true to "TRAILING STOP (NET_NEAR_ZERO)"
                else true to "TRAILING STOP"
            }
            signal.score < settings.scoreThreshold-20 -> true to "SCORE DROP"
            else -> false to "HOLD"
        }
    }
    fun dailyLossLocked(settings:TradingSettings, startValue:Double, currentValue:Double): Boolean = startValue > 0 && (currentValue/startValue-1.0)*100.0 <= settings.dailyMaxLossPercent
    fun consecutiveLossLocked(settings:TradingSettings, consecutiveLossCount:Int): Boolean = settings.maxConsecutiveLosses > 0 && consecutiveLossCount >= settings.maxConsecutiveLosses
}


class StrategyOtaClient(
    private val url: String = "https://riderapp.duckdns.org/bithumb-strategy.json",
    private val client: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(java.time.Duration.ofSeconds(8))
        .readTimeout(java.time.Duration.ofSeconds(8))
        .build()
) {
    private val adapter = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
        .adapter(RemoteStrategyConfig::class.java)

    fun fetch(): RemoteStrategyConfig {
        val request = Request.Builder().url(url).get().build()
        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) error("OTA HTTP ${response.code}")
            val body = response.body?.string().orEmpty()
            return adapter.fromJson(body) ?: error("OTA JSON 파싱 실패")
        }
    }
}

interface MarketDataProvider { suspend fun loadKrwMarkets(): List<MarketModel>; suspend fun ticker(markets:List<String>): List<TickerModel>; suspend fun orderbook(markets:List<String>): List<OrderbookModel>; suspend fun candles(market:String, unit:Int = 5): List<CandleModel> }
interface ExecutionEngine {
    suspend fun buy(market:String, krwAmount:Double, marketPrice:Double, reason:String): OrderEntity
    suspend fun sell(position:PositionModel, marketPrice:Double, reason:String): OrderEntity
    suspend fun reconcile(): List<OrderEntity>
}

object TickerCoverage {
    fun requiredMarkets(scanBatch: Collection<String>, heldMarkets: Collection<String>): List<String> =
        (scanBatch + heldMarkets).distinct()
}

object PaperCashLedger {
    fun balance(baseKrw: Double, trades: List<TradeEntity>): Double {
        val result = baseKrw + trades.sumOf {
            when (it.side) {
                "BUY" -> -it.amount
                "SELL" -> it.amount
                else -> 0.0
            }
        }
        return if (result.isFinite()) result.coerceAtLeast(0.0) else baseKrw.coerceAtLeast(0.0)
    }
}

class BithumbMarketDataProvider(private val api:BithumbPublicApi): MarketDataProvider {
    private class NonRetryableHttpException(message: String) : IllegalStateException(message)

    private suspend fun <T> requestList(
        label: String,
        request: suspend () -> retrofit2.Response<List<T>>
    ): List<T> {
        var lastError: Throwable? = null
        for (attempt in 0 until 3) {
            try {
                val response = request()
                if (response.isSuccessful) {
                    return response.body() ?: error("$label 응답 본문 없음")
                }
                val code = response.code()
                response.errorBody()?.close()
                if (code != 429 && code !in 500..599) throw NonRetryableHttpException("$label HTTP $code")
                lastError = IllegalStateException("$label HTTP $code")
            } catch (e: CancellationException) {
                throw e
            } catch (e: NonRetryableHttpException) {
                throw e
            } catch (e: Exception) {
                lastError = e
            }
            if (attempt < 2) delay(500L * (1L shl attempt))
        }
        throw IllegalStateException("$label 요청 3회 실패", lastError)
    }

    override suspend fun loadKrwMarkets(): List<MarketModel> =
        requestList("마켓 목록") { api.markets(true) }
            .filter { it.market.startsWith("KRW-") && (it.marketWarning ?: "NONE") == "NONE" }
            .map { MarketModel(it.market, it.koreanName.orEmpty(), it.englishName.orEmpty(), it.marketWarning ?: "NONE") }

    override suspend fun ticker(markets:List<String>): List<TickerModel> =
        markets.chunked(80).flatMap { chunk ->
            requestList("티커") { api.ticker(chunk.joinToString(",")) }
                .map { TickerModel(it.market, it.tradePrice ?: 0.0, it.accTradePrice24h ?: 0.0, it.signedChangeRate ?: 0.0, it.tradeVolume ?: 0.0, it.timestamp ?: System.currentTimeMillis()) }
        }

    override suspend fun orderbook(markets:List<String>): List<OrderbookModel> =
        markets.chunked(40).flatMap { chunk ->
            requestList("호가") { api.orderbook(chunk.joinToString(",")) }
                .mapNotNull {
                    val unit = it.units?.firstOrNull() ?: return@mapNotNull null
                    OrderbookModel(it.market, unit.askPrice ?: 0.0, unit.bidPrice ?: 0.0, unit.askSize ?: 0.0, unit.bidSize ?: 0.0, it.timestamp ?: System.currentTimeMillis())
                }
        }

    override suspend fun candles(market:String, unit:Int): List<CandleModel> =
        requestList("캔들 ${unit}분 $market") { api.minuteCandles(unit, market, 120) }
            .map { CandleModel(market, it.timestamp ?: 0L, it.tradePrice ?: 0.0, it.highPrice ?: 0.0, it.lowPrice ?: 0.0, it.volume ?: 0.0) }
}

class PaperExecutionEngine(
    private val dao: TradingDao,
    private val feeRate: Double = 0.0025,
    private val slippageRate: Double = 0.001
) : ExecutionEngine {
    private val mutex = Mutex()

    override suspend fun buy(
        market: String,
        krwAmount: Double,
        marketPrice: Double,
        reason: String
    ): OrderEntity = mutex.withLock {
        val existingHolding = dao.currentPositions("PAPER").any { it.market == market && it.quantity > 0.0 }
        if (existingHolding) {
            return@withLock OrderEntity(
                clientOrderId = "paper-buy-reject-holding-${System.currentTimeMillis()}-$market",
                exchangeOrderId = null,
                market = market,
                side = "BUY",
                amount = krwAmount,
                quantity = 0.0,
                state = OrderState.FAILED.name,
                updatedAt = System.currentTimeMillis()
            )
        }
        val clientId = "paper-buy-${System.currentTimeMillis()}-$market"
        val fill = PaperTradingMath.buyFill(krwAmount, marketPrice, feeRate, slippageRate)
            ?: return@withLock OrderEntity(
                clientId, null, market, "BUY", krwAmount, 0.0,
                OrderState.FAILED.name, System.currentTimeMillis()
            )
        val now = System.currentTimeMillis()
        val position = PositionEntity(
            market = market,
            quantity = fill.quantity,
            avgPrice = fill.executionPrice,
            highestPrice = fill.executionPrice,
            openedAt = now,
            updatedAt = now
        )
        dao.upsertPosition(position)
        dao.upsertTrade(
            TradeEntity(
                time = now,
                market = market,
                side = "BUY",
                amount = krwAmount,
                quantity = fill.quantity,
                avgPrice = fill.executionPrice,
                fee = fill.fee,
                realizedPnl = 0.0,
                pnlRate = 0.0,
                reason = reason
            )
        )
        return@withLock OrderEntity(
            clientOrderId = clientId,
            exchangeOrderId = null,
            market = market,
            side = "BUY",
            amount = krwAmount,
            quantity = fill.quantity,
            state = OrderState.FILLED.name,
            updatedAt = now
        ).also { dao.upsertOrder(it) }
    }

    override suspend fun sell(
        position: PositionModel,
        marketPrice: Double,
        reason: String
    ): OrderEntity = mutex.withLock {
        val clientId = "paper-sell-${System.currentTimeMillis()}-${position.market}"
        val fill = PaperTradingMath.sellFill(position.quantity, marketPrice, feeRate, slippageRate)
            ?: return@withLock OrderEntity(
                clientId, null, position.market, "SELL", 0.0, 0.0,
                OrderState.FAILED.name, System.currentTimeMillis()
            )
        val now = System.currentTimeMillis()
        val buyTrade = dao.latestBuyTrade(position.market, "PAPER")
        val buyCashSpent = buyTrade?.amount?.takeIf { it.isFinite() && it > 0.0 }
            ?: PaperTradingMath.buyCashSpent(position.quantity, position.avgPrice, feeRate)
        val sellNet = fill.grossAmount - fill.fee
        // Economic net: includes buy fee once. Slippage already in execution prices — no second subtract.
        val realized = PaperTradingMath.economicRealizedPnl(
            quantity = position.quantity,
            avgBuyPrice = position.avgPrice,
            sellGrossAmount = fill.grossAmount,
            sellFee = fill.fee,
            feeRate = feeRate,
            buyCashSpentOverride = buyCashSpent
        )
        val pnlRate = if (buyCashSpent > 0.0) realized / buyCashSpent * 100.0 else 0.0
        dao.upsertPosition(
            PositionEntity(
                position.market, 0.0, position.avgPrice, position.highestPrice,
                position.openedAt, now
            )
        )
        dao.upsertTrade(
            TradeEntity(
                time = now,
                market = position.market,
                side = "SELL",
                amount = sellNet,
                quantity = fill.quantity,
                avgPrice = fill.executionPrice,
                fee = fill.fee,
                realizedPnl = realized,
                pnlRate = pnlRate,
                reason = reason
            )
        )
        return@withLock OrderEntity(
            clientOrderId = clientId,
            exchangeOrderId = null,
            market = position.market,
            side = "SELL",
            amount = sellNet,
            quantity = fill.quantity,
            state = OrderState.FILLED.name,
            updatedAt = now
        ).also { dao.upsertOrder(it) }
    }

    override suspend fun reconcile(): List<OrderEntity> = dao.openOrders()
}

class BithumbExecutionEngine(private val privateApi:BithumbPrivateApi, private val jwt:***REDACTED*** private val dao:TradingDao): ExecutionEngine {
    private val mutex = Mutex()
    override suspend fun buy(market:String, krwAmount:Double, marketPrice:Double, reason:String): OrderEntity = mutex.withLock { val chanceAuth=jwt.token(listOf("market" to market)); val chance=privateApi.orderChance(chanceAuth, market).body() ?: error("주문 가능 정보 조회 실패"); val minTotal=chance.market?.bid?.minTotal?.toDoubleOrNull() ?: error("최소주문금액 확인 실패"); if(krwAmount < minTotal) error("최소주문금액 미달"); val body=OrderRequestDto(market=market,side="bid",orderType="price",price=krwAmount.toLong().toString()); val auth=jwt.token(listOf("market" to body.market,"side" to body.side,"order_type" to body.orderType,"price" to body.price,"client_order_id" to body.clientOrderId)); val res=privateApi.createOrder(auth, body); val orderId=res.body()?.orderId; OrderEntity(body.clientOrderId,orderId,market,"BUY",krwAmount,0.0, if(res.isSuccessful) OrderState.WAITING.name else OrderState.FAILED.name,System.currentTimeMillis(), mode="LIVE").also{dao.upsertOrder(it)} }
    override suspend fun sell(position:PositionModel, marketPrice:Double, reason:String): OrderEntity = mutex.withLock { val body=OrderRequestDto(market=position.market,side="ask",orderType="market",volume=position.quantity.toString()); val auth=jwt.token(listOf("market" to body.market,"side" to body.side,"order_type" to body.orderType,"volume" to body.volume,"client_order_id" to body.clientOrderId)); val res=privateApi.createOrder(auth, body); OrderEntity(body.clientOrderId,res.body()?.orderId,position.market,"SELL",0.0,position.quantity,if(res.isSuccessful) OrderState.WAITING.name else OrderState.FAILED.name,System.currentTimeMillis(), mode="LIVE").also{dao.upsertOrder(it)} }
    override suspend fun reconcile(): List<OrderEntity> = dao.openOrders()
}

class TradingRepository(
    private val marketData: MarketDataProvider,
    private val dao: TradingDao,
    initialAiModel: OnDeviceAiModel,
    private val tickerStream: BithumbTickerStream,
    initialSettings: TradingSettings,
    private val settingsStore: TradingSettingsStore,
    private val newsProvider: NewsDataProvider,
    private val derivativesProvider: BybitDerivativesDataProvider? = null,
    private val tradingAiTokenProvider: () -> String = { "" }
) {
    private val strategy=StrategyEngine()
    private val risk=RiskManager()
    private val otaClient=StrategyOtaClient()
    private val aiModelOtaClient=AiModelOtaClient()
    private val paperExecution=PaperExecutionEngine(dao)
    private var settings=TradingSettingsValidator.sanitize(initialSettings)
    private var strategyVersion=1L
    private var aiModel: OnDeviceAiModel = initialAiModel
    private val bundledAiModel: OnDeviceAiModel = initialAiModel
    private val localAiDecisionProvider = LocalAiDecisionProvider { aiModel }
    private val hetznerAiDecisionProvider = HetznerAiDecisionProvider(
        client = HetznerAiBrainClient(
            baseUrl = settings.remoteAiBaseUrl,
            tokenProvider = tradingAiTokenProvider
        ),
        local = localAiDecisionProvider,
        settingsProvider = { settings }
    )
    private val aiDecisionProvider: AiDecisionProvider = CompositeAiDecisionProvider(
        remote = hetznerAiDecisionProvider,
        local = localAiDecisionProvider,
        settingsProvider = { settings }
    )
    /** 앱/엔진 세션 시작. 재실행 직후 서버에 남아 있던 과거 BUY 자동 체결 방지. */
    private val engineSessionStartedAtMs: Long = System.currentTimeMillis()
    private val deviceSessionId: String = java.util.UUID.randomUUID().toString()
    @Volatile private var lastUiMatchUploadAtMs: Long = 0L
    @Volatile private var lastModeReportUploadAtMs: Long = 0L
    private val UI_MATCH_UPLOAD_MIN_INTERVAL_MS = 45_000L
    private val MODE_REPORT_UPLOAD_MIN_INTERVAL_MS = 30_000L
    private var aiModelRestored=false
    private var aiRetrainStateLoaded=false
    private var lastRetrainResolvedCount=0
    private var aiLocalGeneration=0
    private var riskStateLoaded=false
    private var deepLearningModel: DeepLearningCandidateModel? = null
    private var deepLearningModelVersion = "NONE"
    private var lastHistoricalBootstrapAt = 0L
    private var lastContinuousTrainingSampleCount = 0
    private var lastContinuousTrainingAt = 0L
    private var continuousLearningInitialized = false
    private var continuousTrainingRunning = false
    private var learningContinuityInitialized = false
    private var currentAppVersion = "-"
    private var currentAppVersionCode = 0
    private var currentSchemaVersion = AppDatabase.SCHEMA_VERSION
    private val continuousLearningScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private var consecutiveLossCount=0
    private var consecutiveLossLockEngaged=false
    private var activeRecommendation: RecommendationRuntimeState?=null
    private var lastRecommendationCheckCount=0
    private var dailyTrackingLoaded=false
    private var currentTradingDate=""
    private var dayStartValue=0.0
    private var dayPeakValue=0.0
    private var dayMaxDrawdownPercent=0.0
    private var lastBalanceSnapshotAt=0L
    private val BALANCE_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000L
    private val recentChangeRates = ArrayDeque<Double>()
    private val REGIME_WINDOW_SIZE = 300
    private var lastRegimePersistAt = 0L
    private val REGIME_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000L
    private var knownAnomalies: Set<String> = emptySet()
    private var crashStateLoaded = false
    private var currentCrashEventId: String? = null
    private var crashProtectionEngaged = false
    private var cooldownUntil = 0L
    private var crashMinHealthScore = 100.0
    private var lastHealthLevel = MarketHealthLevel.HEALTHY
    private var paperCash=settings.paperInitialKrw
    private var paperBaseKrw=settings.paperInitialKrw
    private var paperLoaded=false
    private val regimeHysteresis = RegimeHysteresis()
    private var regimeInitialized = false
    private var pendingRegimeTransition: Pair<MarketRegime, MarketRegime>? = null
    private val previousTickerVolumes = mutableMapOf<String, Double>()
    private val paperSlippageRate=0.001
    private var marketScanCursor=0
    private val signalCache=mutableMapOf<String, StrategySignalModel>()
    private val scoreHistory = mutableMapOf<String, ArrayDeque<ScorePoint>>()
    private val previousOrderbooks = ConcurrentHashMap<String, OrderbookModel>()
    private val remoteDecisionByMarket = ConcurrentHashMap<String, AiDecisionBundle>()
    private val derivativesSnapshots = ConcurrentHashMap<String, DerivativesDataSnapshot>()
    private val derivativesIntelligence = ConcurrentHashMap<String, GlobalDerivativesIntelligence>()
    private val scanMutex = Mutex()
    private val candidateLatencySamples = ArrayDeque<Long>()
    private val liquidityOverblockingTracker = LiquidityOverblockingTracker()
    private val reentryCoordinator = MarketReentryCoordinator()
    private val smartReentryCoordinator = SmartReentryCoordinator()
    private var smartReentryLoaded = false
    private var lastLiquidityValues: List<Double> = emptyList()
    private val hourlyGateFunnelTracker = HourlyGateFunnelTracker()
    private var scanSequence: Long = 0L
    private var scanSuccessCount: Long = 0L
    private var scanFailureCount: Long = 0L
    private var consecutiveScanFailures: Int = 0
    private var lastSuccessfulScanAt: Long = 0L
    private var stageStartedAt: Long = 0L
    private var lastNewsCheckAt = 0L
    private var lastNewsReactionAt = 0L
    private var newsEventsLoaded = false
    private var knownKrwMarkets: Set<String> = emptySet()
    private val NEWS_REACTION_HORIZONS = listOf(1, 3, 5, 15, 30, 60, 180, 360, 1440)
    private val _state=MutableStateFlow(
        DashboardState(
            settings = settings,
            mode = settings.mode,
            testMode = settings.testMode,
            totalValue = settings.paperInitialKrw,
            krwBalance = settings.paperInitialKrw,
            strategyVersion = strategyVersion,
            aiModelVersion = initialAiModel.version
        )
    )
    val state:StateFlow<DashboardState> = _state

    fun stopMarketStream() {
        tickerStream.stop()
        derivativesProvider?.stop()
        _state.value = _state.value.copy(
            engineStatus = EngineStatus.STOPPED,
            websocketStatus = ConnectionStatus.DISCONNECTED,
            currentAnalyzingMarket = "-"
        )
    }

    suspend fun reportEngineError(message: String) {
        _state.value = _state.value.copy(
            engineStatus = EngineStatus.ERROR,
            apiStatus = ConnectionStatus.ERROR,
            websocketStatus = tickerStream.status.value
        )
        log("서비스 오류: $message")
    }

    suspend fun log(message:String){
        val stamped = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.KOREA).format(java.util.Date()) + " " + message
        dao.upsertEvent(AppEventEntity(time=System.currentTimeMillis(),level="INFO",message=stamped.take(500)))
        _state.value=_state.value.copy(logs=(listOf(stamped)+_state.value.logs).take(200))
    }

    fun updateSettings(next: TradingSettings) {
        val validated = TradingSettingsValidator.sanitize(next)
        settings = validated
        settingsStore.save(validated)
        _state.value = _state.value.copy(settings = validated, testMode = validated.testMode, mode = validated.mode)
    }

    fun selectExchange(exchange: ExchangeId) {
        val next = exchange.name
        if (_state.value.selectedExchange == next) return
        _state.value = _state.value.copy(selectedExchange = next)
        // Non-suspend path: ViewModel logs the switch.
    }

    fun selectedExchangeId(): ExchangeId = ExchangeId.from(_state.value.selectedExchange)

    suspend fun initializeLearningContinuity(
        appVersion: String,
        appVersionCode: Int,
        schemaVersion: Int
    ) {
        if (learningContinuityInitialized) return
        currentAppVersion = appVersion
        currentAppVersionCode = appVersionCode
        currentSchemaVersion = schemaVersion
        val saved = dao.settings()
        strategyVersion = saved.firstOrNull { it.key == "strategy_version" }?.value?.toLongOrNull() ?: strategyVersion
        restorePersistedAiModel()
        ensureAiRetrainStateLoaded()
        ensureContinuousLearningStateLoaded()
        val shadow = dao.shadowPortfolios().sortedBy { it.strategy }
        val latestPromotion = dao.recentStrategyPromotions().firstOrNull()
        _state.value = _state.value.copy(
            strategyVersion = strategyVersion,
            shadowPortfolios = shadow,
            championChallenger = latestPromotion?.let {
                ChampionChallengerState(
                    champion = it.champion,
                    challenger = it.challenger,
                    status = it.status,
                    championScore = it.championScore,
                    challengerScore = it.challengerScore,
                    sampleCount = it.sampleCount,
                    reason = it.reason
                )
            } ?: _state.value.championChallenger
        )
        val previousAppVersion = saved.firstOrNull { it.key == "last_app_version_name" }?.value ?: "-"
        val previousManifestJson = saved.firstOrNull { it.key == "learning_asset_manifest" }?.value
        val previousManifest = if (previousManifestJson.isNullOrBlank()) null else {
            try {
                LearningAssetIntegrity.fromJson(previousManifestJson)
            } catch (e: Exception) {
                log("LEARNING_MANIFEST_RESTORE_FAILED error=${e.javaClass.simpleName}")
                null
            }
        }
        val lineage = saved.firstOrNull { it.key == "learning_lineage_id" }?.value?.takeIf { it.isNotBlank() }
            ?: java.util.UUID.randomUUID().toString().also {
                dao.upsertSetting(SettingEntity("learning_lineage_id", it))
            }
        val currentManifest = buildLearningAssetManifest(
            lineageId = lineage,
            createdAt = previousManifest?.createdAt ?: System.currentTimeMillis()
        )
        val comparison = LearningAssetIntegrity.compare(previousManifest, currentManifest)
        val upgradeDetected = previousAppVersion != "-" && previousAppVersion != appVersion
        val message = when (comparison.status) {
            LearningAssetPreservationStatus.PRESERVED -> "LEARNING_ASSETS_PRESERVED"
            LearningAssetPreservationStatus.BASELINE_CREATED -> "학습 자산 기준선 생성 — 다음 APK 업데이트부터 자동 비교"
            LearningAssetPreservationStatus.LOSS_DETECTED -> "LEARNING_ASSET_LOSS_DETECTED: ${comparison.reasons.joinToString(", ")}"
            LearningAssetPreservationStatus.RESTORE_FAILED -> "LEARNING_ASSET_RESTORE_FAILED"
        }
        _state.value = _state.value.copy(
            learningAssetContinuity = LearningAssetContinuityState(
                status = comparison.status,
                message = message,
                previousAppVersion = previousAppVersion,
                currentAppVersion = appVersion,
                previousSchemaVersion = previousManifest?.schemaVersion ?: 0,
                currentSchemaVersion = schemaVersion,
                migrationStatus = if (previousManifest == null) "BASELINE" else "SUCCESS",
                modelRestoreStatus = if (_state.value.aiModelSource == "LOCAL_RETRAIN") "LOCAL_VALIDATED_RESTORED" else "${_state.value.aiModelSource}_RESTORED",
                checksumStatus = if (comparison.checksumMatches) "MODEL_CHECKSUM_MATCH" else "MODEL_CHECKSUM_MISMATCH",
                lineageStatus = if (comparison.lineageMatches) "PRESERVED" else "LEARNING_LINEAGE_RESET",
                manifest = currentManifest
            )
        )
        if (upgradeDetected) log("APP_UPGRADE_DETECTED $previousAppVersion → $appVersion")
        log("DB_MIGRATION ${previousManifest?.schemaVersion ?: "UNKNOWN"} → $schemaVersion ${if (comparison.status == LearningAssetPreservationStatus.LOSS_DETECTED) "WARNING" else "SUCCESS"}")
        log("AI_SAMPLES ${previousManifest?.aiTrainingSampleCount ?: "BASELINE"} → ${currentManifest.aiTrainingSampleCount} ${comparison.status}")
        log("PREDICTION_JOURNAL ${previousManifest?.predictionJournalCount ?: "BASELINE"} → ${currentManifest.predictionJournalCount} ${comparison.status}")
        log("MODEL ${currentManifest.modelVersion} ${_state.value.learningAssetContinuity.modelRestoreStatus}")
        log("CHECKSUM ${_state.value.learningAssetContinuity.checksumStatus} / LEARNING_LINEAGE ${_state.value.learningAssetContinuity.lineageStatus}")
        persistLearningAssetManifest(currentManifest)
        learningContinuityInitialized = true
    }

    private suspend fun buildLearningAssetManifest(
        lineageId: String,
        createdAt: Long
    ): LearningAssetManifest {
        val saved = dao.settings()
        val productionJson = saved.firstOrNull { it.key == "ai_model_json" }?.value.orEmpty()
        val continuousJson = saved.firstOrNull { it.key == "continuous_learning_model_json" }?.value.orEmpty()
        val promotion = dao.recentStrategyPromotions().firstOrNull()
        val productionChecksum = LearningAssetIntegrity.checksum(productionJson)
        val continuousChecksum = LearningAssetIntegrity.checksum(continuousJson)
        return LearningAssetManifest(
            schemaVersion = currentSchemaVersion,
            appVersion = currentAppVersion,
            appVersionCode = currentAppVersionCode,
            learningLineageId = lineageId,
            aiTrainingSampleCount = dao.totalAiSampleCount(),
            historicalSampleCount = dao.aiSampleCountBySource(LearningDataSource.HISTORICAL.name),
            paperSampleCount = dao.aiSampleCountBySource(LearningDataSource.PAPER.name),
            liveSampleCount = dao.aiSampleCountBySource(LearningDataSource.LIVE.name),
            resolvedSampleCount = dao.resolvedAiSampleCount(),
            predictionJournalCount = dao.predictionJournalCount(),
            hardExampleCount = dao.hardExampleCount(),
            modelVersion = if (deepLearningModelVersion != "NONE") deepLearningModelVersion else "${_state.value.aiModelSource}:${aiModel.version}",
            modelSource = if (deepLearningModelVersion != "NONE") "CONTINUOUS_LOCAL_VALIDATED" else _state.value.aiModelSource,
            modelChecksum = LearningAssetIntegrity.checksum("$productionChecksum:$continuousChecksum"),
            productionModelChecksum = productionChecksum,
            continuousModelChecksum = continuousChecksum,
            continuousLearningLastCount = saved.firstOrNull { it.key == "continuous_learning_last_count" }?.value?.toIntOrNull() ?: 0,
            continuousLearningLastAt = saved.firstOrNull { it.key == "continuous_learning_last_at" }?.value?.toLongOrNull() ?: 0L,
            championVersion = promotion?.champion ?: "CURRENT_STRATEGY",
            challengerVersion = if (deepLearningModelVersion != "NONE") deepLearningModelVersion else promotion?.challenger ?: "-",
            shadowTradeCount = dao.shadowTradeCount(),
            researchHypothesisCount = dao.researchHypothesisCount(),
            smartReentryStateCount = dao.smartReentryStateCount(),
            scalpingDiagnosticCount = dao.scalpingDiagnosticCount(),
            createdAt = createdAt,
            updatedAt = System.currentTimeMillis()
        )
    }

    private suspend fun persistLearningAssetManifest(manifest: LearningAssetManifest? = null) {
        if (currentAppVersion == "-") return
        val saved = dao.settings()
        val lineage = saved.firstOrNull { it.key == "learning_lineage_id" }?.value ?: return
        val current = manifest ?: buildLearningAssetManifest(
            lineage,
            saved.firstOrNull { it.key == "learning_asset_manifest" }?.value
                ?.let { runCatching { LearningAssetIntegrity.fromJson(it).createdAt }.getOrNull() }
                ?: System.currentTimeMillis()
        )
        dao.upsertSetting(SettingEntity("learning_asset_manifest", LearningAssetIntegrity.toJson(current)))
        dao.upsertSetting(SettingEntity("last_app_version_name", currentAppVersion))
        dao.upsertSetting(SettingEntity("last_app_version_code", currentAppVersionCode.toString()))
        dao.upsertSetting(SettingEntity("last_db_schema_version", currentSchemaVersion.toString()))
    }

    suspend fun resetPaperAccount(initialKrw: Double) {
        val amount = initialKrw.takeIf { it.isFinite() && it >= 0.0 } ?: return
        dao.clearPaperPositions()
        dao.clearPaperOrders()
        dao.clearPaperTrades()
        dao.clearReentryGuards()
        dao.clearSmartReentryStates()
        signalCache.clear()
        reentryCoordinator.clear()
        smartReentryCoordinator.clear()
        paperCash = amount
        paperBaseKrw = amount
        paperLoaded = true
        settings = settings.copy(paperInitialKrw = amount)
        settingsStore.save(settings)
        savePaperCash()
        dao.upsertSetting(SettingEntity("paper_base_krw", paperBaseKrw.toString()))
        currentTradingDate = currentKstDate()
        dayStartValue = amount
        dayPeakValue = amount
        dayMaxDrawdownPercent = 0.0
        dailyTrackingLoaded = true
        dao.upsertSetting(SettingEntity("day_start_date", currentTradingDate))
        dao.upsertSetting(SettingEntity("day_start_value", amount.toString()))
        dao.upsertSetting(SettingEntity("day_peak_value", amount.toString()))
        dao.upsertSetting(SettingEntity("day_max_drawdown", "0.0"))
        _state.value = _state.value.copy(
            settings = settings,
            totalValue = amount,
            krwBalance = amount,
            coinValue = 0.0,
            todayPnl = 0.0,
            todayPnlRate = 0.0,
            cumulativePnl = 0.0,
            cumulativePnlRate = 0.0,
            realizedPnl = 0.0,
            unrealizedPnl = 0.0,
            holdingCount = 0,
            topSignals = emptyList(),
            candidateCount = 0,
            buyReadyCount = 0
        )
        persistLearningAssetManifest()
        log("Paper 계좌 초기화 완료: ${amount.toLong()} KRW (AI 학습 Sample/Model/Prediction Journal/Research/Smart Re-entry 이력 유지)")
    }

    /**
     * Paper Reset과 완전히 분리된 명시적 AI 학습 초기화.
     * Historical/Paper samples, Prediction Journal, Continuous model settings만 지운다.
     * Shadow/Research/거래 원장/Champion 승격 이력은 감사 목적으로 유지한다.
     */
    suspend fun resetAiLearning(confirmToken: ***REDACTED*** {
        require(confirmToken =***REDACTED*** "RESET AI LEARNING") { "AI 학습 초기화는 명시 확인 토큰이 필요합니다" }
        dao.clearAiTrainingSamples()
        dao.clearPredictionJournal()
        dao.upsertSetting(SettingEntity("continuous_learning_model_json", ""))
        dao.upsertSetting(SettingEntity("continuous_learning_model_version", "NONE"))
        dao.upsertSetting(SettingEntity("continuous_learning_last_count", "0"))
        dao.upsertSetting(SettingEntity("continuous_learning_last_at", "0"))
        dao.upsertSetting(SettingEntity("ai_model_json", ""))
        dao.upsertSetting(SettingEntity("ai_model_source", "BUNDLED"))
        deepLearningModel = null
        deepLearningModelVersion = "NONE"
        aiModel = bundledAiModel
        _state.value = _state.value.copy(aiModelVersion = bundledAiModel.version, aiModelSource = "BUNDLED")
        lastContinuousTrainingSampleCount = 0
        lastContinuousTrainingAt = 0L
        continuousLearningInitialized = false
        aiModelRestored = false
        aiRetrainStateLoaded = false
        ensureContinuousLearningStateLoaded()
        restorePersistedAiModel()
        ensureAiRetrainStateLoaded()
        persistLearningAssetManifest()
        refreshContinuousLearningDashboard()
        log("RESET_AI_LEARNING 실행 — AI Sample/Prediction Journal/Continuous·Production model settings 초기화. Shadow/Research/거래원장은 유지. Paper Reset과 별개.")
    }

    private fun validateRemote(config: RemoteStrategyConfig): Pair<Boolean, String> {
        fun validPercent(value: Double?, min: Double, max: Double) = value == null || (value.isFinite() && value in min..max)
        if (config.version <= strategyVersion) return false to "이미 최신 버전"
        if (!validPercent(config.scoreThreshold, 0.0, 100.0)) return false to "scoreThreshold 검증 실패"
        if (!validPercent(config.maxSpreadPercent, 0.0, 20.0)) return false to "maxSpreadPercent 검증 실패"
        if (!validPercent(config.maxOrderPercent, 0.1, 100.0)) return false to "maxOrderPercent 검증 실패"
        if (!validPercent(config.maxAssetPercentPerCoin, 0.1, 100.0)) return false to "maxAssetPercentPerCoin 검증 실패"
        if (!validPercent(config.minKrwCashPercent, 0.0, 100.0)) return false to "minKrwCashPercent 검증 실패"
        if (!validPercent(config.stopLossPercent, -80.0, 0.0)) return false to "stopLossPercent 검증 실패"
        if (!validPercent(config.takeProfitPercent, 0.0, 300.0)) return false to "takeProfitPercent 검증 실패"
        if (!validPercent(config.trailingStopPercent, 0.0, 80.0)) return false to "trailingStopPercent 검증 실패"
        if (!validPercent(config.dailyMaxLossPercent, -80.0, 0.0)) return false to "dailyMaxLossPercent 검증 실패"
        if (config.maxPositions != null && config.maxPositions !in 1..50) return false to "maxPositions 검증 실패"
        if (config.min24hTradePrice != null && (!config.min24hTradePrice.isFinite() || config.min24hTradePrice < 0.0)) return false to "min24hTradePrice 검증 실패"
        if (config.min24hTradeValueKrw != null && (!config.min24hTradeValueKrw.isFinite() || config.min24hTradeValueKrw < 0.0)) return false to "min24hTradeValueKrw 검증 실패"
        if (config.liquidityPercentileThreshold != null && (!config.liquidityPercentileThreshold.isFinite() || config.liquidityPercentileThreshold !in 0.0..1.0)) return false to "liquidityPercentileThreshold 검증 실패"
        if (config.overblockingWarningThreshold != null && (!config.overblockingWarningThreshold.isFinite() || config.overblockingWarningThreshold !in 0.5..1.0)) return false to "overblockingWarningThreshold 검증 실패"
        if (config.staleTickerMillis != null && config.staleTickerMillis !in 5_000L..600_000L) return false to "staleTickerMillis 검증 실패"
        if (config.maxConsecutiveLosses != null && config.maxConsecutiveLosses !in 0..50) return false to "maxConsecutiveLosses 검증 실패"
        if (config.stopLossCooldownMinutes != null && config.stopLossCooldownMinutes !in 1..24 * 60) return false to "stopLossCooldownMinutes 검증 실패"
        if (config.trailingStopCooldownMinutes != null && config.trailingStopCooldownMinutes !in 1..24 * 60) return false to "trailingStopCooldownMinutes 검증 실패"
        val profitLevels = listOf(config.profitProtectionLevel1Percent, config.profitProtectionLevel2Percent, config.profitProtectionLevel3Percent, config.profitProtectionLevel4Percent)
        if (profitLevels.any { it != null && (!it.isFinite() || it < 0.1) }) return false to "profit protection level 검증 실패"
        if (profitLevels.filterNotNull().zipWithNext().any { it.first >= it.second }) return false to "profit protection level 순서 검증 실패"
        val givebacks = listOf(config.profitCautionGivebackPercentPoints, config.profitDefenseGivebackPercentPoints, config.profitLockedGivebackPercentPoints)
        if (givebacks.any { it != null && (!it.isFinite() || it <= 0.0) }) return false to "profit giveback 검증 실패"
        if (listOf(config.profitCautionMultiplier, config.profitDefenseMultiplier).any { it != null && (!it.isFinite() || it !in 0.0..1.0) }) return false to "profit multiplier 검증 실패"
        if (config.minNetEdgeMarginPercent != null && (!config.minNetEdgeMarginPercent.isFinite() || config.minNetEdgeMarginPercent !in 0.0..10.0)) return false to "minNetEdgeMarginPercent 검증 실패"
        if (config.maxPortfolioHeatPercent != null && (!config.maxPortfolioHeatPercent.isFinite() || config.maxPortfolioHeatPercent !in 10.0..100.0)) return false to "maxPortfolioHeatPercent 검증 실패"
        if (config.chaseRejectScore != null && (!config.chaseRejectScore.isFinite() || config.chaseRejectScore !in 50.0..100.0)) return false to "chaseRejectScore 검증 실패"
        if (config.extremeChaseScore != null && (!config.extremeChaseScore.isFinite() || config.extremeChaseScore !in 50.0..100.0)) return false to "extremeChaseScore 검증 실패"
        if (config.minimumEntryTimingScore != null && (!config.minimumEntryTimingScore.isFinite() || config.minimumEntryTimingScore !in 0.0..100.0)) return false to "minimumEntryTimingScore 검증 실패"
        if (config.overextensionAtrMultiple != null && (!config.overextensionAtrMultiple.isFinite() || config.overextensionAtrMultiple !in 0.5..10.0)) return false to "overextensionAtrMultiple 검증 실패"
        if (config.highScoreFailureWarningRate != null && (!config.highScoreFailureWarningRate.isFinite() || config.highScoreFailureWarningRate !in 0.1..1.0)) return false to "highScoreFailureWarningRate 검증 실패"
        if (config.entrySignalMaxAgeMillis != null && config.entrySignalMaxAgeMillis !in 10_000L..600_000L) return false to "entrySignalMaxAgeMillis 검증 실패"
        if (config.scalpingMinimumExecutionScore != null && (!config.scalpingMinimumExecutionScore.isFinite() || config.scalpingMinimumExecutionScore !in 0.0..100.0)) return false to "scalpingMinimumExecutionScore 검증 실패"
        if (config.scalpingMinimumShortNetEdgePercent != null && (!config.scalpingMinimumShortNetEdgePercent.isFinite() || config.scalpingMinimumShortNetEdgePercent !in 0.0..20.0)) return false to "scalpingMinimumShortNetEdgePercent 검증 실패"
        if (config.scalpingSafetyMargin != null && (!config.scalpingSafetyMargin.isFinite() || config.scalpingSafetyMargin !in 1.0..5.0)) return false to "scalpingSafetyMargin 검증 실패"
        if (config.scalpingMaximumSpreadPercent != null && (!config.scalpingMaximumSpreadPercent.isFinite() || config.scalpingMaximumSpreadPercent !in 0.01..10.0)) return false to "scalpingMaximumSpreadPercent 검증 실패"
        if (config.scalpingSignalTtlMillis != null && config.scalpingSignalTtlMillis !in 10_000L..600_000L) return false to "scalpingSignalTtlMillis 검증 실패"
        if (config.scalpingPriceMovedAwayAtrMultiple != null && (!config.scalpingPriceMovedAwayAtrMultiple.isFinite() || config.scalpingPriceMovedAwayAtrMultiple !in 0.25..10.0)) return false to "scalpingPriceMovedAwayAtrMultiple 검증 실패"
        if (config.profitReentryCooldownMinutes != null && config.profitReentryCooldownMinutes !in 1..24 * 60) return false to "profitReentryCooldownMinutes 검증 실패"
        if (config.profitReentryScoreResetDrop != null && (!config.profitReentryScoreResetDrop.isFinite() || config.profitReentryScoreResetDrop !in 1.0..50.0)) return false to "profitReentryScoreResetDrop 검증 실패"
        if (config.minimumProfitReentryQuality != null && (!config.minimumProfitReentryQuality.isFinite() || config.minimumProfitReentryQuality !in 0.0..100.0)) return false to "minimumProfitReentryQuality 검증 실패"
        if (config.maxProfitReentryRiskPercent != null && (!config.maxProfitReentryRiskPercent.isFinite() || config.maxProfitReentryRiskPercent !in 0.0..500.0)) return false to "maxProfitReentryRiskPercent 검증 실패"
        if (config.reentryChainWindowMinutes != null && config.reentryChainWindowMinutes !in 1..24 * 60) return false to "reentryChainWindowMinutes 검증 실패"
        if (config.churnWindowMinutes != null && config.churnWindowMinutes !in 5..24 * 60) return false to "churnWindowMinutes 검증 실패"
        if (config.continuousLearningTriggerSamples != null && config.continuousLearningTriggerSamples !in 10..500) return false to "continuousLearningTriggerSamples 검증 실패"
        if (config.continuousLearningIntervalMinutes != null && config.continuousLearningIntervalMinutes !in 5..24 * 60) return false to "continuousLearningIntervalMinutes 검증 실패"
        if (config.replayBufferSize != null && config.replayBufferSize !in 100..10_000) return false to "replayBufferSize 검증 실패"
        return true to "OK"
    }

    private fun applyRemote(config: RemoteStrategyConfig): TradingSettings = settings.copy(
        scoreThreshold = config.scoreThreshold ?: settings.scoreThreshold,
        min24hTradePrice = config.min24hTradeValueKrw ?: config.min24hTradePrice ?: settings.min24hTradePrice,
        liquidityPercentileThreshold = config.liquidityPercentileThreshold ?: settings.liquidityPercentileThreshold,
        dynamicLiquidityEnabled = config.dynamicLiquidityEnabled ?: settings.dynamicLiquidityEnabled,
        overblockingWarningThreshold = config.overblockingWarningThreshold ?: settings.overblockingWarningThreshold,
        maxSpreadPercent = config.maxSpreadPercent ?: settings.maxSpreadPercent,
        maxPositions = config.maxPositions ?: settings.maxPositions,
        maxOrderPercent = config.maxOrderPercent ?: settings.maxOrderPercent,
        maxAssetPercentPerCoin = config.maxAssetPercentPerCoin ?: settings.maxAssetPercentPerCoin,
        minKrwCashPercent = config.minKrwCashPercent ?: settings.minKrwCashPercent,
        stopLossPercent = config.stopLossPercent ?: settings.stopLossPercent,
        takeProfitPercent = config.takeProfitPercent ?: settings.takeProfitPercent,
        trailingStopPercent = config.trailingStopPercent ?: settings.trailingStopPercent,
        dailyMaxLossPercent = config.dailyMaxLossPercent ?: settings.dailyMaxLossPercent,
        staleTickerMillis = config.staleTickerMillis ?: settings.staleTickerMillis,
        maxConsecutiveLosses = config.maxConsecutiveLosses ?: settings.maxConsecutiveLosses,
        profitProtectionLevel1Percent = config.profitProtectionLevel1Percent ?: settings.profitProtectionLevel1Percent,
        profitProtectionLevel2Percent = config.profitProtectionLevel2Percent ?: settings.profitProtectionLevel2Percent,
        profitProtectionLevel3Percent = config.profitProtectionLevel3Percent ?: settings.profitProtectionLevel3Percent,
        profitProtectionLevel4Percent = config.profitProtectionLevel4Percent ?: settings.profitProtectionLevel4Percent,
        profitCautionGivebackPercentPoints = config.profitCautionGivebackPercentPoints ?: settings.profitCautionGivebackPercentPoints,
        profitDefenseGivebackPercentPoints = config.profitDefenseGivebackPercentPoints ?: settings.profitDefenseGivebackPercentPoints,
        profitLockedGivebackPercentPoints = config.profitLockedGivebackPercentPoints ?: settings.profitLockedGivebackPercentPoints,
        profitCautionMultiplier = config.profitCautionMultiplier ?: settings.profitCautionMultiplier,
        profitDefenseMultiplier = config.profitDefenseMultiplier ?: settings.profitDefenseMultiplier,
        profitLockedEntryAllowed = config.profitLockedEntryAllowed ?: settings.profitLockedEntryAllowed,
        stopLossCooldownMinutes = config.stopLossCooldownMinutes ?: settings.stopLossCooldownMinutes,
        trailingStopCooldownMinutes = config.trailingStopCooldownMinutes ?: settings.trailingStopCooldownMinutes,
        minNetEdgeMarginPercent = config.minNetEdgeMarginPercent ?: settings.minNetEdgeMarginPercent,
        maxPortfolioHeatPercent = config.maxPortfolioHeatPercent ?: settings.maxPortfolioHeatPercent,
        executionDepthCheckEnabled = config.executionDepthCheckEnabled ?: settings.executionDepthCheckEnabled,
        entryTimingGateEnabled = config.entryTimingGateEnabled ?: settings.entryTimingGateEnabled,
        chaseRejectScore = config.chaseRejectScore ?: settings.chaseRejectScore,
        extremeChaseScore = config.extremeChaseScore ?: settings.extremeChaseScore,
        minimumEntryTimingScore = config.minimumEntryTimingScore ?: settings.minimumEntryTimingScore,
        overextensionAtrMultiple = config.overextensionAtrMultiple ?: settings.overextensionAtrMultiple,
        highScoreFailureWarningRate = config.highScoreFailureWarningRate ?: settings.highScoreFailureWarningRate,
        entrySignalMaxAgeMillis = config.entrySignalMaxAgeMillis ?: settings.entrySignalMaxAgeMillis,
        scalpingExecutionEnabled = config.scalpingExecutionEnabled ?: settings.scalpingExecutionEnabled,
        scalpingMinimumExecutionScore = config.scalpingMinimumExecutionScore ?: settings.scalpingMinimumExecutionScore,
        scalpingMinimumShortNetEdgePercent = config.scalpingMinimumShortNetEdgePercent ?: settings.scalpingMinimumShortNetEdgePercent,
        scalpingSafetyMargin = config.scalpingSafetyMargin ?: settings.scalpingSafetyMargin,
        scalpingMaximumSpreadPercent = config.scalpingMaximumSpreadPercent ?: settings.scalpingMaximumSpreadPercent,
        scalpingSignalTtlMillis = config.scalpingSignalTtlMillis ?: settings.scalpingSignalTtlMillis,
        scalpingPriceMovedAwayAtrMultiple = config.scalpingPriceMovedAwayAtrMultiple ?: settings.scalpingPriceMovedAwayAtrMultiple,
        profitReentryEnabled = config.profitReentryEnabled ?: settings.profitReentryEnabled,
        profitReentryCooldownMinutes = config.profitReentryCooldownMinutes ?: settings.profitReentryCooldownMinutes,
        profitReentryScoreResetDrop = config.profitReentryScoreResetDrop ?: settings.profitReentryScoreResetDrop,
        minimumProfitReentryQuality = config.minimumProfitReentryQuality ?: settings.minimumProfitReentryQuality,
        maxProfitReentryRiskPercent = config.maxProfitReentryRiskPercent ?: settings.maxProfitReentryRiskPercent,
        reentryChainWindowMinutes = config.reentryChainWindowMinutes ?: settings.reentryChainWindowMinutes,
        churnWindowMinutes = config.churnWindowMinutes ?: settings.churnWindowMinutes,
        continuousLearningEnabled = config.continuousLearningEnabled ?: settings.continuousLearningEnabled,
        continuousLearningTriggerSamples = config.continuousLearningTriggerSamples ?: settings.continuousLearningTriggerSamples,
        continuousLearningIntervalMinutes = config.continuousLearningIntervalMinutes ?: settings.continuousLearningIntervalMinutes,
        replayBufferSize = config.replayBufferSize ?: settings.replayBufferSize,
        historicalBootstrapEnabled = config.historicalBootstrapEnabled ?: settings.historicalBootstrapEnabled,
        regimeStrategySetsEnabled = config.regimeStrategySetsEnabled ?: settings.regimeStrategySetsEnabled,
        regimeStrategySetsPaperApplyEnabled = config.regimeStrategySetsPaperApplyEnabled ?: settings.regimeStrategySetsPaperApplyEnabled
    )

    suspend fun checkStrategyOta(force: Boolean = false) {
        val previousSettings = settings
        val previousVersion = strategyVersion
        _state.value = _state.value.copy(otaStatus = OtaStatus.CHECKING, otaMessage = "전략 업데이트 확인 중")
        try {
            val config = withContext(Dispatchers.IO) { otaClient.fetch() }
            val validation = validateRemote(config)
            if (!validation.first) {
                _state.value = _state.value.copy(otaStatus = OtaStatus.NO_UPDATE, otaMessage = validation.second)
                if (force) log("OTA 확인: ${validation.second}")
                return
            }
            val nextSettings = applyRemote(config)
            settings = nextSettings
            settingsStore.save(nextSettings)
            strategyVersion = config.version
            dao.upsertSetting(SettingEntity("strategy_version", strategyVersion.toString()))
            dao.upsertSetting(SettingEntity("strategy_message", config.message.orEmpty()))
            _state.value = _state.value.copy(
                settings = settings,
                testMode = settings.testMode,
                strategyVersion = strategyVersion,
                lastStrategyUpdateAt = System.currentTimeMillis(),
                otaStatus = OtaStatus.UPDATED,
                otaMessage = config.message ?: "전략 v${config.version} 적용"
            )
            log("OTA 전략 적용 완료: v${config.version}")
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            settings = previousSettings
            settingsStore.save(previousSettings)
            strategyVersion = previousVersion
            _state.value = _state.value.copy(
                settings = previousSettings,
                strategyVersion = previousVersion,
                otaStatus = OtaStatus.ROLLED_BACK,
                otaMessage = "OTA 실패, 기존 전략 유지: ${e.message ?: "알 수 없음"}"
            )
            log("OTA 실패/롤백: ${e.message ?: "알 수 없음"}")
        }
    }

    private suspend fun restorePersistedAiModel() {
        if (aiModelRestored) return
        val storedSettings = dao.settings()
        val stored = storedSettings.firstOrNull { it.key == "ai_model_json" }?.value
        val storedSource = storedSettings.firstOrNull { it.key == "ai_model_source" }?.value ?: "BUNDLED"
        val adoptedLocal = dao.latestAdoptedLocalModel()
        if (!stored.isNullOrBlank()) {
            try {
                val restored = OnDeviceAiModel.fromJson(stored)
                val shouldRestore = AiModelPrecedencePolicy.shouldRestorePersisted(
                    storedSource,
                    restored.version,
                    aiModel.version,
                    adoptedLocal != null
                )
                if (shouldRestore) {
                    aiModel = restored
                    _state.value = _state.value.copy(aiModelVersion = restored.version, aiModelSource = storedSource)
                    log("LOCAL_AI_MODEL_RESTORED source=$storedSource version=${restored.version}")
                } else {
                    log("PERSISTED_AI_DEFERRED source=$storedSource persisted=${restored.version} bundled=${aiModel.version}")
                }
            } catch (e: Exception) {
                log("AI_MODEL_RESTORE_FAILED source=$storedSource jsonExists=true error=${e.javaClass.simpleName} fallback=BUNDLED sampleCount=${dao.totalAiSampleCount()}")
                val fallback = adoptedLocal?.let { runCatching { OnDeviceAiModel.fromJson(it.modelJson) }.getOrNull() }
                if (fallback != null) {
                    aiModel = fallback
                    _state.value = _state.value.copy(aiModelVersion = fallback.version, aiModelSource = "LOCAL_RETRAIN")
                    log("AI_MODEL_RESTORE_FALLBACK latestAdoptedGeneration=${adoptedLocal.generation}")
                }
            }
        }
        aiModelRestored = true
    }

    suspend fun checkAiModelOta(force: Boolean = false) {
        restorePersistedAiModel()
        val previousModel = aiModel
        _state.value = _state.value.copy(
            aiOtaStatus = OtaStatus.CHECKING,
            aiOtaMessage = "AI 모델 업데이트 확인 중"
        )
        try {
            val (artifact, json) = withContext(Dispatchers.IO) { aiModelOtaClient.fetch() }
            if (artifact.modelVersion <= aiModel.version) {
                _state.value = _state.value.copy(
                    aiOtaStatus = OtaStatus.NO_UPDATE,
                    aiOtaMessage = "이미 최신 AI 모델 v${aiModel.version}"
                )
                if (force) log("AI OTA 확인: 최신 v${aiModel.version}")
                return
            }
            val localValidated = dao.latestAdoptedLocalModel() != null
            if (!AiModelPrecedencePolicy.shouldApplyOta(
                    _state.value.aiModelSource,
                    artifact.modelVersion,
                    aiModel.version,
                    localValidated
                )
            ) {
                _state.value = _state.value.copy(
                    aiOtaStatus = OtaStatus.NO_UPDATE,
                    aiOtaMessage = "검증된 로컬 모델 유지 — OTA 후보는 수동 검증 필요"
                )
                log("OTA_MODEL_DEFERRED currentSource=${_state.value.aiModelSource} ota=${artifact.modelVersion} localValidated=$localValidated")
                return
            }
            val nextModel = OnDeviceAiModel.fromJson(json)
            require(nextModel.version == artifact.modelVersion)
            aiModel = nextModel
            dao.upsertSetting(SettingEntity("ai_model_version", nextModel.version.toString()))
            dao.upsertSetting(SettingEntity("ai_model_json", json))
            dao.upsertSetting(SettingEntity("ai_model_source", "OTA"))
            _state.value = _state.value.copy(
                aiModelVersion = nextModel.version,
                lastAiModelUpdateAt = System.currentTimeMillis(),
                aiOtaStatus = OtaStatus.UPDATED,
                aiOtaMessage = "AI 모델 v${nextModel.version} 적용",
                aiModelSource = "OTA"
            )
            persistLearningAssetManifest()
            log("AI OTA 모델 적용 완료: v${nextModel.version}")
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            aiModel = previousModel
            _state.value = _state.value.copy(
                aiModelVersion = previousModel.version,
                aiOtaStatus = OtaStatus.ROLLED_BACK,
                aiOtaMessage = "AI OTA 실패, 기존 모델 유지: ${e.message ?: "알 수 없음"}"
            )
            log("AI OTA 실패/롤백: ${e.message ?: "알 수 없음"}")
        }
    }

    fun setTestMode(enabled: Boolean) {
        updateSettings(settings.copy(testMode = enabled))
    }

    // ---- 반자동 학습(페이퍼 트레이딩 결과 기반) -----------------------------------------
    // AI 는 매매를 직접 실행하지 않고 Paper 매수/매도 결과만으로 학습 데이터를 쌓는다.
    // 새 후보 모델은 검증셋에서 기존 모델보다 실제로 더 정확할 때만 채택되어,
    // "이상한 전략"이 갑자기 반영되는 위험을 줄인다.

    private suspend fun refreshModelHistory() {
        val recent = dao.recentModelVersions(5).map {
            AiModelVersionSummary(
                generation = it.generation,
                source = it.source,
                createdAt = it.createdAt,
                adopted = it.adopted,
                validationAccuracy = it.validationAccuracy,
                baselineAccuracy = it.baselineAccuracy,
                trainingSamples = it.trainingSamples,
                reason = it.reason
            )
        }
        _state.value = _state.value.copy(recentModelVersions = recent)
    }

    private suspend fun ensureAiRetrainStateLoaded() {
        if (aiRetrainStateLoaded) return
        val settingsList = dao.settings()
        lastRetrainResolvedCount = settingsList.firstOrNull { it.key == "ai_retrain_last_count" }?.value?.toIntOrNull() ?: 0
        aiLocalGeneration = settingsList.firstOrNull { it.key == "ai_local_generation" }?.value?.toIntOrNull() ?: 0
        val modelSource = settingsList.firstOrNull { it.key == "ai_model_source" }?.value ?: "BUNDLED"
        val total = dao.totalAiSampleCount()
        val resolved = dao.resolvedAiSampleCount()
        _state.value = _state.value.copy(
            aiLocalGeneration = aiLocalGeneration,
            aiTrainingSampleCount = total,
            aiResolvedSampleCount = resolved,
            aiModelSource = modelSource
        )
        refreshModelHistory()
        aiRetrainStateLoaded = true
    }

    private suspend fun recordAiTrainingSample(candidate: StrategySignalModel, buyTradeId: String) {
        if (candidate.aiFeatures.isEmpty()) return
        dao.upsertAiSample(
            AiTrainingSampleEntity(
                market = candidate.market,
                time = System.currentTimeMillis(),
                featuresJson = candidate.aiFeatures.joinToString(","),
                strategyScore = candidate.score,
                aiScoreAtCapture = candidate.aiScore,
                buyTradeId = buyTradeId,
                marketRegime = _state.value.currentRegime.regime.name,
                source = LearningDataSource.PAPER.name,
                goodEntry = candidate.entryQualityClassification in setOf(EntryQualityClassification.EARLY_GOOD.name, EntryQualityClassification.GOOD.name),
                chaseEntry = candidate.chaseEntryScore >= 65.0,
                immediateDrawdown = candidate.preEntry5mReturn <= -2.0,
                lookaheadSafe = true
            )
        )
        val total = dao.totalAiSampleCount()
        _state.value = _state.value.copy(aiTrainingSampleCount = total)
    }

    private suspend fun recordPredictionJournal(signal: StrategySignalModel) {
        val prediction = deepLearningModel?.predict(signal.aiFeatures)
        dao.upsertPredictionJournal(
            PredictionJournalEntity(
                predictionId = signal.predictionId,
                modelVersion = deepLearningModelVersion,
                market = signal.market,
                timestamp = signal.timestamp,
                featuresJson = signal.aiFeatures.joinToString(","),
                predictionJson = prediction?.let {
                    "p5=${it.probabilityPositive5m},p15=${it.probabilityPositive15m},stop=${it.probabilityStopBeforeProfit},return5=${it.expectedReturn5m}"
                } ?: "MODEL_UNAVAILABLE",
                confidence = prediction?.confidence ?: 0.0,
                decision = signal.status.name,
                reason = signal.reason + " / " + signal.failureReason,
                source = LearningDataSource.PAPER.name,
                expectedReturn5m = prediction?.expectedReturn5m ?: 0.0
            )
        )
    }

    private suspend fun resolveAiOutcome(market: String, pnlRate: Double) {
        val pending = dao.pendingAiSample(market) ?: return
        // pnlRate = Economic Net % after buyFee+sellFee (slippage already in fill prices). Not Gross.
        val profitableAfterCost = pnlRate > 0.0
        // Tiny after-cost wins (fee scrape) must not be strong positive labels.
        val meaningfulNetWin = pnlRate >= 0.10
        val label = if (meaningfulNetWin) 1.0 else 0.0
        dao.upsertAiSample(
            pending.copy(
                outcomeLabel = label,
                realizedPnlRate = pnlRate,
                resolvedAt = System.currentTimeMillis(),
                profitableAfterCost = profitableAfterCost,
                netEdgeRealized = pnlRate,
                hardExample = pending.hardExample ||
                    (pending.chaseEntry && pnlRate < 0.0) ||
                    pending.immediateDrawdown ||
                    (profitableAfterCost && !meaningfulNetWin)
            )
        )
        val resolvedCount = dao.resolvedAiSampleCount()
        _state.value = _state.value.copy(
            aiResolvedSampleCount = resolvedCount,
            aiTrainingSampleCount = dao.totalAiSampleCount()
        )
        log("AI 학습 라벨 기록: $market ${if (label >= 0.5) "성공(Net)" else if (profitableAfterCost) "약승(비용후미미)" else "실패"} (Net ${"%.2f".format(pnlRate)}%)")
        ensureAiRetrainStateLoaded()
        ensureRiskStateLoaded()
        recordTradeOutcomeForRisk(pnlRate)
        if (resolvedCount - lastRetrainResolvedCount >= AiRetrainPolicy.RETRAIN_TRIGGER_COUNT) {
            attemptLocalRetrain(resolvedCount)
        }
        evaluateRecommendationPipeline(resolvedCount)
    }

    private suspend fun resolvePredictionOutcome(tradeId: String, pnlRate: Double) {
        dao.predictionJournalsForTrade(tradeId).forEach { journal ->
            dao.upsertPredictionJournal(
                journal.copy(
                    outcomeReturn5m = pnlRate,
                    predictionError5m = pnlRate - journal.expectedReturn5m,
                    resolvedAt = System.currentTimeMillis()
                )
            )
        }
    }

    private suspend fun ensureContinuousLearningStateLoaded() {
        if (continuousLearningInitialized) return
        val saved = dao.settings()
        lastContinuousTrainingSampleCount = saved.firstOrNull { it.key == "continuous_learning_last_count" }?.value?.toIntOrNull() ?: 0
        lastContinuousTrainingAt = saved.firstOrNull { it.key == "continuous_learning_last_at" }?.value?.toLongOrNull() ?: 0L
        val modelJson = saved.firstOrNull { it.key == "continuous_learning_model_json" }?.value
        if (!modelJson.isNullOrBlank()) {
            try {
                val artifact = ContinuousLearningEngine.fromJson(modelJson)
                deepLearningModel = DeepLearningCandidateModel(artifact)
                deepLearningModelVersion = artifact.modelVersion
                log("CONTINUOUS_MODEL_RESTORED version=${artifact.modelVersion} checksum=${LearningAssetIntegrity.checksum(modelJson).take(12)} sampleCount=${artifact.trainingSampleCount}")
            } catch (e: Exception) {
                log("CONTINUOUS_MODEL_RESTORE_FAILED modelVersion=UNKNOWN jsonExists=true error=${e.javaClass.simpleName} fallback=MODEL_REGISTRY sampleCount=${dao.totalAiSampleCount()}")
                val fallback = dao.latestContinuousCandidateModel()
                val fallbackArtifact = fallback?.let { runCatching { ContinuousLearningEngine.fromJson(it.modelJson) }.getOrNull() }
                if (fallbackArtifact != null) {
                    deepLearningModel = DeepLearningCandidateModel(fallbackArtifact)
                    deepLearningModelVersion = fallbackArtifact.modelVersion
                    dao.upsertSetting(SettingEntity("continuous_learning_model_json", fallback.modelJson))
                    log("CONTINUOUS_MODEL_FALLBACK_RESTORED version=${fallbackArtifact.modelVersion}")
                }
            }
        }
        continuousLearningInitialized = true
        refreshContinuousLearningDashboard()
    }

    private suspend fun bootstrapHistoricalLearning(markets: List<MarketModel>) {
        if (settings.remoteAiPrimary || settings.remoteLearningEnabled) {
            log("SERVER_PRIMARY: Android Historical Bootstrap skipped")
            return
        }
        if (!settings.historicalBootstrapEnabled) return
        val now = System.currentTimeMillis()
        if (now - lastHistoricalBootstrapAt < 10 * 60_000L) return
        lastHistoricalBootstrapAt = now
        val focus = markets.take(3)
        val historical = limitedParallelMap(focus, concurrency = 2) { market ->
            val candles = runCatching { marketData.candles(market.market, 1) }.getOrDefault(emptyList())
            HistoricalBootstrapEngine.generate(market.market, candles)
        }.flatten()
        historical.forEach { dao.upsertAiSample(it) }
        refreshContinuousLearningDashboard()
        if (historical.isNotEmpty()) log("Historical bootstrap: ${historical.size}개 실제 Bithumb candle sample 수집")
    }

    private suspend fun scheduleContinuousLearning() {
        if (settings.remoteAiPrimary || settings.remoteLearningEnabled) {
            log("SERVER_PRIMARY: Android Continuous Learning skipped")
            refreshContinuousLearningDashboard()
            return
        }
        if (!settings.continuousLearningEnabled) return
        val samples = dao.continuousLearningSamples()
        val now = System.currentTimeMillis()
        val trigger = samples.size - lastContinuousTrainingSampleCount >= settings.continuousLearningTriggerSamples ||
            (now - lastContinuousTrainingAt >= settings.continuousLearningIntervalMinutes * 60_000L && samples.size >= settings.continuousLearningTriggerSamples)
        if (!trigger || samples.size < settings.continuousLearningTriggerSamples) {
            refreshContinuousLearningDashboard()
            return
        }
        synchronized(this) {
            if (continuousTrainingRunning) return
            continuousTrainingRunning = true
        }
        _state.value = _state.value.copy(continuousLearning = _state.value.continuousLearning.copy(
            stage = LearningLoopStage.TRAINING,
            statusMessage = "Replay Buffer 기반 후보 모델 학습 중"
        ))
        continuousLearningScope.launch {
            try {
                val replay = LearningReplayBuffer.select(samples, settings.replayBufferSize)
                val examples = ContinuousLearningEngine.examples(replay)
                if (examples.size < settings.continuousLearningTriggerSamples) return@launch
                val trainEnd = (examples.size * 0.60).toInt().coerceAtLeast(1)
                val validationEnd = (examples.size * 0.80).toInt().coerceAtLeast(trainEnd + 1).coerceAtMost(examples.size)
                val train = examples.take(trainEnd)
                val validation = examples.subList(trainEnd, validationEnd)
                val oos = examples.drop(validationEnd)
                val artifact = ContinuousLearningEngine.train(train, "DL_CANDIDATE_${now}", now) ?: return@launch
                val model = DeepLearningCandidateModel(artifact)
                val validationMetrics = ContinuousLearningEngine.metrics(model, validation)
                val oosMetrics = ContinuousLearningEngine.metrics(model, oos)
                val baselineMetrics = if (deepLearningModel != null) {
                    ContinuousLearningEngine.metrics(deepLearningModel!!, oos)
                } else {
                    ContinuousLearningEngine.metrics(model, train)
                }
                val promotion = ContinuousLearningEngine.promotionAllowed(
                    validationMetrics, oosMetrics, baselineMetrics, settings.continuousLearningTriggerSamples
                )
                val status = if (promotion) LearningModelStatus.PROMOTION_CANDIDATE else LearningModelStatus.REJECTED
                dao.insertModelVersion(
                    StrategyModelVersionEntity(
                        generation = artifact.modelVersion.hashCode(),
                        source = "CONTINUOUS_DEEP_LEARNING",
                        createdAt = now,
                        trainingSamples = train.size,
                        validationSamples = validation.size,
                        validationAccuracy = validationMetrics.accuracy5m,
                        baselineAccuracy = baselineMetrics.accuracy5m,
                        adopted = false,
                        reason = "$status; Validation=${validationMetrics.accuracy5m}; OOS=${oosMetrics.accuracy5m}; PF=${oosMetrics.profitFactor}; MDD=${oosMetrics.maxDrawdown}",
                        modelJson = ContinuousLearningEngine.toJson(artifact)
                    )
                )
                // 자동 Production 변경은 하지 않고, 통계적으로 통과한 후보만 Challenger로 유지한다.
                if (promotion) {
                    deepLearningModel = model
                    deepLearningModelVersion = artifact.modelVersion
                    dao.upsertSetting(SettingEntity("continuous_learning_model_json", ContinuousLearningEngine.toJson(artifact)))
                }
                lastContinuousTrainingSampleCount = samples.size
                lastContinuousTrainingAt = now
                dao.upsertSetting(SettingEntity("continuous_learning_last_count", samples.size.toString()))
                dao.upsertSetting(SettingEntity("continuous_learning_last_at", now.toString()))
                persistLearningAssetManifest()
                withContext(Dispatchers.Main) {
                    _state.value = _state.value.copy(continuousLearning = _state.value.continuousLearning.copy(
                        stage = if (promotion) LearningLoopStage.SHADOW_TESTING else LearningLoopStage.COLLECTING,
                        challengerVersion = artifact.modelVersion,
                        replaySamples = replay.size,
                        lastTrainingAt = now,
                        nextTrainingAt = now + settings.continuousLearningIntervalMinutes * 60_000L,
                        modelStatus = status,
                        lastMetrics = oosMetrics,
                        statusMessage = if (promotion) "OOS 통과 Challenger — Shadow 검증 대기" else "OOS/성과 기준 미달 — Champion 유지"
                    ))
                }
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    _state.value = _state.value.copy(continuousLearning = _state.value.continuousLearning.copy(
                        stage = LearningLoopStage.COLLECTING,
                        statusMessage = "학습 실패 — 기존 Strategy/Paper 유지: ${e.message ?: "unknown"}"
                    ))
                }
            } finally {
                continuousTrainingRunning = false
            }
        }
    }

    private suspend fun refreshContinuousLearningDashboard() {
        val samples = dao.continuousLearningSamples()
        val drift = ContinuousLearningEngine.drift(samples)
        val state = _state.value.continuousLearning
        _state.value = _state.value.copy(
            continuousLearning = state.copy(
                stage = if (drift.first == LearningDriftState.MAJOR_DRIFT) LearningLoopStage.DRIFT_DETECTED else if (deepLearningModel == null) LearningLoopStage.COLLECTING else state.stage,
                historicalSamples = samples.count { it.source == LearningDataSource.HISTORICAL.name },
                paperSamples = samples.count { it.source == LearningDataSource.PAPER.name },
                liveSamples = samples.count { it.source == LearningDataSource.LIVE.name },
                hardExamples = samples.count { it.hardExample },
                replaySamples = minOf(samples.size, settings.replayBufferSize),
                championVersion = "CURRENT_STRATEGY",
                challengerVersion = deepLearningModelVersion,
                lastTrainingAt = lastContinuousTrainingAt,
                nextTrainingAt = if (lastContinuousTrainingAt > 0L) lastContinuousTrainingAt + settings.continuousLearningIntervalMinutes * 60_000L else 0L,
                drift = drift.first,
                driftReason = drift.second
            )
        )
    }

    /** QA/수동 확인용: 트리거 조건과 무관하게 즉시 재학습을 시도한다. */
    suspend fun forceLocalRetrainCheck() {
        ensureAiRetrainStateLoaded()
        attemptLocalRetrain(dao.resolvedAiSampleCount())
    }

    private suspend fun attemptLocalRetrain(resolvedCount: Int) {
        ensureAiRetrainStateLoaded()
        val researchDataset = AiResearchDatasetBuilder.summarize(
            postEntrySnapshots = dao.allPostEntrySnapshots(),
            missedOpportunities = dao.recentMissedOpportunities(),
            shadowTrades = dao.recentShadowTrades(),
            opportunityDecisions = dao.recentOpportunities(),
            postExitSnapshots = dao.allPostExitSnapshots(),
            lossRootCauses = dao.allLossRootCauses(),
            scalpingDiagnostics = dao.recentScalpingDiagnostics(),
            smartReentryAttempts = dao.recentSmartReentryAttempts()
        )
        log("AI 연구 데이터셋 집계: 진입품질 ${researchDataset.postEntrySnapshots}건 / Scalping ${researchDataset.scalpingDiagnostics}건 / Smart Re-entry ${researchDataset.smartReentryAttempts}건 / 매도이후추적 ${researchDataset.postExitSnapshots}건 / 손실원인 ${researchDataset.lossRootCauses}건 / 놓친기회 ${researchDataset.missedOpportunities}건 / Shadow ${researchDataset.shadowTrades}건")
        _state.value = _state.value.copy(
            aiRetrainStatus = OtaStatus.CHECKING,
            aiRetrainMessage = "Paper 결과 기반 재학습 시도 중"
        )
        if (!AiRetrainPolicy.hasEnoughData(resolvedCount)) {
            _state.value = _state.value.copy(
                aiRetrainStatus = OtaStatus.NO_UPDATE,
                aiRetrainMessage = "샘플 부족 (${resolvedCount}/${AiRetrainPolicy.MIN_TRAINING_SAMPLES})"
            )
            log("AI 재학습 보류: 라벨된 샘플 부족 ${resolvedCount}개")
            return
        }
        val examples = dao.resolvedAiSamples().mapNotNull { entity ->
            val features = entity.featuresJson.split(",").mapNotNull { it.toDoubleOrNull() }
            val label = entity.outcomeLabel
            if (features.isEmpty() || label == null) null else OnDeviceModelTrainer.TrainingExample(features, label)
        }
        val validationSize = AiRetrainPolicy.validationSize(examples.size)
        if (examples.size <= validationSize) {
            _state.value = _state.value.copy(aiRetrainStatus = OtaStatus.NO_UPDATE, aiRetrainMessage = "검증 데이터 부족")
            log("AI 재학습 보류: 검증 데이터 부족")
            return
        }
        val trainSet = examples.dropLast(validationSize)
        val validationSet = examples.takeLast(validationSize)
        val candidateArtifact = OnDeviceModelTrainer.train(trainSet)
        if (candidateArtifact == null) {
            _state.value = _state.value.copy(aiRetrainStatus = OtaStatus.FAILED, aiRetrainMessage = "학습 실패: 데이터 부적합")
            log("AI 재학습 실패: 학습 데이터 부적합")
            return
        }
        val nextGeneration = aiLocalGeneration + 1
        val versionedArtifact = candidateArtifact.copy(modelVersion = maxOf(aiModel.version + 1, nextGeneration))
        val candidateModel = BundledLogisticAiModel(versionedArtifact)
        val candidateAccuracy = OnDeviceModelTrainer.accuracyOfModel(candidateModel, validationSet)
        val baselineAccuracy = OnDeviceModelTrainer.accuracyOfModel(aiModel, validationSet)
        val adopted = AiRetrainPolicy.shouldAdopt(candidateAccuracy, baselineAccuracy)
        val reason = if (adopted)
            "검증 정확도 개선 (${"%.1f".format(baselineAccuracy * 100)}% → ${"%.1f".format(candidateAccuracy * 100)}%)"
        else
            "개선 없음, 기존 모델 유지 (후보 ${"%.1f".format(candidateAccuracy * 100)}% vs 기존 ${"%.1f".format(baselineAccuracy * 100)}%)"

        dao.insertModelVersion(
            StrategyModelVersionEntity(
                generation = nextGeneration,
                source = "LOCAL_RETRAIN",
                createdAt = System.currentTimeMillis(),
                trainingSamples = trainSet.size,
                validationSamples = validationSet.size,
                validationAccuracy = candidateAccuracy,
                baselineAccuracy = baselineAccuracy,
                adopted = adopted,
                reason = reason,
                modelJson = OnDeviceAiModel.toJson(versionedArtifact)
            )
        )
        lastRetrainResolvedCount = resolvedCount
        dao.upsertSetting(SettingEntity("ai_retrain_last_count", resolvedCount.toString()))
        aiLocalGeneration = nextGeneration
        dao.upsertSetting(SettingEntity("ai_local_generation", nextGeneration.toString()))

        if (adopted) {
            aiModel = candidateModel
            dao.upsertSetting(SettingEntity("ai_model_json", OnDeviceAiModel.toJson(versionedArtifact)))
            dao.upsertSetting(SettingEntity("ai_model_source", "LOCAL_RETRAIN"))
            _state.value = _state.value.copy(
                aiLocalGeneration = nextGeneration,
                lastAiLocalRetrainAt = System.currentTimeMillis(),
                aiRetrainStatus = OtaStatus.UPDATED,
                aiRetrainMessage = reason,
                aiModelSource = "LOCAL_RETRAIN"
            )
            persistLearningAssetManifest()
            log("AI 재학습 채택: 세대 $nextGeneration / $reason")
        } else {
            _state.value = _state.value.copy(
                aiLocalGeneration = nextGeneration,
                lastAiLocalRetrainAt = System.currentTimeMillis(),
                aiRetrainStatus = OtaStatus.NO_UPDATE,
                aiRetrainMessage = reason
            )
            log("AI 재학습 기각: $reason")
        }
        refreshModelHistory()
    }

    private suspend fun ensurePaperPortfolioLoaded() {
        if (paperLoaded) return
        val storedSettings = dao.settings()
        paperBaseKrw = storedSettings.firstOrNull { it.key == "paper_base_krw" }?.value?.toDoubleOrNull()
            ?.takeIf { it.isFinite() && it >= 0.0 }
            ?: settings.paperInitialKrw
        val storedCash = storedSettings.firstOrNull { it.key == "paper_cash" }?.value?.toDoubleOrNull()
        paperCash = reconcilePaperCash(storedCash)
        dao.upsertSetting(SettingEntity("paper_base_krw", paperBaseKrw.toString()))
        savePaperCash()
        val guards = dao.allReentryGuards().map {
            MarketReentryState(
                market = it.market,
                lossStreak = it.lossStreak,
                cooldownUntil = it.cooldownUntil,
                status = runCatching { MarketGuardStatus.valueOf(it.status) }.getOrDefault(MarketGuardStatus.NORMAL),
                lastExitReason = it.lastExitReason,
                exitScore = it.exitScore,
                exitTime = it.exitTime,
                signalResetRequired = it.signalResetRequired,
                scoreResetObserved = it.scoreResetObserved
            )
        }
        reentryCoordinator.restore(guards)
        smartReentryCoordinator.restore(dao.allSmartReentryStates().map { it.toModel() })
        smartReentryLoaded = true
        paperLoaded = true
    }

    private suspend fun reconcilePaperCash(storedCash: Double?): Double {
        val trades = dao.tradesByMode("PAPER")
        val ledgerCash = PaperCashLedger.balance(paperBaseKrw, trades)
        return if (ledgerCash.isFinite()) ledgerCash
        else storedCash?.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: paperBaseKrw
    }

    private suspend fun savePaperCash() {
        dao.upsertSetting(SettingEntity("paper_cash", paperCash.toString()))
    }

    // ---- 성과 리포트(일별 스냅샷 / 잔고 스냅샷) ------------------------------------------
    // 초기 스캐폴딩부터 존재했지만 실제로 기록되지 않던 daily_performance / balance_snapshots
    // 테이블을 실제로 채우고, 동일한 당일 시작 평가금액을 일일 손실 차단 기준으로 사용한다.
    private fun currentKstDate(): String = java.time.LocalDate.now(java.time.ZoneId.of("Asia/Seoul")).toString().replace("-", "")

    private suspend fun ensureDailyTrackingLoaded(currentTotalValue: Double) {
        if (dailyTrackingLoaded) return
        val today = currentKstDate()
        val settingsList = dao.settings()
        val storedDate = settingsList.firstOrNull { it.key == "day_start_date" }?.value
        val storedStart = settingsList.firstOrNull { it.key == "day_start_value" }?.value?.toDoubleOrNull()
        val storedPeak = settingsList.firstOrNull { it.key == "day_peak_value" }?.value?.toDoubleOrNull()
        val storedDrawdown = settingsList.firstOrNull { it.key == "day_max_drawdown" }?.value?.toDoubleOrNull()
        if (storedDate == today && storedStart != null) {
            currentTradingDate = today
            dayStartValue = storedStart
            dayPeakValue = storedPeak ?: storedStart
            dayMaxDrawdownPercent = storedDrawdown ?: 0.0
        } else {
            currentTradingDate = today
            dayStartValue = currentTotalValue.takeIf { it > 0.0 } ?: settings.paperInitialKrw
            dayPeakValue = dayStartValue
            dayMaxDrawdownPercent = 0.0
            dao.upsertSetting(SettingEntity("day_start_date", today))
            dao.upsertSetting(SettingEntity("day_start_value", dayStartValue.toString()))
            dao.upsertSetting(SettingEntity("day_peak_value", dayPeakValue.toString()))
            dao.upsertSetting(SettingEntity("day_max_drawdown", "0.0"))
        }
        dailyTrackingLoaded = true
    }

    private suspend fun recordPerformanceSnapshots(totalValue: Double, krwBalance: Double, coinValue: Double) {
        ensureDailyTrackingLoaded(totalValue)
        val now = System.currentTimeMillis()
        if (now - lastBalanceSnapshotAt >= BALANCE_SNAPSHOT_INTERVAL_MS) {
            dao.upsertBalance(BalanceSnapshotEntity(time = now, totalValue = totalValue, krw = krwBalance, coinValue = coinValue))
            lastBalanceSnapshotAt = now
        }
        val today = currentKstDate()
        if (today != currentTradingDate) {
            currentTradingDate = today
            dayStartValue = totalValue
            dayPeakValue = totalValue
            dayMaxDrawdownPercent = 0.0
            dao.upsertSetting(SettingEntity("day_start_date", today))
            dao.upsertSetting(SettingEntity("day_start_value", dayStartValue.toString()))
        }
        if (totalValue > dayPeakValue) dayPeakValue = totalValue
        val currentDrawdown = if (dayPeakValue > 0.0) (totalValue / dayPeakValue - 1.0) * 100.0 else 0.0
        if (currentDrawdown < dayMaxDrawdownPercent) dayMaxDrawdownPercent = currentDrawdown
        dao.upsertSetting(SettingEntity("day_peak_value", dayPeakValue.toString()))
        dao.upsertSetting(SettingEntity("day_max_drawdown", dayMaxDrawdownPercent.toString()))
        val todayRealized = dao.todayRealizedPnl(
            java.time.LocalDate.now(java.time.ZoneId.of("Asia/Seoul")).atStartOfDay(java.time.ZoneId.of("Asia/Seoul")).toInstant().toEpochMilli()
        )
        dao.upsertDaily(
            DailyPerformanceEntity(
                yyyymmdd = currentTradingDate,
                startValue = dayStartValue,
                endValue = totalValue,
                realizedPnl = todayRealized,
                maxDrawdown = dayMaxDrawdownPercent
            )
        )
    }

    private suspend fun updatePaperDashboard(tickers: Map<String, TickerModel>) {
        paperCash = reconcilePaperCash(paperCash)
        savePaperCash()
        val positions = dao.currentPositions("PAPER").map {
            PositionModel(it.market, it.quantity, it.avgPrice, it.highestPrice, it.openedAt)
        }
        val coinValue = positions.sumOf { position ->
            position.quantity * (tickers[position.market]?.tradePrice ?: position.avgPrice)
        }
        val totalValue = (paperCash + coinValue).coerceAtLeast(0.0)
        val realized = dao.realizedPnl()
        val unrealized = positions.sumOf { position ->
            val price = tickers[position.market]?.tradePrice ?: position.avgPrice
            (price - position.avgPrice) * position.quantity
        }
        val overallPerformance = StrategyPerformanceEngine.fromTrades(dao.sellTradesByMode(settings.mode.name))
        val allTrades = dao.tradesByMode(settings.mode.name)
        val tradingCostLedgerBase = NetProfitAfterCostEngine.ledgerFromTrades(allTrades)
        val hourLedger = NetProfitAfterCostEngine.ledgerFromTrades(allTrades, windowMs = 3_600_000L)
        val tradingCostLedger = if (hourLedger.overtradingCostDrag) {
            tradingCostLedgerBase.copy(
                overtradingCostDrag = true,
                warning = "OVERTRADING_COST_DRAG",
                status = when (tradingCostLedgerBase.status) {
                    "INSUFFICIENT_DATA" -> "OVERTRADING"
                    else -> tradingCostLedgerBase.status
                }
            )
        } else tradingCostLedgerBase
        recordPerformanceSnapshots(totalValue, paperCash, coinValue)
        val profitProtection = ProfitProtectionEngine.evaluate(
            start = dayStartValue,
            peak = dayPeakValue,
            current = totalValue,
            settings = ProfitProtectionSettings(
                settings.profitProtectionLevel1Percent,
                settings.profitProtectionLevel2Percent,
                settings.profitProtectionLevel3Percent,
                settings.profitProtectionLevel4Percent,
                settings.profitCautionGivebackPercentPoints,
                settings.profitDefenseGivebackPercentPoints,
                settings.profitLockedGivebackPercentPoints,
                settings.profitCautionMultiplier,
                settings.profitDefenseMultiplier,
                settings.profitLockedEntryAllowed
            ),
            health = _state.value.marketHealth,
            consecutiveLosses = consecutiveLossCount
        )
        val profitVelocity = ProfitVelocityEngine.evaluate(dao.recentBalanceSnapshots())
        val todayPnl = totalValue - dayStartValue
        val dailyLossLocked = risk.dailyLossLocked(settings, dayStartValue, totalValue)
        val lossAutopsy = PaperLossAutopsyEngine.analyze(
            trades = allTrades,
            initialCapital = settings.paperInitialKrw,
            cash = paperCash,
            positionValue = coinValue,
            realizedPnlReported = realized,
            unrealizedPnl = unrealized,
            exchange = ExchangeId.BITHUMB.name,
            peakEquityOverride = dayPeakValue
        )
        val paperRisk = PaperRiskEngine.evaluate(
            mode = settings.mode,
            lossStreak = consecutiveLossCount,
            dailyLossLocked = dailyLossLocked,
            recentStats = overallPerformance,
            health = _state.value.marketHealth,
            regime = _state.value.currentRegime,
            autopsyHint = lossAutopsy.protectionHint,
            autopsySampleTooSmall = lossAutopsy.sampleTooSmall
        )
        val capitalGrowth = AutonomousCapitalGrowthEngine.evaluate(
            currentEquity = totalValue,
            availableCash = paperCash,
            dayStartEquity = dayStartValue,
            equityPeak = dayPeakValue,
            recentStats = overallPerformance,
            marketRegime = _state.value.currentRegime,
            marketHealth = _state.value.marketHealth,
            tailRisk = _state.value.tailRisk,
            portfolioHeat = _state.value.portfolioHeat,
            profitProtection = profitProtection,
            consecutiveLosses = consecutiveLossCount,
            settings = AutonomousCapitalSettings(
                baseRiskPercent = settings.baseRiskPercent,
                normalReinvestmentRatio = settings.normalReinvestmentRatio,
                growthReinvestmentRatio = settings.growthReinvestmentRatio,
                cautionReinvestmentRatio = settings.cautionReinvestmentRatio,
                defenseReinvestmentRatio = settings.defenseReinvestmentRatio,
                reserveLockProfitPercent = settings.reserveLockProfitPercent,
                reserveAccumulationRatio = settings.reserveAccumulationRatio,
                dynamicCashBufferEnabled = settings.dynamicCashBufferEnabled
            )
        )
        val capitalAllocations = CapitalStrategyAllocator.recommend(
            regime = _state.value.currentRegime,
            capitalState = capitalGrowth.capitalState,
            shadowPortfolios = _state.value.shadowPortfolios
        )
        val counterfactualCapital = CounterfactualCapitalLabEngine.simulate(
            tradePnlRates = dao.sellTradesByMode("PAPER").map { it.pnlRate },
            initialCapital = settings.paperInitialKrw
        )
        dao.upsertCapitalGrowthSnapshot(
            CapitalGrowthSnapshotEntity(
                time = System.currentTimeMillis(),
                totalEquity = capitalGrowth.totalEquity,
                peakEquity = capitalGrowth.peakEquity,
                protectedReserve = capitalGrowth.protectedReserve,
                tradingCapital = capitalGrowth.tradingCapital,
                capitalState = capitalGrowth.capitalState.name,
                growthConfidence = capitalGrowth.growthConfidence,
                riskBudgetKrw = capitalGrowth.riskBudgetKrw,
                positionSizeMultiplier = capitalGrowth.positionSizeMultiplier,
                reinvestmentRatio = capitalGrowth.reinvestmentRatio,
                drawdownFromPeakPercent = capitalGrowth.drawdownFromPeakPercent,
                ruinRisk = capitalGrowth.ruinRisk.name,
                tradingActive = capitalGrowth.tradingActive,
                reason = capitalGrowth.reason
            )
        )
        _state.value = _state.value.copy(
            totalValue = totalValue,
            krwBalance = paperCash,
            coinValue = coinValue,
            todayPnl = todayPnl,
            todayPnlRate = if (dayStartValue > 0) todayPnl / dayStartValue * 100.0 else 0.0,
            cumulativePnl = realized + unrealized,
            cumulativePnlRate = if (settings.paperInitialKrw > 0) (realized + unrealized) / settings.paperInitialKrw * 100.0 else 0.0,
            realizedPnl = realized,
            unrealizedPnl = unrealized,
            holdingCount = positions.size,
            dailyLossLocked = dailyLossLocked,
            overallPerformance = overallPerformance,
            tradingCostLedger = tradingCostLedger,
            paperLossAutopsy = lossAutopsy,
            profitProtection = profitProtection,
            profitVelocity = profitVelocity,
            paperRisk = paperRisk,
            capitalGrowth = capitalGrowth,
            capitalStrategyAllocations = capitalAllocations,
            counterfactualCapital = counterfactualCapital
        )
    }

    private fun effectiveSettings(): TradingSettings {
        val recommended = activeRecommendation
            ?.takeIf { it.stage == RecommendationStage.PAPER_TRIAL }
            ?.let { it.proposal.apply(settings) }
            ?: settings
        val testAdjusted = if (!recommended.testMode) recommended else recommended.copy(
            scoreThreshold = 0.0,
            min24hTradePrice = 0.0,
            maxSpreadPercent = 5.0,
            minKrwCashPercent = 0.0,
            dynamicLiquidityEnabled = false,
            maxPositions = 10
        )
        // 다이내믹 익스포저 컨트롤 / 비상 포지션 보호: 테스트 모드 여부와 무관하게 항상 적용되는
        // 시장 건강도 기반 안전장치. 기존 한도를 절대 넘지 않고 축소하는 방향으로만 작동한다.
        val health = _state.value.marketHealth
        val exposureFactor = ExposureControlEngine.exposureFactor(health.score)
        var protected = testAdjusted.copy(
            maxPositions = maxOf(1, (testAdjusted.maxPositions * exposureFactor).toInt()),
            maxOrderPercent = testAdjusted.maxOrderPercent * exposureFactor,
            maxAssetPercentPerCoin = testAdjusted.maxAssetPercentPerCoin * exposureFactor
        )
        if (health.level == MarketHealthLevel.CRASH) {
            protected = protected.copy(
                stopLossPercent = (protected.stopLossPercent * 0.6).coerceIn(-80.0, -0.1),
                trailingStopPercent = (protected.trailingStopPercent * 0.5).coerceAtLeast(0.3)
            )
        }
        val heatMult = _state.value.portfolioHeat.multiplier
        if (heatMult < 1.0) {
            protected = protected.copy(
                maxOrderPercent = protected.maxOrderPercent * heatMult,
                maxAssetPercentPerCoin = protected.maxAssetPercentPerCoin * heatMult
            )
        }
        val ddMult = DrawdownRecoveryController.exposureMultiplier(_state.value.profitProtection.drawdownFromPeakPercent)
        if (ddMult < 1.0) {
            protected = protected.copy(
                maxOrderPercent = protected.maxOrderPercent * ddMult,
                maxAssetPercentPerCoin = protected.maxAssetPercentPerCoin * ddMult
            )
        }
        if (_state.value.mode == TradeMode.PAPER && _state.value.paperRisk.thresholdOffset > 0.0) {
            protected = protected.copy(
                scoreThreshold = (protected.scoreThreshold + _state.value.paperRisk.thresholdOffset).coerceIn(0.0, 100.0)
            )
        }
        // 국면별 전략 파라미터 세트: PAPER만 자동 적용, LIVE는 추천만. Strategy Score 공식은 변경하지 않음.
        protected = RegimeStrategySetEngine.applyOverlay(
            base = protected,
            regime = _state.value.currentRegime,
            mode = _state.value.mode,
            enabled = settings.regimeStrategySetsEnabled,
            paperApplyEnabled = settings.regimeStrategySetsPaperApplyEnabled
        )
        return protected
    }

    // ---- 리스크 엔진(연속 손실 잠금 / 킬 스위치) --------------------------------------

    private suspend fun ensureRiskStateLoaded() {
        if (riskStateLoaded) return
        val settingsList = dao.settings()
        consecutiveLossCount = settingsList.firstOrNull { it.key == "consecutive_loss_count" }?.value?.toIntOrNull() ?: 0
        consecutiveLossLockEngaged = settingsList.firstOrNull { it.key == "consecutive_loss_lock" }?.value?.toBoolean() ?: false
        val killSwitchEngaged = settingsList.firstOrNull { it.key == "kill_switch_engaged" }?.value?.toBoolean() ?: false
        val killSwitchReason = settingsList.firstOrNull { it.key == "kill_switch_reason" }?.value.orEmpty()
        lastRecommendationCheckCount = settingsList.firstOrNull { it.key == "recommendation_check_count" }?.value?.toIntOrNull() ?: 0
        _state.value = _state.value.copy(
            consecutiveLossCount = consecutiveLossCount,
            consecutiveLossLocked = consecutiveLossLockEngaged,
            killSwitchEngaged = killSwitchEngaged,
            killSwitchReason = killSwitchReason
        )
        refreshRecommendationHistory()
        riskStateLoaded = true
    }

    private suspend fun recordTradeOutcomeForRisk(pnlRate: Double) {
        if (pnlRate > 0.0) {
            consecutiveLossCount = 0
        } else {
            consecutiveLossCount += 1
        }
        dao.upsertSetting(SettingEntity("consecutive_loss_count", consecutiveLossCount.toString()))
        if (!consecutiveLossLockEngaged && risk.consecutiveLossLocked(settings, consecutiveLossCount)) {
            consecutiveLossLockEngaged = true
            dao.upsertSetting(SettingEntity("consecutive_loss_lock", "true"))
            log("연속 손실 ${consecutiveLossCount}회 도달: 신규 매수 잠금 (수동 해제 필요)")
        }
        _state.value = _state.value.copy(
            consecutiveLossCount = consecutiveLossCount,
            consecutiveLossLocked = consecutiveLossLockEngaged
        )
    }

    suspend fun resetConsecutiveLossLock() {
        ensureRiskStateLoaded()
        consecutiveLossCount = 0
        consecutiveLossLockEngaged = false
        dao.upsertSetting(SettingEntity("consecutive_loss_count", "0"))
        dao.upsertSetting(SettingEntity("consecutive_loss_lock", "false"))
        _state.value = _state.value.copy(consecutiveLossCount = 0, consecutiveLossLocked = false)
        log("연속 손실 잠금 수동 해제")
    }

    suspend fun engageKillSwitch(reason: String) {
        ensureRiskStateLoaded()
        val safeReason = reason.ifBlank { "사용자 긴급 조치" }
        dao.upsertSetting(SettingEntity("kill_switch_engaged", "true"))
        dao.upsertSetting(SettingEntity("kill_switch_reason", safeReason))
        _state.value = _state.value.copy(killSwitchEngaged = true, killSwitchReason = safeReason)
        log("킬 스위치 발동: $safeReason — 신규 매수 차단, 보유 포지션 즉시 청산 시도")
        val positions = dao.currentPositions("PAPER")
        if (positions.isEmpty()) return
        val tickers = runCatching { marketData.ticker(positions.map { it.market }) }
            .getOrDefault(emptyList()).associateBy { it.market }
        positions.forEach { position ->
            val price = tickers[position.market]?.tradePrice?.takeIf { it > 0.0 } ?: position.avgPrice
            val model = PositionModel(position.market, position.quantity, position.avgPrice, position.highestPrice, position.openedAt)
            val order = paperExecution.sell(model, price, "KILL SWITCH")
            if (order.state == OrderState.FILLED.name) {
                paperCash += order.amount
                savePaperCash()
                val pnlRate = dao.latestSellTrade(position.market)?.pnlRate ?: 0.0
                resolveAiOutcome(position.market, pnlRate)
                log("${position.market} 킬 스위치 강제 청산 완료")
            } else {
                log("${position.market} 킬 스위치 청산 실패: ${order.state}")
            }
        }
        updatePaperDashboard(tickers)
    }

    suspend fun resetKillSwitch() {
        ensureRiskStateLoaded()
        dao.upsertSetting(SettingEntity("kill_switch_engaged", "false"))
        dao.upsertSetting(SettingEntity("kill_switch_reason", ""))
        _state.value = _state.value.copy(killSwitchEngaged = false, killSwitchReason = "")
        log("킬 스위치 해제, 신규 매수 재개 가능")
    }

    // ---- 전략 추천(성과 기반, 직접 변경 아님) → 백테스트 → Paper 검증 파이프라인 ------

    private suspend fun refreshRecommendationHistory() {
        val recent = dao.recentRecommendations(5).map {
            RecommendationHistoryItem(
                id = it.id,
                createdAt = it.createdAt,
                stage = it.stage,
                reason = it.reason,
                baselineWinRate = it.baselineWinRate,
                baselineProfitFactor = it.baselineProfitFactor,
                backtestWinRate = it.backtestWinRate,
                backtestProfitFactor = it.backtestProfitFactor,
                paperTrialWinRate = it.paperTrialWinRate,
                paperTrialProfitFactor = it.paperTrialProfitFactor
            )
        }
        _state.value = _state.value.copy(recommendationHistory = recent)
    }

    private suspend fun persistRecommendationAudit(state: RecommendationRuntimeState, resolvedAt: Long?) {
        dao.upsertRecommendation(
            StrategyRecommendationEntity(
                id = state.id,
                createdAt = state.createdAt,
                stage = state.stage.name,
                reason = state.reason,
                scoreThresholdDelta = state.proposal.scoreThresholdDelta,
                stopLossDelta = state.proposal.stopLossDelta,
                takeProfitDelta = state.proposal.takeProfitDelta,
                trailingStopDelta = state.proposal.trailingStopDelta,
                maxPositionsDelta = state.proposal.maxPositionsDelta,
                baselineSampleCount = state.baseline.sampleCount,
                baselineWinRate = state.baseline.winRate,
                baselineProfitFactor = state.baseline.profitFactor,
                baselineMaxDrawdown = state.baseline.maxDrawdownPercent,
                baselineExpectedReturn = state.baseline.expectedReturnPercent,
                backtestSampleCount = state.backtest?.sampleCount ?: 0,
                backtestWinRate = state.backtest?.winRate ?: 0.0,
                backtestProfitFactor = state.backtest?.profitFactor ?: 0.0,
                paperTrialSampleCount = state.paperTrial?.sampleCount ?: 0,
                paperTrialWinRate = state.paperTrial?.winRate ?: 0.0,
                paperTrialProfitFactor = state.paperTrial?.profitFactor ?: 0.0,
                resolvedAt = resolvedAt
            )
        )
        refreshRecommendationHistory()
    }

    /** 과거에 실제로 발생한 Paper 매수 이벤트(ai_training_samples)에 후보 Score 임계값을 재적용한 경량 백테스트 */
    private suspend fun backtestCandidate(proposal: RecommendationProposal): PerformanceStats {
        val candidateThreshold = (settings.scoreThreshold + proposal.scoreThresholdDelta).coerceIn(0.0, 100.0)
        val resolved = dao.resolvedAiSamples()
        val matching = resolved.filter { it.strategyScore >= candidateThreshold }
        return StrategyPerformanceEngine.fromPnlRates(matching.mapNotNull { it.realizedPnlRate })
    }

    private suspend fun evaluateRecommendationPipeline(resolvedCount: Int) {
        val current = activeRecommendation
        if (current != null && current.stage == RecommendationStage.PAPER_TRIAL) {
            val trialSamples = dao.resolvedAiSamples().filter { it.time >= current.trialStartedAt }
            if (trialSamples.size != current.trialSamplesCollected) {
                activeRecommendation = current.copy(trialSamplesCollected = trialSamples.size)
                _state.value = _state.value.copy(activeRecommendation = activeRecommendation)
            }
            if (trialSamples.size < current.trialSamplesRequired) return
            val trialStats = StrategyPerformanceEngine.fromPnlRates(trialSamples.mapNotNull { it.realizedPnlRate })
            val improved = StrategyRecommendationEngine.isImprovement(trialStats, current.baseline, StrategyRecommendationEngine.MIN_VALIDATION_SAMPLES)
            val resolved = current.copy(
                stage = if (improved) RecommendationStage.AWAITING_LIVE_VALIDATION else RecommendationStage.PAPER_TRIAL_REJECTED,
                paperTrial = trialStats
            )
            activeRecommendation = resolved
            _state.value = _state.value.copy(activeRecommendation = resolved)
            persistRecommendationAudit(resolved, resolvedAt = System.currentTimeMillis())
            if (improved) {
                log("AI 전략 추천 Paper 검증 통과: ${resolved.reason} — 소액 Live 검증 대기 (이 앱은 Live 미연결, 수동 검토 필요)")
            } else {
                log("AI 전략 추천 Paper 검증 기각: ${resolved.reason} (표본 ${trialStats.sampleCount}건, 기존 설정 유지)")
            }
            return
        }
        if (current != null && current.stage !in setOf(
                RecommendationStage.NONE,
                RecommendationStage.BACKTEST_REJECTED,
                RecommendationStage.PAPER_TRIAL_REJECTED,
                RecommendationStage.AWAITING_LIVE_VALIDATION,
                RecommendationStage.LIVE_VALIDATION_REJECTED
            )
        ) return
        if (resolvedCount - lastRecommendationCheckCount < AiRetrainPolicy.RETRAIN_TRIGGER_COUNT) return
        lastRecommendationCheckCount = resolvedCount
        dao.upsertSetting(SettingEntity("recommendation_check_count", resolvedCount.toString()))

        val recentPnlRates = dao.resolvedAiSamples().takeLast(60).mapNotNull { it.realizedPnlRate }
        val baseline = StrategyPerformanceEngine.fromPnlRates(recentPnlRates)
        _state.value = _state.value.copy(baselinePerformance = baseline)
        val rawProposal = StrategyRecommendationEngine.propose(baseline)
        if (rawProposal == null) {
            activeRecommendation = null
            _state.value = _state.value.copy(activeRecommendation = null)
            return
        }
        val regime = _state.value.currentRegime.regime
        val proposal = if (regime != MarketRegime.UNKNOWN) rawProposal.copy(reason = rawProposal.reason + " (최근 시장국면: ${regime.name})") else rawProposal
        val backtestStats = backtestCandidate(proposal)
        val backtestPass = StrategyRecommendationEngine.isImprovement(backtestStats, baseline, StrategyRecommendationEngine.MIN_VALIDATION_SAMPLES)
        val id = java.util.UUID.randomUUID().toString()
        if (!backtestPass) {
            val rejected = RecommendationRuntimeState(id, RecommendationStage.BACKTEST_REJECTED, proposal.reason, proposal, baseline, backtestStats)
            activeRecommendation = rejected
            _state.value = _state.value.copy(activeRecommendation = rejected)
            persistRecommendationAudit(rejected, resolvedAt = System.currentTimeMillis())
            log("AI 전략 추천 기각(백테스트): ${proposal.reason} / 백테스트 표본 ${backtestStats.sampleCount}건")
            return
        }
        val trial = RecommendationRuntimeState(
            id = id,
            stage = RecommendationStage.PAPER_TRIAL,
            reason = proposal.reason,
            proposal = proposal,
            baseline = baseline,
            backtest = backtestStats,
            trialStartedAt = System.currentTimeMillis()
        )
        activeRecommendation = trial
        _state.value = _state.value.copy(activeRecommendation = trial)
        persistRecommendationAudit(trial, resolvedAt = null)
        log("AI 전략 추천 백테스트 통과, Paper 검증 시작: ${proposal.reason}")
    }

    // ---- 시장 국면 분류(상승/횡보/하락) — 이미 받아오던 티커 등락률만 재활용 -------------

    private fun updateMarketRegime(tickers: Map<String, TickerModel>) {
        tickers.values.forEach { recentChangeRates.addLast(it.signedChangeRate) }
        while (recentChangeRates.size > REGIME_WINDOW_SIZE) recentChangeRates.removeFirst()
        val short = recentChangeRates.takeLast(20)
        val mid = recentChangeRates.takeLast(60)
        val long = recentChangeRates.toList()
        val raw = MarketRegimeClassifier.classifyMultiTimeframe(short, mid, long, _state.value.marketHealth.score, regimeHysteresis.current())
        val previous = regimeHysteresis.current()
        val snapshot = regimeHysteresis.update(raw)
        if (regimeInitialized && previous != snapshot.regime) pendingRegimeTransition = previous to snapshot.regime
        regimeInitialized = true
        _state.value = _state.value.copy(currentRegime = snapshot)
    }

    private suspend fun persistMarketRegimeIfDue() {
        val snapshot = _state.value.currentRegime
        if (snapshot.sampleCount == 0) return
        val now = System.currentTimeMillis()
        if (now - lastRegimePersistAt < REGIME_SNAPSHOT_INTERVAL_MS) return
        lastRegimePersistAt = now
        dao.upsertRegimeSnapshot(
            MarketRegimeEntity(
                time = now,
                regime = snapshot.regime.name,
                breadthPositive = snapshot.breadthPositive,
                averageChangeRatePercent = snapshot.averageChangeRatePercent,
                sampleCount = snapshot.sampleCount,
                confidence = snapshot.confidence,
                trendStrength = snapshot.trendStrength,
                volatilityLevel = snapshot.volatilityLevel,
                shortRegime = snapshot.shortRegime.name,
                midRegime = snapshot.midRegime.name,
                longRegime = snapshot.longRegime.name,
                durationMinutes = snapshot.durationMinutes
            )
        )
        pendingRegimeTransition?.let { (from, to) ->
            dao.insertRegimeTransition(
                RegimeTransitionEntity(
                    fromRegime = from.name,
                    toRegime = to.name,
                    time = now,
                    confidence = snapshot.confidence,
                    healthScore = _state.value.marketHealth.score
                )
            )
            pendingRegimeTransition = null
            log("국면 전환 확정: ${from.name} → ${to.name} (신뢰도 ${(snapshot.confidence * 100).toInt()}%)")
        }
        val transitions = dao.recentRegimeTransitions().map { RegimeTransitionView(it.fromRegime, it.toRegime, it.time, it.confidence) }
        _state.value = _state.value.copy(recentRegimeTransitions = transitions)
    }

    /** 통계 탭 표시용: 국면별로 나눈 Paper 매수 결과 성과 (전략을 바꾸지 않는 참고 정보) */
    private suspend fun refreshRegimePerformance() {
        val items = MarketRegime.values().filter { it != MarketRegime.UNKNOWN }.mapNotNull { regime ->
            val samples = dao.resolvedAiSamplesByRegime(regime.name)
            if (samples.isEmpty()) null
            else RegimePerformanceItem(regime.name, StrategyPerformanceEngine.fromPnlRates(samples.mapNotNull { it.realizedPnlRate }))
        }
        _state.value = _state.value.copy(regimePerformance = items)
    }

    // ---- 이상 징후 자체 진단 — 이미 쌓이는 app_events 로그만 재사용 -------------------

    private suspend fun checkAnomalies() {
        val since = System.currentTimeMillis() - 10 * 60 * 1000L
        val recentMessages = dao.recentEventMessagesSince(since)
        val detected = AnomalyDetector.detect(recentMessages, _state.value)
        _state.value = _state.value.copy(anomalies = detected)
        val newlyDetected = detected.toSet() - knownAnomalies
        newlyDetected.forEach { log("이상 징후 감지: $it") }
        knownAnomalies = detected.toSet()
    }

    // ---- Crash Detection Engine — 신규 진입 보호 / 비상 포지션 보호 / 쿨다운 / Crash Replay --
    // Market Health Score는 이미 계산된 국면(MarketRegimeClassifier)과 이상 징후
    // (AnomalyDetector) 결과만 재사용한다. 킬 스위치(수동, 전량 강제청산)와는 달리 이 엔진은
    // 자동으로 "신규 매수만" 막고 기존 포지션은 손절/트레일링스탑을 타이트닝해 기존
    // RiskManager.shouldSell 경로로 더 빨리 정리되게 유도할 뿐, 별도의 강제청산 로직을
    // 새로 만들지 않는다(킬 스위치와 중복 없음).

    private suspend fun ensureCrashStateLoaded() {
        if (crashStateLoaded) return
        val settingsList = dao.settings()
        currentCrashEventId = settingsList.firstOrNull { it.key == "crash_event_id" }?.value?.takeIf { it.isNotBlank() }
        crashProtectionEngaged = settingsList.firstOrNull { it.key == "crash_protection_engaged" }?.value?.toBoolean() ?: false
        cooldownUntil = settingsList.firstOrNull { it.key == "cooldown_until" }?.value?.toLongOrNull() ?: 0L
        lastHealthLevel = settingsList.firstOrNull { it.key == "last_health_level" }?.value
            ?.let { runCatching { MarketHealthLevel.valueOf(it) }.getOrNull() } ?: MarketHealthLevel.HEALTHY
        val savedRegime = settingsList.firstOrNull { it.key == "stable_regime" }?.value
            ?.let { runCatching { MarketRegime.valueOf(it) }.getOrNull() }
        if (savedRegime != null) {
            regimeHysteresis.restore(savedRegime, settingsList.firstOrNull { it.key == "stable_regime_since" }?.value?.toLongOrNull() ?: 0L)
            regimeInitialized = true
        }
        _state.value = _state.value.copy(
            crashProtectionEngaged = crashProtectionEngaged,
            cooldownUntil = cooldownUntil,
            cooldownActive = System.currentTimeMillis() < cooldownUntil
        )
        refreshCrashHistory()
        crashStateLoaded = true
    }

    private suspend fun persistCrashFlags() {
        dao.upsertSetting(SettingEntity("crash_event_id", currentCrashEventId.orEmpty()))
        dao.upsertSetting(SettingEntity("crash_protection_engaged", crashProtectionEngaged.toString()))
        dao.upsertSetting(SettingEntity("cooldown_until", cooldownUntil.toString()))
        dao.upsertSetting(SettingEntity("last_health_level", lastHealthLevel.name))
        dao.upsertSetting(SettingEntity("stable_regime", regimeHysteresis.current().name))
        dao.upsertSetting(SettingEntity("stable_regime_since", (_state.value.currentRegime.computedAt - _state.value.currentRegime.durationMinutes * 60_000L).toString()))
    }

    private suspend fun refreshCrashHistory() {
        val recent = dao.recentCrashEvents().map {
            CrashHistoryItem(it.id, it.startedAt, it.endedAt, it.minHealthScore, it.regimeAtStart, it.reasons, it.totalValueAtStart, it.resolvedTotalValue)
        }
        _state.value = _state.value.copy(crashHistory = recent)
    }

    private suspend fun evaluateMarketHealth() {
        ensureCrashStateLoaded()
        val regime = _state.value.currentRegime
        val tickerStale = _state.value.lastTickerAt > 0 &&
            System.currentTimeMillis() - _state.value.lastTickerAt > settings.staleTickerMillis * 3
        val apiError = _state.value.apiStatus == ConnectionStatus.ERROR
        val health = MarketHealthEngine.evaluate(regime, _state.value.anomalies.size, tickerStale, apiError)
        _state.value = _state.value.copy(marketHealth = health)

        if (health.level == MarketHealthLevel.CRASH && lastHealthLevel != MarketHealthLevel.CRASH) {
            val id = java.util.UUID.randomUUID().toString()
            currentCrashEventId = id
            crashProtectionEngaged = true
            crashMinHealthScore = health.score
            dao.upsertCrashEvent(
                CrashEventEntity(
                    id = id,
                    startedAt = System.currentTimeMillis(),
                    minHealthScore = health.score,
                    regimeAtStart = regime.regime.name,
                    averageChangeRatePercent = regime.averageChangeRatePercent,
                    breadthPositive = regime.breadthPositive,
                    reasons = health.reasons.joinToString(" / "),
                    holdingCountAtStart = _state.value.holdingCount,
                    totalValueAtStart = _state.value.totalValue
                )
            )
            log("Crash Detection: 시장 급락 감지(Health ${health.score.toInt()}) — 신규 매수 차단, 기존 포지션 손절/트레일링스탑 강화: ${health.reasons.joinToString(", ")}")
            refreshCrashHistory()
        } else if (health.level == MarketHealthLevel.CRASH) {
            crashMinHealthScore = minOf(crashMinHealthScore, health.score)
        } else if (health.level != MarketHealthLevel.CRASH && lastHealthLevel == MarketHealthLevel.CRASH) {
            crashProtectionEngaged = false
            cooldownUntil = System.currentTimeMillis() + settings.crashCooldownMinutes * 60_000L
            val id = currentCrashEventId
            if (id != null) {
                dao.finalizeCrashEvent(id, System.currentTimeMillis(), crashMinHealthScore, _state.value.totalValue)
            }
            currentCrashEventId = null
            log("Crash Detection: 시장 안정화 감지 — ${settings.crashCooldownMinutes}분 쿨다운 모드 진입(신규 매수 일시 차단)")
            refreshCrashHistory()
        }
        lastHealthLevel = health.level
        persistCrashFlags()
        _state.value = _state.value.copy(
            crashProtectionEngaged = crashProtectionEngaged,
            cooldownUntil = cooldownUntil,
            cooldownActive = System.currentTimeMillis() < cooldownUntil
        )
    }

    private fun volumeChangePercent(ticker: TickerModel): Double {
        val previous = previousTickerVolumes.put(ticker.market, ticker.tradeVolume)
        val change = if (previous != null && previous > 0.0) (ticker.tradeVolume / previous - 1.0) * 100.0 else 0.0
        return change
    }

    private suspend fun recordEntryDiagnostic(signal: StrategySignalModel) {
        dao.upsertEntryDiagnostic(
            EntryDiagnosticEntity(
                market = signal.market,
                time = signal.timestamp,
                strategyScore = signal.score,
                aiScore = signal.aiScore,
                entryTimingScore = signal.entryTimingScore,
                chaseScore = signal.chaseEntryScore,
                state = signal.entryTimingState,
                pullbackState = signal.pullbackState,
                retestState = signal.breakoutRetestState,
                overextensionAtr = signal.overextensionAtr,
                overextensionScore = signal.overextensionScore,
                parabolicMove = signal.parabolicMove,
                momentumExhaustion = signal.momentumExhaustion,
                volumeClimax = signal.volumeClimax,
                scoreVelocityPerMinute = signal.scoreVelocityPerMinute,
                signalLagMs = signal.signalLagMs,
                preEntry1mReturn = signal.preEntry1mReturn,
                preEntry3mReturn = signal.preEntry3mReturn,
                preEntry5mReturn = signal.preEntry5mReturn,
                preEntry10mReturn = signal.preEntry10mReturn,
                preEntry15mReturn = signal.preEntry15mReturn,
                classification = signal.entryQualityClassification,
                decision = signal.status.name,
                reason = signal.failureReason.ifBlank { signal.reason }
            )
        )
    }

    private suspend fun refreshEntryTimingResearch() {
        _state.value = _state.value.copy(
            entryTimingResearch = EntryTimingAnalytics.summarize(
                dao.recentEntryDiagnostics(),
                settings.highScoreFailureWarningRate
            )
        )
    }

    private suspend fun persistSmartReentryStates() {
        smartReentryCoordinator.snapshot().forEach { dao.upsertSmartReentryState(it.toEntity()) }
    }

    private suspend fun recordSmartReentryAttempts(signals: List<StrategySignalModel>) {
        signals.forEach { signal ->
            val state = smartReentryCoordinator.get(signal.market) ?: return@forEach
            val anchor = state.anchor ?: return@forEach
            val priceVsExit = if (anchor.exitPrice > 0.0) (signal.currentPrice / anchor.exitPrice - 1.0) * 100.0 else 0.0
            dao.upsertSmartReentryAttempt(
                SmartReentryAttemptEntity(
                    market = signal.market,
                    time = signal.timestamp,
                    signalId = signal.signalId,
                    previousExitPrice = anchor.exitPrice,
                    previousExitTime = anchor.exitTime,
                    signalPrice = signal.currentPrice,
                    priceVsExitPercent = priceVsExit,
                    reentryQualityScore = state.reentryQualityScore,
                    sameWaveProbability = state.sameWaveProbability,
                    newWaveConfidence = state.newWaveConfidence,
                    state = state.waveState.name,
                    decision = signal.status.name,
                    reasonCodes = signal.failureReason.ifBlank { "REENTRY_NOT_REQUIRED" },
                    previousProfit = anchor.realizedProfit,
                    isReentry = true
                )
            )
        }
    }

    private suspend fun refreshSmartReentryResearch() {
        val states = smartReentryCoordinator.snapshot()
        val attempts = dao.recentSmartReentryAttempts()
        val trades = dao.tradesByMode("PAPER")
        _state.value = _state.value.copy(
            smartReentryResearch = SmartReentryAnalytics.summarize(attempts, states, trades)
        )
    }

    private suspend fun recordScalpingDiagnostic(signal: StrategySignalModel) {
        dao.upsertScalpingDiagnostic(
            ScalpingExecutionDiagnosticEntity(
                market = signal.market,
                time = signal.timestamp,
                entryPrice = signal.currentPrice,
                strategyScore = signal.score,
                aiScore = signal.aiScore,
                entryTimingScore = signal.entryTimingScore,
                chaseScore = signal.chaseEntryScore,
                executionScore = signal.scalpExecutionScore,
                executionConfidence = signal.scalpExecutionConfidence,
                state = signal.scalpExecutionState,
                marketState = signal.scalpMarketState,
                momentumState = signal.microMomentumState,
                volatilityRegime = signal.microVolatilityRegime,
                return30s = signal.microReturn30s,
                return1m = signal.microReturn1m,
                return3m = signal.microReturn3m,
                return5m = signal.microReturn5m,
                volume10s = signal.microVolume10s,
                volume30s = signal.microVolume30s,
                volume1m = signal.microVolume1m,
                volumeAcceleration = signal.microVolumeAcceleration,
                momentum = signal.microMomentum,
                momentumSlope = signal.microMomentumSlope,
                momentumAcceleration = signal.microMomentumAcceleration,
                rsi = signal.microRsi,
                emaDistancePercent = signal.microEmaDistancePercent,
                breakoutDistancePercent = signal.microBreakoutDistancePercent,
                pullbackState = signal.pullbackState,
                retestState = signal.breakoutRetestState,
                marketRegime = _state.value.currentRegime.regime.name,
                marketHealth = _state.value.marketHealth.score,
                spreadPercent = signal.microSpreadPercent,
                orderbookImbalance = signal.microOrderbookImbalance,
                depthChangePercent = signal.microDepthChangePercent,
                shortNetEdge = signal.shortHorizonNetEdge,
                recommendedHorizonSeconds = signal.recommendedScalpHorizonSeconds,
                executionCostPercent = signal.expectedExecutionCost,
                decision = signal.status.name,
                reasonCodes = signal.scalpReasonCodes.joinToString(",")
            )
        )
    }

    private suspend fun refreshScalpingResearch(tickers: Map<String, TickerModel>, signals: List<StrategySignalModel>) {
        val now = System.currentTimeMillis()
        dao.recentScalpingDiagnostics().filter { now - it.time <= 6 * 60 * 60 * 1000L }.forEach { row ->
            val ticker = tickers[row.market] ?: return@forEach
            if (row.entryPrice <= 0.0) return@forEach
            val change = (ticker.tradePrice / row.entryPrice - 1.0) * 100.0
            val elapsed = now - row.time
            val outcome = if (elapsed >= 5 * 60_000L) {
                if (row.tradeId != null) {
                    if (change <= -2.0) "FALSE_ENTRY" else "GOOD_ENTRY"
                } else {
                    when {
                        change >= 3.0 -> "FALSE_REJECT"
                        change <= -2.0 -> "GOOD_REJECT"
                        else -> null
                    }
                }
            } else row.outcome
            dao.upsertScalpingDiagnostic(
                row.copy(
                    return30s = if (elapsed >= 30_000L) change else row.return30s,
                    return1m = if (elapsed >= 60_000L) change else row.return1m,
                    return3m = if (elapsed >= 3 * 60_000L) change else row.return3m,
                    return5m = if (elapsed >= 5 * 60_000L) change else row.return5m,
                    return15m = if (elapsed >= 15 * 60_000L) change else row.return15m,
                    first5mReturn = if (elapsed >= 5 * 60_000L) change else row.first5mReturn,
                    mfePercent = maxOf(row.mfePercent ?: change, change),
                    maePercent = minOf(row.maePercent ?: change, change),
                    outcome = outcome
                )
            )
        }
        val account = dao.scalpingShadow() ?: ScalpingShadowEngine.initial(settings.paperInitialKrw)
        val shadowSignal = account.positionMarket?.let { market ->
            signals.firstOrNull { it.market == market }
        } ?: signals.filter { it.currentPrice > 0.0 }.maxByOrNull { it.scalpExecutionScore }
        val updatedAccount = ScalpingShadowEngine.step(account, shadowSignal, now)
        dao.upsertScalpingShadow(updatedAccount)
        val rows = dao.recentScalpingDiagnostics()
        val stats = ScalpingResearchAnalytics.summarize(rows, updatedAccount)
        val rates = rows.filter { it.tradeId != null }.mapNotNull { it.first5mReturn ?: it.netPnl }
        val performance = StrategyPerformanceEngine.fromPnlRates(rates)
        dao.upsertScalpingModel(
            ScalpingExecutionModelEntity(
                modelVersion = stats.modelVersion,
                status = stats.modelStatus.name,
                sampleCount = rates.size,
                validationProfitFactor = performance.profitFactor,
                validationExpectancy = performance.expectedReturnPercent,
                validationMdd = performance.maxDrawdownPercent,
                reason = ScalpingValidationPolicy.validate(
                    rates.size,
                    performance.winRate,
                    performance.profitFactor,
                    performance.expectedReturnPercent,
                    performance.maxDrawdownPercent
                ).reason
            )
        )
        _state.value = _state.value.copy(scalpingResearch = stats)
    }

    private suspend fun recordPostEntryTracker(
        candidate: StrategySignalModel,
        buyTradeId: String,
        position: PositionEntity
    ) {
        dao.upsertPostEntryTracker(
            PostEntryTrackerEntity(
                buyTradeId = buyTradeId,
                market = candidate.market,
                entryTime = System.currentTimeMillis(),
                entryPrice = position.avgPrice,
                strategyScore = candidate.score,
                marketHealthAtEntry = _state.value.marketHealth.score,
                regimeAtEntry = _state.value.currentRegime.regime.name,
                strategyVersion = strategyVersion,
                volumeChangePercent = candidate.volumeChangePercent
            )
        )
        log("${candidate.market} 진입 품질 추적 시작: ${position.avgPrice}")
    }

    private suspend fun recordPostEntrySnapshots(tickers: Map<String, TickerModel>) {
        val trackers = dao.postEntryTrackers()
        if (trackers.isEmpty()) return
        val existing = dao.allPostEntrySnapshots()
        val horizons = EntryQualityHorizon.values()
        trackers.forEach trackerLoop@{ tracker ->
            val ticker = tickers[tracker.market] ?: return@trackerLoop
            val elapsedMinutes = ((System.currentTimeMillis() - tracker.entryTime).coerceAtLeast(0L) / 60_000L).toInt()
            val previous = existing.filter { it.buyTradeId == tracker.buyTradeId }
            horizons.filter { it.minutes <= elapsedMinutes }.forEach horizonLoop@{ horizon ->
                if (dao.postEntrySnapshotExists(tracker.buyTradeId, horizon.minutes) > 0) return@horizonLoop
                val prices = previous.map { it.currentPrice } + ticker.tradePrice + tracker.entryPrice
                val snapshot = PostEntrySnapshotEntity(
                    buyTradeId = tracker.buyTradeId,
                    market = tracker.market,
                    capturedAt = System.currentTimeMillis(),
                    horizonMinutes = horizon.minutes,
                    entryPrice = tracker.entryPrice,
                    currentPrice = ticker.tradePrice,
                    changePercent = if (tracker.entryPrice > 0.0) (ticker.tradePrice / tracker.entryPrice - 1.0) * 100.0 else 0.0,
                    mfePercent = PostEntryQualityEngine.mfe(tracker.entryPrice, prices.maxOrNull() ?: tracker.entryPrice),
                    maePercent = PostEntryQualityEngine.mae(tracker.entryPrice, prices.minOrNull() ?: tracker.entryPrice),
                    strategyScore = tracker.strategyScore,
                    marketHealth = _state.value.marketHealth.score,
                    volumeChangePercent = volumeChangePercent(ticker),
                    marketRegime = _state.value.currentRegime.regime.name,
                    strategyVersion = tracker.strategyVersion
                )
                dao.upsertPostEntrySnapshot(snapshot)
                val diagnostic = dao.entryDiagnosticByTradeId(tracker.buyTradeId)
                if (diagnostic != null) {
                    dao.upsertEntryDiagnostic(
                        diagnostic.copy(
                            first5mReturn = if (horizon.minutes == 5) snapshot.changePercent else diagnostic.first5mReturn,
                            first15mReturn = if (horizon.minutes == 15) snapshot.changePercent else diagnostic.first15mReturn,
                            mfePercent = snapshot.mfePercent,
                            maePercent = snapshot.maePercent
                        )
                    )
                }
            }
        }
        _state.value = _state.value.copy(postEntryQuality = PostEntryQualityEngine.stats(dao.allPostEntrySnapshots()))
        refreshEntryTimingResearch()
    }

    private suspend fun recordPostExitSnapshots(tickers: Map<String, TickerModel>) {
        val trackers = dao.allPostExitTrackers()
        if (trackers.isEmpty()) return
        val horizons = PostExitHorizons.ALL
        val allExitSnapshots = dao.allPostExitSnapshots()

        trackers.forEach exitTrackerLoop@{ tracker ->
            val ticker = tickers[tracker.market] ?: return@exitTrackerLoop
            val elapsedMinutes = ((System.currentTimeMillis() - tracker.sellTime).coerceAtLeast(0L) / 60_000L).toInt()
            horizons.filter { it <= elapsedMinutes }.forEach horizonLoop@{ horizon ->
                if (dao.postExitSnapshotExists(tracker.sellTradeId, horizon) > 0) return@horizonLoop
                val changeFromExit = if (tracker.exitPrice > 0.0) (ticker.tradePrice / tracker.exitPrice - 1.0) * 100.0 else 0.0
                val priceFromEntry = if (tracker.entryPrice > 0.0) (ticker.tradePrice / tracker.entryPrice - 1.0) * 100.0 else 0.0
                dao.upsertPostExitSnapshot(
                    PostExitSnapshotEntity(
                        sellTradeId = tracker.sellTradeId,
                        market = tracker.market,
                        capturedAt = System.currentTimeMillis(),
                        horizonMinutes = horizon,
                        exitPrice = tracker.exitPrice,
                        currentPrice = ticker.tradePrice,
                        changeFromExitPercent = changeFromExit,
                        priceFromEntryPercent = priceFromEntry
                    )
                )
            }
        }

        refreshExitOptimizationState(tickers)
    }

    private suspend fun refreshExitOptimizationState(tickers: Map<String, TickerModel>) {
        val trackers = dao.allPostExitTrackers()
        val allExitSnapshots = dao.allPostExitSnapshots()
        val rootCauses = mutableListOf<LossRootCauseRecordEntity>()

        trackers.forEach { tracker ->
            val snapshots = allExitSnapshots.filter { it.sellTradeId == tracker.sellTradeId }
            val post30Exit = snapshots.firstOrNull { it.horizonMinutes == 30 }?.changeFromExitPercent
            val post60Exit = snapshots.firstOrNull { it.horizonMinutes == 60 }?.changeFromExitPercent
            val post30Entry = snapshots.firstOrNull { it.horizonMinutes == 30 }?.priceFromEntryPercent
            val post60Entry = snapshots.firstOrNull { it.horizonMinutes == 60 }?.priceFromEntryPercent

            val (rootCause, _) = LossRootCauseEngine.analyze(
                pnlRate = tracker.pnlRate,
                exitReason = tracker.exitReason,
                mfePercent = tracker.mfePercent,
                maePercent = tracker.maePercent,
                holdingMinutes = 15L,
                postExit30mChangeFromExit = post30Exit,
                postExit60mChangeFromExit = post60Exit,
                marketRegime = tracker.regime,
                marketHealthScore = tracker.marketHealth,
                newsRiskActive = _state.value.newsResearch.risk.newEntryBlocked,
                liquidityPassed = true,
                executionCostPercent = 0.5
            )

            val stopQuality = StopQualityEngine.evaluateStop(
                exitReason = tracker.exitReason,
                pnlRate = tracker.pnlRate,
                postExit30mPriceFromEntry = post30Entry,
                postExit60mPriceFromEntry = post60Entry
            )

            val trailingQuality = StopQualityEngine.evaluateTrailing(
                exitReason = tracker.exitReason,
                mfePercent = tracker.mfePercent,
                pnlRate = tracker.pnlRate,
                postExit30mChangeFromExit = post30Exit,
                postExit60mChangeFromExit = post60Exit
            )

            val record = LossRootCauseRecordEntity(
                tradeId = tracker.sellTradeId,
                market = tracker.market,
                exitTime = tracker.sellTime,
                rootCause = rootCause.name,
                stopQuality = stopQuality.name,
                trailingQuality = trailingQuality.name,
                mfePercent = tracker.mfePercent,
                maePercent = tracker.maePercent,
                pnlRate = tracker.pnlRate,
                exitReason = tracker.exitReason,
                postExit30mReturn = post30Exit,
                postExit60mReturn = post60Exit
            )
            rootCauses.add(record)
            dao.upsertLossRootCause(record)

            // Counterfactual Exits
            CounterfactualExitType.values().forEach { exitType ->
                val simulated = CounterfactualExitSimulator.simulate(
                    type = exitType,
                    entryPrice = tracker.entryPrice,
                    highestPrice = tracker.entryPrice * (1.0 + tracker.mfePercent / 100.0),
                    lowestPrice = tracker.entryPrice * (1.0 + tracker.maePercent / 100.0),
                    currentOrFinalPrice = tracker.exitPrice,
                    actualPnlRate = tracker.pnlRate,
                    actualExitReason = tracker.exitReason,
                    actualHoldingMinutes = 15L,
                    atrPercent = 1.5,
                    post30mPrice = snapshots.firstOrNull { it.horizonMinutes == 30 }?.currentPrice,
                    post60mPrice = snapshots.firstOrNull { it.horizonMinutes == 60 }?.currentPrice
                )
                dao.upsertCounterfactualExitSnapshot(
                    CounterfactualExitSnapshotEntity(
                        tradeId = tracker.sellTradeId,
                        strategyType = exitType.name,
                        simulatedPnlRate = simulated.pnlRate,
                        simulatedExitReason = simulated.reason,
                        simulatedHoldingMinutes = simulated.holdingMinutes,
                        capturedAt = System.currentTimeMillis()
                    )
                )
            }
        }

        val allCauses = dao.allLossRootCauses()
        val causeSummary = LossRootCauseEngine.summarize(allCauses)

        // Exit Quality Statistics
        val earlyStops = allCauses.count { it.stopQuality == StopQuality.EARLY_STOP.name }
        val earlyTrailing = allCauses.count { it.trailingQuality == TrailingQuality.TRAILING_TOO_TIGHT.name }
        val goodStops = allCauses.count { it.stopQuality == StopQuality.GOOD_STOP.name }
        val totalStops = allCauses.count { it.exitReason.contains("STOP") }

        val mfeCaptures = trackers.map { StopQualityEngine.calculateMfeCaptureRatio(it.mfePercent, it.pnlRate) }
        val givebacks = trackers.map { StopQualityEngine.calculateProfitGiveback(it.mfePercent, it.pnlRate) }

        val horizons = PostExitHorizons.ALL
        val meanReturns = horizons.associateWith { h ->
            val rows = allExitSnapshots.filter { it.horizonMinutes == h }
            MathStatisticsUtil.mean(rows.map { it.changeFromExitPercent })
        }
        val medianReturns = horizons.associateWith { h ->
            val rows = allExitSnapshots.filter { it.horizonMinutes == h }
            MathStatisticsUtil.median(rows.map { it.changeFromExitPercent })
        }

        val stop30Rows = allCauses.filter { it.exitReason.contains("STOP_LOSS") || it.exitReason.contains("STOP LOSS") }.mapNotNull { it.postExit30mReturn }
        val stop60Rows = allCauses.filter { it.exitReason.contains("STOP_LOSS") || it.exitReason.contains("STOP LOSS") }.mapNotNull { it.postExit60mReturn }
        val trail30Rows = allCauses.filter { it.exitReason.contains("TRAILING") }.mapNotNull { it.postExit30mReturn }
        val trail60Rows = allCauses.filter { it.exitReason.contains("TRAILING") }.mapNotNull { it.postExit60mReturn }

        val exitStats = ExitQualityStats(
            sampleCount = trackers.size,
            earlyStopRate = if (totalStops > 0) earlyStops.toDouble() / totalStops else 0.0,
            earlyTrailingRate = if (trackers.isNotEmpty()) earlyTrailing.toDouble() / trackers.size else 0.0,
            goodStopRate = if (totalStops > 0) goodStops.toDouble() / totalStops else 0.0,
            mfeCaptureRatio = MathStatisticsUtil.mean(mfeCaptures),
            profitGivebackPercent = MathStatisticsUtil.mean(givebacks),
            exitEfficiencyScore = (MathStatisticsUtil.mean(mfeCaptures) * 50.0 + (if (totalStops > 0) goodStops.toDouble() / totalStops * 50.0 else 25.0)).coerceIn(0.0, 100.0),
            postExitMeanReturns = meanReturns,
            postExitMedianReturns = medianReturns,
            stopLossPost30mAvg = MathStatisticsUtil.mean(stop30Rows),
            stopLossPost60mAvg = MathStatisticsUtil.mean(stop60Rows),
            trailingPost30mAvg = MathStatisticsUtil.mean(trail30Rows),
            trailingPost60mAvg = MathStatisticsUtil.mean(trail60Rows),
            sampleTooSmall = trackers.size < 30
        )

        // Counterfactual Exit Summaries
        val allCounterfactualSnapshots = dao.allCounterfactualExitSnapshots()
        val summaries = CounterfactualExitType.values().map { type ->
            val rows = allCounterfactualSnapshots.filter { it.strategyType == type.name }
            val rates = rows.map { it.simulatedPnlRate }
            val perf = StrategyPerformanceEngine.fromPnlRates(rates)
            CounterfactualExitSummary(
                type = type,
                tradeCount = rows.size,
                winRate = perf.winRate,
                netReturnPercent = rates.sum(),
                profitFactor = perf.profitFactor,
                expectancyPercent = perf.expectedReturnPercent,
                mddPercent = perf.maxDrawdownPercent,
                mfeCaptureRatio = 0.0,
                averageHoldingMinutes = if (rows.isNotEmpty()) rows.map { it.simulatedHoldingMinutes.toDouble() }.average() else 0.0,
                status = if (rows.size < 30) ExitValidationStatus.RESEARCH else ExitValidationStatus.PAPER_TESTING
            )
        }

        val champion = summaries.firstOrNull { it.type == CounterfactualExitType.CURRENT_EXIT }
        val bestChallenger = summaries.filter { it.type != CounterfactualExitType.CURRENT_EXIT }.maxByOrNull { it.expectancyPercent }
        val exitChampionChallenger = ExitChampionChallengerState(
            champion = "CURRENT_EXIT",
            challenger = bestChallenger?.type?.name ?: "-",
            status = if (trackers.size < 30) "INSUFFICIENT_SAMPLE" else "RESEARCH",
            championExpectancy = champion?.expectancyPercent ?: 0.0,
            challengerExpectancy = bestChallenger?.expectancyPercent ?: 0.0,
            sampleCount = trackers.size,
            reason = if (trackers.size < 30) "표본 부족(${trackers.size}/30) — 정책 자동변경 금지" else "가상 매도 비교 연구 중"
        )

        _state.value = _state.value.copy(
            exitQualityStats = exitStats,
            lossRootCauseSummary = causeSummary,
            counterfactualExitSummaries = summaries,
            exitChampionChallenger = exitChampionChallenger
        )
    }

    private suspend fun recordMissedOpportunities(
        signals: List<StrategySignalModel>,
        tickers: Map<String, TickerModel>,
        purchasedMarket: String?
    ) {
        signals.filter {
            it.market != purchasedMarket && (
                it.status == CandidateStatus.REJECTED || it.status == CandidateStatus.BUY_READY ||
                    it.status == CandidateStatus.WAIT_PULLBACK || it.status == CandidateStatus.WAIT_RETEST ||
                    it.status == CandidateStatus.WAIT_RECONFIRMATION || it.status == CandidateStatus.WAIT_MOMENTUM
                )
        }
            .forEach signalLoop@{ signal ->
                val ticker = tickers[signal.market] ?: return@signalLoop
                val rawReason = signal.failureReason.ifBlank { "BUY_READY_NOT_SELECTED" }
                val reason = when {
                    rawReason.contains("LOW_24H_TRADE_VALUE") -> "LOW_24H_TRADE_VALUE"
                    rawReason.contains("거래대금 부족") -> "LOW_24H_TRADE_VALUE"
                    else -> rawReason
                }
                val existing = dao.latestPendingMissed(signal.market, reason)
                if (existing != null && System.currentTimeMillis() - existing.capturedAt < 6 * 60 * 60 * 1000L) return@signalLoop
                dao.upsertMissedOpportunity(
                    MissedOpportunityEntity(
                        capturedAt = System.currentTimeMillis(),
                        market = signal.market,
                        reason = reason,
                        strategyScore = signal.score,
                        aiScore = signal.aiScore,
                        entryPrice = ticker.tradePrice,
                        marketHealth = _state.value.marketHealth.score,
                        marketRegime = _state.value.currentRegime.regime.name,
                        strategyVersion = strategyVersion
                    )
                )
            }
    }

    private suspend fun advanceMissedOpportunities(tickers: Map<String, TickerModel>) {
        val opportunities = dao.pendingMissedOpportunities()
        val horizons = listOf(5, 15, 30, 60, 180, 360)
        opportunities.forEach opportunityLoop@{ opportunity ->
            val ticker = tickers[opportunity.market] ?: return@opportunityLoop
            val elapsed = ((System.currentTimeMillis() - opportunity.capturedAt).coerceAtLeast(0L) / 60_000L).toInt()
            var maxReturn = opportunity.maxReturnPercent
            var minReturn = opportunity.minReturnPercent
            horizons.filter { it <= elapsed }.forEach horizonLoop@{ horizon ->
                if (dao.missedSnapshotExists(opportunity.id, horizon) > 0) return@horizonLoop
                val change = if (opportunity.entryPrice > 0.0) (ticker.tradePrice / opportunity.entryPrice - 1.0) * 100.0 else 0.0
                maxReturn = maxOf(maxReturn, change)
                minReturn = minOf(minReturn, change)
                dao.upsertMissedSnapshot(
                    MissedOpportunitySnapshotEntity(
                        opportunityId = opportunity.id,
                        market = opportunity.market,
                        capturedAt = System.currentTimeMillis(),
                        horizonMinutes = horizon,
                        entryPrice = opportunity.entryPrice,
                        currentPrice = ticker.tradePrice,
                        changePercent = change,
                        strategyScore = opportunity.strategyScore,
                        marketHealth = _state.value.marketHealth.score,
                        marketRegime = _state.value.currentRegime.regime.name
                    )
                )
            }
            val status = if (elapsed >= 360) MissedOpportunityEngine.classify(maxReturn, minReturn).name else "PENDING"
            dao.upsertMissedOpportunity(opportunity.copy(status = status, maxReturnPercent = maxReturn, minReturnPercent = minReturn, resolvedAt = if (status == "PENDING") null else System.currentTimeMillis()))
        }
        val all = dao.recentMissedOpportunities(200)
        _state.value = _state.value.copy(
            missedOpportunityStats = MissedOpportunityStats(
                total = all.size,
                goodRejection = all.count { it.status == MissedOpportunityOutcome.GOOD_REJECTION.name },
                missedWin = all.count { it.status == MissedOpportunityOutcome.MISSED_WIN.name },
                neutral = all.count { it.status == MissedOpportunityOutcome.NEUTRAL.name },
                byReason = all.groupBy { it.reason }.mapValues { (_, rows) -> "${rows.size}건 / GOOD ${rows.count { it.status == MissedOpportunityOutcome.GOOD_REJECTION.name }} / MISSED ${rows.count { it.status == MissedOpportunityOutcome.MISSED_WIN.name }}" }
            )
        )
    }

    private suspend fun updateShadowPortfolios(signals: List<StrategySignalModel>) {
        val existing = dao.shadowPortfolios().associateBy { it.strategy }.toMutableMap()
        ShadowStrategyType.values().forEach { type ->
            val portfolio = existing[type.name] ?: ShadowSimulationEngine.initial(type, settings.paperInitialKrw)
            val result = ShadowSimulationEngine.step(
                entity = portfolio,
                signals = signals,
                now = System.currentTimeMillis(),
                baseSettings = settings,
                aiProposal = activeRecommendation?.proposal,
                regime = _state.value.currentRegime.regime.name
            )
            dao.upsertShadowPortfolio(result.portfolio)
            result.trades.forEach { dao.insertShadowTrade(it) }
        }
        _state.value = _state.value.copy(shadowPortfolios = dao.shadowPortfolios().sortedBy { it.strategy })
    }

    private suspend fun refreshAdaptiveResearch() {
        val regime = _state.value.currentRegime.regime
        if (regime == MarketRegime.UNKNOWN) return
        val regimeName = regime.name
        val post = dao.allPostEntrySnapshots()
        val paperSamples = dao.resolvedAiSamplesByRegime(regimeName)
        fun performance(strategy: String, rates: List<Double>, holding: Double, mfe: Double, mae: Double): RegimeStrategyPerformanceEntity {
            val stats = StrategyPerformanceEngine.fromPnlRates(rates)
            val riskAdjusted = stats.expectedReturnPercent + (if (stats.profitFactor.isFinite()) stats.profitFactor else 5.0) * 2.0 - abs(stats.maxDrawdownPercent) * 0.5
            return RegimeStrategyPerformanceEntity(
                id = "${strategy}_$regimeName", strategy = strategy, regime = regimeName,
                sampleCount = stats.sampleCount, winRate = stats.winRate, profitFactor = stats.profitFactor,
                expectancy = stats.expectedReturnPercent, maxDrawdownPercent = stats.maxDrawdownPercent,
                averagePnlRate = stats.expectedReturnPercent, averageHoldingMinutes = holding,
                averageMfe = mfe, averageMae = mae, riskAdjustedReturn = riskAdjusted, updatedAt = System.currentTimeMillis()
            )
        }
        val rows = mutableListOf<RegimeStrategyPerformanceEntity>()
        rows += performance("CURRENT", paperSamples.mapNotNull { it.realizedPnlRate }, 0.0,
            post.map { it.mfePercent }.averageOrZero(), post.map { it.maePercent }.averageOrZero())
        ShadowStrategyType.values().filter { it != ShadowStrategyType.CURRENT }.forEach { type ->
            val shadowSells = dao.shadowSellTradesByRegime(regimeName).filter { it.strategy == type.name }
            rows += performance(type.name, shadowSells.map { it.pnlRate }, shadowSells.map { it.holdingMinutes }.averageLongOrZero(), 0.0, 0.0)
        }
        rows.forEach { dao.upsertRegimePerformance(it) }
        val selector = RegimeStrategySelector.select(_state.value.currentRegime, rows, _state.value.marketHealth)
        val portfolios = dao.shadowPortfolios()
        val champion = portfolios.firstOrNull { it.strategy == ShadowStrategyType.CURRENT.name }
        val cc = ChampionChallengerEngine.evaluate(champion, portfolios)
        val challengerReturns = portfolios.firstOrNull { it.strategy == cc.challenger }?.let { portfolio ->
            dao.shadowSellTradesByRegime(regimeName).filter { it.strategy == portfolio.strategy }.map { TimedPnl(it.time, it.pnlRate) }
        }.orEmpty()
        val walkForward = WalkForwardEvaluator.evaluate(challengerReturns, 10, 5, 5)
        dao.upsertStrategyPromotion(
            StrategyPromotionEntity(
                id = "${regimeName}_${cc.challenger}", champion = cc.champion, challenger = cc.challenger,
                status = cc.status, reason = "${cc.reason}; Walk-forward: ${walkForward.reason}",
                championScore = cc.championScore, challengerScore = cc.challengerScore,
                sampleCount = cc.sampleCount, walkForwardPassed = walkForward.passed, createdAt = System.currentTimeMillis()
            )
        )
        val now = System.currentTimeMillis()
        val calibrationRows = dao.confidenceCalibrations()
        if (calibrationRows.none { it.createdAt > now - 5 * 60_000L && it.regime == regimeName }) {
            val consensus = listOf(_state.value.currentRegime.shortRegime, _state.value.currentRegime.midRegime, _state.value.currentRegime.longRegime).count { it == regime } >= 2
            dao.insertConfidenceCalibration(
                ConfidenceCalibrationEntity(
                    regime = regimeName,
                    confidenceBand = ConfidenceCalibrationEngine.band(_state.value.currentRegime.confidence),
                    predictedConfidence = _state.value.currentRegime.confidence,
                    correct = consensus,
                    createdAt = now
                )
            )
        }
        _state.value = _state.value.copy(
            regimeSelector = selector,
            championChallenger = cc,
            confidenceCalibration = ConfidenceCalibrationEngine.accuracy(dao.confidenceCalibrations()),
            regimeStrategySet = refreshRegimeStrategySetState()
        )
    }

    private suspend fun refreshRegimeStrategySetState(): RegimeStrategySetState {
        val previous = _state.value.regimeStrategySet
        val decision = RegimeStrategySetEngine.buildDecision(
            base = settings,
            regime = _state.value.currentRegime,
            mode = _state.value.mode,
            enabled = settings.regimeStrategySetsEnabled,
            paperApplyEnabled = settings.regimeStrategySetsPaperApplyEnabled
        )
        val now = System.currentTimeMillis()
        if (previous.active.setId != decision.setId || previous.active.regime != decision.regime) {
            dao.upsertRegimeStrategySetSnapshot(
                RegimeStrategySetSnapshotEntity(
                    id = "${decision.setId}_$now",
                    regime = decision.regime.name,
                    setId = decision.setId,
                    applied = decision.applied,
                    recommendOnly = decision.recommendOnly,
                    noNewEntries = decision.noNewEntries,
                    scoreThreshold = decision.scoreThreshold,
                    stopLossPercent = decision.stopLossPercent,
                    takeProfitPercent = decision.takeProfitPercent,
                    trailingStopPercent = decision.trailingStopPercent,
                    maxPositions = decision.maxPositions,
                    maxOrderPercent = decision.maxOrderPercent,
                    reason = decision.reason,
                    createdAt = now
                )
            )
            dao.upsertSetting(SettingEntity("active_regime_strategy_set", decision.setId))
            log("국면 전략 세트 전환: ${previous.active.setId} → ${decision.setId} (${decision.regime.name}) · ${if (decision.applied) "PAPER 적용" else "추천만"}")
        }
        // 국면 정확도: 최근 국면 스냅샷 이후 시장 평균 등락을 사후 평가(기존 ticker changeRate만 재사용)
        val forwardReturn = _state.value.currentRegime.averageChangeRatePercent
        if (_state.value.currentRegime.regime != MarketRegime.UNKNOWN &&
            dao.regimeAccuracySampleCount() < 5_000 &&
            dao.recentRegimeAccuracySamples(1).firstOrNull()?.let { now - it.createdAt > 30 * 60_000L } != false
        ) {
            val correct = RegimeAccuracyValidator.isDirectionallyCorrect(_state.value.currentRegime.regime, forwardReturn)
            dao.upsertRegimeAccuracySample(
                RegimeAccuracySampleEntity(
                    id = "acc_${_state.value.currentRegime.regime.name}_$now",
                    predictedRegime = _state.value.currentRegime.regime.name,
                    confidence = _state.value.currentRegime.confidence,
                    forwardAverageReturnPercent = forwardReturn,
                    horizonMinutes = 60,
                    correct = correct,
                    createdAt = now
                )
            )
        }
        val accuracyRows = dao.recentRegimeAccuracySamples(500)
        val accuracy = RegimeAccuracyValidator.summarize(
            accuracyRows.map {
                RegimeAccuracyObservation(
                    predicted = runCatching { MarketRegime.valueOf(it.predictedRegime) }.getOrDefault(MarketRegime.UNKNOWN),
                    confidence = it.confidence,
                    forwardAverageReturnPercent = it.forwardAverageReturnPercent,
                    horizonMinutes = it.horizonMinutes
                )
            }
        )
        val paperPnls = dao.sellTradesByMode("PAPER").map { it.pnlRate }
        // FIXED vs REGIME_ADAPTIVE Shadow: 같은 Paper 손익을 기준으로 단순 비교 프록시.
        // 실제 대안 체결은 별도 Shadow 엔진 표본이 쌓이면 교체한다.
        val adaptiveProxy = if (decision.applied && decision.orderSizeMultiplier < 1.0) {
            paperPnls.map { it * decision.orderSizeMultiplier.coerceAtLeast(0.25) }
        } else paperPnls
        val shadow = RegimeSetShadowEngine.compare(paperPnls, adaptiveProxy)
        return RegimeStrategySetState(
            active = decision,
            catalog = RegimeStrategySetCatalog.defaults(),
            accuracy = accuracy,
            shadow = shadow,
            lastSwitchedAt = if (previous.active.setId != decision.setId) now else previous.lastSwitchedAt,
            previousSetId = if (previous.active.setId != decision.setId) previous.active.setId else previous.previousSetId
        )
    }

    private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
    private fun List<Long>.averageLongOrZero(): Double = if (isEmpty()) 0.0 else map { it.toDouble() }.average()

    private suspend fun updateOpportunityDecisions(signals: List<StrategySignalModel>, tickers: Map<String, TickerModel>, books: Map<String, OrderbookModel>, positions: List<PositionEntity>, effective: TradingSettings) {
        val candidate = signals.firstOrNull { it.status == CandidateStatus.BUY_READY }
        val decisions = positions.mapNotNull { position ->
            val held = signals.firstOrNull { it.market == position.market }
            val book = books[position.market]
            val candidateBook = candidate?.let { books[it.market] }
            val candidateAllowed = candidate != null && risk.canBuy(
                effective,
                _state.value,
                candidate,
                candidateBook,
                holding = false,
                orderInFlight = false,
                orderAmount = candidate.estimatedInvestment
            ).first
            OpportunityCostEngine.evaluate(
                OpportunityInput(
                    heldMarket = position.market,
                    heldPnlPercent = if (position.avgPrice > 0.0) ((tickers[position.market]?.tradePrice ?: position.avgPrice) / position.avgPrice - 1.0) * 100.0 else 0.0,
                    heldScore = held?.score ?: 50.0,
                    heldMomentumPercent = held?.momentumPercent ?: 0.0,
                    heldVolumeChangePercent = held?.volumeChangePercent ?: 0.0,
                    heldVolatilityPercent = held?.volatilityPercent ?: 0.0,
                    heldSpreadPercent = book?.let { if (it.askPrice > 0.0) (it.askPrice - it.bidPrice) / it.askPrice * 100.0 else 99.0 } ?: 99.0,
                    heldHealthScore = _state.value.marketHealth.score,
                    heldAgeMinutes = (System.currentTimeMillis() - position.openedAt).coerceAtLeast(0L) / 60_000L,
                    candidateMarket = candidate?.market,
                    candidateScore = candidate?.score ?: 0.0,
                    candidateMomentumPercent = candidate?.momentumPercent ?: 0.0,
                    candidateVolumeChangePercent = candidate?.volumeChangePercent ?: 0.0,
                    candidateVolatilityPercent = candidate?.volatilityPercent ?: 0.0,
                    candidateSpreadPercent = candidateBook?.let { if (it.askPrice > 0.0) (it.askPrice - it.bidPrice) / it.askPrice * 100.0 else 99.0 } ?: 99.0,
                    candidateHealthScore = _state.value.marketHealth.score,
                    estimatedFeePercent = 0.25,
                    estimatedSlippagePercent = 0.1,
                    riskAllowsCandidate = candidateAllowed
                )
            )
        }
        decisions.forEach { decision ->
            dao.upsertOpportunity(
                OpportunityDecisionEntity(
                    time = decision.computedAt,
                    heldMarket = decision.heldMarket,
                    candidateMarket = decision.candidateMarket,
                    keepScore = decision.keepScore,
                    rotateScore = decision.rotateScore,
                    doNothingScore = decision.doNothingScore,
                    action = decision.action.name,
                    reason = decision.reason,
                    strategyVersion = strategyVersion
                )
            )
        }
        _state.value = _state.value.copy(opportunityDecisions = decisions)
    }

    private fun enrichSignal(
        signal: StrategySignalModel,
        ticker: TickerModel?,
        orderbook: OrderbookModel?,
        holdings: Set<String>,
        inFlight: Set<String>,
        effective: TradingSettings,
        candles: List<CandleModel> = emptyList(),
        entryEvaluation: EntryTimingEvaluation? = null
    ): StrategySignalModel {
        val price = ticker?.tradePrice ?: 0.0
        val baseEstimated = minOf(_state.value.totalValue * effective.maxOrderPercent / 100.0, _state.value.totalValue * effective.maxAssetPercentPerCoin / 100.0)
        val allocationFactor = if (effective.dynamicAllocationEnabled)
            PortfolioAllocationEngine.allocationFactor(signal.score, effective.scoreThreshold, signal.aiScore)
        else 1.0
        val paperMultiplier = if (_state.value.mode == TradeMode.PAPER) _state.value.paperRisk.positionSizeMultiplier else 1.0
        val capitalMultiplier = if (effective.autonomousCapitalGrowthEnabled) _state.value.capitalGrowth.positionSizeMultiplier else 1.0
        val estimated = baseEstimated * allocationFactor * _state.value.profitProtection.positionSizeMultiplier * paperMultiplier * capitalMultiplier
        val ratio = if (_state.value.totalValue > 0) estimated / _state.value.totalValue * 100.0 else 0.0
        val isHolding = holdings.contains(signal.market)
        val isInflight = inFlight.contains(signal.market)
        val guardDecision = ReentryGuardPolicy.evaluate(
            state = reentryCoordinator.get(signal.market),
            candidateSignal = signal,
            holding = isHolding,
            inflight = isInflight
        )
        val timing = entryEvaluation ?: if (candles.size >= 16) {
            EntryTimingEngine.evaluate(
                currentPrice = price,
                candles = candles,
                orderbook = orderbook,
                marketRegime = _state.value.currentRegime.regime,
                marketHealth = _state.value.marketHealth,
                currentStrategyScore = signal.score,
                scoreThreshold = effective.scoreThreshold
            )
        } else {
            // Deep-scan에서 이미 계산된 timing을 빈 candles로 0/50으로 덮어쓰지 않는다.
            EntryTimingEvaluation(
                chaseScore = signal.chaseEntryScore,
                entryTimingScore = signal.entryTimingScore.takeIf { it > 0.0 } ?: 50.0,
                state = runCatching { EntryTimingState.valueOf(signal.entryTimingState) }.getOrDefault(EntryTimingState.NORMAL),
                pullbackState = runCatching { PullbackState.valueOf(signal.pullbackState) }.getOrDefault(PullbackState.NONE),
                retestState = runCatching { BreakoutRetestState.valueOf(signal.breakoutRetestState) }.getOrDefault(BreakoutRetestState.NONE),
                overextensionAtr = signal.overextensionAtr,
                overextensionScore = signal.overextensionScore,
                parabolicMove = signal.parabolicMove,
                momentumExhaustion = signal.momentumExhaustion,
                volumeClimax = signal.volumeClimax,
                scoreVelocityPerMinute = signal.scoreVelocityPerMinute,
                signalLagMs = signal.signalLagMs,
                priceMoveStartTime = signal.priceMoveStartTime,
                scoreCrossTime = signal.scoreCrossTime,
                preEntry1mReturn = signal.preEntry1mReturn,
                preEntry3mReturn = signal.preEntry3mReturn,
                preEntry5mReturn = signal.preEntry5mReturn,
                preEntry10mReturn = signal.preEntry10mReturn,
                preEntry15mReturn = signal.preEntry15mReturn,
                atrPercent = signal.entryAtrPercent,
                spreadPercent = signal.microSpreadPercent,
                orderbookImbalance = signal.microOrderbookImbalance,
                reason = "REUSED_DEEP_SCAN_TIMING"
            )
        }
        val timingGate = EntryTimingEngine.gate(
            evaluation = timing,
            enabled = effective.entryTimingGateEnabled,
            rejectScore = effective.chaseRejectScore,
            extremeScore = effective.extremeChaseScore,
            minimumScore = effective.minimumEntryTimingScore,
            maxExtensionAtr = effective.overextensionAtrMultiple
        )
        val smartReentryDecision = if (effective.profitReentryEnabled) {
            SmartReentryEngine.evaluate(
                state = smartReentryCoordinator.get(signal.market),
                signal = signal.copy(currentPrice = price, estimatedInvestment = estimated),
                cooldownMinutes = effective.profitReentryCooldownMinutes,
                scoreResetDrop = effective.profitReentryScoreResetDrop,
                minimumQuality = effective.minimumProfitReentryQuality,
                maxRiskPercentOfProfit = effective.maxProfitReentryRiskPercent
            )
        } else {
            SmartReentryDecision(
                allowed = true,
                status = ProfitReentryStatus.NONE,
                waveState = NewWaveState.NEW_WAVE_CONFIRMED,
                qualityScore = 100.0,
                sameWaveProbability = 0.0,
                newWaveConfidence = 100.0,
                reasonCodes = listOf("PROFIT_REENTRY_GUARD_DISABLED"),
                nextState = smartReentryCoordinator.get(signal.market) ?: SmartReentryState(signal.market)
            )
        }
        smartReentryCoordinator.observe(signal.market, smartReentryDecision)
        val dataQuality = DataIntegrityGuardian.evaluate(
            ticker = ticker,
            wsTicker = tickerStream.latest(listOf(signal.market), effective.staleTickerMillis)[signal.market],
            orderbook = orderbook,
            candles = candles
        )
        val depthWalk = if (effective.executionDepthCheckEnabled && orderbook != null && orderbook.askPrice > 0.0) {
            val levels = listOf(
                OrderbookLevel(orderbook.askPrice, maxOf(0.1, orderbook.askSize)),
                OrderbookLevel(orderbook.askPrice * 1.002, maxOf(0.2, orderbook.askSize * 1.5)),
                OrderbookLevel(orderbook.askPrice * 1.005, maxOf(0.5, orderbook.askSize * 2.0))
            )
            OrderbookDepthEngine.walkBuy(levels, estimated.coerceAtLeast(1000.0), price)
        } else null

        val netEdge = NetEdgeGateEngine.evaluate(
            signalScore = signal.score,
            expectedReturnPercent = effective.takeProfitPercent, // 중기/익절목표 기준(초단기 Short Edge와 별개)
            feePercent = 0.25,
            spreadPercent = depthWalk?.spreadPercent ?: 0.0,
            slippagePercent = depthWalk?.slippagePercent ?: 0.1,
            marketImpactPercent = depthWalk?.marketImpactPercent ?: 0.0,
            riskPenaltyPercent = if (_state.value.marketHealth.level != MarketHealthLevel.HEALTHY) 0.2 else 0.0,
            historicalAccuracy = _state.value.postEntryQuality.immediateRiseProbability,
            sampleCount = _state.value.postEntryQuality.sampleCount,
            minNetEdgeMargin = effective.minNetEdgeMarginPercent
        )
        val grossMoveForNetProfit = when {
            signal.shortEdgeGrossMovePercent.isFinite() && signal.shortEdgeGrossMovePercent > 0.0 ->
                signal.shortEdgeGrossMovePercent
            signal.microReturn1m.isFinite() && signal.microReturn1m > 0.0 ->
                maxOf(signal.microReturn30s, signal.microReturn1m, signal.microReturn3m, signal.microReturn5m)
            else -> effective.takeProfitPercent
        }
        val oneWaySlipPct = (depthWalk?.slippagePercent ?: 0.1).coerceAtLeast(0.0)
        val netProfitAfterCost = NetProfitAfterCostEngine.evaluate(
            entryPrice = price,
            plannedCapitalKrw = estimated.coerceAtLeast(0.0),
            expectedGrossMovePercent = grossMoveForNetProfit,
            spreadPercent = depthWalk?.spreadPercent ?: signal.microSpreadPercent,
            oneWaySlippagePercent = oneWaySlipPct,
            marketImpactPercent = depthWalk?.marketImpactPercent ?: 0.0,
            safetyMargin = effective.scalpingSafetyMargin,
            oneWayFeePercent = NetProfitAfterCostEngine.ONE_WAY_FEE_PERCENT,
            absoluteMinimumNetProfitKrw = effective.absoluteMinimumNetProfitKrw,
            minimumNetProfitPercentOfOrder = effective.minimumNetProfitPercentOfOrder,
            minimumCostCoverageMultiple = effective.minimumCostCoverageMultiple
        )
        val horizonConflict = EntryUrgencyAudit.horizonConflict(netEdge.netExpectedEdge, signal.shortHorizonNetEdge)
        val urgencyClass = EntryUrgencyAudit.entryQualityFromExisting(
            chaseScore = timing.chaseScore,
            entryTimingScore = timing.entryTimingScore,
            classification = timing.classification.name,
            preEntry5mReturn = timing.preEntry5mReturn
        )
        val decisionSummary = EntryUrgencyAudit.decisionSummary(
            scalpState = signal.scalpExecutionState,
            reasonCodes = signal.scalpReasonCodes,
            horizonConflict = horizonConflict,
            chaseScore = timing.chaseScore,
            shortEdge = signal.shortHorizonNetEdge,
            generalNetEdge = netEdge.netExpectedEdge
        )
        val liquidity = ticker?.let { liquidityDecision(it) }
        val liquidityReady = liquidity != null && liquidity.total > 0 && liquidity.code != "LIQUIDITY_UNINITIALIZED"

        val statusAndReason = when {
            dataQuality.status == DataQualityStatus.BAD || dataQuality.status == DataQualityStatus.QUARANTINED -> {
                CandidateStatus.REJECTED to "DATA_QUALITY_FAIL: ${dataQuality.reasons.joinToString(", ")}"
            }
            depthWalk?.status == ExecutionDepthStatus.INSUFFICIENT_DEPTH -> {
                CandidateStatus.REJECTED to "INSUFFICIENT_DEPTH (호가 깊이 부족)"
            }
            !timingGate.allowed -> {
                if (signal.score >= 90.0 && timing.entryTimingScore < effective.minimumEntryTimingScore) {
                    CandidateStatus.REJECTED to "HIGH_STRATEGY_SCORE_BAD_ENTRY_TIMING: ${timingGate.reason}"
                } else {
                    (timingGate.waitStatus ?: CandidateStatus.REJECTED) to timingGate.reason
                }
            }
            effective.scalpingExecutionEnabled && !signal.scalpEntryAllowed -> {
                when (signal.scalpExecutionState) {
                    ScalpingExecutionState.WAIT_PULLBACK.name -> CandidateStatus.WAIT_PULLBACK to "SCALP_WAIT_PULLBACK: ${signal.scalpReasonCodes.joinToString(", ")}"
                    ScalpingExecutionState.WAIT_RETEST.name -> CandidateStatus.WAIT_RETEST to "SCALP_WAIT_RETEST: ${signal.scalpReasonCodes.joinToString(", ")}"
                    ScalpingExecutionState.WAIT_REACCELERATION.name -> CandidateStatus.WAIT_MOMENTUM to "SCALP_WAIT_REACCELERATION: ${signal.scalpReasonCodes.joinToString(", ")}"
                    ScalpingExecutionState.WAIT.name -> CandidateStatus.WAIT_RECONFIRMATION to "SCALP_WAIT: ${signal.scalpReasonCodes.joinToString(", ")}"
                    ScalpingExecutionState.WARMING_UP.name -> CandidateStatus.WAIT_RECONFIRMATION to "SCALP_WARMING_UP: ${signal.scalpReasonCodes.joinToString(", ")}"
                    ScalpingExecutionState.DATA_INSUFFICIENT.name -> CandidateStatus.REJECTED to "SCALP_DATA_INSUFFICIENT: ${signal.scalpReasonCodes.joinToString(", ")}"
                    else -> CandidateStatus.REJECTED to "SCALP_${signal.scalpExecutionState}: ${signal.scalpReasonCodes.joinToString(", ")}"
                }
            }
            effective.profitReentryEnabled && !smartReentryDecision.allowed -> {
                when (smartReentryDecision.status) {
                    ProfitReentryStatus.WAIT_PULLBACK,
                    ProfitReentryStatus.WAIT_STABILIZATION -> CandidateStatus.WAIT_PULLBACK to "PROFIT_REENTRY_WAIT: ${smartReentryDecision.reasonCodes.joinToString(", ")}"
                    ProfitReentryStatus.WAIT_REACCELERATION -> CandidateStatus.WAIT_MOMENTUM to "PROFIT_REENTRY_WAIT: ${smartReentryDecision.reasonCodes.joinToString(", ")}"
                    ProfitReentryStatus.REENTRY_REJECTED_CHASE -> CandidateStatus.REJECTED to "PROFIT_REENTRY_CHASE: ${smartReentryDecision.reasonCodes.joinToString(", ")}"
                    ProfitReentryStatus.PROFIT_EXIT_COOLDOWN,
                    ProfitReentryStatus.WAIT_NEW_SETUP,
                    ProfitReentryStatus.BLOCKED_UNTIL_NEW_WAVE -> CandidateStatus.REJECTED to "PROFIT_REENTRY_BLOCKED: ${smartReentryDecision.reasonCodes.joinToString(", ")}"
                    ProfitReentryStatus.NONE, ProfitReentryStatus.REENTRY_READY -> CandidateStatus.REJECTED to "PROFIT_REENTRY_REJECTED: ${smartReentryDecision.reasonCodes.joinToString(", ")}"
                }
            }
            !netEdge.allowed -> {
                CandidateStatus.REJECTED to netEdge.reason
            }
            effective.netProfitAfterCostGateEnabled && !netProfitAfterCost.allowed -> {
                CandidateStatus.REJECTED to netProfitAfterCost.reason
            }
            isHolding -> CandidateStatus.HOLDING to "보유 중"
            isInflight -> CandidateStatus.ORDERING to "주문 중"
            !guardDecision.allowed -> CandidateStatus.REJECTED to guardDecision.detail
            ticker == null -> CandidateStatus.REJECTED to "실시간 티커 없음"
            !liquidityReady -> CandidateStatus.REJECTED to (liquidity?.detail ?: "LIQUIDITY_UNINITIALIZED")
            liquidity != null && !liquidity.passed -> CandidateStatus.REJECTED to liquidity.detail
            ticker.tradeVolume < effective.minTradeVolume -> CandidateStatus.REJECTED to "거래량 부족"
            signal.aiScore < effective.aiMinScore -> CandidateStatus.REJECTED to "AI Score 부족"
            else -> {
                val remoteBundle = remoteDecisionByMarket[signal.market]
                val (blocked, blockReason) = ServerBuyGate.blockNewBuy(
                    settings = effective,
                    linkStatus = remoteBundle?.linkStatus
                        ?: if (effective.remoteAiEnabled) hetznerAiDecisionProvider.lastLinkStatus else AiBrainLinkStatus.DISABLED,
                    remoteDecision = remoteBundle?.remote
                )
                if (blocked) {
                    CandidateStatus.WAIT_RECONFIRMATION to "SERVER_BUY_BLOCKED: $blockReason"
                } else {
                    val remote = remoteBundle?.remote
                    val movedAway = remote != null && effective.remoteAiPrimary &&
                        RemoteDecisionPolicy.priceMovedAway(
                            remote.signalPrice,
                            price,
                            signal.entryAtrPercent.takeIf { it > 0.0 } ?: 0.5,
                            effective.scalpingPriceMovedAwayAtrMultiple
                        )
                    when {
                        movedAway -> CandidateStatus.REJECTED to "SERVER_PRICE_MOVED_AWAY"
                        remote != null && effective.remoteAiPrimary && remote.decisionId != null &&
                            !hetznerAiDecisionProvider.markDecisionUsed(remote.decisionId) ->
                            CandidateStatus.REJECTED to "SERVER_DECISION_DUPLICATE"
                        else -> {
                            val remoteNetKrw = remoteBundle?.remote?.expectedNetProfitKrw
                            if (remoteNetKrw != null && remoteNetKrw.isFinite() && remoteNetKrw <= 0.0) {
                                CandidateStatus.REJECTED to "REMOTE_NET_PROFIT_INVALID"
                            } else {
                                val riskResult = risk.canBuy(
                                    effective,
                                    _state.value.copy(lastTickerAt = ticker.timestamp),
                                    signal,
                                    orderbook,
                                    false,
                                    false,
                                    estimated
                                )
                                if (riskResult.first) {
                                    val tag = if (effective.remoteAiPrimary) "매수 예정(REMOTE)" else "매수 예정"
                                    CandidateStatus.BUY_READY to tag
                                } else CandidateStatus.REJECTED to riskResult.second
                            }
                        }
                    }
                }
            }
        }
        return signal.copy(
            currentPrice = price,
            estimatedInvestment = estimated,
            investmentRatio = ratio,
            expectedStopPrice = if (price > 0) price * (1.0 + effective.stopLossPercent / 100.0) else 0.0,
            expectedTakeProfitPrice = if (price > 0) price * (1.0 + effective.takeProfitPercent / 100.0) else 0.0,
            status = statusAndReason.first,
            failureReason = statusAndReason.second,
            actual24hTradeValueKrw = ticker?.accTradePrice24h ?: 0.0,
            required24hTradeValueKrw = _state.value.liquidityDiagnostics.requiredKrw,
            liquidityRank = liquidity?.rank ?: 0,
            liquidityTotal = liquidity?.total ?: 0,
            liquidityPercentile = liquidity?.percentile ?: 0.0,
            liquidityPassed = liquidity?.passed ?: false,
            liquidityReady = liquidityReady,
            decisionPipeline = "DATA → HEALTH → STRATEGY → CHASE → TIMING → SCALP(SHORT EDGE) → PROFIT REENTRY → NET EDGE(익절목표) → NET PROFIT AFTER COST → REENTRY/DUPLICATE → LIQUIDITY → RISK → ORDER",
            grossExpectedEdge = netEdge.grossExpectedEdge,
            expectedExecutionCost = netEdge.expectedExecutionCost,
            netExpectedEdge = netEdge.netExpectedEdge,
            netEdgePassed = netEdge.allowed && (!effective.netProfitAfterCostGateEnabled || netProfitAfterCost.allowed),
            depthWeightedFillPrice = depthWalk?.weightedAverageFillPrice ?: price,
            expectedSlippagePercent = depthWalk?.slippagePercent ?: 0.0,
            expectedMarketImpactPercent = depthWalk?.marketImpactPercent ?: 0.0,
            expectedGrossProfitKrw = netProfitAfterCost.expectedGrossProfitKrw,
            expectedRoundTripCostKrw = netProfitAfterCost.expectedRoundTripCostKrw,
            expectedRoundTripCostPercent = netProfitAfterCost.expectedRoundTripCostPercent,
            expectedNetProfitKrw = netProfitAfterCost.expectedNetProfitKrw,
            expectedNetProfitPercent = netProfitAfterCost.expectedNetProfitPercent,
            costToGrossProfitRatio = netProfitAfterCost.costToGrossProfitRatio,
            costCoverageMultiple = netProfitAfterCost.costCoverageMultiple,
            breakEvenPrice = netProfitAfterCost.breakEvenPrice,
            netProfitAfterCostPassed = netProfitAfterCost.allowed,
            netProfitAfterCostReason = netProfitAfterCost.reason,
            dataQualityScore = dataQuality.score,
            dataQualityStatus = dataQuality.status.name,
            entryTimingScore = timing.entryTimingScore,
            chaseEntryScore = timing.chaseScore,
            entryTimingState = timing.state.name,
            pullbackState = timing.pullbackState.name,
            breakoutRetestState = timing.retestState.name,
            overextensionAtr = timing.overextensionAtr,
            overextensionScore = timing.overextensionScore,
            parabolicMove = timing.parabolicMove,
            momentumExhaustion = timing.momentumExhaustion,
            volumeClimax = timing.volumeClimax,
            scoreVelocityPerMinute = timing.scoreVelocityPerMinute,
            signalLagMs = timing.signalLagMs,
            priceMoveStartTime = timing.priceMoveStartTime,
            scoreCrossTime = timing.scoreCrossTime,
            preEntry1mReturn = timing.preEntry1mReturn,
            preEntry3mReturn = timing.preEntry3mReturn,
            preEntry5mReturn = timing.preEntry5mReturn,
            preEntry10mReturn = timing.preEntry10mReturn,
            preEntry15mReturn = timing.preEntry15mReturn,
            entryQualityClassification = timing.classification.name,
            horizonConflict = horizonConflict.name,
            entryDecisionSummary = decisionSummary,
            entryUrgencyClass = urgencyClass.name,
            shortEdgeGrossMovePercent = signal.shortEdgeGrossMovePercent,
            shortEdgeFeePercent = signal.shortEdgeFeePercent,
            shortEdgeSpreadPercent = signal.shortEdgeSpreadPercent,
            shortEdgeSlippagePercent = signal.shortEdgeSlippagePercent,
            shortEdgeImpactPercent = signal.shortEdgeImpactPercent,
            shortEdgeSafetyMargin = signal.shortEdgeSafetyMargin,
            shortEdgeCostBreakdownText = signal.shortEdgeCostBreakdownText
        )
    }

    private fun liquidityDecision(ticker: TickerModel): LiquidityFilterDecision =
        LiquidityFilterEngine.decide(ticker.accTradePrice24h, _state.value.liquidityDiagnostics.requiredKrw, lastLiquidityValues)

    private fun newsEntityToModel(entity: NewsEventEntity): NewsEventModel = NewsEventModel(
        id = entity.id,
        source = entity.source,
        sourceTier = runCatching { NewsSourceTier.valueOf(entity.sourceTier) }.getOrDefault(NewsSourceTier.TIER_D),
        publishedAt = entity.publishedAt,
        receivedAt = entity.receivedAt,
        url = entity.url,
        title = entity.title,
        summary = entity.summary,
        fingerprint = entity.fingerprint,
        symbols = entity.symbols.split(",").filter { it.isNotBlank() },
        eventType = runCatching { NewsEventType.valueOf(entity.eventType) }.getOrDefault(NewsEventType.OTHER),
        sentiment = entity.sentiment,
        confidence = entity.confidence,
        expectedImpact = entity.expectedImpact,
        urgency = entity.urgency,
        scope = runCatching { NewsScope.valueOf(entity.scope) }.getOrDefault(NewsScope.COIN),
        horizon = runCatching { NewsHorizon.valueOf(entity.horizon) }.getOrDefault(NewsHorizon.HOURS),
        status = runCatching { NewsEventStatus.valueOf(entity.status) }.getOrDefault(NewsEventStatus.UNVERIFIED),
        impactScore = entity.impactScore
    )

    suspend fun checkNews(force: Boolean = false) {
        val now = System.currentTimeMillis()
        if (!force && now - lastNewsCheckAt < 9 * 60_000L) return
        lastNewsCheckAt = now
        _state.value = _state.value.copy(newsResearch = _state.value.newsResearch.copy(engineStatus = NewsEngineStatus.DEGRADED, lastCheckAt = now, lastMessage = "뉴스 확인 중"))
        try {
            val existing = dao.latestNewsEvents()
            val incoming = newsProvider.fetch(now - 24 * 60 * 60 * 1000L)
            incoming.forEach { raw ->
                val type = NewsEventClassifier.classify(raw.title, raw.summary)
                val (symbols, scope) = NewsCoinMappingEngine.map(raw.title, raw.summary, knownKrwMarkets)
                val fingerprint = NewsFingerprint.create(raw, type, symbols)
                if (existing.any { it.fingerprint == fingerprint } || NewsDeduplicator.isDuplicate(NewsEventModel(fingerprint, raw.source, raw.sourceTier, raw.publishedAt, now, raw.url, raw.title, raw.summary, fingerprint, symbols, type, 0.0, 0.0, 0.0, 0.0, scope, NewsHorizon.HOURS, NewsEventStatus.NEW, 0.0), existing)) return@forEach
                val (sentiment, confidence, impact) = NewsEventClassifier.analyze(type, raw.title, raw.summary, raw.sourceTier)
                val urgency = when (type) { NewsEventType.HACK, NewsEventType.SECURITY_INCIDENT, NewsEventType.DELISTING, NewsEventType.NETWORK_FAILURE, NewsEventType.STABLECOIN_RISK -> 98.0; else -> 55.0 }
                val horizon = when (type) { NewsEventType.HACK, NewsEventType.SECURITY_INCIDENT, NewsEventType.DELISTING, NewsEventType.EXCHANGE_NOTICE -> NewsHorizon.MINUTES; NewsEventType.MACRO_NEGATIVE, NewsEventType.MACRO_POSITIVE, NewsEventType.TOKEN_UNLOCK -> NewsHorizon.DAYS; else -> NewsHorizon.HOURS }
                val status = when (raw.sourceTier) { NewsSourceTier.TIER_A -> NewsEventStatus.CONFIRMED; NewsSourceTier.TIER_D -> NewsEventStatus.UNVERIFIED; else -> NewsEventStatus.NEW }
                val impactScore = NewsImpactEngine.score(raw.sourceTier, sentiment, confidence, impact, urgency, raw.publishedAt, now)
                val id = java.util.UUID.randomUUID().toString()
                dao.upsertNewsEvent(NewsEventEntity(id, raw.source, raw.sourceTier.name, raw.publishedAt, now, raw.url, raw.title, raw.summary, fingerprint, symbols.joinToString(","), type.name, sentiment, confidence, impact, urgency, scope.name, horizon.name, status.name, impactScore))
                dao.upsertNewsPrediction(NewsPredictionEntity(eventId = id, market = symbols.firstOrNull() ?: "MARKET_WIDE", predictedDirection = sentiment, expectedImpact = impact))
            }
            refreshNewsState(NewsEngineStatus.ONLINE, "뉴스 ${incoming.size}건 확인")
        } catch (e: CancellationException) { throw e } catch (e: Exception) {
            _state.value = _state.value.copy(newsResearch = _state.value.newsResearch.copy(engineStatus = NewsEngineStatus.OFFLINE, lastCheckAt = now, lastMessage = "뉴스 수집 실패: ${e.message ?: "알 수 없음"}"))
            log("News Engine 오프라인: ${e.message ?: "알 수 없음"}")
        }
    }

    private suspend fun refreshNewsState(status: NewsEngineStatus = _state.value.newsResearch.engineStatus, message: String = _state.value.newsResearch.lastMessage) {
        val now = System.currentTimeMillis()
        val events = dao.latestNewsEvents()
        val models = events.map(::newsEntityToModel)
        val reactions = dao.allNewsReactions()
        val paperReturns = dao.sellTradesByMode("PAPER").map { it.pnlRate }
        val recentReturns = paperReturns.takeLast(100)
        val drift = ConceptDriftDetector.detect(recentReturns, paperReturns)
        val stats = NewsReactionEngine.stats(reactions)
        val hypothesis = ResearchHypothesisEngine.generate(stats, drift)
        if (!hypothesis.isNullOrBlank() && dao.researchHypotheses().none { it.hypothesis == hypothesis }) {
            dao.upsertResearchHypothesis(ResearchHypothesisEntity(createdAt = now, hypothesis = hypothesis, status = "TESTING", evidenceJson = "news_reactions=${stats.sampleCount}"))
        }
        if (drift.first) dao.upsertNewsDrift(NewsDriftEventEntity(createdAt = now, metric = "paper_pnl", recentValue = recentReturns.averageOrZero(), longTermValue = paperReturns.averageOrZero(), message = drift.second, status = "DRIFT_WARNING"))
        dao.upsertNewsModel(NewsModelRegistryEntity("AI_NEWS_001", "COLLECT", System.currentTimeMillis(), reactions.size, "rolling", "future", "eventType,trust,impact,reaction,regime", "news-only-research", 0.0, 0.0, stats.averageReturnByHorizon[30] ?: 0.0))
        val risk = NewsRiskEngine.evaluate(models)
        val latestHypothesis = dao.researchHypotheses().firstOrNull()?.hypothesis.orEmpty()
        _state.value = _state.value.copy(newsResearch = NewsResearchState(status, System.currentTimeMillis(), message, models.take(20).map { NewsDashboardItem(it.title, it.source, it.symbols.joinToString(","), it.eventType.name, it.impactScore, it.confidence, it.status.name, it.receivedAt) }, risk, stats, NewsReactionEngine.learningStats(events, reactions), "AI_NEWS_001", "COLLECT", drift.first, drift.second, latestHypothesis))
    }

    private suspend fun recordNewsReactions(tickers: Map<String, TickerModel>) {
        val now = System.currentTimeMillis()
        if (now - lastNewsReactionAt < 60_000L) return
        lastNewsReactionAt = now
        val events = dao.latestNewsEvents().filter { it.publishedAt >= System.currentTimeMillis() - 24 * 60 * 60 * 1000L }
        events.forEach eventLoop@{ event ->
            event.symbols.split(",").filter { it.isNotBlank() }.forEach marketLoop@{ market ->
                val ticker = tickers[market] ?: return@marketLoop
                val previous = dao.newsReactions(event.id).sortedBy { it.horizonMinutes }
                val reference = previous.firstOrNull()?.referencePrice ?: ticker.tradePrice
                val prices = previous.map { it.currentPrice } + ticker.tradePrice + reference
                val elapsed = ((System.currentTimeMillis() - event.publishedAt).coerceAtLeast(0L) / 60_000L).toInt()
                NEWS_REACTION_HORIZONS.filter { it <= elapsed }.forEach horizonLoop@{ horizon ->
                    if (previous.any { it.horizonMinutes == horizon }) return@horizonLoop
                    val change = if (reference > 0.0) (ticker.tradePrice / reference - 1.0) * 100.0 else 0.0
                    val sentiment = event.sentiment
                    val correct = if (sentiment == 0.0) abs(change) < 1.0 else (sentiment > 0 && change > 0) || (sentiment < 0 && change < 0)
                    dao.upsertNewsReaction(NewsReactionEntity(event.id, market, System.currentTimeMillis(), horizon, reference, ticker.tradePrice, change, 0.0, 0.0, 0.0, _state.value.currentRegime.regime.name, _state.value.marketHealth.score, prices.maxOrNull()?.let { if (reference > 0) (it / reference - 1) * 100 else 0.0 } ?: 0.0, prices.minOrNull()?.let { if (reference > 0) (it / reference - 1) * 100 else 0.0 } ?: 0.0, correct))
                }
            }
        }
        refreshNewsState()
    }

    private suspend fun refreshDerivatives(markets: List<MarketModel>) {
        val provider = derivativesProvider ?: return
        val focused = markets.take(5).map { it.market }
        runCatching { provider.subscribeLiquidations(focused) }
        val snapshots = limitedParallelMap(focused, concurrency = 2) { market ->
            provider.snapshot(market)
        }
        snapshots.forEach {
            derivativesSnapshots[it.market] = it
            if (it.timestamp > 0L) dao.upsertDerivativesSnapshot(it.toEntity())
        }
        val all = derivativesSnapshots.values.toList()
        _state.value = _state.value.copy(
            derivativesSupportedCount = all.count { it.supportStatus == DerivativeSupportStatus.SUPPORTED },
            derivativesUnsupportedCount = all.count { it.supportStatus == DerivativeSupportStatus.UNSUPPORTED },
            derivativesUnavailableCount = all.count {
                it.supportStatus == DerivativeSupportStatus.TEMP_UNAVAILABLE ||
                    it.providerStatus == DerivativesProviderStatus.UNAVAILABLE ||
                    it.freshness == DerivativeFreshness.STALE
            }
        )
    }

    private suspend fun processPaperPositionExits(
        tickers: Map<String, TickerModel>,
        signals: List<StrategySignalModel>,
        effective: TradingSettings,
        allowPaperExecution: Boolean
    ) {
        val positions = dao.currentPositions("PAPER").map { PositionModel(it.market, it.quantity, it.avgPrice, it.highestPrice, it.openedAt) }
        positions.forEach positionLoop@{ position ->
            val ticker = tickers[position.market] ?: return@positionLoop
            val highestPrice = PaperTradingMath.highestPrice(position.highestPrice, ticker.tradePrice)
            val trackedPosition = position.copy(highestPrice = highestPrice)
            if (highestPrice > position.highestPrice) {
                dao.upsertPosition(PositionEntity(position.market, position.quantity, position.avgPrice, highestPrice, position.openedAt, System.currentTimeMillis(), "PAPER"))
            }
            val signal = signals.firstOrNull { it.market == position.market } ?: StrategySignalModel(position.market, 100.0, "보유 포지션 Fast Lane")
            val sell = risk.shouldSell(effective, trackedPosition, ticker.tradePrice, signal)
            if (allowPaperExecution && settings.mode == TradeMode.PAPER && sell.first) {
                log("${position.market} 매도 예정: ${sell.second}")
                val order = paperExecution.sell(position, ticker.tradePrice, sell.second)
                if (order.state == OrderState.FILLED.name) {
                    paperCash += order.amount
                    savePaperCash()
                    val exitScore = signal.score
                    signalCache.remove(position.market)
                    val sellTrade = dao.latestSellTrade(position.market)
                    val pnlRate = sellTrade?.pnlRate ?: 0.0
                    val guard = reentryCoordinator.recordExit(
                        market = position.market,
                        exitReason = sell.second,
                        pnlRate = pnlRate,
                        exitScore = exitScore,
                        stopLossCooldownMs = settings.stopLossCooldownMinutes * 60_000L,
                        trailingCooldownMs = settings.trailingStopCooldownMinutes * 60_000L
                    )
                    dao.upsertReentryGuard(
                        MarketReentryGuardEntity(
                            market = guard.market,
                            lossStreak = guard.lossStreak,
                            cooldownUntil = guard.cooldownUntil,
                            status = guard.status.name,
                            lastExitReason = guard.lastExitReason,
                            exitScore = guard.exitScore,
                            exitTime = guard.exitTime,
                            signalResetRequired = guard.signalResetRequired,
                            scoreResetObserved = guard.scoreResetObserved
                        )
                    )
                    log("${position.market} PAPER SELL 체결 / 포지션 종료: ${sell.second} (손실연속 ${guard.lossStreak}회, 쿨다운 ${(guard.cooldownUntil - System.currentTimeMillis()).coerceAtLeast(0L)/1000}초)")
                    val tracker = dao.postEntryTrackers().firstOrNull { it.market == position.market }
                    val entryMfe = if (tracker != null && tracker.entryPrice > 0) (position.highestPrice / tracker.entryPrice - 1.0) * 100.0 else 0.0
                    val entryMae = if (tracker != null && tracker.entryPrice > 0) (minOf(position.avgPrice, ticker.tradePrice) / tracker.entryPrice - 1.0) * 100.0 else 0.0
                    dao.upsertPostExitTracker(
                        PostExitTrackerEntity(
                            sellTradeId = order.clientOrderId,
                            market = position.market,
                            sellTime = System.currentTimeMillis(),
                            entryPrice = position.avgPrice,
                            exitPrice = ticker.tradePrice,
                            exitReason = sell.second,
                            pnlRate = pnlRate,
                            mfePercent = entryMfe,
                            maePercent = entryMae,
                            entryScore = tracker?.strategyScore ?: exitScore,
                            aiScore = 50.0,
                            netEdge = 0.5,
                            regime = _state.value.currentRegime.regime.name,
                            marketHealth = _state.value.marketHealth.score
                        )
                    )
                    tracker?.let { entryTracker ->
                        dao.entryDiagnosticByTradeId(entryTracker.buyTradeId)?.let { diagnostic ->
                            dao.upsertEntryDiagnostic(
                                diagnostic.copy(
                                    exitReason = sell.second,
                                    pnlRate = pnlRate,
                                    mfePercent = entryMfe,
                                    maePercent = entryMae
                                )
                            )
                        }
                        dao.scalpingDiagnosticByTradeId(entryTracker.buyTradeId)?.let { diagnostic ->
                            dao.upsertScalpingDiagnostic(
                                diagnostic.copy(
                                    exitReason = sell.second,
                                    netPnl = pnlRate
                                )
                            )
                        }
                    }
                    val now = System.currentTimeMillis()
                    val smartState = smartReentryCoordinator.get(position.market)
                    if (settings.profitReentryEnabled && pnlRate > 0.0) {
                        val profitAnchor = ProfitExitAnchor(
                            market = position.market,
                            exitPrice = ticker.tradePrice,
                            exitTime = now,
                            entryPrice = position.avgPrice,
                            peakPrice = position.highestPrice,
                            realizedProfit = sellTrade?.realizedPnl ?: 0.0,
                            realizedProfitPercent = pnlRate,
                            exitReason = sell.second,
                            strategyScoreAtExit = signal.score,
                            aiScoreAtExit = signal.aiScore,
                            regimeAtExit = _state.value.currentRegime.regime.name,
                            marketHealthAtExit = _state.value.marketHealth.score,
                            consumedSignalId = signal.signalId
                        )
                        val marketProfit = (smartState?.marketSessionRealizedProfit ?: 0.0) + profitAnchor.realizedProfit
                        val next = smartReentryCoordinator.recordProfitExit(profitAnchor, settings.profitReentryCooldownMinutes)
                            .copy(marketSessionRealizedProfit = marketProfit)
                        smartReentryCoordinator.observe(position.market, SmartReentryDecision(
                            allowed = false,
                            status = next.status,
                            waveState = next.waveState,
                            qualityScore = next.reentryQualityScore,
                            sameWaveProbability = next.sameWaveProbability,
                            newWaveConfidence = next.newWaveConfidence,
                            reasonCodes = listOf("ENTRY_SIGNAL_CONSUMED", "PROFIT_EXIT_COOLDOWN"),
                            nextState = next
                        ))
                        persistSmartReentryStates()
                        log("${position.market} 익절 후 PROFIT_EXIT_COOLDOWN 시작: ${settings.profitReentryCooldownMinutes}분 / ENTRY_SIGNAL_CONSUMED")
                    } else if (settings.profitReentryEnabled && pnlRate < 0.0) {
                        smartState?.let { prior ->
                            val updated = smartReentryCoordinator.recordLoss(
                                position.market,
                                sellTrade?.realizedPnl ?: 0.0,
                                now,
                                settings.reentryChainWindowMinutes
                            )
                            if (updated != null) {
                                persistSmartReentryStates()
                                val attempt = dao.smartReentryAttemptBySignal(prior.lastSignalId)
                                if (attempt != null) dao.upsertSmartReentryAttempt(
                                    attempt.copy(resultPnl = pnlRate, outcome = if (updated.reentryLossChain > prior.reentryLossChain) "PROFIT_REENTRY_GIVEBACK" else "REENTRY_LOSS")
                                )
                                if (updated.reentryLossChain > prior.reentryLossChain) {
                                    log("${position.market} PROFIT_REENTRY_GIVEBACK / REENTRY_CHAIN_RISK: 이전 수익 반납 ${updated.profitGivenBackAmount}")
                                }
                            }
                        }
                    }
                    resolveAiOutcome(position.market, pnlRate)
                    resolvePredictionOutcome(order.clientOrderId, pnlRate)
                } else {
                    log("${position.market} PAPER SELL 실패: ${order.state}")
                }
            }
        }
    }

    suspend fun scanOnce(allowPaperExecution: Boolean = false) =
        scanMutex.withLock {
            try {
                scanOnceInternal(allowPaperExecution)
            } catch (t: Throwable) {
                scanFailureCount += 1
                consecutiveScanFailures += 1
                val hb = _state.value.scanHeartbeat.copy(
                    scanFailureCount = scanFailureCount,
                    consecutiveScanFailures = consecutiveScanFailures,
                    stallWarning = true,
                    stallMessage = "SCAN_EXCEPTION: ${t.message ?: t::class.java.simpleName}"
                )
                _state.value = _state.value.copy(
                    engineStatus = EngineStatus.ERROR,
                    scanHeartbeat = hb,
                    noTradeDiagnosis = NoTradeDiagnostics.diagnose(EngineStatus.ERROR, hb, hourlyGateFunnelTracker.snapshots())
                )
                throw t
            }
        }

    private fun setScanStage(stage: ScanStage, extra: DashboardState.() -> DashboardState = { this }) {
        val now = System.currentTimeMillis()
        stageStartedAt = now
        val stall = NoTradeDiagnostics.stallWarning(
            engineRunning = _state.value.engineStatus == EngineStatus.RUNNING || stage != ScanStage.IDLE,
            lastSuccessfulScanAt = lastSuccessfulScanAt,
            now = now
        )
        _state.value = _state.value.copy(
            pipelineStage = stage.name,
            scanHeartbeat = _state.value.scanHeartbeat.copy(
                currentStage = stage.name,
                stageStartedAt = now,
                stageElapsedMs = 0L,
                stallWarning = stall.first,
                stallMessage = stall.second
            )
        ).extra()
    }

    private suspend fun scanOncePositionSafetyOnly(
        allowPaperExecution: Boolean,
        startedAt: Long,
        reason: String
    ) {
        setScanStage(ScanStage.POSITION_FAST_LANE) {
            copy(
                engineStatus = EngineStatus.RUNNING,
                androidAnalysisMode = "POSITION_SAFETY_ONLY",
                aiBrainStatus = hetznerAiDecisionProvider.lastLinkStatus.name,
                lastRemoteDecisionSummary = "NEW_BUY_BLOCKED:$reason",
                scanHeartbeat = scanHeartbeat.copy(scanSequence = scanSequence, scanStartedAt = startedAt)
            )
        }
        log("SERVER_PRIMARY degraded ($reason): NEW BUY blocked, position protection only")
        log("FLOW BUY_CHECK market=- decision=BLOCK BLOCK_REASON=SERVER_OFF detail=$reason")
        log("FLOW PAPER_ORDER market=- decision=SKIP reason=SERVER_OFF")
        val held = dao.currentPositions("PAPER").map { it.market }
        tickerStream.subscribe(held)
        val tickers = if (held.isEmpty()) emptyMap() else {
            val ws = tickerStream.latest(held, settings.staleTickerMillis)
            val missing = held.filterNot(ws::containsKey)
            val rest = if (missing.isEmpty()) emptyMap() else marketData.ticker(missing).associateBy { it.market }
            rest + ws
        }
        processPaperPositionExits(tickers, emptyList(), effectiveSettings(), allowPaperExecution)
        lastSuccessfulScanAt = System.currentTimeMillis()
        scanSuccessCount += 1
        consecutiveScanFailures = 0
        setScanStage(ScanStage.COMPLETE) {
            copy(
                topSignals = emptyList(),
                buyReadyCount = 0,
                pipelineStage = ScanStage.COMPLETE.name,
                androidAnalysisMode = "POSITION_SAFETY_ONLY",
                scanHeartbeat = scanHeartbeat.copy(
                    scanCompletedAt = lastSuccessfulScanAt,
                    lastSuccessfulScanAt = lastSuccessfulScanAt,
                    scanSuccessCount = scanSuccessCount,
                    consecutiveScanFailures = 0,
                    nextExpectedScanAt = lastSuccessfulScanAt + NoTradeDiagnostics.SCAN_INTERVAL_MS,
                    buyReady = 0,
                    actualBuysThisScan = 0
                )
            )
        }
    }

    private suspend fun scanOnceServerPrimaryInternal(allowPaperExecution: Boolean, startedAt: Long) {
        val effective = effectiveSettings()
        if (!ensureTradingAuthReady("SERVER_PRIMARY_SCAN")) {
            scanOncePositionSafetyOnly(allowPaperExecution = false, startedAt, "AUTH_TOKEN_MISSING")
            return
        }
        setScanStage(ScanStage.SCALPING_AI) {
            copy(
                engineStatus = EngineStatus.RUNNING,
                androidAnalysisMode = "SERVER_PRIMARY_VIEWER",
                scanHeartbeat = scanHeartbeat.copy(scanSequence = scanSequence, scanStartedAt = startedAt)
            )
        }
        log("SERVER_PRIMARY scan #$scanSequence: Android analysis OFF + local PAPER exec OFF exchange=${selectedExchangeId().name}")
        log("FLOW SERVER_ANALYSIS -> SNAPSHOT_CREATED request (local FAST/DEEP/AI/SCALP/PAPER_EXEC OFF)")
        val exchange = selectedExchangeId()
        val dashTimed = runCatching { hetznerAiDecisionProvider.fetchDashboardFor(exchange, limit = 15, refresh = false) }
            .getOrElse {
                val code = AuthFailureCodes.fromThrowable(it)
                log("SERVER_PRIMARY dashboard failed exchange=${exchange.name}: $code detail=${it.message}")
                log("FLOW BUY_CHECK market=- decision=BLOCK BLOCK_REASON=SERVER_OFF detail=$code")
                // Isolate: Upbit failure must not stop Bithumb viewer state and vice versa.
                scanOncePositionSafetyOnly(allowPaperExecution = false, startedAt, "${exchange.name}_$code")
                return
            }
        val snapshot = dashTimed.value
        // Dual brain status from combined health when present.
        val health = snapshot.health
        val bithumbStatus = health?.bithumbStatus ?: health?.status ?: snapshot.serverHealth ?: "-"
        val upbitStatus = health?.upbitStatus ?: if (exchange == ExchangeId.UPBIT) (snapshot.serverHealth ?: "-") else (_state.value.upbitBrainStatus)
        log(
            ServerPrimaryCoordinator.flowLogLine(
                stage = "SNAPSHOT_CREATED -> ANDROID_RECEIVED",
                market = "MULTI",
                decision = snapshot.serverHealth ?: "-",
                reason = "exchange=${exchange.name} fast=${snapshot.fastScanCount} deep=${snapshot.deepScanCount}",
                extra = "ts=${snapshot.serverTimestamp} latencyMs=${dashTimed.networkLatencyMs} candidates=${snapshot.candidates.orEmpty().size} androidPaperExec=OFF"
            )
        )
        val serverPaper = snapshot.paper ?: runCatching { hetznerAiDecisionProvider.fetchPaperStateFor(exchange).value }.getOrNull()
        val held = serverPaper?.positions.orEmpty().mapNotNull { it.market }
        val candidateMarkets = snapshot.candidates.orEmpty().mapNotNull { it.market }.filter { it.startsWith("KRW-") }
        val needed = (held + candidateMarkets).distinct()
        // SERVER PRIMARY: do not run full-market phone analysis for either exchange.
        // Optional local ticker subscribe only for sanity display of held/candidates.
        tickerStream.subscribe(needed.ifEmpty { held })
        val ws = if (needed.isEmpty()) emptyMap() else tickerStream.latest(needed, effective.staleTickerMillis)
        val missing = needed.filterNot(ws::containsKey)
        // Skip REST ticker for UPBIT view — Android must not become Upbit market analyzer.
        val rest = if (exchange == ExchangeId.UPBIT || missing.isEmpty()) emptyMap()
        else marketData.ticker(missing).associateBy { it.market }
        val tickers = rest + ws
        val now = System.currentTimeMillis()
        remoteDecisionByMarket.clear()
        val signals = snapshot.candidates.orEmpty().mapNotNull { c ->
            val market = c.market?.takeIf { it.startsWith("KRW-") } ?: return@mapNotNull null
            val remote = ServerPrimaryCoordinator.toRemoteDecision(c)
            remoteDecisionByMarket[market] = AiDecisionBundle(
                prediction = RemoteDecisionPolicy.toPrediction(remote),
                source = AiDecisionSource.HETZNER,
                remote = remote,
                linkStatus = AiBrainLinkStatus.ONLINE,
                networkLatencyMs = dashTimed.networkLatencyMs,
                decisionAgeMs = remote.serverTimestamp?.let { now - it }
            )
            val base = ServerPrimaryCoordinator.toSignal(c, now)
            log(
                ServerPrimaryCoordinator.flowLogLine(
                    stage = "ANDROID_RECEIVED",
                    market = market,
                    decision = c.decision ?: "-",
                    score = c.strategyScore,
                    reason = (c.reasonCodes.orEmpty()).joinToString(",").ifBlank { "-" },
                    extra = "exchange=${c.exchange ?: exchange.name} ai=${c.aiScore} exec=${c.executionScore} ts=${c.signalCreatedAt ?: c.serverTimestamp}"
                )
            )
            val remoteNet = c.expectedNetProfitKrw
            val checked = if ((c.decision ?: "").uppercase() == "BUY") {
                // Sanity only — do not recompute full round-trip analysis on device.
                if (remoteNet != null && remoteNet.isFinite() && remoteNet <= 0.0) {
                    base.copy(
                        status = CandidateStatus.REJECTED,
                        failureReason = "REMOTE_NET_PROFIT_INVALID",
                        scalpEntryAllowed = false,
                        scalpEntryWindowOpen = false,
                        netProfitAfterCostPassed = false,
                        netProfitAfterCostReason = "REMOTE_NET_PROFIT_INVALID"
                    )
                } else {
                    base.copy(
                        status = CandidateStatus.BUY_READY,
                        failureReason = "SERVER_PAPER_ENGINE_HANDLES_BUY",
                        scalpEntryAllowed = false,
                        scalpEntryWindowOpen = false
                    )
                }
            } else base
            val blockCode = ServerPrimaryCoordinator.blockReasonCode(c.decision, checked.status, checked.failureReason)
            if ((c.decision ?: "").uppercase() != "BUY") {
                log("FLOW BUY_CHECK market=$market decision=${c.decision ?: "-"} BLOCK_REASON=${blockCode ?: "SIGNAL_NOT_BUY"} detail=${checked.failureReason}")
            } else if (checked.status == CandidateStatus.REJECTED) {
                log("FLOW BUY_CHECK market=$market decision=BUY BLOCK_REASON=REMOTE_NET_PROFIT_INVALID net=$remoteNet")
            } else {
                log("FLOW BUY_CHECK market=$market decision=BUY reason=DELEGATED_TO_SERVER_PAPER androidLocalOrder=NO exchange=${exchange.name}")
            }
            checked
        }.sortedByDescending { it.score }.take(10)
        signals.forEach { signalCache[it.market] = it }
        val buyReady = signals.filter { (remoteDecisionByMarket[it.market]?.remote?.decision ?: "").uppercase() == "BUY" }
        log("FLOW PAPER_ORDER market=- decision=DELEGATED reason=SERVER_PAPER_ENGINE androidLocalExec=OFF exchange=${exchange.name}")
        val completedAt = System.currentTimeMillis()
        lastSuccessfulScanAt = completedAt
        scanSuccessCount += 1
        consecutiveScanFailures = 0
        val age = snapshot.serverTimestamp?.let { completedAt - it } ?: -1L
        val paperAuto = serverPaper?.paperAuto == true
        val mirrored = if (serverPaper != null) {
            ServerPrimaryCoordinator.applyServerPaperMirror(
                base = _state.value,
                paper = serverPaper,
                serverStatus = snapshot.serverHealth ?: snapshot.health?.status ?: "ONLINE"
            )
        } else {
            _state.value
        }
        val withTrades = attachServerPaperTrades(
            base = mirrored,
            exchange = exchange,
            embedded = snapshot.recentTrades.orEmpty().ifEmpty { snapshot.paper?.recentTrades.orEmpty() }
        )
        _state.value = withTrades.copy(
            engineStatus = if (paperAuto) EngineStatus.RUNNING else EngineStatus.STOPPED,
            topSignals = signals,
            buyReadyCount = buyReady.size,
            candidateCount = signals.size,
            watchingCount = snapshot.marketCount ?: needed.size,
            fastCandidateCount = snapshot.fastScanCount ?: 0,
            deepCandidateCount = snapshot.deepScanCount ?: 0,
            finalCandidateCount = buyReady.size,
            pipelineStage = ScanStage.COMPLETE.name,
            androidAnalysisMode = "SERVER_PRIMARY_VIEWER",
            androidFullMarketAnalysis = "OFF",
            selectedExchange = exchange.name,
            bithumbBrainStatus = bithumbStatus,
            upbitBrainStatus = upbitStatus,
            upbitMarketCount = if (exchange == ExchangeId.UPBIT) (snapshot.marketCount ?: 0) else _state.value.upbitMarketCount,
            upbitMicroReady = if (exchange == ExchangeId.UPBIT) (snapshot.microBufferReadyMarkets ?: 0) else _state.value.upbitMicroReady,
            aiBrainStatus = AiBrainLinkStatus.ONLINE.name,
            aiBrainLatencyMs = dashTimed.networkLatencyMs,
            aiBrainLastDecisionAt = snapshot.serverTimestamp ?: 0L,
            aiBrainModelVersion = snapshot.autonomousLearning?.activeModel
                ?: snapshot.modelVersion
                ?: "-",
            aiBrainBithumbWs = snapshot.health?.bithumbWs?.connectionState
                ?: if (exchange == ExchangeId.BITHUMB) (snapshot.health?.upbitWs?.connectionState ?: "-") else (snapshot.health?.bithumbWs?.connectionState ?: "-"),
            aiBrainMicroBufferReady = snapshot.microBufferReadyMarkets ?: 0,
            aiBrainLocalFallback = "FAIL_CLOSED_NO_AUTO_LOCAL_BUY",
            hetznerLearningStatus = snapshot.autonomousLearning?.learningStatus
                ?: snapshot.learningStatus
                ?: "-",
            hetznerBrainState = snapshot.autonomousLearning?.brainState ?: "-",
            hetznerActiveModel = snapshot.autonomousLearning?.activeModel ?: snapshot.modelVersion ?: "-",
            hetznerActiveModelHash = snapshot.autonomousLearning?.activeModelHash ?: "-",
            hetznerLastLearningAt = snapshot.autonomousLearning?.lastLearningAt ?: 0L,
            hetznerSamplesTotal = snapshot.autonomousLearning?.samplesTotal ?: 0,
            hetznerSamplesSinceLearning = snapshot.autonomousLearning?.samplesSinceLastLearning ?: 0,
            hetznerChallengerVersion = snapshot.autonomousLearning?.challengerVersion ?: "-",
            hetznerShadowStatus = snapshot.autonomousLearning?.shadowStatus ?: "-",
            hetznerLearningHealth = snapshot.autonomousLearning?.learningHealth ?: "-",
            hetznerRecentLearningProblem = snapshot.recentLearning?.problem ?: "-",
            hetznerRecentLearningHypothesis = snapshot.recentLearning?.hypothesis ?: "-",
            hetznerRecentLearningCandidate = snapshot.recentLearning?.candidate ?: "-",
            hetznerRecentLearningStatus = snapshot.recentLearning?.status ?: "-",
            hetznerLearningProofSource = snapshot.autonomousLearning?.learningProofSource ?: "-",
            hetznerModelStatus = snapshot.autonomousLearning?.modelStatus ?: "-",
            hetznerProductionEvidence = snapshot.autonomousLearning?.productionEvidence
                ?: snapshot.recentLearning?.productionEvidence
                ?: "NONE",
            hetznerRealSampleCount = snapshot.autonomousLearning?.realSampleCount ?: 0,
            hetznerRealShadowSampleCount = snapshot.autonomousLearning?.realShadowSampleCount ?: 0,
            hetznerRealCycleCount = snapshot.autonomousLearning?.realLearningCycleCount ?: 0,
            hetznerIsLearning = when (snapshot.autonomousLearning?.isLearning) {
                true -> "YES"
                false -> "NO"
                null -> "NO"
            },
            hetznerIsImproving = snapshot.autonomousLearning?.isImproving ?: "NOT_ENOUGH_EVIDENCE",
            hetznerLayer2Status = snapshot.autonomousLearning?.layerStatus ?: "-",
            hetznerOosStatus = snapshot.autonomousLearning?.oosStatus ?: "-",
            hetznerPredictionChangeRate = snapshot.autonomousLearning?.predictionChangeRate?.let { "${"%.1f".format(it * 100.0)}%" } ?: "-",
            hetznerRecentLearningBadge = snapshot.recentLearning?.badge
                ?: snapshot.recentLearning?.dataBadge
                ?: "NONE",
            serverDashboardAgeMs = age,
            serverFastScanCount = snapshot.fastScanCount ?: 0,
            serverDeepScanCount = snapshot.deepScanCount ?: 0,
            lastRemoteDecisionId = buyReady.firstOrNull()?.let { remoteDecisionByMarket[it.market]?.remote?.decisionId }.orEmpty(),
            lastRemoteDecisionSummary = buyReady.firstOrNull()?.let { "${it.market} SERVER_BUY" }
                ?: signals.firstOrNull()?.let { "${it.market} ${it.scalpExecutionState}" }
                ?: "-",
            lastAnalysisAt = completedAt,
            lastTickerAt = listOfNotNull(
                tickers.values.maxOfOrNull { it.timestamp },
                tickerStream.lastMessageAt.takeIf { it > 0L },
                completedAt
            ).maxOrNull() ?: _state.value.lastTickerAt,
            lastWebSocketAt = tickerStream.lastMessageAt,
            websocketStatus = tickerStream.status.value,
            // Primary uses Hetzner dashboard — do not leave stale local REST DISCONNECTED alarm.
            apiStatus = ConnectionStatus.CONNECTED,
            scanHeartbeat = _state.value.scanHeartbeat.copy(
                scanCompletedAt = completedAt,
                lastSuccessfulScanAt = completedAt,
                scanSuccessCount = scanSuccessCount,
                consecutiveScanFailures = 0,
                nextExpectedScanAt = completedAt + NoTradeDiagnostics.SCAN_INTERVAL_MS,
                watchingMarkets = snapshot.marketCount ?: needed.size,
                fastCandidates = snapshot.fastScanCount ?: 0,
                deepCandidates = snapshot.deepScanCount ?: 0,
                buyReady = buyReady.size,
                actualBuysThisScan = 0,
                currentStage = ScanStage.COMPLETE.name
            ),
            scanPerformance = ScanPerformanceSnapshot(
                totalScanMs = completedAt - startedAt,
                deepScanMs = 0L,
                marketDataMs = dashTimed.networkLatencyMs,
                apiCallCount = 1,
                websocketUpdates = if (tickerStream.lastMessageAt > 0L) 1 else 0
            )
        )
        if (serverPaper != null) {
            val match = ServerPrimaryCoordinator.uiMatchesServerPaper(_state.value, serverPaper)
            log(
                ServerPrimaryCoordinator.flowLogLine(
                    stage = "UI_SERVER_DATA_MATCH",
                    market = "PORTFOLIO",
                    decision = if (match) "PASS" else "FAIL",
                    reason = "exchange=${exchange.name} cash=${serverPaper.cash} pos=${serverPaper.positionCount} auto=${serverPaper.paperAuto}",
                    extra = "ts=${serverPaper.lastTickAt ?: serverPaper.updatedAt} noLocalRecalc=YES"
                )
            )
            uploadDeviceVerifySafe(
                event = "UI_SERVER_DATA_MATCH",
                decision = if (match) "PASS" else "FAIL",
                paper = serverPaper,
                reason = "scanPrimary exchange=${exchange.name} cash=${serverPaper.cash} pos=${serverPaper.positionCount} auto=${serverPaper.paperAuto}",
                throttlePass = true
            )
        }
        log(
            "SERVER_PRIMARY complete exchange=${exchange.name} markets=${snapshot.marketCount} fast=${snapshot.fastScanCount} deep=${snapshot.deepScanCount} " +
                "serverPaperAuto=$paperAuto serverCash=${serverPaper?.cash} serverPos=${serverPaper?.positionCount} androidLocalPaper=OFF"
        )
        log(
            "FLOW SERVER_ANALYSIS -> SNAPSHOT_CREATED -> ANDROID_RECEIVED -> BUY_CHECK -> PAPER_ORDER " +
                "done androidLocalAnalysis=OFF androidLocalPaper=OFF serverPaperAuto=$paperAuto exchange=${exchange.name}"
        )
    }

    /**
     * PHASE6: upload verify result to Hetzner. Never throws into trading path.
     */
    private suspend fun reportAndroidModeSafe(primaryNow: Boolean) {
        val now = System.currentTimeMillis()
        if (now - lastModeReportUploadAtMs < MODE_REPORT_UPLOAD_MIN_INTERVAL_MS) return
        lastModeReportUploadAtMs = now
        val settingsNow = effectiveSettings()
        val decision = if (primaryNow) "PASS" else "FAIL"
        val reason =
            "primary=${settingsNow.remoteAiPrimary} remoteAi=${settingsNow.remoteAiEnabled} " +
                "dashboard=${settingsNow.remoteDashboardEnabled} mode=${_state.value.androidAnalysisMode} " +
                "path=${if (primaryNow) "SERVER_PRIMARY" else "LOCAL_DECISION"}"
        runCatching {
            kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
                hetznerAiDecisionProvider.postDeviceVerify(
                    DeviceVerifyRequest(
                        deviceSessionId = deviceSessionId,
                        appVersion = BuildConfig.VERSION_NAME,
                        timestamp = now,
                        event = "ANDROID_MODE_REPORT",
                        decision = decision,
                        serverStateTimestamp = _state.value.serverPaperLastTickAt.takeIf { it > 0L },
                        reason = reason.take(240),
                        expected = mapOf("path" to "SERVER_PRIMARY"),
                        actual = mapOf(
                            "path" to if (primaryNow) "SERVER_PRIMARY" else "LOCAL_DECISION",
                            "androidAnalysisMode" to _state.value.androidAnalysisMode
                        )
                    )
                )
            }
            log("FLOW ANDROID_MODE_REPORT decision=$decision $reason")
        }.onFailure {
            log("FLOW ANDROID_MODE_REPORT upload failed detail=${it.message?.take(120)}")
        }
    }

    private suspend fun uploadDeviceVerifySafe(
        event: String,
        decision: String,
        paper: RemotePaperState,
        reason: String,
        throttlePass: Boolean
    ) {
        val now = System.currentTimeMillis()
        if (throttlePass && decision == "PASS" && now - lastUiMatchUploadAtMs < UI_MATCH_UPLOAD_MIN_INTERVAL_MS) {
            return
        }
        if (event == "UI_SERVER_DATA_MATCH") lastUiMatchUploadAtMs = now
        runCatching {
            kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
                hetznerAiDecisionProvider.postDeviceVerify(
                    DeviceVerifyRequest(
                        deviceSessionId = deviceSessionId,
                        appVersion = BuildConfig.VERSION_NAME,
                        timestamp = now,
                        event = event,
                        decision = decision,
                        serverStateTimestamp = paper.lastTickAt ?: paper.updatedAt,
                        reason = reason.take(240),
                        expected = ServerPrimaryCoordinator.paperExpectedSnapshot(paper),
                        actual = ServerPrimaryCoordinator.paperActualUiSnapshot(_state.value)
                    )
                )
            }
            log("FLOW DEVICE_VERIFY_UPLOAD event=$event decision=$decision accepted=YES")
        }.onFailure {
            // Diagnostic only — trading continues.
            log("FLOW DEVICE_VERIFY_UPLOAD event=$event decision=$decision accepted=NO detail=${it.message?.take(120)}")
        }
    }

    /**
     * PHASE6 hotfix: authenticated Trading API readiness. Never silently skip.
     */
    private suspend fun ensureTradingAuthReady(context: String): Boolean {
        val base = settings.remoteAiBaseUrl.ifBlank { BuildConfig.DEFAULT_REMOTE_AI_BASE_URL }
        val token = ***REDACTED***
        if (token.isBlank()) {
            log("AUTH_TOKEN_MISSING context=$context baseUrl=$base")
            _state.value = _state.value.copy(
                serverStatusLabel = "AUTH_TOKEN_MISSING",
                aiBrainLocalFallback = "AUTH_TOKEN_MISSING",
                androidAnalysisMode = if (settings.remoteAiPrimary) "SERVER_PRIMARY_VIEWER" else _state.value.androidAnalysisMode
            )
            return false
        }
        log("AUTH_CLIENT_READY context=$context baseUrl=$base token=***REDACTED***")
        return true
    }

    /**
     * PHASE5: APP START / REOPEN → fetch Hetzner paper SoT → UI restore.
     * Does not place BUY/SELL. Does not restore local Room paper session.
     */
    suspend fun restoreServerPaperOnAppStart(source: String = "APP_START"): Boolean {
        val settingsNow = effectiveSettings()
        if (!(settingsNow.remoteAiEnabled && settingsNow.remoteAiPrimary)) {
            log("FLOW APP_REOPEN_RESTORE skipped reason=PRIMARY_OFF source=$source")
            return false
        }
        log("FLOW APP_START -> SERVER_HEALTH_CHECK source=$source")
        if (!ensureTradingAuthReady("APP_REOPEN:$source")) {
            log("FLOW APP_REOPEN_RESTORE FAIL reason=AUTH_TOKEN_MISSING source=$source")
            return false
        }
        val health = runCatching { hetznerAiDecisionProvider.refreshHealth() }.getOrElse {
            log("FLOW APP_REOPEN_RESTORE FAIL reason=HEALTH_${it.message}")
            _state.value = _state.value.copy(
                aiBrainStatus = AiBrainLinkStatus.OFFLINE.name,
                serverStatusLabel = "OFFLINE",
                androidAnalysisMode = "SERVER_PRIMARY_VIEWER"
            )
            return false
        }
        if (health != AiBrainLinkStatus.ONLINE) {
            log("FLOW APP_REOPEN_RESTORE FAIL reason=HEALTH_$health")
            _state.value = _state.value.copy(
                aiBrainStatus = health.name,
                serverStatusLabel = health.name,
                androidAnalysisMode = "SERVER_PRIMARY_VIEWER"
            )
            return false
        }
        log("FLOW APP_START -> AUTH_CLIENT_READY -> FETCH_PAPER_STATE source=$source")
        val exchange = selectedExchangeId()
        val paperTimed = runCatching { hetznerAiDecisionProvider.fetchPaperStateFor(exchange) }.getOrElse {
            val code = AuthFailureCodes.fromThrowable(it)
            log("FLOW APP_REOPEN_RESTORE FAIL reason=$code detail=${it.message}")
            log("PAPER_STATE_REQUEST_FAILED source=$source code=$code exchange=${exchange.name}")
            return false
        }
        log("FLOW APP_START -> SERVER_STATE_RECEIVED exchange=${exchange.name} cash=${paperTimed.value.cash} pos=${paperTimed.value.positionCount} auto=${paperTimed.value.paperAuto}")
        val paper = paperTimed.value
        val mirrored = ServerPrimaryCoordinator.applyServerPaperMirror(
            base = _state.value.copy(
                aiBrainStatus = AiBrainLinkStatus.ONLINE.name,
                aiBrainLatencyMs = paperTimed.networkLatencyMs,
                serverStatusLabel = "ONLINE",
                selectedExchange = exchange.name,
                androidFullMarketAnalysis = "OFF"
            ),
            paper = paper,
            serverStatus = "ONLINE"
        )
        _state.value = attachServerPaperTrades(mirrored, exchange)
        log("FLOW APP_START -> UI_RESTORE_FROM_SERVER source=$source trades=${_state.value.serverPaperTrades.size}")
        val match = ServerPrimaryCoordinator.uiMatchesServerPaper(_state.value, paper)
        val decision = if (match) "PASS" else "FAIL"
        log(
            ServerPrimaryCoordinator.flowLogLine(
                stage = "APP_REOPEN_SERVER_STATE_RESTORE",
                market = "PORTFOLIO",
                decision = decision,
                reason = "source=$source",
                extra = "cash=${paper.cash} realized=${paper.realizedPnl} unrealized=${paper.unrealizedPnl} " +
                    "pos=${paper.positionCount} auto=${paper.paperAuto} tick=${paper.tickCount} " +
                    "noBuySellOnReopen=YES localSessionIgnored=YES"
            )
        )
        paper.positions.orEmpty().filter { (it.quantity ?: 0.0) > 0.0 }.forEach { p ->
            log(
                "FLOW UI_RESTORE_POSITION market=${p.market} qty=${p.quantity} avg=${p.avgPrice} " +
                    "mark=${p.markPrice} uPnL=${p.unrealizedPnl}"
            )
        }
        uploadDeviceVerifySafe(
            event = "APP_REOPEN_SERVER_STATE_RESTORE",
            decision = decision,
            paper = paper,
            reason = "source=$source noBuySellOnReopen=YES",
            throttlePass = false
        )
        return match
    }

    /**
     * SERVER PRIMARY: trades live on Hetzner, not local Room.
     * Fetch /paper/trades and mirror into UI state (display + Net cost ledger).
     */
    /**
     * SERVER PRIMARY: trades live on Hetzner, not local Room.
     * Prefer dedicated /paper/trades; also accept dashboard/paper embedded recentTrades.
     * On failure keep previous non-empty list (do not wipe history to 0).
     */
    private suspend fun attachServerPaperTrades(
        base: DashboardState,
        exchange: ExchangeId = selectedExchangeId(),
        embedded: List<RemotePaperTrade> = emptyList()
    ): DashboardState {
        val prior = base.serverPaperTrades
        // Fast path: embedded trades from paper/state or dashboard (works against older/newer servers).
        if (embedded.isNotEmpty()) {
            log("FLOW PAPER_TRADES_SYNC exchange=${exchange.name} source=EMBEDDED count=${embedded.size}")
            return ServerPrimaryCoordinator.withServerPaperTrades(base, embedded)
        }
        val timed = runCatching { hetznerAiDecisionProvider.fetchPaperTradesFor(exchange, 100) }.getOrElse {
            val code = AuthFailureCodes.fromThrowable(it)
            log("PAPER_TRADES_FETCH_FAILED exchange=${exchange.name} code=$code detail=${it.message}")
            return base.copy(
                serverPaperTradesSyncStatus = if (prior.isNotEmpty()) "OK" else "FAILED",
                serverPaperTradesSyncError = code,
                // Keep prior trades — never blank the tab because of a transient fetch failure.
                serverPaperTrades = prior
            )
        }
        val remote = timed.value.trades.orEmpty()
        log("FLOW PAPER_TRADES_SYNC exchange=${exchange.name} source=API count=${remote.size} latencyMs=${timed.networkLatencyMs}")
        return ServerPrimaryCoordinator.withServerPaperTrades(base, remote)
    }

    /** Explicit refresh for 거래내역 tab — always hits /paper/trades. */
    suspend fun refreshServerPaperTrades(reason: String = "UI_TAB"): Boolean {
        val settingsNow = effectiveSettings()
        val primary = settingsNow.remoteAiEnabled && settingsNow.remoteAiPrimary
        if (!primary) {
            log("FLOW PAPER_TRADES_REFRESH skipped reason=NOT_PRIMARY source=$reason")
            return false
        }
        if (!ensureTradingAuthReady("PAPER_TRADES_REFRESH")) {
            _state.value = _state.value.copy(
                serverPaperTradesSyncStatus = "FAILED",
                serverPaperTradesSyncError = "AUTH_TOKEN_MISSING"
            )
            return false
        }
        val exchange = selectedExchangeId()
        _state.value = _state.value.copy(serverPaperTradesSyncStatus = "SYNCING", serverPaperTradesSyncError = "")
        val next = attachServerPaperTrades(_state.value, exchange)
        _state.value = next
        val ok = next.serverPaperTradesSyncStatus == "OK" || next.serverPaperTradesSyncStatus == "EMPTY"
        // If Primary is on but sync still empty/failed, surface clearly for UI.
        if (next.serverPaperTrades.isEmpty() && next.serverPaperTradesSyncStatus == "IDLE") {
            _state.value = next.copy(serverPaperTradesSyncStatus = "EMPTY")
        }
        log(
            "FLOW PAPER_TRADES_REFRESH source=$reason exchange=${exchange.name} " +
                "status=${_state.value.serverPaperTradesSyncStatus} count=${_state.value.serverPaperTrades.size} " +
                "err=${_state.value.serverPaperTradesSyncError.ifBlank { "-" }}"
        )
        return ok || _state.value.serverPaperTrades.isNotEmpty()
    }

    suspend fun setServerPaperAuto(enabled: Boolean): Boolean {
        val settingsNow = effectiveSettings()
        if (!(settingsNow.remoteAiEnabled && settingsNow.remoteAiPrimary)) {
            log("setServerPaperAuto ignored: remoteAiPrimary OFF")
            return false
        }
        if (!ensureTradingAuthReady("PAPER_AUTO_CMD")) {
            log("FLOW PAPER_AUTO_CMD failed: AUTH_TOKEN_MISSING")
            return false
        }
        val exchange = selectedExchangeId()
        return try {
            val timed = hetznerAiDecisionProvider.setPaperAutoFor(exchange, enabled, source = "ANDROID")
            val paper = timed.value.paper
            if (paper != null) {
                val mirrored = ServerPrimaryCoordinator.applyServerPaperMirror(
                    base = _state.value,
                    paper = paper,
                    serverStatus = "ONLINE"
                )
                _state.value = attachServerPaperTrades(mirrored, exchange)
            } else {
                _state.value = _state.value.copy(
                    engineStatus = if (enabled) EngineStatus.RUNNING else EngineStatus.STOPPED,
                    serverPaperAuto = enabled
                )
            }
            log("FLOW PAPER_AUTO_CMD exchange=${exchange.name} enabled=$enabled accepted=${timed.value.accepted} source=ANDROID persisted_on_server=YES")
            true
        } catch (t: Throwable) {
            log("FLOW PAPER_AUTO_CMD failed exchange=${exchange.name}: ${AuthFailureCodes.fromThrowable(t)} detail=${t.message}")
            false
        }
    }

    private suspend fun scanOnceInternal(allowPaperExecution: Boolean){
        restorePersistedAiModel()
        val tokenReady = tradingAiTokenProvider().trim().isNotBlank()
        // PHASE6 hard fix: token + Remote AI => ALWAYS Server Primary path (ignore persisted Primary OFF).
        if (ServerPrimaryCoordinator.shouldEnterServerPrimaryPath(settings, tokenReady) && !settings.remoteAiPrimary) {
            updateSettings(settings.copy(remoteAiPrimary = true))
            log("SERVER PRIMARY hard-ON (token+remote) — LOCAL /decision path blocked")
        }
        val primaryNow = ServerPrimaryCoordinator.shouldEnterServerPrimaryPath(effectiveSettings(), tokenReady)
        reportAndroidModeSafe(primaryNow)
        // Primary: do not load local Room paper into UI SoT; restore from Hetzner instead.
        if (primaryNow) {
            if (!_state.value.serverPaperUiSynced) {
                restoreServerPaperOnAppStart(source = "SCAN_PRIMARY_BOOTSTRAP")
            }
        } else {
            ensurePaperPortfolioLoaded()
        }
        ensureAiRetrainStateLoaded()
        ensureRiskStateLoaded()
        ensureCrashStateLoaded()
        ensureContinuousLearningStateLoaded()
        val startedAt = System.currentTimeMillis()
        scanSequence += 1
        var actualBuysThisScan = 0
        var sellEvaluationsThisScan = 0
        var actualSellsThisScan = 0
        var effective = effectiveSettings()
        var marketDataMs = 0L
        var fastScanMs = 0L
        var deepScanMs = 0L
        var executionScanMs = 0L
        var positionFastLaneMs = 0L
        // PHASE3/6: token+remote => never run Local FAST/DEEP/AI/Scalp that used to flood /decision.
        if (ServerPrimaryCoordinator.shouldEnterServerPrimaryPath(effective, tokenReady)) {
            val link = runCatching { hetznerAiDecisionProvider.refreshHealth() }.getOrElse { AiBrainLinkStatus.OFFLINE }
            val health = hetznerAiDecisionProvider.lastHealth
            val upbitStatus = health?.upbitStatus
                ?: runCatching { hetznerAiDecisionProvider.refreshUpbitHealthStatus() }.getOrDefault(_state.value.upbitBrainStatus)
            val bithumbStatus = health?.bithumbStatus ?: health?.status ?: link.name
            _state.value = _state.value.copy(
                bithumbBrainStatus = bithumbStatus,
                upbitBrainStatus = upbitStatus,
                androidFullMarketAnalysis = "OFF"
            )
            val selected = selectedExchangeId()
            val selectedOnline = when (selected) {
                ExchangeId.UPBIT -> upbitStatus.equals("ONLINE", ignoreCase = true) ||
                    upbitStatus.equals("DEGRADED", ignoreCase = true)
                ExchangeId.BITHUMB -> link == AiBrainLinkStatus.ONLINE ||
                    bithumbStatus.equals("ONLINE", ignoreCase = true) ||
                    bithumbStatus.equals("DEGRADED", ignoreCase = true)
            }
            if (selectedOnline) {
                scanOnceServerPrimaryInternal(allowPaperExecution, startedAt)
                return
            }
            // Selected exchange offline: block NEW BUY for that exchange only; keep position safety.
            scanOncePositionSafetyOnly(allowPaperExecution, startedAt, "SERVER_${selected.name}_OFFLINE")
            return
        }
        setScanStage(ScanStage.LOADING_MARKETS) {
            copy(
                engineStatus = EngineStatus.RUNNING,
                apiStatus = ConnectionStatus.CONNECTING,
                lastAnalysisAt = startedAt,
                androidAnalysisMode = "LOCAL_FULL_SCAN",
                scanHeartbeat = scanHeartbeat.copy(
                    scanSequence = scanSequence,
                    scanStartedAt = startedAt,
                    scanCompletedAt = 0L,
                    watchingMarkets = watchingCount,
                    actualBuysThisScan = 0,
                    sellEvaluationsThisScan = 0,
                    actualSellsThisScan = 0
                )
            )
        }
        log("분석 시작 #$scanSequence (LOCAL_FULL_SCAN)")
        val markets=marketData.loadKrwMarkets()
        knownKrwMarkets = markets.map { it.market }.toSet()
        tickerStream.subscribe(markets.map { it.market })
        _state.value=_state.value.copy(watchingCount=markets.size, apiStatus=ConnectionStatus.CONNECTED)
        log("감시 대상 ${markets.size}개 KRW 마켓 로드")
        if (markets.isEmpty()) {
            lastSuccessfulScanAt = System.currentTimeMillis()
            scanSuccessCount += 1
            consecutiveScanFailures = 0
            setScanStage(ScanStage.COMPLETE) {
                copy(
                    currentAnalyzingMarket = "-",
                    analysisBatchIndex = 0,
                    analysisBatchTotal = 0,
                    analysisProgressPercent = 0,
                    scanHeartbeat = scanHeartbeat.copy(
                        scanCompletedAt = lastSuccessfulScanAt,
                        lastSuccessfulScanAt = lastSuccessfulScanAt,
                        scanSuccessCount = scanSuccessCount,
                        consecutiveScanFailures = 0,
                        nextExpectedScanAt = lastSuccessfulScanAt + NoTradeDiagnostics.SCAN_INTERVAL_MS,
                        watchingMarkets = 0
                    )
                )
            }
            return
        }
        val batchSize = 10
        val batchTotal = (markets.size + batchSize - 1) / batchSize
        val batchStart = marketScanCursor % markets.size
        val batchMarkets = (0 until minOf(batchSize, markets.size)).map { offset ->
            markets[(batchStart + offset) % markets.size]
        }
        marketScanCursor = (batchStart + batchMarkets.size) % markets.size
        val batchIndex = batchStart / batchSize + 1
        _state.value = _state.value.copy(
            analysisBatchIndex = batchIndex.coerceAtMost(batchTotal),
            analysisBatchTotal = batchTotal,
            analysisProgressPercent = (batchIndex.toDouble() / batchTotal * 100.0).toInt()
        )
        val allCodes = markets.map { it.market }
        setScanStage(ScanStage.TICKER)
        val marketDataStartedAt = System.currentTimeMillis()
        val heldPositions = dao.currentPositions("PAPER")
        val heldMarkets = heldPositions.map { it.market }
        val researchMarkets = dao.postEntryTrackers().map { it.market } +
            dao.pendingMissedOpportunities().map { it.market } +
            dao.allPostExitTrackers().map { it.market } +
            dao.latestNewsEvents().flatMap { it.symbols.split(",").filter { symbol -> symbol.isNotBlank() } }.take(20)
        val requiredTickerMarkets = TickerCoverage.requiredMarkets(allCodes, heldMarkets)
            .let { TickerCoverage.requiredMarkets(it, researchMarkets) }
        val wsTickers = tickerStream.latest(requiredTickerMarkets, effective.staleTickerMillis)
        val restCodes = requiredTickerMarkets.filterNot(wsTickers::containsKey)
        val restFallbackStarted = System.currentTimeMillis()
        val restTickers = if (restCodes.isEmpty()) emptyMap() else {
            ExecutionDataPipelineDiagnostics.recordRestFallback(triggered = true, success = null, latencyMs = 0L)
                .forEach { msg -> log(msg) }
            val fetched = runCatching { marketData.ticker(restCodes).associateBy { it.market } }
            val latency = System.currentTimeMillis() - restFallbackStarted
            if (fetched.isSuccess) {
                ExecutionDataPipelineDiagnostics.recordRestFallback(triggered = true, success = true, latencyMs = latency)
                    .forEach { msg -> log(msg) }
            } else {
                ExecutionDataPipelineDiagnostics.recordRestFallback(triggered = true, success = false, latencyMs = latency)
                    .forEach { msg -> log(msg) }
            }
            fetched.getOrDefault(emptyMap())
        }
        val tickers = restTickers + wsTickers
        val wsHealth = ExecutionDataPipelineDiagnostics.webSocketHealth(
            connectionStatus = tickerStream.status.value,
            lastMessageAt = tickerStream.lastMessageAt
        )
        if (wsHealth.health == WebSocketHealth.WEBSOCKET_ZOMBIE) {
            log("WEBSOCKET_ZOMBIE lastMessageAt=${wsHealth.lastMessageAt} ageMs=${wsHealth.ageMs} messageCount=${wsHealth.messageCount}")
        }
        setScanStage(ScanStage.POSITION_FAST_LANE)
        val fastLaneStartedAt = System.currentTimeMillis()
        processPaperPositionExits(tickers, emptyList(), effectiveSettings(), allowPaperExecution)
        positionFastLaneMs += System.currentTimeMillis() - fastLaneStartedAt
        marketDataMs = System.currentTimeMillis() - marketDataStartedAt
        setScanStage(ScanStage.FAST_SCAN)
        val fastStartedAt = System.currentTimeMillis()
        val fastResult = FastScanEngine.scan(markets, tickers, batchMarkets.map { it.market })
        val fastDetectedAt = fastResult.candidates.associate { it.market to it.detectedAt }
        fastScanMs = System.currentTimeMillis() - fastStartedAt
        val deepCount = minOf(fastResult.candidates.size, maxOf(10, minOf(15, fastResult.candidates.size)))
        val deepMarkets = fastResult.candidates.take(deepCount).mapNotNull { candidate -> markets.firstOrNull { it.market == candidate.market } }
        val deepCodes = deepMarkets.map { it.market }
        setScanStage(ScanStage.DEEP_SCAN) {
            copy(
                lastTickerAt = tickers.values.maxOfOrNull { it.timestamp } ?: lastTickerAt,
                apiStatus = ConnectionStatus.CONNECTED,
                websocketStatus = tickerStream.status.value,
                fastCandidateCount = fastResult.candidates.size,
                deepCandidateCount = deepMarkets.size,
                scanHeartbeat = scanHeartbeat.copy(
                    watchingMarkets = markets.size,
                    fastCandidates = fastResult.candidates.size,
                    deepCandidates = deepMarkets.size
                )
            )
        }
        val books = marketData.orderbook(deepCodes).associateBy { it.market }
        refreshDerivatives(deepMarkets)
        updateMarketRegime(tickers.filterKeys(allCodes.toSet()::contains))
        persistMarketRegimeIfDue()
        checkAnomalies()
        evaluateMarketHealth()
        effective = effectiveSettings()
        setScanStage(ScanStage.LIQUIDITY)
        val liquidityDiagnostics = LiquidityFilterEngine.diagnose(
            tickers.values,
            effective,
            _state.value.currentRegime.regime,
            _state.value.marketHealth.score
        )
        lastLiquidityValues = tickers.values.map { it.accTradePrice24h }.filter { it.isFinite() && it >= 0.0 }.sortedDescending()
        _state.value = _state.value.copy(liquidityDiagnostics = liquidityDiagnostics)
        recordNewsReactions(tickers)
        val (sessionQual, _) = SessionEdgeEngine.sessionQuality(java.time.LocalTime.now(java.time.ZoneId.of("Asia/Seoul")).hour)
        val heldModelList = heldPositions.map { PositionModel(it.market, it.quantity, it.avgPrice, it.highestPrice, it.openedAt) }
        val heat = PortfolioHeatEngine.evaluate(heldModelList, _state.value.totalValue, effective.stopLossPercent)
        var capacity = DynamicPortfolioCapacityEngine.evaluateSnapshot(
            settings = effective,
            positions = heldModelList,
            totalEquityKrw = _state.value.totalValue,
            availableCashKrw = _state.value.krwBalance,
            heat = heat
        )
        val riskBudgetMult = _state.value.paperRisk.remainingRiskBudgetMultiplier
        if (riskBudgetMult < 1.0) {
            capacity = capacity.copy(
                remainingRiskBudgetPercent = capacity.remainingRiskBudgetPercent * riskBudgetMult,
                remainingRiskBudgetKrw = capacity.remainingRiskBudgetKrw * riskBudgetMult,
                detail = capacity.detail + " · autopsyRisk×${"%.2f".format(riskBudgetMult)}"
            )
        }
        val tail = TailRiskEngine.evaluate(
            recentReturns = dao.sellTradesByMode("PAPER").takeLast(60).map { it.pnlRate },
            healthScore = _state.value.marketHealth.score,
            regime = _state.value.currentRegime.regime,
            portfolioExposurePercent = heat.portfolioExposurePercent
        )
        dao.upsertPortfolioRiskSnapshot(
            PortfolioRiskSnapshotEntity(
                time = System.currentTimeMillis(),
                heatLevel = heat.level.name,
                totalOpenRiskPercent = heat.totalOpenRiskPercent,
                portfolioExposurePercent = heat.portfolioExposurePercent,
                correlatedExposurePercent = heat.correlatedExposurePercent,
                clusterName = heat.clusterName,
                worstCaseLossPercent = heat.worstCaseLossPercent,
                tailRiskScore = tail.score,
                var95Percent = tail.var95Percent,
                expectedShortfall95Percent = tail.expectedShortfall95Percent
            )
        )
        _state.value = _state.value.copy(
            sessionQuality = sessionQual,
            portfolioHeat = heat,
            portfolioCapacity = capacity,
            tailRisk = tail
        )
        effective = effectiveSettings()
        val positions = heldMarkets.toSet()
        val deepStartedAt = System.currentTimeMillis()
        val inFlight = dao.openOrders().map { it.market }.toSet()
        setScanStage(ScanStage.SCALPING_AI)
        remoteDecisionByMarket.clear()
        val rawSignals=limitedParallelMap(deepMarkets, concurrency = 4) { m ->
            _state.value = _state.value.copy(currentAnalyzingMarket = m.market)
            val candles=runCatching{marketData.candles(m.market, 5)}.getOrDefault(emptyList())
            val entryCandles=runCatching{marketData.candles(m.market, 1)}.getOrDefault(candles)
            val baseSignal = strategy.score(m.market,tickers[m.market],books[m.market],candles)
            val history = scoreHistory[m.market]?.toList().orEmpty()
            val timing = EntryTimingEngine.evaluate(
                currentPrice = tickers[m.market]?.tradePrice ?: 0.0,
                candles = entryCandles,
                strategyCandles = candles,
                orderbook = books[m.market],
                marketRegime = _state.value.currentRegime.regime,
                marketHealth = _state.value.marketHealth,
                scoreHistory = history,
                currentStrategyScore = baseSignal.score,
                scoreThreshold = effective.scoreThreshold
            )
            val features = AiFeatureBuilder.build(tickers[m.market], candles)
            val aiBundle = aiDecisionProvider.decide(m.market, features)
            val prediction = aiBundle.prediction
            if (aiBundle.remote != null) {
                log(
                    "REMOTE AI ${m.market} Decision ${aiBundle.remote.decision ?: "-"} " +
                        "Strategy ${aiBundle.remote.strategyScore?.toInt() ?: "-"} AI ${prediction.score.toInt()} " +
                        "Execution ${aiBundle.remote.executionScore?.toInt() ?: "-"} " +
                        "ShortEdge ${aiBundle.remote.shortEdge?.let { "%+.2f".format(it) } ?: "null"} " +
                        "Latency ${aiBundle.networkLatencyMs ?: -1}ms DecisionAge ${aiBundle.decisionAgeMs ?: -1}ms " +
                        "Source ${aiBundle.source} Link ${aiBundle.linkStatus}"
                )
                remoteDecisionByMarket[m.market] = aiBundle
            } else if (aiBundle.linkStatus == AiBrainLinkStatus.OFFLINE && settings.remoteAiEnabled) {
                remoteDecisionByMarket[m.market] = aiBundle
            }
            val previousBook = previousOrderbooks[m.market]
            val book = books[m.market]
            val pressure = ScalpingExecutionEngine.orderbookPressure(book, previousBook)
            book?.let { previousOrderbooks[m.market] = it }
            val nowTs = System.currentTimeMillis()
            val liveMicro = tickerStream.recentMicroSamples(listOf(m.market))[m.market].orEmpty()
            val usedCandleProxy = liveMicro.isEmpty() && entryCandles.isNotEmpty()
            // 1m candle을 micro로 쓰면 Short Edge가 과대평가될 수 있어 proxy는 플래그만 세우고 비어있으면 empty flow.
            val microSamples = liveMicro
            val microFlow = ScalpingExecutionEngine.microFlow(
                samples = microSamples,
                currentPrice = tickers[m.market]?.tradePrice ?: 0.0
            )
            val tickerAgeMs = tickers[m.market]?.let { TimestampUnits.ageMs(it.timestamp, nowTs) } ?: Long.MAX_VALUE
            val orderbookAgeMs = book?.let { TimestampUnits.ageMs(it.timestamp, nowTs) } ?: Long.MAX_VALUE
            val derivatives = GlobalDerivativesIntelligenceEngine.evaluate(
                snapshot = derivativesSnapshots[m.market],
                spotChangePercent = (tickers[m.market]?.signedChangeRate ?: 0.0) * 100.0,
                spotSamples = tickerStream.recentMicroSamples(listOf(m.market))[m.market].orEmpty(),
                derivativeHistory = derivativesProvider?.recentHistory(m.market).orEmpty(),
                chaseScore = timing.chaseScore,
                entryTimingScore = timing.entryTimingScore,
                microMomentum = ScalpingExecutionEngine.momentumState(
                    microFlow.return30s,
                    microFlow.return1m,
                    microFlow.return3m,
                    microFlow.return30s - (microFlow.return1m - microFlow.return30s)
                ),
                buyPressure = pressure.buyPressure,
                btcLongLiquidationCascade = derivativesSnapshots["KRW-BTC"]?.liquidationState == LiquidationState.LIQUIDATION_CASCADE
            )
            derivativesIntelligence[m.market] = derivatives
            val momentumSlope = microFlow.return30s - (microFlow.return1m - microFlow.return30s)
            val momentumAcceleration = microFlow.return30s - (microFlow.return1m - microFlow.return30s)
            val (micro10, micro30, micro1m) = ExecutionDataPipelineDiagnostics.countMicroWindows(microSamples, nowTs)
            val preScalpSnapshot = ExecutionDataPipelineDiagnostics.buildInputSnapshot(
                market = m.market,
                now = nowTs,
                tickerPrice = tickers[m.market]?.tradePrice,
                tickerAgeMs = if (tickerAgeMs == Long.MAX_VALUE) null else tickerAgeMs,
                orderbookAgeMs = if (book == null) null else orderbookAgeMs,
                bidDepth = book?.bidSize,
                askDepth = book?.askSize,
                imbalance = pressure.imbalance,
                buyPressure = pressure.buyPressure,
                spread = if (book != null) timing.spreadPercent else null,
                samples = microSamples,
                tradeIntensity = microFlow.tradeIntensity.takeIf { microSamples.isNotEmpty() },
                volumeAcceleration = timing.volumeAcceleration,
                flow = microFlow,
                atr = timing.atrPercent,
                momentum = baseSignal.momentumPercent,
                momentumSlope = momentumSlope,
                strategyScore = baseSignal.score,
                aiScore = prediction.score,
                entryTimingScore = timing.entryTimingScore,
                chaseScore = timing.chaseScore,
                shortEdge = null
            )
            log(preScalpSnapshot.formatLog() + " microWindows=$micro10/$micro30/$micro1m")
            val scalp = ScalpingExecutionEngine.evaluate(
                ScalpingExecutionInput(
                    market = m.market,
                    currentPrice = tickers[m.market]?.tradePrice ?: 0.0,
                    return10s = microFlow.return10s,
                    return30s = microFlow.return30s,
                    return1m = microFlow.return1m,
                    return3m = microFlow.return3m,
                    return5m = microFlow.return5m,
                    volume10s = microFlow.volume10s,
                    volume30s = microFlow.volume30s,
                    volume1m = microFlow.volume1m,
                    volumeAcceleration = timing.volumeAcceleration,
                    bidAskSpread = if (book != null) timing.spreadPercent else 99.0,
                    orderbookImbalance = pressure.imbalance,
                    bidDepth = book?.bidSize ?: 0.0,
                    askDepth = book?.askSize ?: 0.0,
                    depthChangePercent = pressure.depthChangePercent,
                    orderbookStable = pressure.stable,
                    tradeIntensity = microFlow.tradeIntensity,
                    buyPressure = pressure.buyPressure,
                    sellPressure = pressure.sellPressure,
                    atrPercent = timing.atrPercent,
                    shortVolatilityPercent = Indicators.volatility(entryCandles.map { it.close }) * 100.0,
                    momentum = baseSignal.momentumPercent,
                    momentumSlope = momentumSlope,
                    momentumAcceleration = momentumAcceleration,
                    rsi = timing.rsi,
                    emaDistancePercent = timing.distanceFromEmaPercent,
                    breakoutDistancePercent = timing.breakoutDistancePercent,
                    pullbackState = timing.pullbackState,
                    retestState = timing.retestState,
                    strategyScore = baseSignal.score,
                    aiScore = prediction.score,
                    marketRegime = _state.value.currentRegime.regime,
                    marketHealth = _state.value.marketHealth.score,
                    netEdge = maxOf(0.0, (baseSignal.score - 70.0) * 0.08),
                    chaseScore = timing.chaseScore,
                    entryTimingScore = timing.entryTimingScore,
                    currentSignalPrice = tickers[m.market]?.tradePrice ?: 0.0
                    ,
                    derivativesAvailable = derivatives.supportStatus == DerivativeSupportStatus.SUPPORTED &&
                        derivatives.freshness != DerivativeFreshness.STALE,
                    derivativesSentiment = derivatives.derivativesSentiment,
                    derivativesRisk = derivatives.derivativesRisk,
                    openInterestChange5m = derivativesSnapshots[m.market]?.oiChange5m,
                    derivativesFundingState = derivatives.fundingState,
                    derivativesPositioningState = derivatives.positioningState,
                    shortSqueezeScore = derivatives.shortSqueezeScore,
                    longSqueezeRisk = derivatives.longSqueezeRisk,
                    spotFuturesDivergence = derivatives.spotFuturesDivergence,
                    globalLeadState = derivatives.globalLeadState,
                    microSampleCount = microSamples.size,
                    orderbookPresent = book != null && (book.bidSize > 0.0 && book.askSize > 0.0),
                    usedCandleProxyForMicro = usedCandleProxy,
                    tickerAgeMs = if (tickerAgeMs == Long.MAX_VALUE) 999_999L else tickerAgeMs,
                    orderbookAgeMs = if (orderbookAgeMs == Long.MAX_VALUE) 999_999L else orderbookAgeMs
                ),
                minShortNetEdgePercent = effective.scalpingMinimumShortNetEdgePercent,
                safetyMargin = effective.scalpingSafetyMargin,
                minimumExecutionScore = effective.scalpingMinimumExecutionScore,
                maximumSpreadPercent = effective.scalpingMaximumSpreadPercent,
                priceMovedAwayAtrMultiple = effective.scalpingPriceMovedAwayAtrMultiple
            )
            ExecutionDataPipelineDiagnostics.recordState(scalp.state.name)
            ExecutionDataPipelineDiagnostics.recordGap(m.market, scalp.executionDataStatus, scalp.executionDataGaps)?.let { tracker ->
                if (tracker.bugCandidate) {
                    log(
                        "BUG_CANDIDATE market=${tracker.market} status=${tracker.lastStatus} " +
                            "durationMs=${tracker.durationMs} consecutive=${tracker.consecutiveCount} gaps=${tracker.lastGaps.joinToString(",")}"
                    )
                }
            }
            val now = System.currentTimeMillis()
            val nextHistory = (history + ScorePoint(now, baseSignal.score)).takeLast(30)
            synchronized(scoreHistory) { scoreHistory[m.market] = ArrayDeque(nextHistory) }
            val volumeChange = tickers[m.market]?.let(::volumeChangePercent) ?: 0.0
            val candleType = EntryUrgencyAudit.candleSignalType(candles.lastOrNull()?.timestamp ?: 0L, 5)
            baseSignal.copy(
                timestamp = now,
                candleSignalType = candleType,
                aiScore = prediction.score,
                aiLabel = prediction.label,
                aiFeatures = features,
                volumeChangePercent = volumeChange,
                entryTimingScore = timing.entryTimingScore,
                chaseEntryScore = timing.chaseScore,
                entryTimingState = timing.state.name,
                pullbackState = timing.pullbackState.name,
                breakoutRetestState = timing.retestState.name,
                overextensionAtr = timing.overextensionAtr,
                overextensionScore = timing.overextensionScore,
                parabolicMove = timing.parabolicMove,
                momentumExhaustion = timing.momentumExhaustion,
                volumeClimax = timing.volumeClimax,
                scoreVelocityPerMinute = timing.scoreVelocityPerMinute,
                signalLagMs = timing.signalLagMs,
                priceMoveStartTime = timing.priceMoveStartTime,
                scoreCrossTime = timing.scoreCrossTime,
                preEntry1mReturn = timing.preEntry1mReturn,
                preEntry3mReturn = timing.preEntry3mReturn,
                preEntry5mReturn = timing.preEntry5mReturn,
                preEntry10mReturn = timing.preEntry10mReturn,
                preEntry15mReturn = timing.preEntry15mReturn,
                entryAtrPercent = timing.atrPercent,
                entryQualityClassification = timing.classification.name,
                scalpExecutionScore = scalp.executionScore,
                scalpExecutionConfidence = scalp.executionConfidence,
                scalpExecutionState = scalp.state.name,
                scalpMarketState = scalp.marketState.name,
                microMomentumState = scalp.momentumState.name,
                microVolatilityRegime = scalp.volatilityRegime.name,
                shortHorizonNetEdge = scalp.shortNetEdge,
                shortEdgeGrossMovePercent = scalp.shortEdgeBreakdown.grossExpectedMovePercent,
                shortEdgeFeePercent = scalp.shortEdgeBreakdown.feePercent,
                shortEdgeSpreadPercent = scalp.shortEdgeBreakdown.spreadPercent,
                shortEdgeSlippagePercent = scalp.shortEdgeBreakdown.slippagePercent,
                shortEdgeImpactPercent = scalp.shortEdgeBreakdown.marketImpactPercent,
                shortEdgeSafetyMargin = scalp.shortEdgeBreakdown.safetyMargin,
                shortEdgeCostBreakdownText = scalp.shortEdgeBreakdown.format(),
                recommendedScalpHorizonSeconds = scalp.recommendedHorizonSeconds,
                scalpReasonCodes = scalp.reasonCodes,
                scalpEntryAllowed = scalp.allowed,
                scalpPriceMovedAway = scalp.priceMovedAway,
                scalpEntryWindowOpen = scalp.entryWindowOpen,
                executionDataStatus = scalp.executionDataStatus.name,
                executionDataGaps = scalp.executionDataGaps,
                shortEdgeReliable = scalp.shortEdgeReliable,
                microSampleCount = microSamples.size,
                microReturn10s = microFlow.return10s,
                microReturn30s = microFlow.return30s,
                microReturn1m = microFlow.return1m,
                microReturn3m = microFlow.return3m,
                microReturn5m = microFlow.return5m,
                microVolume10s = microFlow.volume10s,
                microVolume30s = microFlow.volume30s,
                microVolume1m = microFlow.volume1m,
                microVolumeAcceleration = timing.volumeAcceleration,
                microSpreadPercent = timing.spreadPercent,
                microOrderbookImbalance = pressure.imbalance,
                microDepthChangePercent = pressure.depthChangePercent,
                microMomentum = baseSignal.momentumPercent,
                microMomentumSlope = momentumSlope,
                microMomentumAcceleration = momentumAcceleration,
                microRsi = timing.rsi,
                microEmaDistancePercent = timing.distanceFromEmaPercent,
                microBreakoutDistancePercent = timing.breakoutDistancePercent
                ,
                derivativesSupportStatus = derivatives.supportStatus.name,
                derivativesProviderStatus = derivatives.providerStatus.name,
                derivativesFreshness = derivatives.freshness.name,
                derivativesSentiment = derivatives.derivativesSentiment,
                derivativesRisk = derivatives.derivativesRisk,
                derivativesOiChange5m = derivativesSnapshots[m.market]?.oiChange5m,
                derivativesFundingState = derivatives.fundingState.name,
                derivativesPositioningState = derivatives.positioningState.name,
                shortSqueezeScore = derivatives.shortSqueezeScore,
                longSqueezeRisk = derivatives.longSqueezeRisk,
                spotFuturesDivergence = derivatives.spotFuturesDivergence.name,
                globalLeadState = derivatives.globalLeadState.name,
                derivativesConfidence = derivatives.derivativesConfidence,
                globalMoveConfirmed = derivatives.globalMoveConfirmed,
                derivativesReasonCodes = derivatives.reasonCodes
            )
        }.sortedByDescending{it.score}
        deepScanMs = System.currentTimeMillis() - deepStartedAt
        bootstrapHistoricalLearning(deepMarkets)
        scheduleContinuousLearning()
        setScanStage(ScanStage.NET_EDGE)
        val batchSignals = rawSignals.map { enrichSignal(it, tickers[it.market], books[it.market], positions, inFlight, effective) }
        batchSignals.forEach {
            reentryCoordinator.updateScore(it.market, it.score)
            signalCache[it.market] = it
        }
        persistSmartReentryStates()
        val freshNow = System.currentTimeMillis()
        val signals = signalCache.values
            .filter {
                ScalpingSignalPolicy.isFresh(
                    it.timestamp,
                    freshNow,
                    minOf(effective.entrySignalMaxAgeMillis, effective.scalpingSignalTtlMillis)
                )
            }
            .sortedByDescending { it.score }
            .take(10)
            .toMutableList()
        signals.forEach { recordEntryDiagnostic(it) }
        signals.forEach { recordScalpingDiagnostic(it) }
        recordSmartReentryAttempts(signals)
        refreshEntryTimingResearch()
        refreshScalpingResearch(tickers, signals)
        refreshSmartReentryResearch()
        signals.forEach{
            recordPredictionJournal(it)
            dao.upsertSignal(StrategySignalEntity(time=it.timestamp,market=it.market,score=it.score,reason=it.reason))
            val msg = when(it.status){
                CandidateStatus.BUY_READY -> "${it.market} 후보 등록 / 검증 통과 / AI ${it.aiScore.toInt()} / NetEdge ${"%.2f".format(it.netExpectedEdge)}% / 매수 예정 score=${it.score.toInt()}"
                CandidateStatus.ORDERING -> "${it.market} 주문 중"
                CandidateStatus.HOLDING -> "${it.market} 보유 중"
                CandidateStatus.REJECTED -> "${it.market} rejected: ${it.failureReason} score=${it.score.toInt()} AI=${it.aiScore.toInt()}"
                CandidateStatus.WATCHING -> "${it.market} 분석 중 score=${it.score.toInt()}"
                CandidateStatus.WAIT_PULLBACK, CandidateStatus.WAIT_RETEST,
                CandidateStatus.WAIT_RECONFIRMATION, CandidateStatus.WAIT_MOMENTUM ->
                    "${it.market} ${it.status.name}: ${it.failureReason} score=${it.score.toInt()}"
            }
            log(msg)
            dao.upsertNetEdgeDecision(
                NetEdgeDecisionEntity(
                    time = System.currentTimeMillis(),
                    market = it.market,
                    signalScore = it.score,
                    grossExpectedEdge = it.grossExpectedEdge,
                    executionCost = it.expectedExecutionCost,
                    netExpectedEdge = it.netExpectedEdge,
                    confidence = 75.0,
                    sampleCount = _state.value.postEntryQuality.sampleCount,
                    allowed = it.netEdgePassed,
                    reason = it.failureReason.ifBlank { "NET_EDGE_PASS" }
                )
            )
        }
        val fastLowLiquidity = fastResult.candidates.count { candidate -> tickers[candidate.market]?.let { !liquidityDecision(it).passed } == true }
        val overblocking = liquidityOverblockingTracker.record(fastResult.candidates.size, fastLowLiquidity, signals.count { it.status == CandidateStatus.BUY_READY })
        _state.value = _state.value.copy(
            liquidityDiagnostics = _state.value.liquidityDiagnostics.copy(overblockingWarning = overblocking.first, overblockingMessage = overblocking.second)
        )
        val d = _state.value.liquidityDiagnostics
        dao.upsertLiquidityScan(LiquidityScanEntity(System.currentTimeMillis(), d.total, d.pass, d.rejectLowTradeValue, d.distribution.p10, d.distribution.p25, d.distribution.p50, d.distribution.p75, d.distribution.p90, d.requiredKrw, d.percentileThreshold, d.overblockingWarning))
        if (overblocking.first) log(overblocking.second)
        setScanStage(ScanStage.POST_PROCESSING)
        recordPostEntrySnapshots(tickers)
        recordPostExitSnapshots(tickers)
        advanceMissedOpportunities(tickers)
        updateOpportunityDecisions(signals, tickers, books, heldPositions, effective)
        updateShadowPortfolios(signals)
        refreshAdaptiveResearch()
        executionScanMs = System.currentTimeMillis() - deepStartedAt
        val currentReadySignals = batchSignals
            .filter {
                it.status == CandidateStatus.BUY_READY &&
                    ScalpingSignalPolicy.isFresh(
                        it.timestamp,
                        System.currentTimeMillis(),
                        minOf(effective.entrySignalMaxAgeMillis, effective.scalpingSignalTtlMillis)
                    )
            }
            .sortedByDescending { it.score }
        currentReadySignals.forEach { signal ->
            val detectedAt = fastDetectedAt[signal.market]?.takeIf { it > 0L } ?: startedAt
            candidateLatencySamples.addLast((System.currentTimeMillis() - detectedAt).coerceAtLeast(0L))
        }
        while (candidateLatencySamples.size > 200) candidateLatencySamples.removeFirst()
        val canAttemptPaperBuy = allowPaperExecution && settings.mode == TradeMode.PAPER &&
            !_state.value.killSwitchEngaged &&
            !_state.value.crashProtectionEngaged &&
            !_state.value.cooldownActive
        // BUG FIX: 이번 스캔 BUY_READY만 사용. top-10 표시 목록 교집합은 매수 누락 버그.
        val selectedReadySignal = if (canAttemptPaperBuy) NoTradeDiagnostics.selectPaperBuyCandidate(currentReadySignals) else null
        val plannedPurchase = selectedReadySignal?.market
        recordMissedOpportunities(signals, tickers, plannedPurchase)
        if (allowPaperExecution && settings.mode == TradeMode.PAPER &&
            !_state.value.killSwitchEngaged &&
            !_state.value.crashProtectionEngaged && !_state.value.cooldownActive) {
            // 오래된 signalCache 후보는 표시/연구에는 남기되 실제 Paper 주문에는 사용하지 않는다.
            val candidate = selectedReadySignal
            if (candidate != null) {
                setScanStage(ScanStage.ORDER)
                val marketLock = reentryCoordinator.lockFor(candidate.market)
                marketLock.withLock {
                    val freshHolding = dao.currentPositions("PAPER").any { it.market == candidate.market && it.quantity > 0.0 }
                    val freshInflight = dao.openOrders().any { it.market == candidate.market }
                    val executionPrice = tickers[candidate.market]?.tradePrice ?: 0.0
                    val priceMovedAway = ScalpingSignalPolicy.priceMovedAway(
                        executionPrice,
                        candidate.currentPrice,
                        candidate.entryAtrPercent,
                        effective.scalpingPriceMovedAwayAtrMultiple
                    )
                    val finalSmartDecision = if (effective.profitReentryEnabled) {
                        SmartReentryEngine.evaluate(
                            smartReentryCoordinator.get(candidate.market),
                            candidate.copy(currentPrice = executionPrice),
                            cooldownMinutes = effective.profitReentryCooldownMinutes,
                            scoreResetDrop = effective.profitReentryScoreResetDrop,
                            minimumQuality = effective.minimumProfitReentryQuality,
                            maxRiskPercentOfProfit = effective.maxProfitReentryRiskPercent
                        ).also { smartReentryCoordinator.observe(candidate.market, it) }
                    } else null
                    persistSmartReentryStates()
                    val finalDecision = when {
                        priceMovedAway -> {
                            ReentryDecision(false, "PRICE_MOVED_AWAY", "PRICE_MOVED_AWAY 신호가 대비 체결가격 이탈")
                        }
                        finalSmartDecision != null && !finalSmartDecision.allowed -> {
                            ReentryDecision(false, "PROFIT_REENTRY_${finalSmartDecision.status.name}", finalSmartDecision.reasonCodes.joinToString(", "))
                        }
                        else -> ReentryGuardPolicy.evaluate(
                            state = reentryCoordinator.get(candidate.market),
                            candidateSignal = candidate,
                            holding = freshHolding,
                            inflight = freshInflight
                        )
                    }
                    if (!finalDecision.allowed) {
                        log("${candidate.market} 최종 매수 직전 차단: ${finalDecision.detail}")
                        val index = signals.indexOfFirst { it.market == candidate.market }
                        if (index >= 0) signals[index] = signals[index].copy(
                            status = if (freshHolding) CandidateStatus.HOLDING else CandidateStatus.REJECTED,
                            failureReason = finalDecision.detail
                        )
                        signalCache[candidate.market] = signals[index]
                    } else {
                        log("${candidate.market} 매수 예정")
                        val smartStateBeforeBuy = smartReentryCoordinator.get(candidate.market)
                        val isProfitReentry = smartStateBeforeBuy?.status == ProfitReentryStatus.REENTRY_READY
                        val order = paperExecution.buy(
                            market = candidate.market,
                            krwAmount = candidate.estimatedInvestment.coerceAtMost(paperCash),
                            marketPrice = executionPrice,
                            reason = if (isProfitReentry) {
                                "PAPER BUY / REENTRY / NEW_WAVE_CONFIRMED / score ${candidate.score.toInt()}"
                            } else {
                                "PAPER BUY / score ${candidate.score.toInt()}"
                            }
                        )
                        if (order.state == OrderState.FILLED.name) {
                            actualBuysThisScan += 1
                            paperCash = (paperCash - order.amount).coerceAtLeast(0.0)
                            savePaperCash()
                            recordAiTrainingSample(candidate, order.clientOrderId)
                            dao.predictionJournal(candidate.predictionId)?.let { journal ->
                                dao.upsertPredictionJournal(journal.copy(tradeId = order.clientOrderId))
                            }
                            dao.latestEntryDiagnostic(candidate.market)?.let { diagnostic ->
                                dao.upsertEntryDiagnostic(diagnostic.copy(
                                    tradeId = order.clientOrderId,
                                    decision = "BUY_NOW",
                                    time = candidate.timestamp
                                ))
                            }
                            dao.latestScalpingDiagnostic(candidate.market)?.let { diagnostic ->
                                dao.upsertScalpingDiagnostic(diagnostic.copy(
                                    tradeId = order.clientOrderId,
                                    decision = "ENTER_NOW",
                                    entryPrice = executionPrice,
                                    time = candidate.timestamp
                                ))
                            }
                            if (isProfitReentry) {
                                smartReentryCoordinator.recordReentryBuy(candidate.market, candidate.signalId)
                                persistSmartReentryStates()
                                dao.smartReentryAttemptBySignal(candidate.signalId)?.let { attempt ->
                                    dao.upsertSmartReentryAttempt(attempt.copy(outcome = "VALID_SECOND_ENTRY"))
                                }
                                log("${candidate.market} VALID_SECOND_ENTRY 체결: 새로운 Signal/Reacceleration 확인")
                            }
                            dao.upsertExecutionQuality(
                                ExecutionQualitySnapshotEntity(
                                    time = System.currentTimeMillis(),
                                    market = candidate.market,
                                    side = "BUY",
                                    decisionPrice = executionPrice,
                                    quotedPrice = executionPrice,
                                    simulatedAverageFillPrice = candidate.depthWeightedFillPrice,
                                    feeKrw = order.amount * 0.0025,
                                    spreadCostPercent = 0.0,
                                    slippageCostPercent = candidate.expectedSlippagePercent,
                                    marketImpactCostPercent = candidate.expectedMarketImpactPercent,
                                    executionQualityScore = 90.0
                                )
                            )
                            val opened = dao.currentPositions("PAPER").firstOrNull { it.market == candidate.market }
                            if (opened != null) {
                                recordPostEntryTracker(
                                    candidate = candidate,
                                    buyTradeId = order.clientOrderId,
                                    position = opened
                                )
                            }
                            val index = signals.indexOfFirst { it.market == candidate.market }
                            if (index >= 0) signals[index] = signals[index].copy(
                                status = CandidateStatus.HOLDING,
                                failureReason = "보유 중"
                            )
                            signalCache[candidate.market] = signals[index]
                            log("${candidate.market} PAPER BUY 체결 / 포지션 생성")
                        } else {
                            log("${candidate.market} PAPER BUY 실패: ${order.state}")
                        }
                    }
                }
            }
        }

        sellEvaluationsThisScan = heldPositions.size
        val sellsBefore = dao.sellTradesByMode("PAPER").size
        processPaperPositionExits(tickers, signals, effective, allowPaperExecution)
        actualSellsThisScan = (dao.sellTradesByMode("PAPER").size - sellsBefore).coerceAtLeast(0)

        updatePaperDashboard(tickers)
        checkAnomalies()
        evaluateMarketHealth()
        refreshRegimePerformance()
        val updatedPositions = dao.currentPositions("PAPER").map { it.market }.toSet()
        val updatedSignals = signals.map { signal ->
            if (updatedPositions.contains(signal.market)) signal.copy(
                status = CandidateStatus.HOLDING,
                failureReason = "보유 중"
            ) else signal
        }
        val funnel = NoTradeDiagnostics.buildFunnel(
            totalKrw = markets.size,
            fastCandidates = fastResult.candidates.size,
            deepCandidates = deepMarkets.size,
            signals = batchSignals,
            scoreThreshold = effective.scoreThreshold,
            aiMinScore = effective.aiMinScore,
            chaseRejectScore = effective.chaseRejectScore,
            minEntryTiming = effective.minimumEntryTimingScore,
            minShortEdge = effective.scalpingMinimumShortNetEdgePercent,
            actualBuys = actualBuysThisScan
        )
        hourlyGateFunnelTracker.record(funnel)
        val completedAt = System.currentTimeMillis()
        lastSuccessfulScanAt = completedAt
        scanSuccessCount += 1
        consecutiveScanFailures = 0
        val lastJudged = updatedSignals.firstOrNull()
        val lastBlock = updatedSignals.firstOrNull { it.status == CandidateStatus.REJECTED || it.status.name.startsWith("WAIT_") }
        val stall = NoTradeDiagnostics.stallWarning(true, lastSuccessfulScanAt, completedAt)
        val heartbeat = ScanHeartbeatSnapshot(
            scanSequence = scanSequence,
            scanStartedAt = startedAt,
            scanCompletedAt = completedAt,
            scanDurationMs = completedAt - startedAt,
            lastSuccessfulScanAt = lastSuccessfulScanAt,
            nextExpectedScanAt = completedAt + NoTradeDiagnostics.SCAN_INTERVAL_MS,
            scanSuccessCount = scanSuccessCount,
            scanFailureCount = scanFailureCount,
            consecutiveScanFailures = consecutiveScanFailures,
            currentStage = ScanStage.COMPLETE.name,
            stageStartedAt = completedAt,
            stageElapsedMs = 0L,
            stallWarning = stall.first,
            stallMessage = stall.second,
            watchingMarkets = markets.size,
            fastCandidates = fastResult.candidates.size,
            deepCandidates = deepMarkets.size,
            finalCandidates = updatedSignals.count { it.status == CandidateStatus.BUY_READY },
            buyReady = updatedSignals.count { it.status == CandidateStatus.BUY_READY },
            actualBuysThisScan = actualBuysThisScan,
            sellEvaluationsThisScan = sellEvaluationsThisScan,
            actualSellsThisScan = actualSellsThisScan,
            lastJudgedMarket = lastJudged?.market ?: "-",
            lastBlockGate = lastBlock?.let { NoTradeDiagnostics.classifyRejectGate(it.failureReason, it.status) } ?: "-",
            lastBlockReason = lastBlock?.failureReason ?: "-"
        )
        var diagnosis = NoTradeDiagnostics.diagnose(EngineStatus.RUNNING, heartbeat, hourlyGateFunnelTracker.snapshots(), completedAt)
        diagnosis = diagnosis.copy(
            shortEdgeDistribution = NoTradeDiagnostics.distributionLabel(batchSignals.map { it.shortHorizonNetEdge }, effective.scalpingMinimumShortNetEdgePercent),
            strategyScoreDistribution = NoTradeDiagnostics.distributionLabel(batchSignals.map { it.score }, effective.scoreThreshold),
            aiScoreDistribution = NoTradeDiagnostics.distributionLabel(batchSignals.map { it.aiScore }, effective.aiMinScore),
            aiPassRate = if (batchSignals.isEmpty()) 0.0 else batchSignals.count { it.aiScore >= effective.aiMinScore }.toDouble() / batchSignals.size
        )
        val rate30 = ExecutionDataPipelineDiagnostics.rateStats(30 * 60_000L, completedAt, "30M")
        val rate1h = ExecutionDataPipelineDiagnostics.rateStats(60 * 60_000L, completedAt, "1H")
        val rate3h = ExecutionDataPipelineDiagnostics.rateStats(3 * 60 * 60_000L, completedAt, "3H")
        fun formatRate(stats: ExecutionStateRateStats): String =
            if (stats.note == "NO_RUNTIME_DATA" || stats.executionDataInsufficientRate == null) "NO_RUNTIME_DATA"
            else "${"%.1f".format(stats.executionDataInsufficientRate * 100.0)}%${if (stats.pipelineDegraded) " PIPELINE_DEGRADED" else ""}"
        if (rate30.pipelineDegraded) log("EXECUTION_DATA_PIPELINE_DEGRADED window=30M rate=${formatRate(rate30)}")
        val endWsHealth = ExecutionDataPipelineDiagnostics.webSocketHealth(
            connectionStatus = tickerStream.status.value,
            lastMessageAt = tickerStream.lastMessageAt,
            now = completedAt
        )
        val brainStatus = runCatching { hetznerAiDecisionProvider.refreshHealth() }
            .getOrElse { AiBrainLinkStatus.OFFLINE }
        val brainHealth = hetznerAiDecisionProvider.lastHealth
        val lastRemote = remoteDecisionByMarket.values.mapNotNull { it.remote }.maxByOrNull { it.serverTimestamp ?: 0L }
        _state.value=_state.value.copy(
            lastTickerAt=tickers.values.maxOfOrNull { it.timestamp } ?: _state.value.lastTickerAt,
            lastWebSocketAt=tickerStream.lastMessageAt,
            topSignals=updatedSignals,
            candidateCount=updatedSignals.count {
                it.status == CandidateStatus.BUY_READY || it.status == CandidateStatus.ORDERING ||
                    it.status == CandidateStatus.HOLDING || it.status == CandidateStatus.WAIT_PULLBACK ||
                    it.status == CandidateStatus.WAIT_RETEST || it.status == CandidateStatus.WAIT_RECONFIRMATION ||
                    it.status == CandidateStatus.WAIT_MOMENTUM
            },
            buyReadyCount=updatedSignals.count { it.status == CandidateStatus.BUY_READY },
            currentAnalyzingMarket="-",
            websocketStatus=tickerStream.status.value,
            websocketHealthLabel=endWsHealth.health.name,
            executionDataInsufficientRate30m=formatRate(rate30),
            executionDataInsufficientRate1h=formatRate(rate1h),
            executionDataInsufficientRate3h=formatRate(rate3h),
            aiBrainStatus = brainStatus.name,
            aiBrainLatencyMs = hetznerAiDecisionProvider.lastNetworkLatencyMs ?: -1L,
            aiBrainLastDecisionAt = brainHealth?.lastDecisionAt ?: lastRemote?.serverTimestamp ?: 0L,
            aiBrainModelVersion = brainHealth?.modelVersion ?: "-",
            aiBrainBithumbWs = brainHealth?.bithumbWs?.connectionState ?: "-",
            aiBrainMicroBufferReady = brainHealth?.microBufferReadyMarkets ?: 0,
            aiBrainLocalFallback = when {
                !settings.remoteAiEnabled -> "LOCAL_PRIMARY"
                settings.remoteAiPrimary -> "FAIL_CLOSED_NO_AUTO_LOCAL_BUY"
                else -> "SHADOW"
            },
            lastRemoteDecisionId = lastRemote?.decisionId.orEmpty(),
            lastRemoteDecisionSummary = lastRemote?.let {
                "${it.market} ${it.decision} AI=${it.aiScore?.toInt() ?: "-"} Exec=${it.executionState}"
            } ?: "-",
            lastAnalysisAt=completedAt,
            settings=settings,
            testMode=settings.testMode,
            strategyVersion=strategyVersion,
            finalCandidateCount=updatedSignals.count { it.status == CandidateStatus.BUY_READY },
            pipelineStage=ScanStage.COMPLETE.name,
            smartReentryStates = smartReentryCoordinator.snapshot(),
            globalDerivatives = derivativesIntelligence.values.sortedBy { it.market }.take(20),
            globalDerivativesMarketState = GlobalDerivativesMarketEngine.classify(derivativesIntelligence.values.toList()),
            candidateLatency=ScanPerformanceProfiler.latency(candidateLatencySamples.toList()),
            scanPerformance=ScanPerformanceSnapshot(
                totalScanMs = completedAt - startedAt,
                fastScanMs = fastScanMs,
                deepScanMs = deepScanMs,
                executionScanMs = executionScanMs,
                positionFastLaneMs = positionFastLaneMs,
                marketDataMs = marketDataMs,
                dbMs = 0L,
                apiCallCount = (marketData as? CachedMarketDataProvider)?.optimizationStats()?.first ?: 0,
                cacheHitRate = (marketData as? CachedMarketDataProvider)?.optimizationStats()?.third ?: 0.0,
                websocketUpdates = if (tickerStream.lastMessageAt > 0L) 1 else 0,
                rateLimitWaitMs = (marketData as? CachedMarketDataProvider)?.optimizationStats()?.second ?: 0L
            ),
            scanHeartbeat = heartbeat,
            gateFunnel = funnel,
            noTradeDiagnosis = diagnosis
        )
        persistLearningAssetManifest()
        log("분석 완료 #$scanSequence: 후보 ${_state.value.candidateCount}개 / BUY_READY ${_state.value.buyReadyCount} / BUY $actualBuysThisScan / TOP ${heartbeat.lastBlockGate}")
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/TradingCore.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/TradingForegroundService.kt =====

package com.example.bithumbtrader

import android.app.*
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import kotlinx.coroutines.*

class TradingForegroundService: LifecycleService() {
    private var job: Job? = null
    private var newsJob: Job? = null
    private var lastOtaCheckAt: Long = 0L
    private var wakeLock: PowerManager.WakeLock? = null
    override fun onCreate(){ super.onCreate(); createChannel() }
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
        when(intent?.action){ ACTION_STOP -> stopTrading(); else -> startTrading() }
        return START_STICKY
    }
    private fun startTrading(){
        startForeground(1, notification("빗썸 자동매매 실행 중", "초기화 중"))
        val repo=(application as TraderApp).repository
        if(job?.isActive==true) return
        val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
        job=serviceScope.launch {
            repo.log("Foreground trading service started app=${BuildConfig.VERSION_NAME}")
            while(isActive){
                try {
                    acquireShortWakeLock()
                    if (System.currentTimeMillis() - lastOtaCheckAt >= OTA_INTERVAL_MS) { repo.checkStrategyOta(); repo.checkAiModelOta(); lastOtaCheckAt = System.currentTimeMillis() }
                    // SERVER PRIMARY: local PAPER execution must stay OFF.
                    // TradingForegroundService only refreshes viewer state; server paper engine trades.
                    val creds = SecureCredentialStore(applicationContext)
                    val settings0 = repo.state.value.settings
                    if (settings0.remoteAiEnabled && !settings0.remoteAiPrimary && creds.hasTradingAiToken()) {
                        repo.updateSettings(settings0.copy(remoteAiPrimary = true))
                        repo.log("SERVER PRIMARY force-ON in service loop (token ready)")
                        runCatching { repo.restoreServerPaperOnAppStart(source = "SERVICE_FORCE_PRIMARY") }
                        runCatching { repo.setServerPaperAuto(true) }
                    }
                    val primary = repo.state.value.settings.let { it.remoteAiEnabled && it.remoteAiPrimary }
                    repo.scanOnce(allowPaperExecution = !primary)
                    val s=repo.state.value
                    val mode = s.androidAnalysisMode.ifBlank { "LOCAL_FULL_SCAN" }
                    val title = when {
                        mode == "SERVER_PRIMARY_VIEWER" -> "빗썸 SERVER PRIMARY"
                        mode == "POSITION_SAFETY_ONLY" -> "빗썸 포지션 보호(서버 OFF)"
                        else -> "빗썸 자동매매 실행 중"
                    }
                    val content = when {
                        mode == "SERVER_PRIMARY_VIEWER" ->
                            "PAPER ${if (s.serverPaperAuto) "ON" else "OFF"} · 서버현금 ${s.serverPaperCash.toLong()} · pos ${s.serverPaperPositionCount}"
                        mode == "POSITION_SAFETY_ONLY" ->
                            "NEW BUY BLOCK · 포지션 보호 · 평가 ${s.totalValue.toLong()}원"
                        else ->
                            "평가 ${s.totalValue.toLong()}원 / 오늘 ${s.todayPnl.toLong()}원 / 뉴스 ${s.newsResearch.engineStatus.name}"
                    }
                    getSystemService(NotificationManager::class.java).notify(1, notification(title, content))
                    delay(30_000)
                } catch(e: CancellationException){ throw e } catch(e: Exception){ repo.reportEngineError(e.message ?: "알 수 없음"); delay(10_000) } finally { releaseWakeLock() }
            }
        }
        newsJob=serviceScope.launch {
            while(isActive){
                // SERVER PRIMARY: News AI는 서버 담당. Android 뉴스 전체분석 루프는 건너뛴다.
                val settings = repo.state.value.settings
                if (!(settings.remoteAiEnabled && settings.remoteAiPrimary)) {
                    repo.checkNews()
                }
                delay(NEWS_INTERVAL_MS)
            }
        }
    }
    private fun stopTrading(){ job?.cancel(); newsJob?.cancel(); job=null; newsJob=null; (application as TraderApp).repository.stopMarketStream(); releaseWakeLock(); stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() }
    private fun acquireShortWakeLock(){
        val pm=getSystemService(PowerManager::class.java)
        // Scan+API는 종종 20초를 넘김. 짧은 wake lock은 mid-scan sleep/stall의 원인이 될 수 있음.
        wakeLock = (wakeLock ?: pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "BithumbTrader:scan")).also {
            if(!it.isHeld) it.acquire(5 * 60_000L)
        }
    }
    private fun releaseWakeLock(){ wakeLock?.takeIf{it.isHeld}?.release() }
    private fun createChannel(){ val nm=getSystemService(NotificationManager::class.java); nm.createNotificationChannel(NotificationChannel(CHANNEL, "자동매매", NotificationManager.IMPORTANCE_LOW)) }
    private fun notification(title:String, text:String): Notification = NotificationCompat.Builder(this, CHANNEL).setContentTitle(title).setContentText(text).setSmallIcon(android.R.drawable.stat_notify_sync).setOngoing(true).build()
    override fun onDestroy(){ job?.cancel(); newsJob?.cancel(); (application as TraderApp).repository.stopMarketStream(); releaseWakeLock(); super.onDestroy() }
    override fun onBind(intent: Intent): IBinder? = super.onBind(intent)
    companion object { const val ACTION_START="start"; const val ACTION_STOP="stop"; private const val CHANNEL="trading"; private const val OTA_INTERVAL_MS = 10 * 60 * 1000L; private const val NEWS_INTERVAL_MS = 10 * 60 * 1000L }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/TradingForegroundService.kt =====

===== FILE: app/src/main/java/com/example/bithumbtrader/TradingSettingsStore.kt =====
package com.example.bithumbtrader

import android.content.Context
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory

object TradingSettingsValidator {
    fun sanitize(value: TradingSettings): TradingSettings = value.copy(
        paperInitialKrw = value.paperInitialKrw.takeIf { it.isFinite() && it >= 0.0 } ?: 100_000.0,
        maxPositions = value.maxPositions.coerceIn(1, 50),
        maxPositionsHardCap = value.maxPositionsHardCap.coerceIn(1, 50),
        dynamicPortfolioCapacityEnabled = value.dynamicPortfolioCapacityEnabled,
        maxOpenRiskPercent = value.maxOpenRiskPercent.finiteOr(5.0).coerceIn(1.0, 20.0),
        minimumViableOrderKrw = value.minimumViableOrderKrw.finiteOr(8_000.0).coerceIn(1_000.0, 100_000.0),
        maxAssetPercentPerCoin = value.maxAssetPercentPerCoin.finiteOr(20.0).coerceIn(0.1, 100.0),
        maxOrderPercent = value.maxOrderPercent.finiteOr(20.0).coerceIn(0.1, 100.0),
        minKrwCashPercent = value.minKrwCashPercent.finiteOr(30.0).coerceIn(0.0, 100.0),
        scoreThreshold = value.scoreThreshold.finiteOr(75.0).coerceIn(0.0, 100.0),
        min24hTradePrice = value.min24hTradePrice.finiteOr(500_000_000.0).coerceAtLeast(0.0),
        liquidityPercentileThreshold = value.liquidityPercentileThreshold.finiteOr(0.25).coerceIn(0.0, 1.0),
        overblockingWarningThreshold = value.overblockingWarningThreshold.finiteOr(0.8).coerceIn(0.5, 1.0),
        maxSpreadPercent = value.maxSpreadPercent.finiteOr(0.7).coerceIn(0.0, 20.0),
        minTradeVolume = value.minTradeVolume.finiteOr(0.0).coerceAtLeast(0.0),
        stopLossPercent = value.stopLossPercent.finiteOr(-2.5).coerceIn(-80.0, -0.1),
        takeProfitPercent = value.takeProfitPercent.finiteOr(6.0).coerceIn(0.1, 300.0),
        trailingStopPercent = value.trailingStopPercent.finiteOr(2.5).coerceIn(0.1, 80.0),
        dailyMaxLossPercent = value.dailyMaxLossPercent.finiteOr(-5.0).coerceIn(-80.0, -0.1),
        staleTickerMillis = value.staleTickerMillis.coerceIn(5_000L, 600_000L),
        aiMinScore = value.aiMinScore.finiteOr(55.0).coerceIn(0.0, 100.0),
        maxConsecutiveLosses = value.maxConsecutiveLosses.coerceIn(0, 50),
        crashCooldownMinutes = value.crashCooldownMinutes.coerceIn(1, 24 * 60),
        profitProtectionLevel1Percent = value.profitProtectionLevel1Percent.finiteOr(1.0).coerceIn(0.1, 50.0),
        profitProtectionLevel2Percent = value.profitProtectionLevel2Percent.finiteOr(3.0).coerceIn(0.1, 100.0),
        profitProtectionLevel3Percent = value.profitProtectionLevel3Percent.finiteOr(5.0).coerceIn(0.1, 200.0),
        profitProtectionLevel4Percent = value.profitProtectionLevel4Percent.finiteOr(10.0).coerceIn(0.1, 500.0),
        profitCautionGivebackPercentPoints = value.profitCautionGivebackPercentPoints.finiteOr(0.75).coerceIn(0.1, 50.0),
        profitDefenseGivebackPercentPoints = value.profitDefenseGivebackPercentPoints.finiteOr(1.5).coerceIn(0.1, 50.0),
        profitLockedGivebackPercentPoints = value.profitLockedGivebackPercentPoints.finiteOr(2.5).coerceIn(0.1, 100.0),
        profitCautionMultiplier = value.profitCautionMultiplier.finiteOr(0.7).coerceIn(0.0, 1.0),
        profitDefenseMultiplier = value.profitDefenseMultiplier.finiteOr(0.4).coerceIn(0.0, 1.0),
        stopLossCooldownMinutes = value.stopLossCooldownMinutes.coerceIn(1, 24 * 60),
        trailingStopCooldownMinutes = value.trailingStopCooldownMinutes.coerceIn(1, 24 * 60),
        minNetEdgeMarginPercent = value.minNetEdgeMarginPercent.finiteOr(0.35).coerceIn(0.0, 10.0),
        maxPortfolioHeatPercent = value.maxPortfolioHeatPercent.finiteOr(60.0).coerceIn(10.0, 100.0),
        chaseRejectScore = value.chaseRejectScore.finiteOr(80.0).coerceIn(50.0, 100.0),
        extremeChaseScore = value.extremeChaseScore.finiteOr(90.0).coerceIn(value.chaseRejectScore.coerceIn(50.0, 100.0), 100.0),
        minimumEntryTimingScore = value.minimumEntryTimingScore.finiteOr(45.0).coerceIn(0.0, 100.0),
        overextensionAtrMultiple = value.overextensionAtrMultiple.finiteOr(1.75).coerceIn(0.5, 10.0),
        highScoreFailureWarningRate = value.highScoreFailureWarningRate.finiteOr(0.40).coerceIn(0.1, 1.0),
        entrySignalMaxAgeMillis = value.entrySignalMaxAgeMillis.coerceIn(10_000L, 600_000L),
        scalpingMinimumExecutionScore = value.scalpingMinimumExecutionScore.finiteOr(60.0).coerceIn(0.0, 100.0),
        scalpingMinimumShortNetEdgePercent = value.scalpingMinimumShortNetEdgePercent.finiteOr(0.15).coerceIn(0.0, 20.0),
        scalpingSafetyMargin = value.scalpingSafetyMargin.finiteOr(1.35).coerceIn(1.0, 5.0),
        scalpingMaximumSpreadPercent = value.scalpingMaximumSpreadPercent.finiteOr(0.7).coerceIn(0.01, 10.0),
        scalpingSignalTtlMillis = value.scalpingSignalTtlMillis.coerceIn(10_000L, 600_000L),
        scalpingPriceMovedAwayAtrMultiple = value.scalpingPriceMovedAwayAtrMultiple.finiteOr(1.5).coerceIn(0.25, 10.0),
        profitReentryCooldownMinutes = value.profitReentryCooldownMinutes.coerceIn(1, 24 * 60),
        profitReentryScoreResetDrop = value.profitReentryScoreResetDrop.finiteOr(12.0).coerceIn(1.0, 50.0),
        minimumProfitReentryQuality = value.minimumProfitReentryQuality.finiteOr(62.0).coerceIn(0.0, 100.0),
        maxProfitReentryRiskPercent = value.maxProfitReentryRiskPercent.finiteOr(100.0).coerceIn(0.0, 500.0),
        reentryChainWindowMinutes = value.reentryChainWindowMinutes.coerceIn(1, 24 * 60),
        churnWindowMinutes = value.churnWindowMinutes.coerceIn(5, 24 * 60),
        continuousLearningTriggerSamples = value.continuousLearningTriggerSamples.coerceIn(10, 500),
        continuousLearningIntervalMinutes = value.continuousLearningIntervalMinutes.coerceIn(5, 24 * 60),
        replayBufferSize = value.replayBufferSize.coerceIn(100, 10_000),
        regimeStrategySetsEnabled = value.regimeStrategySetsEnabled,
        regimeStrategySetsPaperApplyEnabled = value.regimeStrategySetsPaperApplyEnabled,
        remoteAiBaseUrl = value.remoteAiBaseUrl.ifBlank { "https://riderapp.duckdns.org" },
        serverDecisionMaxAgeMillis = value.serverDecisionMaxAgeMillis.coerceIn(10_000L, 600_000L),
        localShadowIntervalMinutes = value.localShadowIntervalMinutes.coerceIn(1, 180),
        remoteAiPrimary = value.remoteAiPrimary && value.remoteAiEnabled,
        absoluteMinimumNetProfitKrw = value.absoluteMinimumNetProfitKrw.finiteOr(30.0).coerceIn(0.0, 10_000.0),
        minimumNetProfitPercentOfOrder = value.minimumNetProfitPercentOfOrder.finiteOr(0.05).coerceIn(0.0, 5.0),
        minimumCostCoverageMultiple = value.minimumCostCoverageMultiple.finiteOr(1.5).coerceIn(1.0, 10.0),
        paperFeeRate = value.paperFeeRate.finiteOr(0.0025).coerceIn(0.0, 0.02),
        paperSlippageRate = value.paperSlippageRate.finiteOr(0.001).coerceIn(0.0, 0.02),
        netProfitAfterCostGateEnabled = value.netProfitAfterCostGateEnabled
    )

    private fun Double.finiteOr(fallback: Double): Double = if (isFinite()) this else fallback
}

class TradingSettingsStore(context: Context) {
    private val preferences = context.applicationContext
        .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
    private val adapter = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()
        .adapter(TradingSettings::class.java)

    fun load(): TradingSettings {
        val json = preferences.getString(KEY_SETTINGS_JSON, null) ?: return TradingSettings()
        val loaded = runCatching { adapter.fromJson(json) }
            .getOrNull()
            ?.let(TradingSettingsValidator::sanitize)
            ?: TradingSettings()
        // PHASE6 hard migrate: Phase1/2 left remoteAiPrimary=false persisted → LOCAL /decision flood forever.
        if (loaded.remoteAiEnabled && (!loaded.remoteAiPrimary || !loaded.remoteDashboardEnabled)) {
            val migrated = TradingSettingsValidator.sanitize(
                loaded.copy(remoteAiPrimary = true, remoteDashboardEnabled = true)
            )
            save(migrated)
            return migrated
        }
        return loaded
    }

    fun save(settings: TradingSettings) {
        val validated = TradingSettingsValidator.sanitize(settings)
        preferences.edit()
            .putString(KEY_SETTINGS_JSON, adapter.toJson(validated))
            .putBoolean(KEY_AUTO_RESUME, validated.autoResumeAfterBoot)
            .apply()
    }

    fun autoResumeAfterBoot(): Boolean = preferences.getBoolean(KEY_AUTO_RESUME, false)

    companion object {
        private const val PREFERENCES_NAME = "bithumb_trading_settings"
        private const val KEY_SETTINGS_JSON = "settings_json"
        private const val KEY_AUTO_RESUME = "auto_resume_after_boot"
    }
}

===== END FILE: app/src/main/java/com/example/bithumbtrader/TradingSettingsStore.kt =====

===== FILE: app/src/main/res/values/strings.xml =====
<resources><string name="app_name">빗썸 올인원</string></resources>

===== END FILE: app/src/main/res/values/strings.xml =====

===== FILE: app/src/main/res/values/styles.xml =====
<resources><style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar" /></resources>

===== END FILE: app/src/main/res/values/styles.xml =====

===== FILE: app/src/test/java/com/example/bithumbtrader/CoreUnitTest.kt =====

package com.example.bithumbtrader

import kotlinx.coroutines.test.runTest
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.Response
import kotlin.math.abs

class CoreUnitTest {
    @Test fun ema_isFinite(){ val e=Indicators.ema((1..60).map{it.toDouble()}, 20); assertTrue(e.isFinite()); assertTrue(e > 1.0) }
    @Test fun rsi_range(){ val r=Indicators.rsi((1..30).map{it.toDouble()},14); assertTrue(r in 0.0..100.0) }
    @Test fun macd_isFinite(){ assertTrue(Indicators.macd((1..60).map{it.toDouble()}).isFinite()) }
    @Test fun score_handlesMissingData(){ val s=StrategyEngine().score("KRW-BTC", null, null, emptyList()); assertEquals(0.0, s.score, 0.0) }
    @Test fun score_range(){ val candles=(1..120).map{ CandleModel("KRW-BTC", it.toLong(), 1000.0+it, 1001.0+it, 999.0+it, 10.0) }; val s=StrategyEngine().score("KRW-BTC", TickerModel("KRW-BTC",1121.0,10_000_000_000.0,0.01,1.0,System.currentTimeMillis()), OrderbookModel("KRW-BTC",1122.0,1121.0,5.0,7.0,System.currentTimeMillis()), candles); assertTrue(s.score in 0.0..100.0); assertTrue(s.momentumPercent.isFinite()); assertTrue(s.volatilityPercent.isFinite()) }
    @Test fun risk_rejectsDailyLoss(){ val ok=RiskManager().canBuy(TradingSettings(), DashboardState(mode=TradeMode.LIVE, dailyLossLocked=true,lastTickerAt=System.currentTimeMillis()), StrategySignalModel("KRW-BTC",90.0,""), OrderbookModel("KRW-BTC",100.0,99.9,1.0,1.0,0), false, false, 10000.0); assertFalse(ok.first) }
    @Test fun risk_acceptsSafePaperBuy(){ val ok=RiskManager().canBuy(TradingSettings(), DashboardState(lastTickerAt=System.currentTimeMillis(), apiStatus=ConnectionStatus.CONNECTED), StrategySignalModel("KRW-BTC",90.0,""), OrderbookModel("KRW-BTC",100.0,99.9,1.0,1.0,0), false, false, 10000.0); assertTrue(ok.second, ok.first) }
    @Test fun stopLossTriggers(){ val r=RiskManager().shouldSell(TradingSettings(), PositionModel("KRW-BTC",1.0,100.0,105.0,0), 96.0, StrategySignalModel("KRW-BTC",80.0,"")); assertTrue(r.first); assertEquals("STOP LOSS", r.second) }
    @Test fun takeProfitTriggers(){ val r=RiskManager().shouldSell(TradingSettings(), PositionModel("KRW-BTC",1.0,100.0,105.0,0), 107.0, StrategySignalModel("KRW-BTC",80.0,"")); assertTrue(r.first); assertEquals("TAKE PROFIT", r.second) }
    @Test fun trailingStopTriggers(){ val r=RiskManager().shouldSell(TradingSettings(), PositionModel("KRW-BTC",1.0,100.0,110.0,0), 104.0, StrategySignalModel("KRW-BTC",80.0,"")); assertTrue(r.first); assertEquals("TRAILING STOP", r.second) }
    @Test fun trailingStopDoesNotFireBeforeArmWhileNetNegative(){
        // HOOK pattern: peak only ~+0.97%, then -2.5% from peak → still net-negative vs entry.
        val r = RiskManager().shouldSell(
            TradingSettings(trailingArmMinProfitPercent = 1.0, trailingStopPercent = 2.5, stopLossPercent = -2.5),
            PositionModel("KRW-HOOK", 1.0, 7.964957, 8.04205, 0),
            7.841,
            StrategySignalModel("KRW-HOOK", 100.0, "")
        )
        assertFalse(r.first)
        assertEquals("HOLD", r.second)
    }
    @Test fun trailingStopFiresAfterArmEvenIfStillSlightlyPositive(){
        val r = RiskManager().shouldSell(
            TradingSettings(trailingArmMinProfitPercent = 1.0, trailingStopPercent = 2.5),
            PositionModel("KRW-BTC", 1.0, 100.0, 103.0, 0),
            100.4,
            StrategySignalModel("KRW-BTC", 80.0, "")
        )
        assertTrue(r.first)
        assertEquals("TRAILING STOP", r.second)
    }
    @Test fun dailyLossCalculation(){ assertTrue(RiskManager().dailyLossLocked(TradingSettings(), 100000.0, 94000.0)) }
    @Test fun jwtContainsBearerAndQueryHash(){ val jwt=***REDACTED***"ak","sk"); val token=***REDACTED***"market" to "KRW-BTC"), nonce="n", timestamp=1); assertTrue(token.startsWith("Bearer ")); assertEquals(128, jwt.sha512Hex("market=KRW-BTC").length) }
    @Test fun queryStringOrderIsStable(){ val jwt=***REDACTED***"a","s"); assertEquals("market=KRW-BTC&side=bid&order_type=price&price=1000", jwt.queryString(listOf("market" to "KRW-BTC","side" to "bid","order_type" to "price","price" to 1000))) }
    @Test fun orderStatesContainUnknown(){ assertNotNull(OrderState.valueOf("UNKNOWN")) }
    @Test fun duplicateOrderGuardRejectsInFlight(){ val ok=RiskManager().canBuy(TradingSettings(), DashboardState(lastTickerAt=System.currentTimeMillis(), apiStatus=ConnectionStatus.CONNECTED), StrategySignalModel("KRW-BTC",90.0,""), OrderbookModel("KRW-BTC",100.0,99.9,1.0,1.0,0), false, true, 10000.0); assertFalse(ok.first) }
    @Test fun paperExecutionModelReady() = runTest { assertTrue(TradeMode.PAPER.name == "PAPER") }
    @Test fun paperBuyUsesMarketPriceAndFee(){ val fill=PaperTradingMath.buyFill(100_000.0, 10_000.0, 0.0025, 0.001)!!; assertTrue(fill.executionPrice > 10_000.0); assertEquals(100_000.0 * 0.0025, fill.fee, 0.0001); assertTrue(fill.quantity > 0.0) }
    @Test fun paperSellUsesMarketPriceAndFee(){ val fill=PaperTradingMath.sellFill(10.0, 10_000.0, 0.0025, 0.001)!!; assertTrue(fill.executionPrice < 10_000.0); assertTrue(fill.grossAmount > 0.0); assertTrue(fill.fee > 0.0) }
    @Test fun paperSellNetCashIsGrossMinusFee(){ val fill=PaperTradingMath.sellFill(10.0, 10_000.0, 0.0025, 0.001)!!; assertEquals(fill.grossAmount - fill.fee, 99_650.25, 0.01) }
    @Test fun highestPriceOnlyMovesUp(){ assertEquals(110.0, PaperTradingMath.highestPrice(100.0, 110.0), 0.0); assertEquals(110.0, PaperTradingMath.highestPrice(110.0, 105.0), 0.0); assertEquals(110.0, PaperTradingMath.highestPrice(110.0, Double.NaN), 0.0) }
    @Test fun aiFeatureBuilderRejectsInsufficientData(){ assertTrue(AiFeatureBuilder.build(null, emptyList()).isEmpty()) }
    @Test fun bundledAiModelProducesBoundedScore(){ val model=BundledLogisticAiModel(AiModelArtifact(1, List(8) { "f$it" }, List(8){0.0}, List(8){1.0}, List(8){0.1}, 0.0)); val prediction=model.predict(List(8){0.0}); assertTrue(prediction.score in 0.0..100.0) }
    @Test fun aiModelJsonCanBeLoadedForOta(){ val json="""{"modelVersion":7,"featureNames":["a"],"means":[0.0],"scales":[1.0],"weights":[1.0],"bias":0.0,"positiveThreshold":0.5}"""; assertEquals(7, OnDeviceAiModel.fromJson(json).version) }
    @Test fun aiModelArtifactRoundTripsThroughToJson(){ val artifact=AiModelArtifact(3, listOf("a"), listOf(0.0), listOf(1.0), listOf(0.5), 0.0); val json=OnDeviceAiModel.toJson(artifact); assertEquals(3, OnDeviceAiModel.fromJson(json).version) }

    // ---- 반자동 학습(페이퍼 트레이딩 기반 재학습) ----
    @Test fun retrainPolicyRequiresMinimumLabeledSamples(){ assertFalse(AiRetrainPolicy.hasEnoughData(5)); assertTrue(AiRetrainPolicy.hasEnoughData(AiRetrainPolicy.MIN_TRAINING_SAMPLES)) }
    @Test fun retrainPolicyOnlyAdoptsWhenMeasurablyBetter(){
        assertFalse(AiRetrainPolicy.shouldAdopt(0.60, 0.60))
        assertFalse(AiRetrainPolicy.shouldAdopt(0.61, 0.60))
        assertTrue(AiRetrainPolicy.shouldAdopt(0.60 + AiRetrainPolicy.IMPROVEMENT_MARGIN, 0.60))
    }
    @Test fun trainerRejectsTooFewExamples(){ assertNull(OnDeviceModelTrainer.train(listOf(OnDeviceModelTrainer.TrainingExample(listOf(0.0), 0.0)))) }
    @Test fun trainerRejectsMismatchedFeatureDimensions(){
        val examples = listOf(
            OnDeviceModelTrainer.TrainingExample(listOf(0.0, 1.0), 1.0),
            OnDeviceModelTrainer.TrainingExample(listOf(0.0), 0.0)
        ) + List(10) { OnDeviceModelTrainer.TrainingExample(listOf(0.0, 1.0), 1.0) }
        assertNull(OnDeviceModelTrainer.train(examples))
    }
    @Test fun trainerLearnsSeparableSignalFromPaperOutcomes(){
        val examples = (0 until 40).map { i ->
            val positive = i % 2 == 0
            OnDeviceModelTrainer.TrainingExample(listOf(if (positive) 1.0 else -1.0, 0.0), if (positive) 1.0 else 0.0)
        }
        val artifact = OnDeviceModelTrainer.train(examples)
        assertNotNull(artifact)
        val model = BundledLogisticAiModel(artifact!!.copy(modelVersion = 1))
        val accuracy = OnDeviceModelTrainer.accuracyOfModel(model, examples)
        assertTrue("학습된 모델은 분리 가능한 데이터에서 높은 정확도를 보여야 함, 실제=$accuracy", accuracy >= 0.9)
    }
    @Test fun candidateModelBeatsWeakBaselineOnValidationSet(){
        val trainExamples = (0 until 32).map { i ->
            val positive = i % 2 == 0
            OnDeviceModelTrainer.TrainingExample(listOf(if (positive) 1.0 else -1.0, 0.0), if (positive) 1.0 else 0.0)
        }
        val validationExamples = (0 until 10).map { i ->
            val positive = i % 2 == 0
            OnDeviceModelTrainer.TrainingExample(listOf(if (positive) 1.0 else -1.0, 0.0), if (positive) 1.0 else 0.0)
        }
        val candidateArtifact = OnDeviceModelTrainer.train(trainExamples)!!.copy(modelVersion = 1)
        val candidateModel = BundledLogisticAiModel(candidateArtifact)
        val disabledBaseline = DisabledAiModel("초기 모델")
        val candidateAccuracy = OnDeviceModelTrainer.accuracyOfModel(candidateModel, validationExamples)
        val baselineAccuracy = OnDeviceModelTrainer.accuracyOfModel(disabledBaseline, validationExamples)
        assertTrue(AiRetrainPolicy.shouldAdopt(candidateAccuracy, baselineAccuracy))
    }

    // ---- 성과 지표(StrategyPerformanceEngine) ----
    @Test fun performanceEngineHandlesEmptyData(){ val stats=StrategyPerformanceEngine.fromPnlRates(emptyList()); assertEquals(0, stats.sampleCount) }
    @Test fun performanceEngineComputesWinRateAndProfitFactor(){
        val stats = StrategyPerformanceEngine.fromPnlRates(listOf(2.0, -1.0, 3.0, -1.0, 2.0))
        assertEquals(5, stats.sampleCount)
        assertEquals(0.6, stats.winRate, 0.0001)
        assertEquals(7.0/2.0, stats.profitFactor, 0.0001)
        assertEquals(1.0, stats.expectedReturnPercent, 0.0001)
    }
    @Test fun performanceEngineComputesMaxDrawdown(){
        val stats = StrategyPerformanceEngine.fromPnlRates(listOf(5.0, -8.0, 2.0, -1.0))
        assertEquals(-8.0, stats.maxDrawdownPercent, 0.0001)
    }
    @Test fun performanceEngineTreatsAllWinsAsInfiniteProfitFactor(){
        val stats = StrategyPerformanceEngine.fromPnlRates(listOf(1.0, 2.0, 3.0))
        assertTrue(stats.profitFactor.isInfinite())
    }
    @Test fun performanceEngineFromTradesFiltersSellSideOnly(){
        val trades = listOf(
            TradeEntity(time=1, market="KRW-BTC", side="BUY", amount=10000.0, quantity=1.0, avgPrice=10000.0, fee=0.0, realizedPnl=0.0, pnlRate=0.0, reason=""),
            TradeEntity(time=2, market="KRW-BTC", side="SELL", amount=11000.0, quantity=1.0, avgPrice=11000.0, fee=0.0, realizedPnl=1000.0, pnlRate=10.0, reason=""),
            TradeEntity(time=3, market="KRW-BTC", side="SELL", amount=9500.0, quantity=1.0, avgPrice=9500.0, fee=0.0, realizedPnl=-500.0, pnlRate=-5.0, reason="")
        )
        val stats = StrategyPerformanceEngine.fromTrades(trades)
        assertEquals(2, stats.sampleCount)
    }

    // ---- 리스크 엔진(연속 손실 / 킬 스위치) ----
    @Test fun consecutiveLossLockEngagesAtThreshold(){
        val settings = TradingSettings(maxConsecutiveLosses = 3)
        assertFalse(RiskManager().consecutiveLossLocked(settings, 2))
        assertTrue(RiskManager().consecutiveLossLocked(settings, 3))
    }
    @Test fun consecutiveLossLockDisabledWhenThresholdIsZero(){
        assertFalse(RiskManager().consecutiveLossLocked(TradingSettings(maxConsecutiveLosses = 0), 100))
    }
    @Test fun riskManagerRejectsBuyWhenKillSwitchEngaged(){
        val ok = RiskManager().canBuy(TradingSettings(), DashboardState(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED, killSwitchEngaged = true), StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(ok.first)
    }
    @Test fun riskManagerRejectsBuyWhenConsecutiveLossLocked(){
        val ok = RiskManager().canBuy(TradingSettings(), DashboardState(mode=TradeMode.LIVE, lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED, consecutiveLossLocked = true), StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(ok.first)
    }

    // ---- 전략 추천(StrategyRecommendationEngine) ----
    @Test fun recommendationEngineRequiresMinimumSamples(){
        assertNull(StrategyRecommendationEngine.propose(PerformanceStats(sampleCount = 10, winRate = 0.2, profitFactor = 0.5)))
    }
    @Test fun recommendationEngineTightensOnPoorPerformance(){
        val proposal = StrategyRecommendationEngine.propose(PerformanceStats(sampleCount = 40, winRate = 0.3, profitFactor = 0.7, maxDrawdownPercent = -5.0, expectedReturnPercent = -0.5))
        assertNotNull(proposal)
        assertTrue(proposal!!.scoreThresholdDelta > 0.0)
        assertTrue(proposal.stopLossDelta > 0.0)
    }
    @Test fun recommendationEngineReducesExposureOnHighDrawdown(){
        val proposal = StrategyRecommendationEngine.propose(PerformanceStats(sampleCount = 40, winRate = 0.55, profitFactor = 1.2, maxDrawdownPercent = -20.0, expectedReturnPercent = 0.1))
        assertNotNull(proposal)
        assertEquals(-1, proposal!!.maxPositionsDelta)
    }
    @Test fun recommendationEngineRelaxesOnStrongPerformance(){
        val proposal = StrategyRecommendationEngine.propose(PerformanceStats(sampleCount = 40, winRate = 0.7, profitFactor = 2.0, maxDrawdownPercent = -3.0, expectedReturnPercent = 1.0))
        assertNotNull(proposal)
        assertTrue(proposal!!.scoreThresholdDelta < 0.0)
    }
    @Test fun recommendationEngineNoOpinionOnMediocrePerformance(){
        val proposal = StrategyRecommendationEngine.propose(PerformanceStats(sampleCount = 40, winRate = 0.5, profitFactor = 1.1, maxDrawdownPercent = -5.0, expectedReturnPercent = 0.05))
        assertNull(proposal)
    }
    @Test fun recommendationProposalClampsSettingsWithinBounds(){
        val proposal = RecommendationProposal(reason = "test", scoreThresholdDelta = -1000.0, maxPositionsDelta = -100)
        val applied = proposal.apply(TradingSettings())
        assertEquals(0.0, applied.scoreThreshold, 0.0001)
        assertEquals(1, applied.maxPositions)
    }
    @Test fun isImprovementRequiresMinimumValidationSamples(){
        val candidate = PerformanceStats(sampleCount = 5, winRate = 0.9, profitFactor = 3.0)
        val baseline = PerformanceStats(sampleCount = 40, winRate = 0.4, profitFactor = 1.0)
        assertFalse(StrategyRecommendationEngine.isImprovement(candidate, baseline))
    }
    @Test fun isImprovementRejectsWhenNotMeaningfullyBetter(){
        val candidate = PerformanceStats(sampleCount = 20, winRate = 0.41, profitFactor = 1.01)
        val baseline = PerformanceStats(sampleCount = 40, winRate = 0.4, profitFactor = 1.0)
        assertFalse(StrategyRecommendationEngine.isImprovement(candidate, baseline))
    }
    @Test fun isImprovementAcceptsClearWinRateGain(){
        val candidate = PerformanceStats(sampleCount = 20, winRate = 0.6, profitFactor = 1.0)
        val baseline = PerformanceStats(sampleCount = 40, winRate = 0.4, profitFactor = 1.0)
        assertTrue(StrategyRecommendationEngine.isImprovement(candidate, baseline))
    }
    @Test fun isImprovementRejectsWhenProfitFactorRegressesTooMuch(){
        val candidate = PerformanceStats(sampleCount = 20, winRate = 0.6, profitFactor = 0.5)
        val baseline = PerformanceStats(sampleCount = 40, winRate = 0.4, profitFactor = 1.0)
        assertFalse(StrategyRecommendationEngine.isImprovement(candidate, baseline))
    }

    // ---- 시장 국면 분류(MarketRegimeClassifier) ----
    @Test fun regimeClassifierReturnsUnknownWithInsufficientSamples(){
        val snapshot = MarketRegimeClassifier.classify(List(5) { 0.02 })
        assertEquals(MarketRegime.UNKNOWN, snapshot.regime)
    }
    @Test fun regimeClassifierDetectsBullMarket(){
        val rates = List(30) { 0.03 } // +3% 평균, 전부 상승
        val snapshot = MarketRegimeClassifier.classify(rates)
        assertEquals(MarketRegime.BULL, snapshot.regime)
        assertEquals(1.0, snapshot.breadthPositive, 0.0001)
    }
    @Test fun regimeClassifierDetectsBearMarket(){
        val rates = List(30) { -0.03 }
        val snapshot = MarketRegimeClassifier.classify(rates)
        assertEquals(MarketRegime.BEAR, snapshot.regime)
    }
    @Test fun regimeClassifierDetectsSidewaysMarket(){
        val rates = (0 until 30).map { if (it % 2 == 0) 0.005 else -0.005 }
        val snapshot = MarketRegimeClassifier.classify(rates)
        assertEquals(MarketRegime.SIDEWAYS, snapshot.regime)
    }

    // ---- 포트폴리오 동적 비중(PortfolioAllocationEngine) ----
    @Test fun allocationFactorIsFullAtMaxQuality(){
        val factor = PortfolioAllocationEngine.allocationFactor(score = 100.0, scoreThreshold = 75.0, aiScore = 100.0)
        assertEquals(1.0, factor, 0.0001)
    }
    @Test fun allocationFactorIsHalfAtMinimumQuality(){
        val factor = PortfolioAllocationEngine.allocationFactor(score = 75.0, scoreThreshold = 75.0, aiScore = 0.0)
        assertEquals(0.5, factor, 0.0001)
    }
    @Test fun allocationFactorNeverExceedsBounds(){
        val factor = PortfolioAllocationEngine.allocationFactor(score = 500.0, scoreThreshold = 75.0, aiScore = 500.0)
        assertTrue(factor in 0.5..1.0)
    }
    @Test fun allocationFactorHandlesThresholdOf100(){
        val factor = PortfolioAllocationEngine.allocationFactor(score = 90.0, scoreThreshold = 100.0, aiScore = 50.0)
        assertTrue(factor in 0.5..1.0)
    }

    // ---- 이상 징후 자체 진단(AnomalyDetector) ----
    @Test fun anomalyDetectorFindsNothingOnCleanLogs(){
        val anomalies = AnomalyDetector.detect(listOf("분석 시작", "KRW-BTC 후보 등록"), DashboardState())
        assertTrue(anomalies.isEmpty())
    }
    @Test fun anomalyDetectorFlagsRepeatedOrderFailures(){
        val logs = listOf("KRW-BTC PAPER BUY 실패: FAILED", "KRW-ETH PAPER BUY 실패: FAILED", "KRW-XRP PAPER SELL 실패: FAILED")
        val anomalies = AnomalyDetector.detect(logs, DashboardState())
        assertTrue(anomalies.any { it.contains("주문 실패") })
    }
    @Test fun anomalyDetectorFlagsRepeatedOtaFailures(){
        val logs = listOf("OTA 실패/롤백: timeout", "OTA 실패/롤백: timeout", "AI OTA 실패/롤백: timeout")
        val anomalies = AnomalyDetector.detect(logs, DashboardState())
        assertTrue(anomalies.any { it.contains("OTA") })
    }
    @Test fun anomalyDetectorFlagsApiErrorStatus(){
        val anomalies = AnomalyDetector.detect(emptyList(), DashboardState(apiStatus = ConnectionStatus.ERROR))
        assertTrue(anomalies.any { it.contains("API") })
    }
    @Test fun anomalyDetectorFlagsStaleTicker(){
        val staleState = DashboardState(lastTickerAt = System.currentTimeMillis() - 200_000L, settings = TradingSettings(staleTickerMillis = 30_000L))
        val anomalies = AnomalyDetector.detect(emptyList(), staleState)
        assertTrue(anomalies.any { it.contains("시세") })
    }

    // ---- Market Health Score / Crash Detection Engine ----
    @Test fun marketHealthIsPerfectWithNoIssues(){
        val health = MarketHealthEngine.evaluate(MarketRegimeSnapshot(MarketRegime.BULL, 0.8, 3.0, 30, 0L), 0, false, false)
        assertEquals(100.0, health.score, 0.0001)
        assertEquals(MarketHealthLevel.HEALTHY, health.level)
    }
    @Test fun marketHealthDegradesOnBearRegime(){
        val health = MarketHealthEngine.evaluate(MarketRegimeSnapshot(MarketRegime.BEAR, 0.3, -2.0, 30, 0L), 0, false, false)
        assertTrue(health.score < 100.0)
        assertTrue(health.reasons.any { it.contains("하락장") })
    }
    @Test fun marketHealthDetectsCrashOnSevereDropAndLowBreadth(){
        val health = MarketHealthEngine.evaluate(MarketRegimeSnapshot(MarketRegime.BEAR, 0.1, -8.0, 30, 0L), 0, false, false)
        assertEquals(MarketHealthLevel.CRASH, health.level)
        assertTrue(health.reasons.any { it.contains("급락") })
    }
    @Test fun marketHealthCombinesAnomaliesAndApiErrors(){
        val health = MarketHealthEngine.evaluate(MarketRegimeSnapshot(MarketRegime.SIDEWAYS, 0.5, 0.0, 30, 0L), 3, true, true)
        assertTrue(health.score < 100.0 - 5.0)
        assertTrue(health.reasons.any { it.contains("이상 징후") })
        assertTrue(health.reasons.any { it.contains("시세") })
        assertTrue(health.reasons.any { it.contains("API") })
    }
    @Test fun marketHealthNeverGoesBelowZero(){
        val health = MarketHealthEngine.evaluate(MarketRegimeSnapshot(MarketRegime.BEAR, 0.0, -50.0, 30, 0L), 20, true, true)
        assertTrue(health.score >= 0.0)
    }

    @Test fun exposureFactorIsFullWhenHealthy(){
        assertEquals(1.0, ExposureControlEngine.exposureFactor(90.0), 0.0001)
    }
    @Test fun exposureFactorShrinksAsHealthDegrades(){
        val healthy = ExposureControlEngine.exposureFactor(90.0)
        val caution = ExposureControlEngine.exposureFactor(50.0)
        val crash = ExposureControlEngine.exposureFactor(10.0)
        assertTrue(healthy > caution)
        assertTrue(caution > crash)
        assertTrue(crash >= 0.3)
    }

    @Test fun riskManagerRejectsBuyWhenCrashProtectionEngaged(){
        val ok = RiskManager().canBuy(TradingSettings(), DashboardState(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED, crashProtectionEngaged = true), StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(ok.first)
    }
    @Test fun riskManagerRejectsBuyDuringCooldown(){
        val ok = RiskManager().canBuy(TradingSettings(), DashboardState(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED, cooldownActive = true), StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(ok.first)
    }
    @Test fun riskManagerAllowsBuyWhenHealthyAndNoCooldown(){
        val ok = RiskManager().canBuy(TradingSettings(), DashboardState(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED), StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertTrue(ok.first)
    }

    // ---- WebSocket 실시간 티커 / 설정 복원 신뢰성 ----
    @Test fun websocketTickerParserParsesBithumbDefaultFormat(){
        val json = """{"type":"ticker","code":"KRW-BTC","trade_price":123456.0,"acc_trade_price_24h":987654321.0,"signed_change_rate":0.0123,"trade_volume":0.45,"timestamp":1700000000000}"""
        val ticker = BithumbWebSocketTickerParser.parse(json)
        assertNotNull(ticker)
        assertEquals("KRW-BTC", ticker!!.market)
        assertEquals(123456.0, ticker.tradePrice, 0.0001)
        assertEquals(0.0123, ticker.signedChangeRate, 0.0001)
        assertEquals(1700000000000L, ticker.timestamp)
    }
    @Test fun websocketTickerParserRejectsAckAndMalformedMessages(){
        assertNull(BithumbWebSocketTickerParser.parse("""{"status":"0000","resmsg":"Connected Successfully"}"""))
        assertNull(BithumbWebSocketTickerParser.parse("not-json"))
        assertNull(BithumbWebSocketTickerParser.parse("""{"code":"BTC-USDT","trade_price":100.0}"""))
        assertNull(BithumbWebSocketTickerParser.parse("""{"code":"KRW-BTC","trade_price":0.0}"""))
    }
    @Test fun settingsValidatorPreservesValidValues(){
        val input = TradingSettings(paperInitialKrw = 250_000.0, maxPositions = 7, autoResumeAfterBoot = true, crashCooldownMinutes = 45)
        assertEquals(input, TradingSettingsValidator.sanitize(input))
    }
    @Test fun settingsValidatorClampsUnsafeValues(){
        val sanitized = TradingSettingsValidator.sanitize(
            TradingSettings(
                paperInitialKrw = Double.NaN,
                maxPositions = 500,
                maxOrderPercent = 500.0,
                scoreThreshold = -10.0,
                stopLossPercent = 10.0,
                staleTickerMillis = 1L,
                maxConsecutiveLosses = 0,
                crashCooldownMinutes = 100_000
            )
        )
        assertEquals(100_000.0, sanitized.paperInitialKrw, 0.0)
        assertEquals(50, sanitized.maxPositions)
        assertEquals(100.0, sanitized.maxOrderPercent, 0.0)
        assertEquals(0.0, sanitized.scoreThreshold, 0.0)
        assertEquals(-0.1, sanitized.stopLossPercent, 0.0)
        assertEquals(5_000L, sanitized.staleTickerMillis)
        assertEquals(0, sanitized.maxConsecutiveLosses)
        assertEquals(1_440, sanitized.crashCooldownMinutes)
    }

    @Test fun tickerCoverageAlwaysIncludesHeldMarkets(){
        val required = TickerCoverage.requiredMarkets(
            scanBatch = listOf("KRW-BTC", "KRW-ETH"),
            heldMarkets = listOf("KRW-XRP", "KRW-BTC")
        )
        assertEquals(listOf("KRW-BTC", "KRW-ETH", "KRW-XRP"), required)
    }
    @Test fun dailyLossUsesStartOfDayEquityInsteadOfInitialFunding(){
        val settings = TradingSettings(dailyMaxLossPercent = -5.0)
        assertFalse(RiskManager().dailyLossLocked(settings, startValue = 200_000.0, currentValue = 192_000.0))
        assertTrue(RiskManager().dailyLossLocked(settings, startValue = 200_000.0, currentValue = 189_000.0))
    }
    @Test fun publicMarketDataRetries429ThenReturnsTicker() = runTest {
        val api = FakePublicApi(
            tickerResponses = ArrayDeque(
                listOf(
                    errorResponse(429),
                    Response.success(listOf(TickerDto("KRW-BTC", 100.0, 1_000.0, 0.01, 2.0, 123L)))
                )
            )
        )
        val result = BithumbMarketDataProvider(api).ticker(listOf("KRW-BTC"))
        assertEquals(2, api.tickerCalls)
        assertEquals(1, result.size)
        assertEquals(123L, result.single().timestamp)
    }
    @Test fun publicMarketDataDoesNotRetryNonRetryable4xx() = runTest {
        val api = FakePublicApi(tickerResponses = ArrayDeque(listOf(errorResponse(400))))
        var failed = false
        try {
            BithumbMarketDataProvider(api).ticker(listOf("KRW-BTC"))
        } catch (_: IllegalStateException) {
            failed = true
        }
        assertTrue(failed)
        assertEquals(1, api.tickerCalls)
    }
    @Test fun paperCashLedgerRebuildsCashFromBuyAndSellFills(){
        val trades = listOf(
            TradeEntity(time=1, market="KRW-BTC", side="BUY", amount=20_000.0, quantity=1.0, avgPrice=20_000.0, fee=50.0, realizedPnl=0.0, pnlRate=0.0, reason=""),
            TradeEntity(time=2, market="KRW-BTC", side="SELL", amount=21_000.0, quantity=1.0, avgPrice=21_000.0, fee=52.0, realizedPnl=948.0, pnlRate=4.74, reason="")
        )
        assertEquals(101_000.0, PaperCashLedger.balance(100_000.0, trades), 0.0001)
    }
    @Test fun paperCashLedgerNeverReturnsNegativeCash(){
        val trade = TradeEntity(time=1, market="KRW-BTC", side="BUY", amount=200_000.0, quantity=1.0, avgPrice=200_000.0, fee=0.0, realizedPnl=0.0, pnlRate=0.0, reason="")
        assertEquals(0.0, PaperCashLedger.balance(100_000.0, listOf(trade)), 0.0)
    }
    @Test fun opportunityCostRecommendsRotationOnlyWhenClearlyBetter(){
        val decision = OpportunityCostEngine.evaluate(
            OpportunityInput("KRW-XRP", 0.4, 61.0, -4.0, -10.0, 2.0, 0.5, 90.0, 20,
                "KRW-SOL", 96.0, 8.0, 60.0, 1.0, 0.1, 90.0, 0.25, 0.1, true)
        )
        assertEquals(OpportunityAction.ROTATE, decision.action)
        assertTrue(decision.rotateScore > decision.keepScore)
    }
    @Test fun opportunityCostKeepsPositionWhenNoCandidate(){
        val decision = OpportunityCostEngine.evaluate(
            OpportunityInput("KRW-BTC", 3.0, 90.0, 4.0, 10.0, 1.0, 0.1, 95.0, 30,
                null, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, 0.1, false)
        )
        assertEquals(OpportunityAction.KEEP, decision.action)
    }
    @Test fun postEntryQualityComputesMfeAndMae(){
        assertEquals(5.0, PostEntryQualityEngine.mfe(100.0, 105.0), 0.0001)
        assertEquals(-3.0, PostEntryQualityEngine.mae(100.0, 97.0), 0.0001)
        val stats = PostEntryQualityEngine.stats(listOf(
            PostEntrySnapshotEntity("buy-1", "KRW-BTC", 1, 5, 100.0, 102.0, 2.0, 3.0, -1.0, 80.0, 90.0, 10.0, "BULL", 3),
            PostEntrySnapshotEntity("buy-2", "KRW-ETH", 2, 5, 100.0, 98.0, -2.0, 1.0, -3.0, 70.0, 80.0, -5.0, "BEAR", 3)
        ))
        assertEquals(2, stats.sampleCount)
        assertEquals(0.0, stats.averageReturnByHorizon[5] ?: Double.NaN, 0.0001)
        assertEquals(0.0, stats.immediateRiseProbability, 0.0001) // no 1m snapshots => no immediate evidence
    }
    @Test fun shadowPortfoliosAreIndependent(){
        val current = ShadowSimulationEngine.initial(ShadowStrategyType.CURRENT, 100_000.0)
        val aggressive = ShadowSimulationEngine.initial(ShadowStrategyType.AGGRESSIVE, 100_000.0)
        val signal = StrategySignalModel("KRW-BTC", 90.0, "shadow", currentPrice = 10_000.0, aiScore = 80.0)
        val changed = ShadowSimulationEngine.step(current, listOf(signal), 1_000L)
        assertNotEquals(current.positionsJson, changed.portfolio.positionsJson)
        assertEquals(100_000.0, aggressive.cash, 0.0)
        assertEquals("[]", aggressive.positionsJson)
    }
    @Test fun shadowRiskAdjustedScoreProtectsSmallSample(){
        val portfolio = ShadowSimulationEngine.initial(ShadowStrategyType.CURRENT, 100_000.0).copy(equity = 110_000.0, tradeCount = 1)
        assertEquals(Double.NEGATIVE_INFINITY, ShadowSimulationEngine.riskAdjustedScore(portfolio), 0.0)
    }
    @Test fun missedOpportunityClassifiesGoodRejectionAndMissedWin(){
        assertEquals(MissedOpportunityOutcome.GOOD_REJECTION, MissedOpportunityEngine.classify(1.0, -3.0))
        assertEquals(MissedOpportunityOutcome.MISSED_WIN, MissedOpportunityEngine.classify(4.0, -0.5))
        assertEquals(MissedOpportunityOutcome.NEUTRAL, MissedOpportunityEngine.classify(1.0, -1.0))
    }
    @Test fun researchDatasetBuilderAggregatesAllResearchSources(){
        val summary = AiResearchDatasetBuilder.summarize(
            listOf(PostEntrySnapshotEntity("b", "KRW-BTC", 1, 5, 100.0, 101.0, 1.0, 1.0, 0.0, 80.0, 90.0, 0.0, "BULL", 3)),
            listOf(MissedOpportunityEntity(id="m", capturedAt=1, market="KRW-BTC", reason="SPREAD", strategyScore=80.0, aiScore=70.0, entryPrice=100.0, marketHealth=90.0, marketRegime="BULL", strategyVersion=3)),
            listOf(ShadowTradeEntity(strategy="CURRENT", time=1, market="KRW-BTC", side="BUY", amount=100.0, quantity=1.0, price=100.0, pnlRate=0.0, holdingMinutes=0, reason="")),
            listOf(OpportunityDecisionEntity(time=1, heldMarket="KRW-ETH", candidateMarket="KRW-BTC", keepScore=50.0, rotateScore=80.0, doNothingScore=60.0, action="ROTATE", reason="", strategyVersion=3))
        )
        assertEquals(AiResearchDatasetSummary(1, 1, 1, 1), summary)
    }

    @Test fun adaptiveRegimeClassifierSupportsStrongBullAndHighVolatility(){
        val strongBull = MarketRegimeClassifier.classifyMultiTimeframe(List(30){0.04}, List(60){0.04}, List(100){0.04}, 95.0)
        assertEquals(MarketRegime.STRONG_BULL, strongBull.regime)
        assertTrue(strongBull.confidence >= 0.75)
        val highVol = MarketRegimeClassifier.classifyMultiTimeframe(List(30){ if (it % 2 == 0) 0.08 else -0.08 }, List(60){ if (it % 2 == 0) 0.08 else -0.08 }, List(100){ if (it % 2 == 0) 0.08 else -0.08 }, 80.0)
        assertEquals(MarketRegime.HIGH_VOLATILITY, highVol.regime)
        assertEquals("HIGH", highVol.volatilityLevel)
    }
    @Test fun adaptiveRegimeClassifierDetectsCrashAndRecovery(){
        val crash = MarketRegimeClassifier.classifyMultiTimeframe(List(30){-0.08}, List(60){-0.08}, List(100){-0.08}, 30.0)
        assertEquals(MarketRegime.CRASH, crash.regime)
        val recovery = MarketRegimeClassifier.classifyMultiTimeframe(List(30){0.02}, List(60){0.02}, List(100){0.02}, 70.0, MarketRegime.CRASH)
        assertEquals(MarketRegime.RECOVERY, recovery.regime)
    }
    @Test fun regimeHysteresisRequiresThreeHighConfidenceConfirmations(){
        val hysteresis = RegimeHysteresis()
        val bull = MarketRegimeSnapshot(MarketRegime.BULL, 0.7, 2.0, 30, 0L, confidence=0.9)
        assertEquals(MarketRegime.BULL, hysteresis.update(bull, 0L).regime)
        val sideways = bull.copy(regime=MarketRegime.SIDEWAYS, confidence=0.8)
        assertEquals(MarketRegime.BULL, hysteresis.update(sideways, 60_000L).regime)
        assertEquals(MarketRegime.BULL, hysteresis.update(sideways, 120_000L).regime)
        assertEquals(MarketRegime.SIDEWAYS, hysteresis.update(sideways, 180_000L).regime)
    }
    @Test fun crashBypassesNormalRegimeHysteresis(){
        val hysteresis = RegimeHysteresis()
        hysteresis.update(MarketRegimeSnapshot(MarketRegime.BULL, 0.7, 2.0, 30, 0L, confidence=0.9), 0L)
        val crash = hysteresis.update(MarketRegimeSnapshot(MarketRegime.CRASH, 0.1, -8.0, 30, 1L, confidence=0.9), 1_000L)
        assertEquals(MarketRegime.CRASH, crash.regime)
    }
    @Test fun regimeSelectorRecommendsNoTradeDuringCrash(){
        val result = RegimeStrategySelector.select(MarketRegimeSnapshot(MarketRegime.CRASH, 0.1, -8.0, 30, 0L, confidence=0.95), emptyList(), MarketHealthScore(20.0, MarketHealthLevel.CRASH))
        assertTrue(result.noTrade)
        assertEquals("NO_TRADE", result.weights.single().strategy)
    }
    @Test fun regimeSelectorKeepsCurrentWhenSamplesAreInsufficient(){
        val result = RegimeStrategySelector.select(MarketRegimeSnapshot(MarketRegime.BULL, 0.7, 2.0, 30, 0L, confidence=0.8), emptyList(), MarketHealthScore())
        assertEquals("CURRENT", result.weights.first().strategy)
        assertTrue(result.reason.contains("표본 부족"))
    }

    @Test fun regimeStrategySetAppliesOnPaperAndBlocksCrashEntries(){
        val base = TradingSettings(scoreThreshold = 75.0, stopLossPercent = -2.5, maxOrderPercent = 20.0, maxPositions = 3, dailyMaxLossPercent = -5.0)
        val crash = RegimeStrategySetEngine.buildDecision(
            base = base,
            regime = MarketRegimeSnapshot(MarketRegime.CRASH, 0.1, -8.0, 40, 0L, confidence = 0.95),
            mode = TradeMode.PAPER,
            enabled = true,
            paperApplyEnabled = true
        )
        assertTrue(crash.applied)
        assertTrue(crash.noNewEntries)
        assertEquals("SET_CRASH", crash.setId)
        val applied = RegimeStrategySetEngine.applyOverlay(base, MarketRegimeSnapshot(MarketRegime.CRASH, confidence = 0.95), TradeMode.PAPER, true, true)
        assertEquals(-5.0, applied.dailyMaxLossPercent, 0.0001)
        assertEquals(100.0, applied.scoreThreshold, 0.0001)
        assertEquals(0.0, applied.maxOrderPercent, 0.0001)
    }

    @Test fun regimeStrategySetIsRecommendOnlyOnLive(){
        val base = TradingSettings(scoreThreshold = 75.0)
        val live = RegimeStrategySetEngine.buildDecision(
            base = base,
            regime = MarketRegimeSnapshot(MarketRegime.BEAR, confidence = 0.9),
            mode = TradeMode.LIVE,
            enabled = true,
            paperApplyEnabled = true
        )
        assertFalse(live.applied)
        assertTrue(live.recommendOnly)
        val overlay = RegimeStrategySetEngine.applyOverlay(base, MarketRegimeSnapshot(MarketRegime.BEAR, confidence = 0.9), TradeMode.LIVE, true, true)
        assertEquals(base.scoreThreshold, overlay.scoreThreshold, 0.0001)
        assertEquals(base.stopLossPercent, overlay.stopLossPercent, 0.0001)
    }

    @Test fun regimeStrategySetNeverWidensStopOrDailyLoss(){
        val base = TradingSettings(scoreThreshold = 75.0, stopLossPercent = -2.5, dailyMaxLossPercent = -5.0, maxOrderPercent = 20.0)
        val bull = RegimeStrategySetEngine.applyOverlay(
            base,
            MarketRegimeSnapshot(MarketRegime.STRONG_BULL, confidence = 0.9),
            TradeMode.PAPER,
            enabled = true,
            paperApplyEnabled = true
        )
        assertTrue(bull.stopLossPercent >= base.stopLossPercent - 0.0001)
        assertEquals(base.dailyMaxLossPercent, bull.dailyMaxLossPercent, 0.0001)
        assertTrue(bull.scoreThreshold >= base.scoreThreshold - RegimeStrategySetSafety.MAX_SCORE_RELAXATION - 0.0001)
        assertTrue(bull.maxOrderPercent <= base.maxOrderPercent * RegimeStrategySetSafety.MAX_ORDER_MULTIPLIER + 0.0001)
    }

    @Test fun regimeStrategySetRaisesThresholdInBear(){
        val base = TradingSettings(scoreThreshold = 75.0, stopLossPercent = -2.5, maxOrderPercent = 20.0, maxPositions = 3)
        val bear = RegimeStrategySetEngine.applyOverlay(
            base,
            MarketRegimeSnapshot(MarketRegime.BEAR, confidence = 0.85),
            TradeMode.PAPER,
            true,
            true
        )
        assertTrue(bear.scoreThreshold > base.scoreThreshold)
        assertTrue(bear.stopLossPercent > base.stopLossPercent) // tighter (closer to zero)
        assertTrue(bear.maxOrderPercent < base.maxOrderPercent)
        assertTrue(bear.maxPositions <= base.maxPositions)
    }

    @Test fun regimeAccuracyValidatorScoresDirectionalHits(){
        assertTrue(RegimeAccuracyValidator.isDirectionallyCorrect(MarketRegime.BULL, 1.2))
        assertFalse(RegimeAccuracyValidator.isDirectionallyCorrect(MarketRegime.BULL, -1.0))
        assertTrue(RegimeAccuracyValidator.isDirectionallyCorrect(MarketRegime.BEAR, -1.2))
        assertTrue(RegimeAccuracyValidator.isDirectionallyCorrect(MarketRegime.SIDEWAYS, 0.2))
        val stats = RegimeAccuracyValidator.summarize(
            List(25) { RegimeAccuracyObservation(MarketRegime.BULL, 0.8, if (it < 18) 1.0 else -1.0) }
        )
        assertEquals(25, stats.sampleCount)
        assertEquals(18, stats.correctCount)
        assertEquals("ACCEPTABLE", stats.status)
    }

    @Test fun regimeSetShadowEngineNeedsMinimumSamples(){
        val small = RegimeSetShadowEngine.compare(List(5){1.0}, List(5){1.2})
        assertEquals("INSUFFICIENT_SAMPLE", small.winner)
        val large = RegimeSetShadowEngine.compare(List(20){ if (it % 2 == 0) 1.0 else -0.5 }, List(20){ if (it % 2 == 0) 1.5 else -0.3 })
        assertTrue(large.winner in setOf("REGIME_ADAPTIVE", "FIXED_BASELINE", "TIE"))
    }

    @Test fun championChallengerNeedsMinimumSamplesAndOnlyRecommendsPromotion(){
        val champion = ShadowSimulationEngine.initial(ShadowStrategyType.CURRENT, 100_000.0).copy(equity=110_000.0, tradeCount=20, realizedPnl=10_000.0, grossProfit=20.0, grossLoss=10.0)
        val challenger = ShadowSimulationEngine.initial(ShadowStrategyType.AGGRESSIVE, 100_000.0).copy(equity=120_000.0, tradeCount=20, realizedPnl=20_000.0, grossProfit=30.0, grossLoss=5.0)
        val result = ChampionChallengerEngine.evaluate(champion, listOf(champion, challenger))
        assertEquals("AGGRESSIVE", result.challenger)
        assertEquals("PROMOTION_CANDIDATE", result.status)
        assertEquals("CURRENT", result.champion)
    }
    @Test fun championChallengerDoesNotPromoteSmallSample(){
        val champion = ShadowSimulationEngine.initial(ShadowStrategyType.CURRENT, 100_000.0)
        val challenger = ShadowSimulationEngine.initial(ShadowStrategyType.AGGRESSIVE, 100_000.0).copy(tradeCount=9)
        assertEquals("INSUFFICIENT_SAMPLE", ChampionChallengerEngine.evaluate(champion, listOf(challenger)).status)
    }
    @Test fun walkForwardUsesChronologicalWindowsWithoutLookAhead(){
        val values = (0 until 20).map { TimedPnl(it.toLong(), if(it < 10) 1.0 else if(it < 15) 2.0 else 1.0) }
        val result = WalkForwardEvaluator.evaluate(values, 10, 5, 5)
        assertEquals(10, result.train.sampleCount)
        assertEquals(5, result.validation.sampleCount)
        assertEquals(5, result.test.sampleCount)
        assertTrue(result.passed)
    }
    @Test fun walkForwardRejectsInsufficientData(){
        val result = WalkForwardEvaluator.evaluate(List(10){ TimedPnl(it.toLong(), 1.0) }, 10, 5, 5)
        assertFalse(result.passed)
    }
    @Test fun confidenceCalibrationBandsAndAccuracy(){
        assertEquals("80-90", ConfidenceCalibrationEngine.band(0.82))
        val rows = listOf(
            ConfidenceCalibrationEntity(regime="BULL", confidenceBand="80-90", predictedConfidence=0.82, correct=true, createdAt=1),
            ConfidenceCalibrationEntity(regime="BULL", confidenceBand="80-90", predictedConfidence=0.81, correct=false, createdAt=2)
        )
        assertEquals(0.5, ConfidenceCalibrationEngine.accuracy(rows)["80-90"] ?: 0.0, 0.0001)
    }

    @Test fun newsClassifierPrioritizesSecurityEventOverGenericSentiment(){
        assertEquals(NewsEventType.HACK, NewsEventClassifier.classify("Bitcoin project exploit causes losses", "security incident confirmed"))
        assertEquals(NewsEventType.DELISTING, NewsEventClassifier.classify("Exchange delisting notice", "trading support ends"))
        assertEquals(NewsEventType.TOKEN_UNLOCK, NewsEventClassifier.classify("Token unlock scheduled", "supply release"))
    }
    @Test fun newsClassifierUsesSourceTierForConfidence(){
        val (_, a, impact) = NewsEventClassifier.analyze(NewsEventType.HACK, "hack", "", NewsSourceTier.TIER_A)
        val (_, d, _) = NewsEventClassifier.analyze(NewsEventType.HACK, "hack", "", NewsSourceTier.TIER_D)
        assertTrue(a > d)
        assertTrue(impact >= 90.0)
    }
    @Test fun newsFingerprintNormalizesAndMapsKnownCoinAliases(){
        val raw = NewsRawItem("official", NewsSourceTier.TIER_A, 3_600_000L, "https://example", "Bitcoin rises!", "")
        val fingerprint = NewsFingerprint.create(raw, NewsEventType.OTHER, listOf("KRW-BTC"))
        assertTrue(fingerprint.contains("bitcoin rises"))
        val (symbols, scope) = NewsCoinMappingEngine.map("Bitcoin and Ethereum update", "", setOf("KRW-BTC", "KRW-ETH"))
        assertEquals(setOf("KRW-BTC", "KRW-ETH"), symbols.toSet())
        assertEquals(NewsScope.SECTOR, scope)
    }
    @Test fun newsRssParserReadsPublicRssItems(){
        val xml = """<rss><channel><item><title>Bitcoin partnership</title><link>https://example.test/a</link><description>Positive update</description><pubDate>Mon, 31 Aug 2026 12:00:00 GMT</pubDate></item></channel></rss>"""
        val items = NewsRssParser.parse(xml, "https://feed.test/rss", NewsSourceTier.TIER_B)
        assertEquals(1, items.size)
        assertEquals("Bitcoin partnership", items.single().title)
        assertEquals("https://example.test/a", items.single().url)
    }
    @Test fun newsImpactRespectsTrustAndFreshness(){
        val now = System.currentTimeMillis()
        val trusted = NewsImpactEngine.score(NewsSourceTier.TIER_A, -0.9, 95.0, 95.0, 98.0, now, now)
        val unknown = NewsImpactEngine.score(NewsSourceTier.TIER_D, -0.9, 25.0, 95.0, 98.0, now - 86_400_000L * 3, now)
        assertTrue(trusted > unknown)
        assertTrue(trusted in 0.0..100.0)
    }
    @Test fun newsRiskOnlyBlocksForConfirmedCriticalEvents(){
        val model = NewsEventEntity(source="official", sourceTier="TIER_A", publishedAt=1, receivedAt=1, url="u", title="hack", summary="", fingerprint="f", symbols="KRW-BTC", eventType=NewsEventType.HACK.name, sentiment=-0.9, confidence=95.0, expectedImpact=95.0, urgency=98.0, scope=NewsScope.COIN.name, horizon=NewsHorizon.MINUTES.name, status=NewsEventStatus.CONFIRMED.name, impactScore=90.0)
        val risk = NewsRiskEngine.evaluate(listOf(NewsEventModel("1", model.source, NewsSourceTier.TIER_A, 1, 1, model.url, model.title, model.summary, model.fingerprint, listOf("KRW-BTC"), NewsEventType.HACK, model.sentiment, model.confidence, model.expectedImpact, model.urgency, NewsScope.COIN, NewsHorizon.MINUTES, NewsEventStatus.CONFIRMED, model.impactScore)))
        assertEquals(NewsRiskLevel.CRITICAL, risk.level)
        assertTrue(risk.newEntryBlocked)
    }
    @Test fun newsReactionStatsAndPredictionAccuracyAreCalculated(){
        val reactions = listOf(
            NewsReactionEntity("e1", "KRW-BTC", 1, 5, 100.0, 102.0, 2.0, 10.0, 0.2, 1.0, "BULL", 90.0, 3.0, -1.0, true),
            NewsReactionEntity("e2", "KRW-ETH", 2, 5, 100.0, 98.0, -2.0, -5.0, 0.3, 2.0, "BEAR", 50.0, 1.0, -3.0, false)
        )
        val stats = NewsReactionEngine.stats(reactions)
        assertEquals(2, stats.sampleCount)
        assertEquals(0.5, stats.predictionAccuracy, 0.0001)
        assertEquals(0.0, stats.averageReturnByHorizon[5] ?: Double.NaN, 0.0001)
    }
    @Test fun conceptDriftDetectsRecentPerformanceChange(){
        val result = ConceptDriftDetector.detect(List(20) { -2.0 }, List(40) { 1.0 })
        assertTrue(result.first)
        assertTrue(result.second.contains("달라짐"))
    }
    @Test fun newsResearchHypothesisIsGeneratedFromWeakImmediateReaction(){
        val hypothesis = ResearchHypothesisEngine.generate(NewsReactionStats(20, mapOf(5 to -1.0), 0.0, 0.0, 0.7), false to "정상")
        assertNotNull(hypothesis)
        assertTrue(hypothesis!!.contains("5분 대기"))
    }
    @Test fun newsEngineOfflineDoesNotChangePaperRiskByItself(){
        val state = DashboardState(newsResearch = NewsResearchState(engineStatus = NewsEngineStatus.OFFLINE))
        val result = RiskManager().canBuy(TradingSettings(), state.copy(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED), StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertTrue(result.first)
    }

    @Test fun sharedCacheReturnsFreshTickerAndExpiresStaleTicker(){
        val cache = MarketDataSharedCache(tickerTtlMs=100L)
        val ticker = TickerModel("KRW-BTC", 100.0, 1000.0, 0.01, 1.0, 1L)
        cache.putTicker(ticker, 1_000L)
        assertEquals(ticker, cache.ticker("KRW-BTC", 1_100L))
        assertNull(cache.ticker("KRW-BTC", 1_101L))
    }
    @Test fun fastScanUsesVariableCandidateCountAndKeepsRotationPriority(){
        val markets = (1..100).map { MarketModel("KRW-C$it", "", "") }
        val tickers = markets.associate { it.market to TickerModel(it.market, 100.0, 1_000_000.0 + it.market.drop(5).toInt() * 1000.0, 0.01, 1.0, System.currentTimeMillis()) }
        val result = FastScanEngine.scan(markets, tickers, listOf("KRW-C100"), now=1234L)
        assertTrue(result.candidates.size in 20..50)
        assertTrue(result.candidates.all { it.detectedAt == 1234L })
        assertEquals("KRW-C100", result.candidates.first().market)
        assertEquals(100, result.eligibleMarketCount)
    }
    @Test fun limitedParallelMapHonorsConcurrencyLimit() = runTest {
        val running = java.util.concurrent.atomic.AtomicInteger(0)
        val maximum = java.util.concurrent.atomic.AtomicInteger(0)
        val result = limitedParallelMap(1..12, concurrency=3) {
            val active = running.incrementAndGet()
            maximum.updateAndGet { old -> maxOf(old, active) }
            kotlinx.coroutines.delay(2L)
            running.decrementAndGet()
            it * 2
        }
        assertEquals((1..12).map { it*2 }, result)
        assertTrue(maximum.get() <= 3)
    }
    @Test fun scanProfilerComputesAverageP50AndP95(){
        val stats = ScanPerformanceProfiler.latency(listOf(100L, 200L, 300L, 400L, 1000L))
        assertEquals(5, stats.samples)
        assertEquals(300L, stats.p50Ms)
        assertEquals(400L, stats.p95Ms)
        assertEquals(400L, stats.averageMs)
    }
    @Test fun centralRateLimiterCountsRequestsAndWaitsPerEndpoint() = runTest {
        val limiter = CentralApiRateLimiter(minimumIntervalMs=1L)
        limiter.acquire("ticker")
        limiter.acquire("ticker")
        limiter.acquire("orderbook")
        assertEquals(3, limiter.totalCalls())
        assertTrue(limiter.totalWaitMs() >= 0L)
    }
    @Test fun fastScanHandlesMissingTickerWithoutPromotingIt(){
        val markets = listOf(MarketModel("KRW-BTC", "", ""), MarketModel("KRW-ETH", "", ""))
        val result = FastScanEngine.scan(markets, mapOf("KRW-BTC" to TickerModel("KRW-BTC", 100.0, 1000.0, 0.01, 1.0, 1L)))
        assertEquals(1, result.eligibleMarketCount)
        assertTrue(result.candidates.none { it.market == "KRW-ETH" })
    }

    @Test fun liquidityDistributionComputesPercentilesFromKrwValues(){
        val distribution = LiquidityFilterEngine.distribution((1..100).map { it.toDouble() * 1_000_000.0 })
        assertEquals(10_000_000.0, distribution.p10, 1_000_000.0)
        assertEquals(25_000_000.0, distribution.p25, 1_000_000.0)
        assertEquals(50_000_000.0, distribution.p50, 1_000_000.0)
        assertEquals(75_000_000.0, distribution.p75, 1_000_000.0)
        assertEquals(90_000_000.0, distribution.p90, 1_000_000.0)
    }
    @Test fun liquidityThresholdUsesCanonicalKrwValueWithoutUnitConversion(){
        val values = listOf(100_000_000.0, 500_000_000.0, 1_000_000_000.0)
        val threshold = LiquidityFilterEngine.requiredThreshold(values, 500_000_000.0, 0.25, false, MarketRegime.BULL, 90.0)
        assertEquals(500_000_000.0, threshold, 0.0)
        val decision = LiquidityFilterEngine.decide(100_000_000.0, threshold, values)
        assertFalse(decision.passed)
        assertTrue(decision.detail.contains("100,000,000"))
        assertTrue(decision.detail.contains("500,000,000"))
        assertTrue(decision.detail.contains("ratio="))
    }
    @Test fun dynamicLiquidityAddsRelativeMarketThresholdAndCrashTightens(){
        val values = (1..100).map { it.toDouble() * 10_000_000.0 }
        val normal = LiquidityFilterEngine.requiredThreshold(values, 20_000_000.0, 0.75, true, MarketRegime.BULL, 90.0)
        val crash = LiquidityFilterEngine.requiredThreshold(values, 20_000_000.0, 0.25, true, MarketRegime.CRASH, 30.0)
        assertTrue(normal >= 20_000_000.0)
        assertTrue(crash >= normal || crash >= 20_000_000.0)
    }
    @Test fun liquidityDecisionCalculatesRankAndPercentile(){
        val values = listOf(1_000.0, 500.0, 100.0, 50.0)
        val decision = LiquidityFilterEngine.decide(500.0, 400.0, values)
        assertTrue(decision.passed)
        assertEquals(2, decision.rank)
        assertTrue(decision.percentile in 0.0..1.0)
        assertEquals(4, decision.total)
    }
    @Test fun liquidityDistributionIgnoresNaNAndInfinity(){
        val distribution = LiquidityFilterEngine.distribution(listOf(Double.NaN, Double.POSITIVE_INFINITY, 100.0, 200.0))
        assertTrue(distribution.p50.isFinite())
        assertEquals(100.0, LiquidityFilterEngine.percentile(listOf(Double.NaN, 100.0), 0.0), 0.0)
    }
    @Test fun highAiScoreDoesNotBypassLiquidityFilter(){
        val diagnostic = LiquidityFilterEngine.decide(100_000_000.0, 500_000_000.0, listOf(100_000_000.0, 1_000_000_000.0))
        assertFalse(diagnostic.passed)
        val signal = StrategySignalModel("KRW-BTT", 69.0, "", aiScore=99.0, actual24hTradeValueKrw=diagnostic.actualKrw, required24hTradeValueKrw=diagnostic.requiredKrw)
        assertTrue(signal.aiScore > 90.0)
        assertTrue(diagnostic.code == "LOW_24H_TRADE_VALUE")
    }
    @Test fun overblockingWarningRequiresTenScansAndTwentyCandidates(){
        val tracker = LiquidityOverblockingTracker()
        var warning = false to ""
        repeat(10) { warning = tracker.record(20, 17, 0) }
        assertTrue(warning.first)
        assertTrue(warning.second.contains("LIQUIDITY_FILTER_OVERBLOCKING_WARNING"))
    }
    @Test fun overblockingWarningDoesNotTriggerWhenBuyReadyExists(){
        val tracker = LiquidityOverblockingTracker()
        var warning = false to ""
        repeat(10) { warning = tracker.record(20, 20, 1) }
        assertFalse(warning.first)
    }
    @Test fun otaLiquidityFieldsRemainOptionalForBackwardCompatibility(){
        val legacy = RemoteStrategyConfig(version=3, min24hTradePrice=500_000_000.0)
        assertNull(legacy.min24hTradeValueKrw)
        assertEquals(500_000_000.0, TradingSettings().min24hTradeValueKrw, 0.0)
    }

    @Test fun threePercentProfitDoesNotStopTrading(){
        val settings = ProfitProtectionSettings(1.0, 3.0, 5.0, 10.0, 0.75, 1.5, 2.5, 0.7, 0.4, false)
        val snapshot = ProfitProtectionEngine.evaluate(100_000.0, 104_000.0, 103_500.0, settings, MarketHealthScore(), 0)
        assertTrue(snapshot.dailyReturnPercent >= 3.0)
        assertEquals(ProfitProtectionState.PROFIT_RUNNING, snapshot.state)
        assertTrue(snapshot.newEntryAllowed)
    }
    @Test fun highWaterMarkAndGivebackAreCalculated(){
        val settings = ProfitProtectionSettings(1.0, 3.0, 5.0, 10.0, 0.75, 1.5, 2.5, 0.7, 0.4, false)
        val snapshot = ProfitProtectionEngine.evaluate(100_000.0, 107_000.0, 106_000.0, settings, MarketHealthScore(), 0)
        assertEquals(7.0, snapshot.peakReturnPercent, 0.0001)
        assertEquals(6.0, snapshot.dailyReturnPercent, 0.0001)
        assertEquals(1.0, snapshot.givebackPercentPoints, 0.0001)
        assertEquals(ProfitProtectionState.PROFIT_CAUTION, snapshot.state)
        assertEquals(0.7, snapshot.positionSizeMultiplier, 0.0001)
    }
    @Test fun defenseAndLockedStatesOnlyFollowGiveback(){
        val settings = ProfitProtectionSettings(1.0, 3.0, 5.0, 10.0, 0.75, 1.5, 2.5, 0.7, 0.4, false)
        val defense = ProfitProtectionEngine.evaluate(100_000.0, 107_000.0, 105_000.0, settings, MarketHealthScore(), 0)
        assertEquals(ProfitProtectionState.PROFIT_DEFENSE, defense.state)
        assertEquals(0.4, defense.positionSizeMultiplier, 0.0001)
        val locked = ProfitProtectionEngine.evaluate(100_000.0, 110_000.0, 107_000.0, settings, MarketHealthScore(), 0)
        assertEquals(ProfitProtectionState.PROFIT_LOCKED, locked.state)
        assertFalse(locked.newEntryAllowed)
        assertEquals(0.0, locked.positionSizeMultiplier, 0.0001)
    }
    @Test fun profitLockedCanRecoverWhenGivebackShrinks(){
        val settings = ProfitProtectionSettings(1.0, 3.0, 5.0, 10.0, 0.75, 1.5, 2.5, 0.7, 0.4, false)
        val recovered = ProfitProtectionEngine.evaluate(100_000.0, 110_000.0, 109_000.0, settings, MarketHealthScore(), 0)
        assertEquals(ProfitProtectionState.PROFIT_CAUTION, recovered.state)
        assertTrue(recovered.newEntryAllowed)
    }
    @Test fun losingStreakReducesProfitProtectionSizeWithoutMartingale(){
        val settings = ProfitProtectionSettings(1.0, 3.0, 5.0, 10.0, 0.75, 1.5, 2.5, 0.7, 0.4, false)
        val snapshot = ProfitProtectionEngine.evaluate(100_000.0, 104_000.0, 103_500.0, settings, MarketHealthScore(), 3)
        assertEquals(0.7, snapshot.positionSizeMultiplier, 0.0001)
        assertTrue(snapshot.reason.contains("수익") || snapshot.state == ProfitProtectionState.PROFIT_CAUTION)
    }
    @Test fun profitVelocityDetectsAccelerationAndDeceleration(){
        val accelerating = ProfitVelocityEngine.evaluate(listOf(
            BalanceSnapshotEntity(1L, 100_000.0, 100_000.0, 0.0),
            BalanceSnapshotEntity(2L, 101_000.0, 101_000.0, 0.0),
            BalanceSnapshotEntity(3L, 103_000.0, 103_000.0, 0.0),
            BalanceSnapshotEntity(4L, 106_000.0, 106_000.0, 0.0)
        ))
        assertEquals(ProfitVelocityState.ACCELERATING, accelerating.state)
        val decelerating = ProfitVelocityEngine.evaluate(listOf(
            BalanceSnapshotEntity(1L, 107_000.0, 107_000.0, 0.0),
            BalanceSnapshotEntity(2L, 106_000.0, 106_000.0, 0.0),
            BalanceSnapshotEntity(3L, 105_500.0, 105_500.0, 0.0),
            BalanceSnapshotEntity(4L, 105_000.0, 105_000.0, 0.0)
        ))
        assertEquals(ProfitVelocityState.DECELERATING, decelerating.state)
    }
    @Test fun profitCounterfactualChoosesBestObservedScenario(){
        val result = ProfitCounterfactualEngine.compare(listOf(3.0), listOf(2.0), listOf(1.0), listOf(0.5))
        assertEquals("CONTINUE", result.recommendation)
    }
    @Test fun riskManagerKeepsDailyLossAsHigherPriorityThanProfitOverlay(){
        val state = DashboardState(mode=TradeMode.LIVE, dailyLossLocked=true, profitProtection=ProfitProtectionSnapshot(newEntryAllowed=true), lastTickerAt=System.currentTimeMillis(), apiStatus=ConnectionStatus.CONNECTED)
        val result = RiskManager().canBuy(TradingSettings(), state, StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(result.first)
        assertTrue(result.second.contains("일일 손실"))
    }
    @Test fun paperModeDoesNotCompletelyStopOnFiveConsecutiveLosses(){
        val snapshot = PaperRiskEngine.evaluate(
            mode = TradeMode.PAPER,
            lossStreak = 5,
            dailyLossLocked = false,
            recentStats = PerformanceStats(),
            health = MarketHealthScore(),
            regime = MarketRegimeSnapshot(regime = MarketRegime.BULL)
        )
        assertEquals(PaperRiskState.PAPER_DEFENSE, snapshot.state)
        assertTrue(snapshot.tradingActive)
        assertEquals(0.3, snapshot.positionSizeMultiplier, 0.0001)
        assertEquals(5.0, snapshot.thresholdOffset, 0.0001)
    }
    @Test fun paperModeEntersShadowCentricOnEightLossesWithoutStoppingAnalysis(){
        val snapshot = PaperRiskEngine.evaluate(
            mode = TradeMode.PAPER,
            lossStreak = 8,
            dailyLossLocked = false,
            recentStats = PerformanceStats(),
            health = MarketHealthScore(),
            regime = MarketRegimeSnapshot(regime = MarketRegime.SIDEWAYS)
        )
        assertEquals(PaperRiskState.PAPER_SHADOW_MODE, snapshot.state)
        assertTrue(snapshot.tradingActive)
        assertTrue(snapshot.shadowCentric)
        assertEquals(0.1, snapshot.positionSizeMultiplier, 0.0001)
    }
    @Test fun paperModeKeepsTradingActiveOnDailyLossLimitAsWarning(){
        val snapshot = PaperRiskEngine.evaluate(
            mode = TradeMode.PAPER,
            lossStreak = 0,
            dailyLossLocked = true,
            recentStats = PerformanceStats(),
            health = MarketHealthScore(),
            regime = MarketRegimeSnapshot(regime = MarketRegime.BULL)
        )
        assertTrue(snapshot.tradingActive)
        assertTrue(snapshot.isDailyLossWarning)
        val state = DashboardState(mode=TradeMode.PAPER, dailyLossLocked=true, lastTickerAt=System.currentTimeMillis(), apiStatus=ConnectionStatus.CONNECTED)
        val result = RiskManager().canBuy(TradingSettings(), state, StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertTrue(result.first)
    }
    @Test fun liveModeStrictlyMaintainsDailyLossAndConsecutiveLossLocks(){
        val liveDaily = PaperRiskEngine.evaluate(
            mode = TradeMode.LIVE,
            lossStreak = 0,
            dailyLossLocked = true,
            recentStats = PerformanceStats(),
            health = MarketHealthScore(),
            regime = MarketRegimeSnapshot(regime = MarketRegime.BULL)
        )
        assertFalse(liveDaily.tradingActive)
        val liveState = DashboardState(mode=TradeMode.LIVE, dailyLossLocked=true, lastTickerAt=System.currentTimeMillis(), apiStatus=ConnectionStatus.CONNECTED)
        val buyResult = RiskManager().canBuy(TradingSettings(), liveState, StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(buyResult.first)
        val liveStreakState = DashboardState(mode=TradeMode.LIVE, consecutiveLossLocked=true, lastTickerAt=System.currentTimeMillis(), apiStatus=ConnectionStatus.CONNECTED)
        val streakResult = RiskManager().canBuy(TradingSettings(), liveStreakState, StrategySignalModel("KRW-BTC", 90.0, ""), OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0), false, false, 10000.0)
        assertFalse(streakResult.first)
    }
    @Test fun paperModeRecoversStateAutomaticallyWhenPerformanceAndRegimeImprove(){
        val recovered = PaperRiskEngine.evaluate(
            mode = TradeMode.PAPER,
            lossStreak = 5,
            dailyLossLocked = false,
            recentStats = PerformanceStats(sampleCount = 10, winRate = 0.6, expectedReturnPercent = 1.2),
            health = MarketHealthScore(score = 85.0, level = MarketHealthLevel.HEALTHY),
            regime = MarketRegimeSnapshot(regime = MarketRegime.BULL)
        )
        assertEquals(PaperRiskState.PAPER_CAUTION, recovered.state)
        assertEquals(0.7, recovered.positionSizeMultiplier, 0.0001)
    }

    @Test fun duplicateBuyBlockedWhenAlreadyHolding(){
        val signal = StrategySignalModel("KRW-SKR", 95.0, "", aiScore=99.0)
        val decision = ReentryGuardPolicy.evaluate(null, signal, holding=true, inflight=false)
        assertFalse(decision.allowed)
        assertEquals("ALREADY_HOLDING", decision.reasonCode)
    }
    @Test fun duplicateBuyBlockedWhenInflight(){
        val signal = StrategySignalModel("KRW-SKR", 95.0, "", aiScore=99.0)
        val decision = ReentryGuardPolicy.evaluate(null, signal, holding=false, inflight=true)
        assertFalse(decision.allowed)
        assertEquals("BUY_IN_FLIGHT", decision.reasonCode)
    }
    @Test fun stopLossExitCreatesCooldownAndRequiresSignalReset(){
        val state = ReentryGuardPolicy.onExit(null, "KRW-SKR", "STOP LOSS", -3.5, 90.0, 15 * 60_000L, 10 * 60_000L, now=1000L)
        assertEquals(MarketGuardStatus.COOLDOWN, state.status)
        assertEquals(1, state.lossStreak)
        assertTrue(state.signalResetRequired)
        val candidate = StrategySignalModel("KRW-SKR", 89.0, "", aiScore=99.0)
        val duringCooldown = ReentryGuardPolicy.evaluate(state, candidate, holding=false, inflight=false, now=2000L)
        assertFalse(duringCooldown.allowed)
        assertEquals("STOP_LOSS_COOLDOWN", duringCooldown.reasonCode)
        val afterCooldownOldSignal = ReentryGuardPolicy.evaluate(state, candidate, holding=false, inflight=false, now=1000L + 16 * 60_000L)
        assertFalse(afterCooldownOldSignal.allowed)
        assertEquals("WAITING_FOR_NEW_SIGNAL", afterCooldownOldSignal.reasonCode)
    }
    @Test fun signalResetAllowsReentryAfterCooldown(){
        var state = ReentryGuardPolicy.onExit(null, "KRW-SKR", "TRAILING STOP", -2.4, 85.0, 15 * 60_000L, 10 * 60_000L, now=1000L)
        val candidate = StrategySignalModel("KRW-SKR", 80.0, "", aiScore=90.0)
        val coordinator = MarketReentryCoordinator()
        coordinator.restore(listOf(state))
        coordinator.updateScore("KRW-SKR", 50.0)
        val updated = coordinator.get("KRW-SKR")
        assertNotNull(updated)
        assertTrue(updated!!.scoreResetObserved)
        val afterCooldown = ReentryGuardPolicy.evaluate(updated, candidate, holding=false, inflight=false, now=1000L + 11 * 60_000L)
        assertTrue(afterCooldown.allowed)
    }
    @Test fun repeatedLossesEscalateToExtendedCooldownAndTempBlock(){
        val first = ReentryGuardPolicy.onExit(null, "KRW-SKR", "STOP LOSS", -3.0, 90.0, 10_000L, 10_000L, 1000L)
        assertEquals(MarketGuardStatus.COOLDOWN, first.status)
        val second = ReentryGuardPolicy.onExit(first, "KRW-SKR", "STOP LOSS", -2.0, 90.0, 10_000L, 10_000L, 2000L)
        assertEquals(MarketGuardStatus.EXTENDED_COOLDOWN, second.status)
        assertEquals(2, second.lossStreak)
        val third = ReentryGuardPolicy.onExit(second, "KRW-SKR", "STOP LOSS", -1.0, 90.0, 10_000L, 10_000L, 3000L)
        assertEquals(MarketGuardStatus.TEMP_BLOCKED, third.status)
        assertEquals(3, third.lossStreak)
        val otherMarket = ReentryGuardPolicy.evaluate(null, StrategySignalModel("KRW-XRP", 90.0, ""), holding=false, inflight=false, now=4000L)
        assertTrue(otherMarket.allowed)
    }
    @Test fun highAiScoreCannotBypassReentryCooldownOrHoldingGuard(){
        val state = ReentryGuardPolicy.onExit(null, "KRW-SKR", "STOP LOSS", -3.0, 90.0, 15 * 60_000L, 10 * 60_000L, now=1000L)
        val decision = ReentryGuardPolicy.evaluate(state, StrategySignalModel("KRW-SKR", 100.0, "", aiScore=99.0), holding=false, inflight=false, now=2000L)
        assertFalse(decision.allowed)
        val holdingDecision = ReentryGuardPolicy.evaluate(null, StrategySignalModel("KRW-SKR", 100.0, "", aiScore=99.0), holding=true, inflight=false, now=2000L)
        assertFalse(holdingDecision.allowed)
    }

    @Test fun orderbookDepthWalkCalculatesWeightedAverageFillAndSlippage(){
        val levels = listOf(
            OrderbookLevel(100.0, 10.0), // 1,000 KRW
            OrderbookLevel(105.0, 20.0), // 2,100 KRW
            OrderbookLevel(110.0, 50.0)  // 5,500 KRW
        )
        val result = OrderbookDepthEngine.walkBuy(levels, 2_050.0, 100.0)
        assertEquals(ExecutionDepthStatus.FULL_FILL, result.status)
        assertTrue(result.weightedAverageFillPrice > 100.0)
        assertTrue(result.slippagePercent > 0.0)
        assertTrue(result.filledQuantity > 10.0)
    }

    @Test fun orderbookDepthWalkFlagsInsufficientDepth(){
        val levels = listOf(OrderbookLevel(100.0, 1.0))
        val result = OrderbookDepthEngine.walkBuy(levels, 50_000.0, 100.0)
        assertEquals(ExecutionDepthStatus.PARTIAL_FILL, result.status)
        assertTrue(result.unfilledQuantity > 0.0)
    }

    @Test fun netEdgeGateRejectsNegativeNetEdgeEvenWithHighScore(){
        val decision = NetEdgeGateEngine.evaluate(
            signalScore = 96.0,
            expectedReturnPercent = 0.5,
            feePercent = 0.50,
            spreadPercent = 0.80,
            slippagePercent = 0.60,
            marketImpactPercent = 0.30,
            riskPenaltyPercent = 0.20,
            historicalAccuracy = 0.8,
            sampleCount = 50,
            minNetEdgeMargin = 0.35
        )
        assertFalse(decision.allowed)
        assertTrue(decision.netExpectedEdge < 0.0)
        assertTrue(decision.reason.contains("HIGH_SCORE_BUT_NEGATIVE_NET_EDGE"))
    }

    @Test fun netEdgeGatePassesWhenNetEdgeExceedsMargin(){
        val decision = NetEdgeGateEngine.evaluate(
            signalScore = 90.0,
            expectedReturnPercent = 2.5,
            feePercent = 0.25,
            spreadPercent = 0.10,
            slippagePercent = 0.10,
            marketImpactPercent = 0.05,
            riskPenaltyPercent = 0.0,
            historicalAccuracy = 0.8,
            sampleCount = 50,
            minNetEdgeMargin = 0.35
        )
        assertTrue(decision.allowed)
        assertTrue(decision.netExpectedEdge > 1.0)
    }

    @Test fun sessionEdgeIdentifiesKstTimeWindows(){
        assertEquals(TradingSessionQuality.EXCELLENT, SessionEdgeEngine.sessionQuality(10).first)
        assertEquals(TradingSessionQuality.GOOD, SessionEdgeEngine.sessionQuality(20).first)
        assertEquals(TradingSessionQuality.WEAK, SessionEdgeEngine.sessionQuality(4).first)
    }

    @Test fun portfolioHeatCalculatesExposureAndCorrelatedCluster(){
        val positions = listOf(
            PositionModel("KRW-BTC", 1.0, 40_000.0, 40_000.0, 0L),
            PositionModel("KRW-ETH", 1.0, 30_000.0, 30_000.0, 0L)
        )
        val heat = PortfolioHeatEngine.evaluate(positions, totalEquity = 100_000.0, stopLossPercent = -2.5)
        assertEquals(70.0, heat.portfolioExposurePercent, 0.0001)
        assertEquals(70.0, heat.correlatedExposurePercent, 0.0001)
        assertEquals(PortfolioHeatLevel.CRITICAL, heat.level)
        assertEquals(0.0, heat.multiplier, 0.0001)
    }

    @Test fun tailRiskCalculatesVarAndExpectedShortfall(){
        val returns = listOf(-5.0, -4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0)
        val tail = TailRiskEngine.evaluate(returns, healthScore = 80.0, regime = MarketRegime.BULL, portfolioExposurePercent = 20.0)
        assertTrue(tail.var95Percent <= -4.0)
        assertTrue(tail.expectedShortfall95Percent <= tail.var95Percent)
    }

    @Test fun dataIntegrityGuardianCatchesReversedOrderbookAndMismatch(){
        val badBook = OrderbookModel("KRW-BTC", 100.0, 110.0, 1.0, 1.0, 0L) // bid > ask
        val eval = DataIntegrityGuardian.evaluate(
            ticker = TickerModel("KRW-BTC", 100.0, 1_000.0, 0.01, 1.0, 0L),
            wsTicker = TickerModel("KRW-BTC", 130.0, 1_000.0, 0.01, 1.0, 0L), // > 15% diff
            orderbook = badBook,
            candles = emptyList()
        )
        assertEquals(DataQualityStatus.QUARANTINED, eval.status)
        assertTrue(eval.reasons.any { it.contains("호가 역전") })
        assertTrue(eval.reasons.any { it.contains("가격 불일치") })
    }

    @Test fun autonomousCapitalEngineScalesGrowthWhenEdgeAndHealthAreStrong(){
        val settings = AutonomousCapitalSettings()
        val stats = PerformanceStats(sampleCount = 15, winRate = 0.65, profitFactor = 2.1, expectedReturnPercent = 1.8)
        val snapshot = AutonomousCapitalGrowthEngine.evaluate(
            currentEquity = 120_000.0,
            availableCash = 80_000.0,
            dayStartEquity = 100_000.0,
            equityPeak = 120_000.0,
            recentStats = stats,
            marketRegime = MarketRegimeSnapshot(regime = MarketRegime.BULL),
            marketHealth = MarketHealthScore(score = 90.0, level = MarketHealthLevel.HEALTHY),
            tailRisk = TailRiskSnapshot(20.0, "LOW", -1.5, -2.0, "정상"),
            portfolioHeat = PortfolioHeatSnapshot(PortfolioHeatLevel.LOW, 0.0, 0.0, 0.0, "NONE", 0.0, 1.0),
            profitProtection = ProfitProtectionSnapshot(),
            consecutiveLosses = 0,
            settings = settings
        )
        assertEquals(CapitalGrowthState.GROWTH, snapshot.capitalState)
        assertTrue(snapshot.positionSizeMultiplier >= 1.0)
        assertTrue(snapshot.protectedReserve > 0.0) // 20% gain reserve accumulated
    }

    @Test fun autonomousCapitalEngineDefendsOnDrawdownWithoutMartingale(){
        val settings = AutonomousCapitalSettings()
        val stats = PerformanceStats(sampleCount = 15, winRate = 0.40, profitFactor = 0.8, expectedReturnPercent = -0.5)
        val snapshot = AutonomousCapitalGrowthEngine.evaluate(
            currentEquity = 92_000.0,
            availableCash = 50_000.0,
            dayStartEquity = 100_000.0,
            equityPeak = 100_000.0,
            recentStats = stats,
            marketRegime = MarketRegimeSnapshot(regime = MarketRegime.SIDEWAYS),
            marketHealth = MarketHealthScore(score = 65.0, level = MarketHealthLevel.CAUTION),
            tailRisk = TailRiskSnapshot(60.0, "HIGH", -4.5, -6.0, "위험"),
            portfolioHeat = PortfolioHeatSnapshot(PortfolioHeatLevel.MEDIUM, 2.0, 40.0, 0.0, "NONE", 3.0, 0.8),
            profitProtection = ProfitProtectionSnapshot(),
            consecutiveLosses = 6,
            settings = settings
        )
        assertEquals(CapitalGrowthState.DEFENSE, snapshot.capitalState)
        assertTrue(snapshot.positionSizeMultiplier <= 0.4)
    }

    @Test fun riskOfRuinEstimatorDetectsCriticalRuin(){
        val tailRisk = TailRiskSnapshot(90.0, "HIGH", -8.0, -12.0, "치명적")
        val ruin = RiskOfRuinEstimator.estimate(winRate = 0.3, profitFactor = 0.4, sampleCount = 20, lossStreak = 8, tailRisk = tailRisk)
        assertEquals(RuinRiskLevel.CRITICAL, ruin)
    }

    @Test fun capitalStrategyAllocatorRecommendsCashHeavyDuringCrash(){
        val allocations = CapitalStrategyAllocator.recommend(
            regime = MarketRegimeSnapshot(regime = MarketRegime.CRASH),
            capitalState = CapitalGrowthState.PRESERVATION,
            shadowPortfolios = emptyList()
        )
        assertEquals(1, allocations.size)
        assertEquals("CASH_RESERVE", allocations.single().strategy)
        assertEquals(100.0, allocations.single().targetPercent, 0.0)
    }

    @Test fun counterfactualCapitalLabSimulatesMultipleReinvestmentModes(){
        val trades = listOf(5.0, -2.0, 4.0, -1.0, 6.0, -2.0)
        val result = CounterfactualCapitalLabEngine.simulate(trades, 100_000.0)
        assertTrue(result.fixedCapitalFinal > 100_000.0)
        assertTrue(result.autonomousGrowthFinal > 100_000.0)
    }

    @Test fun lossRootCauseClassifiesEarlyStopWhenRecovering30mLater(){
        val (cause, reason) = LossRootCauseEngine.analyze(
            pnlRate = -2.5,
            exitReason = "STOP LOSS",
            mfePercent = 1.0,
            maePercent = -2.6,
            holdingMinutes = 10L,
            postExit30mChangeFromExit = 4.0,
            postExit60mChangeFromExit = 7.0,
            marketRegime = "SIDEWAYS",
            marketHealthScore = 80.0,
            newsRiskActive = false,
            liquidityPassed = true,
            executionCostPercent = 0.5
        )
        assertEquals(LossRootCause.EARLY_STOP, cause)
        assertTrue(reason.contains("Early Stop") || reason.contains("조기 손절"))
    }

    @Test fun lossRootCauseClassifiesRealBadTradeWhenNoBounce(){
        val (cause, _) = LossRootCauseEngine.analyze(
            pnlRate = -3.5,
            exitReason = "STOP LOSS",
            mfePercent = 0.1,
            maePercent = -3.6,
            holdingMinutes = 8L,
            postExit30mChangeFromExit = -2.0,
            postExit60mChangeFromExit = -5.0,
            marketRegime = "SIDEWAYS",
            marketHealthScore = 80.0,
            newsRiskActive = false,
            liquidityPassed = true,
            executionCostPercent = 0.5
        )
        assertEquals(LossRootCause.REAL_BAD_TRADE, cause)
    }

    @Test fun stopQualityDetectsEarlyAndGoodStops(){
        assertEquals(StopQuality.EARLY_STOP, StopQualityEngine.evaluateStop("STOP LOSS", -2.5, 2.0, 4.0))
        assertEquals(StopQuality.GOOD_STOP, StopQualityEngine.evaluateStop("STOP LOSS", -2.5, -3.0, -5.0))
        assertEquals(StopQuality.NEUTRAL_STOP, StopQualityEngine.evaluateStop("TAKE PROFIT", 6.0, 1.0, 1.0))
    }

    @Test fun trailingQualityDetectsTightAndLateTrailing(){
        assertEquals(TrailingQuality.TRAILING_TOO_TIGHT, StopQualityEngine.evaluateTrailing("TRAILING STOP", 4.0, 1.0, 3.5, 5.0))
        assertEquals(TrailingQuality.TRAILING_TOO_LATE, StopQualityEngine.evaluateTrailing("TRAILING STOP", 5.0, 1.0, 0.5, 0.0))
    }

    @Test fun mfeCaptureAndGivebackCalculations(){
        assertEquals(0.5, StopQualityEngine.calculateMfeCaptureRatio(4.0, 2.0), 0.0001)
        assertEquals(2.0, StopQualityEngine.calculateProfitGiveback(4.0, 2.0), 0.0001)
    }

    @Test fun mathStatisticsComputesMeanMedianAndTrimmedMean(){
        val values = listOf(1.0, 2.0, 3.0, 4.0, 100.0)
        assertEquals(22.0, MathStatisticsUtil.mean(values), 0.0001)
        assertEquals(3.0, MathStatisticsUtil.median(values), 0.0001)
        assertTrue(MathStatisticsUtil.trimmedMean(values, 0.20) < 22.0)
    }

    @Test fun counterfactualExitSimulatorComputesSimulatedOutcomes(){
        val simulated30 = CounterfactualExitSimulator.simulate(
            type = CounterfactualExitType.TIME_30M,
            entryPrice = 100.0,
            highestPrice = 105.0,
            lowestPrice = 97.0,
            currentOrFinalPrice = 97.5,
            actualPnlRate = -2.5,
            actualExitReason = "STOP LOSS",
            actualHoldingMinutes = 10L,
            post30mPrice = 104.0
        )
        assertEquals(4.0, simulated30.pnlRate, 0.0001)
        assertEquals("TIME_30M_EXIT", simulated30.reason)
    }

    @Test fun chaseEntryDetectorIdentifiesChasePattern(){
        val closes = listOf(100.0, 100.5, 101.0, 102.0, 106.0, 108.0)
        val maturity = ChaseEntryDetector.detect(
            currentPrice = 108.0,
            closes = closes,
            ema20 = 101.0,
            rsi = 82.0,
            volumeRatio = 4.0
        )
        assertEquals(EntryTimingMaturity.CHASE_ENTRY, maturity)
    }

    @Test fun entryTimingEngineSeparatesHighStrategyScoreFromChaseTiming(){
        val closes = (0 until 20).map { i -> if (i < 16) 100.0 + i * 0.2 else 108.0 + (i - 15) * 5.0 }
        val candles = closes.mapIndexed { i, close -> CandleModel("KRW-TAVA", i.toLong() * 60_000L, close, close * 1.01, close * 0.99, if (i == closes.lastIndex) 400.0 else 100.0) }
        val evaluation = EntryTimingEngine.evaluate(
            currentPrice = closes.last(),
            candles = candles,
            strategyCandles = candles,
            orderbook = OrderbookModel("KRW-TAVA", closes.last() * 1.001, closes.last() * 0.999, 1.0, 2.0, System.currentTimeMillis()),
            currentStrategyScore = 97.0
        )
        assertTrue(evaluation.chaseScore > 0.0)
        assertTrue(evaluation.entryTimingScore in 0.0..100.0)
        assertTrue(evaluation.state in setOf(EntryTimingState.CHASE, EntryTimingState.EXTREME_CHASE, EntryTimingState.EXTENDED))
        assertFalse(EntryTimingEngine.gate(evaluation, rejectScore = 60.0, extremeScore = 80.0).allowed)
    }

    @Test fun entryTimingGateAllowsHealthyTimingAndKeepsRiskSeparate(){
        val evaluation = EntryTimingEvaluation(
            chaseScore = 20.0,
            entryTimingScore = 82.0,
            state = EntryTimingState.SAFE_ENTRY,
            overextensionAtr = 0.4,
            pullbackState = PullbackState.REACCELERATION,
            retestState = BreakoutRetestState.RETEST_CONFIRMED
        )
        assertTrue(EntryTimingEngine.gate(evaluation).allowed)
        val risk = RiskManager().canBuy(
            TradingSettings(),
            DashboardState(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED, killSwitchEngaged = true),
            StrategySignalModel("KRW-BTC", 99.0, "", entryTimingScore = 99.0),
            OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0),
            holding = false,
            orderInFlight = false,
            orderAmount = 10000.0
        )
        assertFalse(risk.first)
    }

    @Test fun overextensionUsesAtrNormalization(){
        val calm = (0 until 20).map { i -> 100.0 + i * 0.1 }
        val candles = calm.mapIndexed { i, close -> CandleModel("KRW-BTC", i.toLong(), close, close + 0.2, close - 0.2, 100.0) }
        val normal = EntryTimingEngine.evaluate(102.0, candles)
        val extended = EntryTimingEngine.evaluate(110.0, candles)
        assertTrue(extended.overextensionAtr > normal.overextensionAtr)
        assertTrue(extended.overextensionScore >= normal.overextensionScore)
    }

    @Test fun shadowComparisonIncludesImmediateAndWaitPolicies(){
        val metrics = EntryShadowComparisonEngine.compare(listOf(100.0, 102.0, 101.0, 103.0, 105.0, 104.0, 108.0, 110.0, 109.0, 112.0))
        assertEquals(6, metrics.size)
        assertTrue(metrics.any { it.policy == EntryShadowPolicy.CURRENT_ENTRY })
        assertTrue(metrics.any { it.policy == EntryShadowPolicy.WAIT_3M })
        assertTrue(metrics.all { it.trades > 0 && it.mddPercent <= 0.0 })
    }

    @Test fun scoreVelocityAndSignalLagAreBounded(){
        assertEquals(10.0, EntryTimingEngine.scoreVelocity(listOf(ScorePoint(0L, 70.0)), 60_000L, 80.0, 75.0), 0.0001)
        val candles = (0 until 20).map { i -> CandleModel("KRW-BTC", i.toLong() * 60_000L, 100.0 + i, 101.0 + i, 99.0 + i, 100.0) }
        val evaluation = EntryTimingEngine.evaluate(120.0, candles, scoreHistory = listOf(ScorePoint(1_000L, 70.0)), currentStrategyScore = 95.0)
        assertTrue(evaluation.signalLagMs >= 0L)
        assertTrue(evaluation.scoreVelocityPerMinute.isFinite())
    }

    @Test fun entryTimingAnalyticsReportsHighScoreWarningAndImmediateDrawdown(){
        val rows = (0 until 20).map { i ->
            EntryDiagnosticEntity(
                market = "KRW-$i",
                time = i.toLong(),
                tradeId = "trade-$i",
                strategyScore = 92.0,
                aiScore = 95.0,
                entryTimingScore = 30.0,
                chaseScore = 90.0,
                state = EntryTimingState.EXTREME_CHASE.name,
                pullbackState = PullbackState.EXTENDED.name,
                retestState = BreakoutRetestState.WAITING_RETEST.name,
                overextensionAtr = 3.0,
                overextensionScore = 80.0,
                parabolicMove = true,
                momentumExhaustion = true,
                volumeClimax = true,
                scoreVelocityPerMinute = 8.0,
                signalLagMs = 120_000L,
                preEntry1mReturn = 1.0,
                preEntry3mReturn = 5.0,
                preEntry5mReturn = 8.0,
                preEntry10mReturn = 10.0,
                preEntry15mReturn = 12.0,
                first5mReturn = -3.0,
                classification = EntryQualityClassification.EXTREME_CHASE.name,
                decision = CandidateStatus.REJECTED.name,
                reason = "test"
            )
        }
        val stats = EntryTimingAnalytics.summarize(rows)
        assertEquals(20, stats.completedEntries)
        assertEquals(1.0, stats.immediateDrawdownRate, 0.0001)
        assertEquals("HIGH_SCORE_CHASE_BIAS_WARNING", stats.warning)
        assertEquals(120_000L, stats.p50SignalLagMs)
    }

    @Test fun reentryTempBlockExpiresButStillRequiresFreshSignal(){
        val blocked = MarketReentryState("KRW-BTC", 3, 1_000L, MarketGuardStatus.TEMP_BLOCKED, "STOP LOSS", 90.0, 0L, true, true)
        val decision = ReentryGuardPolicy.evaluate(blocked, StrategySignalModel("KRW-BTC", 70.0, ""), false, false, now = 2_000L)
        assertTrue(decision.allowed)
    }

    @Test fun noiseStopDetectorFlagsNoiseOnSmallMae(){
        val noise = NoiseStopDetector.detect(atrPercent = 2.0, maePercent = -1.8, holdingMinutes = 5L, exitReason = "STOP LOSS")
        assertEquals(NoiseStopStatus.NORMAL_NOISE, noise)
    }

    @Test fun microMomentumDetectsAccelerationAndDeceleration(){
        assertEquals(MicroMomentumState.ACCELERATING, ScalpingExecutionEngine.momentumState(0.3, 0.8, 1.4, 0.2))
        assertEquals(MicroMomentumState.DECELERATING, ScalpingExecutionEngine.momentumState(1.8, 1.2, 1.1, -0.3))
    }

    @Test fun earlyMomentumCanEnterButChaseCannot(){
        val early = ScalpingExecutionEngine.evaluate(scalpInput(
            return30s = 0.3, return1m = 0.8, return3m = 1.4,
            momentumSlope = 0.2, momentumAcceleration = 0.2, chaseScore = 15.0,
            entryTimingScore = 85.0
        ))
        val chase = ScalpingExecutionEngine.evaluate(scalpInput(
            return30s = 0.2, return1m = 2.0, return3m = 7.0,
            momentumSlope = -0.3, momentumAcceleration = -0.2, chaseScore = 95.0,
            entryTimingScore = 25.0, rsi = 88.0
        ))
        assertEquals(ScalpingExecutionState.ENTER_NOW, early.state)
        assertTrue(early.allowed)
        assertEquals(ScalpingExecutionState.CHASE_RISK, chase.state)
        assertFalse(chase.allowed)
    }

    @Test fun pullbackAndRetestProduceExplainableExecutionReasons(){
        val pullback = ScalpingExecutionEngine.evaluate(scalpInput(
            pullbackState = PullbackState.PULLBACK_STABILIZING,
            return30s = 0.2, return1m = 0.5, return3m = 0.8,
            momentumSlope = 0.1, momentumAcceleration = 0.1
        ))
        val retest = ScalpingExecutionEngine.evaluate(scalpInput(
            retestState = BreakoutRetestState.RETEST_CONFIRMED,
            return30s = 0.2, return1m = 0.5, return3m = 0.8,
            momentumSlope = 0.1, momentumAcceleration = 0.1
        ))
        assertTrue("PULLBACK_CONFIRMED" in pullback.reasonCodes)
        assertTrue("RETEST_CONFIRMED" in retest.reasonCodes)
    }

    @Test fun orderbookPressureDetectsSupportAndInstability(){
        val previous = OrderbookModel("KRW-BTC", 101.0, 100.0, 10.0, 10.0, 0)
        val supported = OrderbookModel("KRW-BTC", 101.0, 100.0, 8.0, 18.0, 0)
        val vanished = OrderbookModel("KRW-BTC", 101.0, 100.0, 8.0, 2.0, 0)
        val pressure = ScalpingExecutionEngine.orderbookPressure(supported, previous)
        val unstable = ScalpingExecutionEngine.orderbookPressure(vanished, previous)
        assertTrue(pressure.buyPressure > pressure.sellPressure)
        assertTrue(pressure.depthChangePercent > 0.0)
        assertFalse(unstable.stable)
        assertTrue("ORDERBOOK_UNSTABLE" in ScalpingExecutionEngine.evaluate(scalpInput(
            orderbookStable = false, depthChangePercent = -80.0
        )).reasonCodes)
    }

    @Test fun shortNetEdgeAndSpreadGateRejectCostlyScalp(){
        val noEdge = ScalpingExecutionEngine.evaluate(scalpInput(
            return30s = 0.05, return1m = 0.08, return3m = 0.10, return5m = 0.12,
            netEdge = 0.1, bidAskSpread = 0.2
        ))
        val wideSpread = ScalpingExecutionEngine.evaluate(scalpInput(bidAskSpread = 2.0, netEdge = 2.0))
        assertEquals(ScalpingExecutionState.NO_EDGE, noEdge.state)
        assertEquals(ScalpingExecutionState.AVOID, wideSpread.state)
        assertTrue("SCALP_EDGE_TOO_SMALL" in noEdge.reasonCodes)
    }

    @Test fun signalTtlAndPriceMovedAwayGuardsAreAtrBased(){
        assertTrue(ScalpingSignalPolicy.isFresh(1_000L, 1_500L, 1_000L))
        assertFalse(ScalpingSignalPolicy.isFresh(1_000L, 2_100L, 1_000L))
        assertTrue(ScalpingSignalPolicy.priceMovedAway(104.0, 100.0, 2.0, 1.5))
        assertFalse(ScalpingSignalPolicy.priceMovedAway(102.0, 100.0, 2.0, 1.5))
    }

    @Test fun setupInvalidationAndWindowClosureAreRecorded(){
        val decision = ScalpingExecutionEngine.evaluate(scalpInput(
            currentPrice = 104.0, currentSignalPrice = 100.0, atrPercent = 2.0,
            return30s = -0.5, return1m = -0.8, return3m = -1.0,
            netEdge = 1.0, bidAskSpread = 0.1
        ))
        assertTrue("SETUP_INVALIDATED" in decision.reasonCodes)
        assertTrue(decision.priceMovedAway)
        assertTrue("ENTRY_WINDOW_CLOSED" in decision.reasonCodes)
    }

    @Test fun highWinRateWithNegativeExpectancyCannotValidateScalpingModel(){
        val result = ScalpingValidationPolicy.validate(40, 0.90, 0.8, -0.01, -2.0)
        assertFalse(result.passed)
        assertEquals("HIGH_WIN_RATE_LOW_EXPECTANCY", result.reason)
    }

    @Test fun confidenceCalibrationDetectsOverconfidence(){
        val rows = (0 until 20).map { scalpDiagnostic(tradeId = "trade-$it", first5mReturn = -0.5, confidence = 95.0) }
        assertEquals("OVERCONFIDENT", ScalpingResearchAnalytics.summarize(rows).calibration)
    }

    @Test fun scalpingResearchTracksFalseRejectAndFalseEntry(){
        val falseReject = scalpDiagnostic(first5mReturn = 3.5, outcome = "FALSE_REJECT", state = ScalpingExecutionState.WAIT.name)
        val falseEntry = scalpDiagnostic(tradeId = "trade", first5mReturn = -3.0, outcome = "FALSE_ENTRY")
        val stats = ScalpingResearchAnalytics.summarize(listOf(falseReject, falseEntry))
        assertEquals(1, stats.falseReject)
        assertEquals(1, stats.falseEntry)
        assertEquals(1, stats.rejected)
        assertEquals(1, stats.entered)
    }

    @Test fun paperModeRemainsDefaultAndLiveModeIsNotEnabledByScalping(){
        assertEquals(TradeMode.PAPER, TradingSettings().mode)
        assertFalse(TradingSettings(mode = TradeMode.LIVE).mode == TradeMode.PAPER)
        assertNotEquals(ScalpingExecutionState.ENTER_NOW, ScalpingExecutionEngine.evaluate(scalpInput(marketHealth = 30.0)).state)
    }

    @Test fun microFlowCalculatesShortHorizonReturnsWithoutRestPolling(){
        val samples = listOf(
            MicroMarketSample(0L, 100.0, 1.0),
            MicroMarketSample(30_000L, 100.5, 2.0),
            MicroMarketSample(60_000L, 101.0, 3.0)
        )
        val flow = ScalpingExecutionEngine.microFlow(samples, 101.5, now = 60_000L)
        assertEquals((101.5 / 100.5 - 1.0) * 100.0, flow.return30s, 0.0001)
        assertEquals(1.5, flow.return1m, 0.0001)
        assertEquals(6.0, flow.volume1m, 0.0001)
    }

    @Test fun scalpingShadowAccountRemainsSeparateFromPaperPosition(){
        val account = ScalpingShadowEngine.initial(100_000.0)
        val signal = StrategySignalModel(
            "KRW-BTC", 95.0, "", currentPrice = 100.0,
            status = CandidateStatus.BUY_READY,
            scalpEntryAllowed = true,
            scalpExecutionState = ScalpingExecutionState.ENTER_NOW.name,
            microMomentumState = MicroMomentumState.ACCELERATING.name
        )
        val opened = ScalpingShadowEngine.step(account, signal, now = 1_000L)
        assertEquals("KRW-BTC", opened.positionMarket)
        assertTrue(opened.cash < account.cash)
    }

    @Test fun derivativeSymbolMappingDoesNotAssumeEveryBithumbMarket(){
        assertEquals("BTCUSDT", DerivativeSymbolMapper.map("KRW-BTC").bybitSymbol)
        assertEquals(DerivativeSupportStatus.SUPPORTED, DerivativeSymbolMapper.map("KRW-BTC").status)
        assertEquals(DerivativeSupportStatus.UNSUPPORTED, DerivativeSymbolMapper.map("KRW-UNKNOWN").status)
    }

    @Test fun priceAndOiMatrixDistinguishesBuildUpAndClosing(){
        val rising = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = 2.0, oiChange = 2.0, funding = FundingState.NEUTRAL),
            spotChangePercent = 2.0
        )
        val falling = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = -2.0, oiChange = 2.0, funding = FundingState.NEGATIVE, shortRatio = 0.65),
            spotChangePercent = -2.0
        )
        assertEquals(DerivativesPositioningState.EARLY_LONG_BUILDUP, rising.positioningState)
        assertEquals(DerivativesPositioningState.EARLY_SHORT_BUILDUP, falling.positioningState)
        assertEquals(OpenInterestState.OI_RISING, rising.openInterestState)
    }

    @Test fun crowdedLongCreatesLongSqueezeRiskAndPenalty(){
        val result = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = 8.0, oiChange = 6.0, funding = FundingState.EXTREME, longRatio = 0.72),
            spotChangePercent = 8.0,
            chaseScore = 90.0,
            entryTimingScore = 25.0,
            microMomentum = MicroMomentumState.DECELERATING,
            buyPressure = 0.35
        )
        assertEquals("state=${result.positioningState}, long=${result.longSqueezeRisk}, funding=${result.fundingState}, micro=${result.reasonCodes}", DerivativesPositioningState.LONG_SQUEEZE_RISK, result.positioningState)
        assertTrue(result.longSqueezeRisk >= 80.0)
        assertTrue(result.buyPenalty > 0.0)
    }

    @Test fun crowdedShortIsNotAnAutomaticBuy(){
        val result = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = -2.0, oiChange = 4.0, funding = FundingState.STRONG_NEGATIVE, shortRatio = 0.70),
            spotChangePercent = -2.0,
            chaseScore = 10.0,
            entryTimingScore = 70.0,
            microMomentum = MicroMomentumState.DECELERATING
        )
        assertEquals(DerivativesPositioningState.CROWDED_SHORT, result.positioningState)
        assertFalse(result.globalMoveConfirmed)
    }

    @Test fun shortSqueezeNeedsStabilizationAndBuyPressure(){
        val result = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = -0.4, oiChange = -2.0, funding = FundingState.STRONG_NEGATIVE, shortRatio = 0.75),
            spotChangePercent = -0.2,
            chaseScore = 20.0,
            entryTimingScore = 75.0,
            microMomentum = MicroMomentumState.ACCELERATING,
            buyPressure = 0.70
        )
        assertTrue("short=${result.shortSqueezeScore}, funding=${result.fundingState}, reasons=${result.reasonCodes}", result.shortSqueezeScore >= 65.0)
        assertTrue(result.reasonCodes.contains("SHORT_SQUEEZE_POTENTIAL") || result.shortSqueezeScore >= 65.0)
    }

    @Test fun globalConfirmationRequiresHealthyOiFundingAndMomentum(){
        val result = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = 2.0, oiChange = 2.0, funding = FundingState.POSITIVE),
            spotChangePercent = 2.0,
            chaseScore = 20.0,
            entryTimingScore = 80.0,
            microMomentum = MicroMomentumState.ACCELERATING
        )
        assertTrue(result.globalMoveConfirmed)
        assertEquals(SpotFuturesDivergence.SYNCHRONIZED, result.spotFuturesDivergence)
    }

    @Test fun spotFuturesDivergenceRaisesRiskWithoutBecomingTruth(){
        val result = GlobalDerivativesIntelligenceEngine.evaluate(
            derivativeSnapshot(priceChange = -2.0, oiChange = 0.5, funding = FundingState.NEUTRAL),
            spotChangePercent = 5.0,
            microMomentum = MicroMomentumState.ACCELERATING
        )
        assertEquals(SpotFuturesDivergence.DIVERGENCE, result.spotFuturesDivergence)
        assertTrue(result.derivativesRisk > 20.0)
    }

    @Test fun leadLagReportsBybitLeadOnlyFromObservedTimestamps(){
        val result = GlobalDerivativesIntelligenceEngine.leadLag(
            spot = listOf(MicroMarketSample(20_000L, 101.0, 1.0)),
            futures = listOf(DerivativeHistorySample(10_000L, 101.0, 100.0))
        )
        assertEquals(GlobalLeadState.BYBIT_LEADING, result.state)
        assertEquals(10_000L, result.leadMs)
    }

    @Test fun staleDerivativesBecomeUnavailableAndSpotCanContinue(){
        val stale = derivativeSnapshot(timestamp = System.currentTimeMillis() - 120_000L)
            .copy(freshness = DerivativeFreshness.STALE)
        val result = GlobalDerivativesIntelligenceEngine.evaluate(stale, spotChangePercent = 2.0)
        assertEquals(DerivativesPositioningState.DATA_UNAVAILABLE, result.positioningState)
        val scalp = ScalpingExecutionEngine.evaluate(scalpInput())
        assertNotEquals(ScalpingExecutionState.AVOID, scalp.state)
    }

    @Test fun unavailableDerivativesNeverBypassChaseProtection(){
        val result = GlobalDerivativesIntelligenceEngine.evaluate(null, spotChangePercent = 8.0, chaseScore = 95.0)
        assertEquals(DerivativesPositioningState.DATA_UNAVAILABLE, result.positioningState)
        val scalp = ScalpingExecutionEngine.evaluate(scalpInput(chaseScore = 95.0, entryTimingScore = 20.0))
        assertEquals(ScalpingExecutionState.CHASE_RISK, scalp.state)
    }

    @Test fun derivativesRiskCanRejectScalpButCannotBypassRiskManager(){
        val decision = ScalpingExecutionEngine.evaluate(scalpInput(
            netEdge = 2.0,
            chaseScore = 80.0,
            entryTimingScore = 70.0
        ).copy(
            derivativesAvailable = true,
            derivativesRisk = 90.0,
            longSqueezeRisk = 90.0,
            derivativesPositioningState = DerivativesPositioningState.LONG_SQUEEZE_RISK
        ))
        assertTrue(decision.state in setOf(ScalpingExecutionState.AVOID, ScalpingExecutionState.CHASE_RISK))
        val risk = RiskManager().canBuy(
            TradingSettings(),
            DashboardState(lastTickerAt = System.currentTimeMillis(), apiStatus = ConnectionStatus.CONNECTED, killSwitchEngaged = true),
            StrategySignalModel("KRW-BTC", 99.0, ""),
            OrderbookModel("KRW-BTC", 100.0, 99.9, 1.0, 1.0, 0),
            false, false, 10000.0
        )
        assertFalse(risk.first)
    }

    @Test fun globalDerivativesMarketStateAggregatesOnlyUsableSnapshots(){
        val longSqueeze = GlobalDerivativesIntelligence(
            market = "KRW-BTC",
            supportStatus = DerivativeSupportStatus.SUPPORTED,
            providerStatus = DerivativesProviderStatus.CONNECTED,
            freshness = DerivativeFreshness.FRESH,
            positioningState = DerivativesPositioningState.LONG_SQUEEZE_RISK,
            derivativesRisk = 80.0
        )
        val shortSqueeze = longSqueeze.copy(market = "KRW-ETH", positioningState = DerivativesPositioningState.SHORT_SQUEEZE_POTENTIAL)
        assertEquals(GlobalDerivativesMarketState.GLOBAL_SQUEEZE, GlobalDerivativesMarketEngine.classify(listOf(longSqueeze, shortSqueeze)))
        assertEquals(GlobalDerivativesMarketState.DATA_UNAVAILABLE, GlobalDerivativesMarketEngine.classify(emptyList()))
    }

    @Test fun dashboardSectionOrderKeepsCorePositionsAndCandidatesAtTop(){
        assertEquals(DashboardSection.CORE_ACCOUNT_SUMMARY, DASHBOARD_SECTION_ORDER[0])
        assertEquals(DashboardSection.CURRENT_POSITIONS, DASHBOARD_SECTION_ORDER[1])
        assertEquals(DashboardSection.BUY_CANDIDATES, DASHBOARD_SECTION_ORDER[2])
        assertTrue(DASHBOARD_SECTION_ORDER.indexOf(DashboardSection.SCALPING_AI) > 2)
        assertTrue(DASHBOARD_SECTION_ORDER.indexOf(DashboardSection.GLOBAL_DERIVATIVES) > 2)
    }

    @Test fun dashboardTradingStatePrioritizesSafetyAndCurrentActivity(){
        assertEquals("RISK_BLOCKED", tradingStateText(DashboardState(killSwitchEngaged = true)))
        assertEquals("COOLDOWN", tradingStateText(DashboardState(cooldownActive = true)))
        assertEquals("HELD", tradingStateText(DashboardState(holdingCount = 1)))
        assertEquals("BUY_READY", tradingStateText(DashboardState(topSignals = listOf(StrategySignalModel("KRW-BTC", 90.0, "", status = CandidateStatus.BUY_READY)))))
    }

    @Test fun profitExitStartsIndependentCooldownAndConsumesSignal(){
        val anchor = profitAnchor()
        val state = SmartReentryEngine.onProfitExit(null, anchor, cooldownMinutes = 10)
        val decision = SmartReentryEngine.evaluate(
            state,
            reentrySignal(signalId = anchor.consumedSignalId, timestamp = anchor.exitTime + 60_000L),
            now = anchor.exitTime + 2 * 60_000L
        )
        assertEquals(ProfitReentryStatus.PROFIT_EXIT_COOLDOWN, decision.status)
        assertFalse(decision.allowed)
        assertTrue("PROFIT_EXIT_COOLDOWN" in decision.reasonCodes)
    }

    @Test fun oldSignalCannotReenterAfterProfitCooldown(){
        val anchor = profitAnchor()
        val state = SmartReentryEngine.onProfitExit(null, anchor, cooldownMinutes = 1)
        val decision = SmartReentryEngine.evaluate(
            state,
            reentrySignal(signalId = anchor.consumedSignalId, timestamp = anchor.exitTime + 2 * 60_000L),
            now = anchor.exitTime + 2 * 60_000L
        )
        assertFalse(decision.allowed)
        assertTrue("OLD_SIGNAL_REENTRY_BLOCKED" in decision.reasonCodes)
    }

    @Test fun priceAbovePreviousExitIsRejectedAsSameWaveChase(){
        val anchor = profitAnchor()
        val state = SmartReentryEngine.onProfitExit(null, anchor, cooldownMinutes = 1)
        val decision = SmartReentryEngine.evaluate(
            state,
            reentrySignal(currentPrice = 103.0, signalId = "new-signal", timestamp = anchor.exitTime + 2 * 60_000L),
            now = anchor.exitTime + 2 * 60_000L
        )
        assertEquals(ProfitReentryStatus.REENTRY_REJECTED_CHASE, decision.status)
        assertTrue("REENTRY_ABOVE_EXIT_CHASE" in decision.reasonCodes)
    }

    @Test fun pullbackAloneDoesNotRearmProfitReentry(){
        val anchor = profitAnchor()
        val state = SmartReentryEngine.onProfitExit(null, anchor, cooldownMinutes = 1)
        val decision = SmartReentryEngine.evaluate(
            state,
            reentrySignal(currentPrice = 98.0, signalId = "new-signal", timestamp = anchor.exitTime + 2 * 60_000L, pullbackState = PullbackState.PULLBACK.name),
            now = anchor.exitTime + 2 * 60_000L
        )
        assertEquals(ProfitReentryStatus.WAIT_PULLBACK, decision.status)
        assertFalse(decision.allowed)
    }

    @Test fun stabilizedPullbackAndReaccelerationAllowNewWave(){
        val anchor = profitAnchor()
        val state = SmartReentryEngine.onProfitExit(null, anchor, cooldownMinutes = 1)
        val decision = SmartReentryEngine.evaluate(
            state,
            reentrySignal(currentPrice = 98.0, signalId = "new-signal", timestamp = anchor.exitTime + 20 * 60_000L, pullbackState = PullbackState.REACCELERATION.name),
            now = anchor.exitTime + 20 * 60_000L
        )
        assertEquals(ProfitReentryStatus.REENTRY_READY, decision.status)
        assertEquals(NewWaveState.NEW_WAVE_CONFIRMED, decision.waveState)
        assertTrue(decision.allowed)
    }

    @Test fun reentryBuyConsumesSmartStateUntilNextProfitAnchor(){
        val anchor = profitAnchor()
        val state = SmartReentryEngine.onProfitExit(null, anchor, cooldownMinutes = 1)
        val ready = SmartReentryEngine.evaluate(
            state,
            reentrySignal(currentPrice = 98.0, signalId = "new-signal", timestamp = anchor.exitTime + 20 * 60_000L, pullbackState = PullbackState.REACCELERATION.name),
            now = anchor.exitTime + 20 * 60_000L
        )
        val afterBuy = SmartReentryEngine.afterReentryBuy(ready.nextState, "new-signal")
        assertEquals(ProfitReentryStatus.NONE, afterBuy.status)
        assertTrue(SmartReentryEngine.evaluate(afterBuy, reentrySignal(signalId = "later"), now = 99_000_000L).allowed)
    }

    @Test fun reentryLossChainDetectsProfitGiveback(){
        val state = SmartReentryEngine.onProfitExit(null, profitAnchor(profit = 1_500.0), cooldownMinutes = 1)
            .copy(wasReentry = true, status = ProfitReentryStatus.NONE, recentCycleProfit = 1_500.0)
        val updated = SmartReentryEngine.onReentryLoss(state, -2_000.0, now = state.anchor!!.exitTime + 30 * 60_000L, chainWindowMinutes = 60)
        assertEquals(1, updated.reentryLossChain)
        assertEquals(1_500.0, updated.profitGivenBackAmount, 0.0001)
        assertEquals(ProfitReentryStatus.BLOCKED_UNTIL_NEW_WAVE, updated.status)
    }

    @Test fun churnDetectorIncludesFeesAndFlagsOvertradingCost(){
        val trades = listOf(
            TradeEntity(time=1, market="KRW-BTC", side="SELL", amount=1010.0, quantity=1.0, avgPrice=1010.0, fee=10.0, realizedPnl=20.0, pnlRate=2.0, reason="TAKE PROFIT"),
            TradeEntity(time=2, market="KRW-BTC", side="BUY", amount=1000.0, quantity=1.0, avgPrice=1000.0, fee=10.0, realizedPnl=0.0, pnlRate=0.0, reason="REENTRY"),
            TradeEntity(time=3, market="KRW-BTC", side="SELL", amount=980.0, quantity=1.0, avgPrice=980.0, fee=10.0, realizedPnl=-20.0, pnlRate=-2.0, reason="STOP LOSS"),
            TradeEntity(time=4, market="KRW-BTC", side="BUY", amount=1000.0, quantity=1.0, avgPrice=1000.0, fee=10.0, realizedPnl=0.0, pnlRate=0.0, reason="REENTRY"),
            TradeEntity(time=5, market="KRW-BTC", side="SELL", amount=970.0, quantity=1.0, avgPrice=970.0, fee=10.0, realizedPnl=-30.0, pnlRate=-3.0, reason="STOP LOSS"),
            TradeEntity(time=6, market="KRW-BTC", side="BUY", amount=1000.0, quantity=1.0, avgPrice=1000.0, fee=10.0, realizedPnl=0.0, pnlRate=0.0, reason="REENTRY")
        )
        val result = TradeChurnDetector.analyze(trades)
        assertEquals(3, result.roundTrips)
        assertTrue(result.fees > 0.0)
        assertEquals("OVERTRADING_COST_WARNING", result.warning)
    }

    @Test fun reentryShadowComparesNoEntryAndWavePolicies(){
        val rows = ReentryShadowEngine.compare(listOf(100.0, 101.0, 99.0, 102.0, 103.0, 101.0, 105.0, 106.0, 104.0, 108.0))
        assertEquals(6, rows.size)
        assertEquals(ReentryShadowPolicy.NO_REENTRY, rows.last().policy)
        assertEquals(0, rows.last().trades)
    }

    @Test fun smartReentryStateRoundTripsThroughRoomModel(){
        val original = SmartReentryEngine.onProfitExit(null, profitAnchor(), 10).copy(reentryQualityScore = 70.0, newSignalValid = true)
        val restored = original.toEntity().toModel()
        assertEquals(original.market, restored.market)
        assertEquals(original.status, restored.status)
        assertEquals(original.anchor?.exitPrice, restored.anchor?.exitPrice)
        assertEquals(original.reentryQualityScore, restored.reentryQualityScore, 0.0001)
    }

    @Test fun historicalBootstrapCreatesOnlyRealLookaheadSafeSamples(){
        val candles = (0 until 130).map { i ->
            val close = 100.0 + i * 0.05
            CandleModel("KRW-BTC", i.toLong(), close, close + 0.2, close - 0.2, 10.0)
        }
        val samples = HistoricalBootstrapEngine.generate("KRW-BTC", candles)
        assertTrue(samples.isNotEmpty())
        assertTrue(samples.all { it.source == LearningDataSource.HISTORICAL.name && it.lookaheadSafe })
        assertTrue(samples.all { it.buyTradeId == null })
    }

    @Test fun historicalFeaturesDoNotChangeWhenOnlyFuturePricesChange(){
        val base = (0 until 130).map { i ->
            val close = 100.0 + i * 0.05
            CandleModel("KRW-BTC", i.toLong(), close, close + 0.2, close - 0.2, 10.0)
        }
        val changedFuture = base.map { if (it.timestamp >= 65L) it.copy(close = it.close * 2.0) else it }
        val original = HistoricalBootstrapEngine.generate("KRW-BTC", base).first()
        val changed = HistoricalBootstrapEngine.generate("KRW-BTC", changedFuture).first()
        assertEquals(original.featuresJson, changed.featuresJson)
        assertNotEquals(original.realizedPnlRate, changed.realizedPnlRate)
    }

    @Test fun replayBufferPreservesHardExamplesAndRegimeDiversity(){
        val samples = listOf(
            learningSample("bull", hard = false),
            learningSample("bear", hard = false),
            learningSample("crash", hard = true)
        )
        val replay = LearningReplayBuffer.select(samples, 3)
        assertEquals(3, replay.size)
        assertTrue(replay.any { it.hardExample })
        assertTrue(replay.map { it.marketRegime }.containsAll(listOf("bull", "bear")))
    }

    @Test fun deepLearningCandidateProducesBoundedMultiHorizonPredictions(){
        val examples = (0 until 40).map { i ->
            DeepLearningTrainingExample(
                time = i.toLong(),
                source = LearningDataSource.HISTORICAL,
                features = List(8) { if (i % 2 == 0) 1.0 else -1.0 },
                positive5m = if (i % 2 == 0) 1.0 else 0.0,
                positive15m = if (i % 2 == 0) 1.0 else 0.0,
                stopBeforeProfit = if (i % 2 == 0) 0.0 else 1.0,
                reboundAfterPullback = if (i % 2 == 0) 1.0 else 0.0,
                chaseFailure = if (i % 2 == 0) 0.0 else 1.0,
                return5m = if (i % 2 == 0) 1.0 else -1.0
            )
        }
        val artifact = ContinuousLearningEngine.train(examples, "DL_TEST")
        assertNotNull(artifact)
        val prediction = DeepLearningCandidateModel(artifact!!).predict(List(8) { 1.0 })
        assertTrue(prediction.probabilityPositive5m in 0.0..1.0)
        assertTrue(prediction.probabilityPositive15m in 0.0..1.0)
        assertTrue(prediction.probabilityChaseFailure in 0.0..1.0)
        assertTrue(prediction.confidence in 0.0..100.0)
    }

    @Test fun continuousLearningMetricsAndOosPromotionNeedEvidence(){
        val examples = (0 until 30).map { i ->
            DeepLearningTrainingExample(
                time = i.toLong(),
                source = LearningDataSource.PAPER,
                features = List(8) { 0.1 * i },
                positive5m = 1.0,
                positive15m = 1.0,
                stopBeforeProfit = 0.0,
                reboundAfterPullback = 1.0,
                chaseFailure = 0.0,
                return5m = 0.5
            )
        }
        val model = DeepLearningCandidateModel(ContinuousLearningEngine.train(examples, "DL_METRICS")!!)
        val metrics = ContinuousLearningEngine.metrics(model, examples)
        assertEquals(30, metrics.sampleCount)
        assertFalse(ContinuousLearningEngine.promotionAllowed(metrics, metrics, metrics, 31))
    }

    @Test fun driftDetectsHistoricalPaperDistributionChange(){
        val historical = List(25) { learningSample("BULL", futureReturn = 0.2) }
        val paper = List(12) { learningSample("PAPER", futureReturn = -4.0, source = LearningDataSource.PAPER.name).copy(realizedPnlRate = -4.0) }
        val drift = ContinuousLearningEngine.drift(historical + paper)
        assertEquals(LearningDriftState.MAJOR_DRIFT, drift.first)
    }

    @Test fun predictionJournalKeepsSourceAndOutcomeFieldsSeparate(){
        val journal = PredictionJournalEntity(
            predictionId = "p1",
            modelVersion = "DL_TEST",
            market = "KRW-BTC",
            timestamp = 1L,
            featuresJson = "0,0,0,0,0,0,0,0",
            predictionJson = "p5=0.8",
            confidence = 80.0,
            decision = "BUY_READY",
            reason = "test",
            source = LearningDataSource.PAPER.name,
            tradeId = "trade-1",
            expectedReturn5m = 1.2,
            outcomeReturn5m = -0.5,
            predictionError5m = -1.7,
            resolvedAt = 2L
        )
        assertEquals(LearningDataSource.PAPER.name, journal.source)
        assertEquals(-1.7, journal.predictionError5m!!, 0.0001)
    }

    @Test fun learningSettingsRemainBoundedAndPaperSafe(){
        val settings = TradingSettingsValidator.sanitize(
            TradingSettings(continuousLearningTriggerSamples = 1, replayBufferSize = 100_000, continuousLearningIntervalMinutes = 1)
        )
        assertEquals(10, settings.continuousLearningTriggerSamples)
        assertEquals(10_000, settings.replayBufferSize)
        assertEquals(5, settings.continuousLearningIntervalMinutes)
        assertEquals(TradeMode.PAPER, settings.mode)
    }

    @Test fun modelTrainingFailureFallsBackWithoutPredictionFailure(){
        assertNull(ContinuousLearningEngine.train(emptyList(), "DL_EMPTY"))
        val prediction = DeepLearningCandidateModel(
            DeepLearningArtifact("DL_EMPTY", List(8) { "f$it" }, List(8) { 0.0 }, List(8) { 1.0 },
                List(8) { List(8) { 0.0 } }, List(8) { 0.0 }, List(6) { List(8) { 0.0 } }, List(6) { 0.0 }, 0L, 0, 0, 0)
        ).predict(emptyList())
        assertEquals(0.0, prediction.confidence, 0.0)
    }

    @Test fun scalpAvoidIsNormalDecisionNotError(){
        val avoid = ScalpingExecutionEngine.evaluate(scalpInput(marketHealth = 30.0, bidAskSpread = 2.0, netEdge = 2.0))
        assertEquals(ScalpingExecutionState.AVOID, avoid.state)
        assertFalse(avoid.allowed)
        assertNotEquals(ScalpingExecutionState.ENTER_NOW, avoid.state)
    }

    @Test fun scalpEdgeTooSmallSetsNoEdgeAndReason(){
        val decision = ScalpingExecutionEngine.evaluate(
            scalpInput(return30s = 0.0, return1m = 0.0, return3m = 0.0, return5m = 0.0, netEdge = 0.05, bidAskSpread = 0.3),
            minShortNetEdgePercent = 0.15,
            safetyMargin = 1.35
        )
        assertEquals(ScalpingExecutionState.NO_EDGE, decision.state)
        assertTrue("SCALP_EDGE_TOO_SMALL" in decision.reasonCodes)
        assertTrue(decision.shortNetEdge < 0.0)
    }

    @Test fun shortEdgeCostDecompositionExplainsNegativePointEightEight(){
        val breakdown = EntryUrgencyAudit.shortEdgeBreakdown(
            expectedMove30s = 0.0, expectedMove1m = 0.0, expectedMove3m = 0.0, expectedMove5m = 0.0,
            feePercent = 0.25, spreadPercent = 0.30, slippagePercent = 0.10, marketImpactPercent = 0.0, safetyMargin = 1.35
        )
        assertEquals(0.0, breakdown.grossExpectedMovePercent, 0.0001)
        assertEquals(0.65, breakdown.totalCostBeforeMargin, 0.0001)
        assertEquals(0.8775, breakdown.totalCostAfterMargin, 0.0001)
        assertEquals(-0.8775, breakdown.finalShortEdgePercent, 0.0001)
        assertTrue(breakdown.format().contains("ShortEdge"))
        val fromEngine = ScalpingExecutionEngine.evaluate(
            scalpInput(return30s = 0.0, return1m = 0.0, return3m = 0.0, return5m = 0.0, momentumAcceleration = 0.0, bidAskSpread = 0.30, netEdge = 0.1),
            safetyMargin = 1.35
        )
        assertEquals(fromEngine.shortEdgeBreakdown.finalShortEdgePercent, fromEngine.shortNetEdge, 0.0001)
        assertTrue(fromEngine.shortNetEdge in -0.95..-0.80)
    }

    @Test fun generalPositiveShortNegativeIsHorizonConflictNotCalcBug(){
        val general = NetEdgeGateEngine.evaluate(
            signalScore = 83.0,
            expectedReturnPercent = 6.0,
            feePercent = 0.25,
            spreadPercent = 0.1,
            slippagePercent = 0.1,
            marketImpactPercent = 0.0,
            riskPenaltyPercent = 0.0,
            historicalAccuracy = 0.5,
            sampleCount = 10,
            minNetEdgeMargin = 0.35
        )
        val short = EntryUrgencyAudit.shortEdgeBreakdown(0.0, 0.0, 0.0, 0.0, 0.25, 0.30, 0.10, 0.0, 1.35)
        assertTrue(general.netExpectedEdge > 4.0)
        assertTrue(short.finalShortEdgePercent < 0.0)
        assertEquals(
            HorizonConflictState.LONGER_TERM_POSITIVE_SHORT_NEGATIVE,
            EntryUrgencyAudit.horizonConflict(general.netExpectedEdge, short.finalShortEdgePercent)
        )
        val summary = EntryUrgencyAudit.decisionSummary(
            ScalpingExecutionState.NO_EDGE.name,
            listOf("SCALP_EDGE_TOO_SMALL"),
            HorizonConflictState.LONGER_TERM_POSITIVE_SHORT_NEGATIVE,
            chaseScore = 40.0,
            shortEdge = short.finalShortEdgePercent,
            generalNetEdge = general.netExpectedEdge
        )
        assertTrue(summary.contains("초단기") || summary.contains("Pullback"))
    }

    @Test fun liquidityUninitializedFailsClosedAndUiPreventsZeroSlashZero(){
        val pending = LiquidityFilterEngine.decide(1_000_000_000.0, 0.0, emptyList())
        assertEquals("LIQUIDITY_UNINITIALIZED", pending.code)
        assertFalse(pending.passed)
        assertEquals(0, pending.total)
        assertEquals("순위 계산 전", EntryUrgencyAudit.liquidityRankLabel(pending.rank, pending.total))
        assertEquals("최소 거래대금 계산 전", EntryUrgencyAudit.liquidityRequiredLabel(pending.requiredKrw, ready = false))
        assertEquals("유동성 백분위 계산 전", EntryUrgencyAudit.liquidityPercentileLabel(1.0, 0))
        assertFalse(EntryUrgencyAudit.liquidityRankLabel(0, 0).contains("0/0"))
        val ready = LiquidityFilterEngine.decide(500.0, 400.0, listOf(1000.0, 500.0, 100.0))
        assertTrue(ready.passed)
        assertEquals("2/3", EntryUrgencyAudit.liquidityRankLabel(ready.rank, ready.total))
    }

    @Test fun falseRejectGoodRejectAndFalseEntryClassification(){
        assertEquals("FALSE_REJECT", EntryUrgencyAudit.classifyRejectOutcome(3.5))
        assertEquals("GOOD_REJECT", EntryUrgencyAudit.classifyRejectOutcome(-2.0))
        assertEquals("FALSE_ENTRY", EntryUrgencyAudit.classifyEntryOutcome(-1.2, maePercent = -0.2))
        assertEquals("FALSE_ENTRY", EntryUrgencyAudit.classifyEntryOutcome(0.1, maePercent = -2.0))
        assertEquals("GOOD_ENTRY", EntryUrgencyAudit.classifyEntryOutcome(1.5, maePercent = -0.2))
    }

    @Test fun lateChaseDetectionReusesEntryQualityClasses(){
        val chase = EntryUrgencyAudit.entryQualityFromExisting(85.0, 40.0, EntryQualityClassification.CHASE.name, 3.5)
        val extreme = EntryUrgencyAudit.entryQualityFromExisting(95.0, 20.0, EntryQualityClassification.EXTREME_CHASE.name, 5.0)
        val early = EntryUrgencyAudit.entryQualityFromExisting(20.0, 80.0, EntryQualityClassification.GOOD.name, 0.4)
        assertEquals(EntryUrgencyClass.CHASE_ENTRY, chase)
        assertEquals(EntryUrgencyClass.EXTREME_CHASE_ENTRY, extreme)
        assertEquals(EntryUrgencyClass.EARLY_GOOD_ENTRY, early)
    }

    @Test fun signalLatencyDistinguishesTooLateFromBalanced(){
        val late = EntryUrgencyAudit.speedDiagnosis(
            signalLagMs = 120_000L,
            priceMoveStartTime = 1_000L,
            scoreCrossTime = 80_000L,
            buyTime = 90_000L
        )
        val balanced = EntryUrgencyAudit.speedDiagnosis(
            signalLagMs = 20_000L,
            priceMoveStartTime = 1_000L,
            scoreCrossTime = 10_000L,
            buyTime = 25_000L
        )
        assertEquals(EntrySpeedDiagnosis.SIGNAL_TOO_LATE, late)
        assertEquals(EntrySpeedDiagnosis.BALANCED, balanced)
        assertEquals(EntrySpeedDiagnosis.INSUFFICIENT_DATA, EntryUrgencyAudit.speedDiagnosis(0, 0, 0, 0))
    }

    @Test fun priceMovedAwayMarksTooLateAndWindowClosed(){
        val decision = ScalpingExecutionEngine.evaluate(scalpInput(
            currentPrice = 104.0, currentSignalPrice = 100.0, atrPercent = 2.0,
            return30s = 0.4, return1m = 0.8, return3m = 1.0, netEdge = 1.0, bidAskSpread = 0.1
        ))
        assertTrue(decision.priceMovedAway)
        assertTrue("PRICE_MOVED_AWAY" in decision.reasonCodes)
        assertTrue(decision.state == ScalpingExecutionState.TOO_LATE || !decision.entryWindowOpen)
    }

    @Test fun waitShadowComparisonStaysSeparateFromPolicyChange(){
        val rows = listOf(
            scalpDiagnostic(tradeId = "b1", first5mReturn = -1.5, outcome = "FALSE_ENTRY", state = ScalpingExecutionState.ENTER_NOW.name),
            scalpDiagnostic(first5mReturn = 2.0, outcome = "FALSE_REJECT", state = ScalpingExecutionState.WAIT_PULLBACK.name)
        )
        val stats = ScalpingResearchAnalytics.summarize(rows)
        assertEquals(1, stats.falseEntry)
        assertEquals(1, stats.falseReject)
        assertTrue(stats.entered + stats.rejected >= 2)
        // Shadow/research only — no threshold mutation
        val defaults = TradingSettings()
        assertEquals(0.15, defaults.scalpingMinimumShortNetEdgePercent, 0.0)
        assertEquals(60.0, defaults.scalpingMinimumExecutionScore, 0.0)
    }

    @Test fun candleSignalTypeSeparatesConfirmedAndInProgress(){
        val now = 600_000L
        assertEquals("IN_PROGRESS_CANDLE_SIGNAL", EntryUrgencyAudit.candleSignalType(now - 60_000L, 5, now))
        assertEquals("CONFIRMED_CANDLE_SIGNAL", EntryUrgencyAudit.candleSignalType(now - 400_000L, 5, now))
        assertEquals("INSUFFICIENT_DATA", EntryUrgencyAudit.candleSignalType(0L, 5, now))
    }

    @Test fun runtimeThresholdTableExposesAppliedDefaults(){
        val table = EntryUrgencyAudit.runtimeThresholdTable(TradingSettings())
        assertEquals("75.0", table["Strategy Score threshold"])
        assertEquals("55.0", table["AI threshold"])
        assertTrue(table["Short Edge minimum"]!!.contains("0.15"))
        assertTrue(table["Take Profit (General Edge gross)"]!!.contains("6.00"))
    }

    @Test fun executionDataInsufficientIsNotMappedToChaseRiskWhenChaseIsZero(){
        val decision = ScalpingExecutionEngine.evaluate(
            scalpInput(
                chaseScore = 0.0,
                entryTimingScore = 50.0,
                return30s = 1.0, return1m = 2.0, return3m = 3.0, return5m = 4.0,
                microSampleCount = 0,
                usedCandleProxyForMicro = true,
                orderbookPresent = true
            )
        )
        assertNotEquals(ScalpingExecutionState.CHASE_RISK, decision.state)
        assertTrue(decision.state == ScalpingExecutionState.DATA_INSUFFICIENT || decision.state == ScalpingExecutionState.WARMING_UP)
        assertTrue(decision.executionDataGaps.isNotEmpty())
        assertFalse(decision.shortEdgeReliable)
    }

    @Test fun chaseRiskRequiresActualChaseScore(){
        val chase = ScalpingExecutionEngine.evaluate(scalpInput(chaseScore = 95.0, return30s = 0.5, return1m = 1.0, return3m = 1.5))
        val notChase = ScalpingExecutionEngine.evaluate(scalpInput(chaseScore = 0.0, microSampleCount = 20))
        assertEquals(ScalpingExecutionState.CHASE_RISK, chase.state)
        assertTrue("CHASE_RISK_CONFIRMED" in chase.reasonCodes)
        assertNotEquals(ScalpingExecutionState.CHASE_RISK, notChase.state)
    }

    @Test fun missingOrderbookMapsToDataInsufficientNotChase(){
        val decision = ScalpingExecutionEngine.evaluate(
            scalpInput(microSampleCount = 20, bidAskSpread = 99.0).copy(
                bidDepth = 0.0, askDepth = 0.0, orderbookPresent = false
            )
        )
        assertTrue(decision.state in setOf(ScalpingExecutionState.DATA_INSUFFICIENT, ScalpingExecutionState.AVOID, ScalpingExecutionState.WARMING_UP))
        assertNotEquals(ScalpingExecutionState.CHASE_RISK, decision.state)
        assertTrue(decision.executionDataGaps.any { it.contains("ORDERBOOK") || it.contains("SPREAD") })
    }

    @Test fun warmUpWhenMicroSamplesPartial(){
        val decision = ScalpingExecutionEngine.evaluate(scalpInput(microSampleCount = 4, usedCandleProxyForMicro = false))
        assertEquals(ScalpingExecutionState.WARMING_UP, decision.state)
        assertEquals(ExecutionDataStatus.WARMING_UP, decision.executionDataStatus)
    }

    @Test fun candleProxyShortEdgeMarkedUnreliable(){
        val decision = ScalpingExecutionEngine.evaluate(
            scalpInput(
                return30s = 2.0, return1m = 3.0, return3m = 4.0, return5m = 5.0,
                microSampleCount = 0, usedCandleProxyForMicro = true, chaseScore = 0.0
            )
        )
        assertFalse(decision.shortEdgeReliable)
        assertTrue(decision.executionDataGaps.isNotEmpty())
        assertNotEquals(ScalpingExecutionState.ENTER_NOW, decision.state)
        assertNotEquals(ScalpingExecutionState.CHASE_RISK, decision.state)
    }

    @Test fun emptyReasonsNeverFabricateExecutionDataInsufficientAlone(){
        val decision = ScalpingExecutionEngine.evaluate(scalpInput(chaseScore = 95.0))
        assertEquals(ScalpingExecutionState.CHASE_RISK, decision.state)
        assertTrue(decision.reasonCodes.isNotEmpty())
        assertTrue("CHASE_RISK_CONFIRMED" in decision.reasonCodes)
        assertFalse(decision.reasonCodes == listOf("EXECUTION_DATA_INSUFFICIENT"))
    }

    @Test fun btrStyleHighScoreLowMicroIsDataGapNotChase(){
        val decision = ScalpingExecutionEngine.evaluate(
            scalpInput(
                chaseScore = 0.0,
                entryTimingScore = 50.0,
                return30s = 2.5, return1m = 3.0, return3m = 3.5, return5m = 4.0,
                netEdge = 2.0,
                microSampleCount = 1,
                usedCandleProxyForMicro = true
            ).copy(strategyScore = 92.0, aiScore = 70.0)
        )
        assertNotEquals(ScalpingExecutionState.CHASE_RISK, decision.state)
        assertTrue(decision.state == ScalpingExecutionState.DATA_INSUFFICIENT || decision.state == ScalpingExecutionState.WARMING_UP)
    }

    @Test fun serverBuyGateFailClosedWhenPrimaryAndOffline(){
        val settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true)
        val (blocked, reason) = ServerBuyGate.blockNewBuy(settings, AiBrainLinkStatus.OFFLINE, null)
        assertTrue(blocked)
        assertEquals("SERVER_OFFLINE", reason)
    }

    @Test fun serverBuyGateAllowsLocalPrimaryShadowMode(){
        val settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = false)
        val (blocked, _) = ServerBuyGate.blockNewBuy(settings, AiBrainLinkStatus.OFFLINE, null)
        assertFalse(blocked)
    }

    @Test fun serverPrimaryPathIgnoresPersistedPrimaryOffWhenTokenReady(){
        val settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = false, remoteDashboardEnabled = true)
        assertTrue(ServerPrimaryCoordinator.shouldEnterServerPrimaryPath(settings, tokenReady = true))
        assertFalse(ServerPrimaryCoordinator.shouldEnterServerPrimaryPath(settings, tokenReady = false))
        assertTrue(ServerPrimaryCoordinator.shouldUseServerPrimary(settings, AiBrainLinkStatus.ONLINE))
    }

    @Test fun remoteDecisionExpiredRejected(){
        val now = 1_000_000L
        val decision = RemoteTradingDecision(
            decisionId = "d1",
            serverTimestamp = now - 200_000L,
            expiresAt = now - 100_000L,
            market = "KRW-BTR",
            decision = "BUY",
            aiScore = 80.0,
            apiVersion = "v1"
        )
        assertTrue(RemoteDecisionPolicy.isExpired(decision, now, 90_000L))
        assertFalse(RemoteDecisionPolicy.isExpired(decision.copy(serverTimestamp = now - 1_000L, expiresAt = now + 60_000L), now, 90_000L))
    }

    @Test fun remotePriceMovedAwayReusesPolicy(){
        assertTrue(RemoteDecisionPolicy.priceMovedAway(100.0, 103.0, 1.0, 1.5))
        assertFalse(RemoteDecisionPolicy.priceMovedAway(100.0, 100.5, 1.0, 1.5))
    }

    @Test fun remoteDecisionNullScoresDoNotBecomeFakeZerosAsPositive(){
        val pred = RemoteDecisionPolicy.toPrediction(RemoteTradingDecision(aiScore = null, aiPositive = true))
        assertEquals(0.0, pred.score, 0.0)
        assertFalse(pred.positive)
    }

    @Test fun serverPrimaryCoordinatorMapsDashboardWithoutLocalReanalysis(){
        val now = 2_000_000L
        val c = RemoteDashboardCandidate(
            market = "KRW-BTC",
            price = 100.0,
            strategyScore = 88.0,
            aiScore = 77.0,
            aiPositive = true,
            aiConfidence = 0.8,
            entryTimingScore = 70.0,
            entryTimingState = "NORMAL",
            chaseScore = 20.0,
            chaseState = "NONE",
            executionScore = 75.0,
            executionConfidence = 0.7,
            executionState = "ENTER_NOW",
            shortEdge = 0.4,
            grossExpectedEdge = 0.9,
            executionCost = 0.5,
            netExpectedEdge = 0.4,
            liquidityPassed = true,
            liquidityRank = 3,
            liquidityTotal = 100,
            liquidityPercentile = 0.97,
            dataQuality = "GOOD",
            executionDataQuality = "AVAILABLE",
            decision = "WAIT",
            decisionId = "d-wait",
            reasonCodes = listOf("SERVER_WAIT"),
            signalCreatedAt = now - 1_000L,
            signalExpiresAt = now + 60_000L,
            serverTimestamp = now - 500L,
            microSampleCount = 12
        )
        val signal = ServerPrimaryCoordinator.toSignal(c, now)
        assertEquals(88.0, signal.score, 0.0)
        assertEquals(77.0, signal.aiScore, 0.0)
        assertEquals(70.0, signal.entryTimingScore, 0.0)
        assertEquals(20.0, signal.chaseEntryScore, 0.0)
        assertEquals(75.0, signal.scalpExecutionScore, 0.0)
        assertEquals(0.4, signal.shortHorizonNetEdge, 0.0)
        assertEquals(CandidateStatus.WAIT_RECONFIRMATION, signal.status)
        assertTrue(signal.reason.startsWith("SERVER_SNAPSHOT:"))
        assertTrue(ServerPrimaryCoordinator.shouldUseServerPrimary(
            TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true, remoteDashboardEnabled = true),
            AiBrainLinkStatus.ONLINE
        ))
        // PHASE6: Primary flag OFF must NOT block path when remote+dashboard enabled (token gate is separate).
        assertTrue(ServerPrimaryCoordinator.shouldUseServerPrimary(
            TradingSettings(remoteAiEnabled = true, remoteAiPrimary = false, remoteDashboardEnabled = true),
            AiBrainLinkStatus.ONLINE
        ))
        assertFalse(ServerPrimaryCoordinator.shouldUseServerPrimary(
            TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true, remoteDashboardEnabled = true),
            AiBrainLinkStatus.OFFLINE
        ))
    }

    @Test fun serverPrimarySafetyRejectsStaleBuyAfterAppRestart(){
        val now = System.currentTimeMillis()
        val sessionStart = now - 10_000L
        val remote = RemoteTradingDecision(
            decisionId = "old-buy",
            serverTimestamp = sessionStart - 60_000L,
            signalCreatedAt = sessionStart - 60_000L,
            expiresAt = now + 30_000L,
            market = "KRW-ETH",
            decision = "BUY",
            aiScore = 80.0,
            signalPrice = 100.0,
            apiVersion = "v1"
        )
        val signal = StrategySignalModel("KRW-ETH", 90.0, "SERVER", currentPrice = 100.0, aiScore = 80.0)
        val state = DashboardState(
            mode = TradeMode.PAPER,
            totalValue = 100_000.0,
            krwBalance = 100_000.0,
            lastTickerAt = now,
            settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true)
        )
        val ticker = TickerModel("KRW-ETH", 100.0, 1e10, 0.01, 1.0, now)
        val orderbook = OrderbookModel("KRW-ETH", 100.1, 99.9, 10.0, 10.0, now)
        val result = ServerPrimaryCoordinator.applySafetyOnly(
            signal = signal,
            remote = remote,
            ticker = ticker,
            orderbook = orderbook,
            holdings = emptySet(),
            inFlight = emptySet(),
            settings = state.settings,
            state = state,
            risk = RiskManager(),
            nowMs = now,
            sessionStartedAtMs = sessionStart,
            markUsed = { true }
        )
        assertEquals(CandidateStatus.REJECTED, result.status)
        assertEquals("SERVER_STALE_ON_APP_RESTART", result.failureReason)
    }

    @Test fun serverPrimarySafetyAllowsFreshBuyAfterRiskPass(){
        val now = System.currentTimeMillis()
        val sessionStart = now - 10_000L
        val remote = RemoteTradingDecision(
            decisionId = "fresh-buy",
            serverTimestamp = now - 1_000L,
            signalCreatedAt = now - 1_000L,
            expiresAt = now + 60_000L,
            market = "KRW-ETH",
            decision = "BUY",
            aiScore = 80.0,
            signalPrice = 100.0,
            apiVersion = "v1"
        )
        val signal = StrategySignalModel("KRW-ETH", 90.0, "SERVER", currentPrice = 100.0, aiScore = 80.0)
        val settings = TradingSettings(
            remoteAiEnabled = true,
            remoteAiPrimary = true,
            scoreThreshold = 70.0,
            aiMinScore = 55.0,
            maxSpreadPercent = 1.0
        )
        val state = DashboardState(
            mode = TradeMode.PAPER,
            totalValue = 100_000.0,
            krwBalance = 100_000.0,
            lastTickerAt = now,
            settings = settings
        )
        val ticker = TickerModel("KRW-ETH", 100.2, 1e10, 0.01, 1.0, now)
        val orderbook = OrderbookModel("KRW-ETH", 100.3, 100.1, 10.0, 10.0, now)
        val result = ServerPrimaryCoordinator.applySafetyOnly(
            signal = signal,
            remote = remote,
            ticker = ticker,
            orderbook = orderbook,
            holdings = emptySet(),
            inFlight = emptySet(),
            settings = settings,
            state = state,
            risk = RiskManager(),
            nowMs = now,
            sessionStartedAtMs = sessionStart,
            markUsed = { true }
        )
        assertEquals(CandidateStatus.BUY_READY, result.status)
        assertTrue(result.failureReason.contains("REMOTE_PRIMARY"))
    }

    @Test fun blockReasonCodesCoverStaleScoreSignalAndRisk(){
        assertEquals(
            "SIGNAL_NOT_BUY",
            ServerPrimaryCoordinator.blockReasonCode("WAIT", CandidateStatus.WAIT_RECONFIRMATION, "WAIT")
        )
        assertEquals(
            "SERVER_STALE",
            ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.REJECTED, "SERVER_STALE_ON_APP_RESTART")
        )
        assertEquals(
            "SCORE_LOW",
            ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.REJECTED, "점수 부족")
        )
        assertEquals(
            "DUPLICATE_SIGNAL",
            ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.REJECTED, "SERVER_DECISION_DUPLICATE")
        )
        assertEquals(
            "SERVER_OFF",
            ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.WAIT_RECONFIRMATION, "SERVER_BUY_BLOCKED: SERVER_OFFLINE")
        )
        assertEquals(
            "COOLDOWN",
            ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.REJECTED, "쿨다운 모드(급락 이후 대기 중)")
        )
        assertNull(ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.BUY_READY, "매수 예정(REMOTE_PRIMARY)"))
    }

    @Test fun serverPrimaryPaperBuyEndToEndFlowLogsAndFills(){
        val logs = mutableListOf<String>()
        fun flow(stage: String, market: String, decision: String, score: Double?, reason: String, extra: String = "") {
            logs += ServerPrimaryCoordinator.flowLogLine(stage, market, decision, score, reason, extra)
        }
        val now = System.currentTimeMillis()
        val sessionStart = now - 5_000L
        // Simulate: server already analyzed and produced BUY snapshot fields (Android does not recompute).
        flow("SERVER_ANALYSIS", "KRW-ETH", "BUY", 90.0, "SERVER", "fast=30 deep=15")
        val candidate = RemoteDashboardCandidate(
            market = "KRW-ETH",
            price = 100.0,
            strategyScore = 90.0,
            aiScore = 80.0,
            aiPositive = true,
            decision = "BUY",
            decisionId = "e2e-buy-1",
            reasonCodes = listOf("SERVER_BUY"),
            signalCreatedAt = now - 1_000L,
            signalExpiresAt = now + 60_000L,
            serverTimestamp = now - 500L,
            executionDataQuality = "AVAILABLE",
            liquidityPassed = true
        )
        flow("SNAPSHOT_CREATED", "KRW-ETH", "BUY", 90.0, "SERVER_BUY", "ts=${candidate.serverTimestamp}")
        val signal = ServerPrimaryCoordinator.toSignal(candidate, now)
        flow("ANDROID_RECEIVED", signal.market, "BUY", signal.score, signal.reason, "ts=${signal.timestamp}")
        assertEquals("SERVER", signal.entryQualityClassification)
        assertTrue(signal.reason.startsWith("SERVER_SNAPSHOT:"))
        val remote = ServerPrimaryCoordinator.toRemoteDecision(candidate)
        val settings = TradingSettings(
            remoteAiEnabled = true,
            remoteAiPrimary = true,
            scoreThreshold = 70.0,
            aiMinScore = 55.0,
            maxSpreadPercent = 1.0
        )
        val state = DashboardState(
            mode = TradeMode.PAPER,
            totalValue = 100_000.0,
            krwBalance = 100_000.0,
            lastTickerAt = now,
            settings = settings
        )
        val ticker = TickerModel("KRW-ETH", 100.1, 1e10, 0.01, 1.0, now)
        val orderbook = OrderbookModel("KRW-ETH", 100.2, 100.0, 10.0, 10.0, now)
        val checked = ServerPrimaryCoordinator.applySafetyOnly(
            signal = signal,
            remote = remote,
            ticker = ticker,
            orderbook = orderbook,
            holdings = emptySet(),
            inFlight = emptySet(),
            settings = settings,
            state = state,
            risk = RiskManager(),
            nowMs = now,
            sessionStartedAtMs = sessionStart,
            markUsed = { true }
        )
        assertEquals(CandidateStatus.BUY_READY, checked.status)
        flow("BUY_CHECK", checked.market, "PASS", checked.score, checked.failureReason, "status=BUY_READY")
        val amount = checked.estimatedInvestment
        val fill = PaperTradingMath.buyFill(amount, ticker.tradePrice, 0.0025, 0.001)
        assertNotNull(fill)
        flow("PAPER_ORDER", checked.market, "FILLED", checked.score, "SUBMIT", "amount=${amount.toLong()} qty=${fill!!.quantity}")
        val joined = logs.joinToString(" | ")
        assertTrue(joined.contains("SERVER_ANALYSIS"))
        assertTrue(joined.contains("SNAPSHOT_CREATED"))
        assertTrue(joined.contains("ANDROID_RECEIVED"))
        assertTrue(joined.contains("BUY_CHECK"))
        assertTrue(joined.contains("PAPER_ORDER"))
        assertTrue(fill.quantity > 0.0)
        // Prove Android did not invent strategy score locally — it is the server snapshot value.
        assertEquals(90.0, checked.score, 0.0)
    }

    @Test fun serverOffBlocksBuyWithoutLocalFallback(){
        val settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true)
        val (blocked, reason) = ServerBuyGate.blockNewBuy(settings, AiBrainLinkStatus.OFFLINE, null)
        assertTrue(blocked)
        assertEquals("SERVER_OFFLINE", reason)
        assertEquals(
            "SERVER_OFF",
            ServerPrimaryCoordinator.blockReasonCode("BUY", CandidateStatus.WAIT_RECONFIRMATION, "SERVER_BUY_BLOCKED: $reason")
        )
        // Primary OFF keeps local path available (Phase1/2) — not auto-fallback when Primary ON.
        val (localOk, _) = ServerBuyGate.blockNewBuy(settings.copy(remoteAiPrimary = false), AiBrainLinkStatus.OFFLINE, null)
        assertFalse(localOk)
    }

    @Test fun phase5ServerPaperMirrorMatchesSoTWithoutLocalRecalc(){
        val paper = RemotePaperState(
            paperAuto = true,
            cash = 77752.89294450669,
            initialCash = 100000.0,
            coinValue = 19400.0,
            totalValue = 97152.89294450669,
            realizedPnl = -2709.2419103022585,
            unrealizedPnl = 150.0,
            totalPnl = -2559.2419103022585,
            totalPnlRate = -2.5592419103022585,
            positionCount = 1,
            positions = listOf(
                RemotePaperPosition(
                    market = "KRW-BTR",
                    quantity = 80.70940591923225,
                    avgPrice = 240.23999999999998,
                    highestPrice = 250.0,
                    openedAt = 1L,
                    markPrice = 248.0,
                    unrealizedPnl = 150.0,
                    pnlRate = 0.77
                )
            ),
            updatedAt = 100L,
            lastTickAt = 200L,
            tickCount = 12,
            androidIndependent = true,
            sourceOfTruth = "HETZNER_SERVER"
        )
        // Local leftover values that must be overwritten by server SoT.
        val local = DashboardState(
            settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true),
            krwBalance = 100000.0,
            coinValue = 0.0,
            totalValue = 100000.0,
            realizedPnl = 0.0,
            unrealizedPnl = 0.0,
            cumulativePnl = 999.0,
            holdingCount = 0,
            serverPaperPositions = emptyList()
        )
        val ui = ServerPrimaryCoordinator.applyServerPaperMirror(local, paper, serverStatus = "ONLINE")
        assertTrue(ui.serverPaperUiSynced)
        assertEquals(true, ui.serverPaperAuto)
        assertEquals(paper.cash!!, ui.krwBalance, 0.0)
        assertEquals(paper.cash!!, ui.serverPaperCash, 0.0)
        assertEquals(paper.realizedPnl!!, ui.realizedPnl, 0.0)
        assertEquals(paper.unrealizedPnl!!, ui.unrealizedPnl, 0.0)
        assertEquals(paper.totalPnl!!, ui.cumulativePnl, 0.0)
        assertEquals(1, ui.serverPaperPositions.size)
        assertEquals("KRW-BTR", ui.serverPaperPositions[0].market)
        assertEquals(paper.positions!![0].quantity!!, ui.serverPaperPositions[0].quantity!!, 0.0)
        assertEquals(paper.positions!![0].avgPrice!!, ui.serverPaperPositions[0].avgPrice!!, 0.0)
        assertEquals(paper.positions!![0].unrealizedPnl!!, ui.serverPaperPositions[0].unrealizedPnl!!, 0.0)
        assertTrue(ServerPrimaryCoordinator.uiMatchesServerPaper(ui, paper))
        // Mutating local cash after mirror must not affect match against same server snapshot object.
        assertFalse(ServerPrimaryCoordinator.uiMatchesServerPaper(ui.copy(krwBalance = 1.0), paper))
    }

    @Test fun phase5AppReopenRestoreUsesServerPositionsNotEmptyLocal(){
        val paper = RemotePaperState(
            paperAuto = true,
            cash = 50000.0,
            initialCash = 100000.0,
            coinValue = 20000.0,
            totalValue = 70000.0,
            realizedPnl = -100.0,
            unrealizedPnl = 50.0,
            totalPnl = -50.0,
            totalPnlRate = -0.05,
            positionCount = 2,
            positions = listOf(
                RemotePaperPosition(market = "KRW-A", quantity = 1.0, avgPrice = 10.0, markPrice = 11.0, unrealizedPnl = 1.0),
                RemotePaperPosition(market = "KRW-B", quantity = 2.0, avgPrice = 20.0, markPrice = 19.0, unrealizedPnl = -2.0)
            ),
            tickCount = 3,
            androidIndependent = true
        )
        val afterReopen = ServerPrimaryCoordinator.applyServerPaperMirror(
            DashboardState(
                settings = TradingSettings(remoteAiPrimary = true, remoteAiEnabled = true),
                krwBalance = 100000.0,
                holdingCount = 0
            ),
            paper
        )
        assertEquals(2, afterReopen.holdingCount)
        assertEquals(2, afterReopen.serverPaperPositions.size)
        assertEquals(50000.0, afterReopen.krwBalance, 0.0)
        assertEquals(-100.0, afterReopen.realizedPnl, 0.0)
        assertEquals(50.0, afterReopen.unrealizedPnl, 0.0)
        assertTrue(afterReopen.serverPaperAuto)
        assertTrue(ServerPrimaryCoordinator.uiMatchesServerPaper(afterReopen, paper))
        val expected = ServerPrimaryCoordinator.paperExpectedSnapshot(paper)
        val actual = ServerPrimaryCoordinator.paperActualUiSnapshot(afterReopen)
        assertEquals(expected["cash"], actual["cash"])
        assertEquals(expected["paperAuto"], actual["paperAuto"])
        assertEquals(expected["positionCount"], actual["positionCount"])
    }

    @Test fun authFailureCodesMapTokenAndHttp(){
        assertEquals("AUTH_TOKEN_MISSING", AuthFailureCodes.fromThrowable(IllegalStateException("AUTH_TOKEN_MISSING")))
        assertEquals("HTTP_401", AuthFailureCodes.fromThrowable(RuntimeException("HTTP_401 api=paper/state")))
        assertEquals("HTTP_403", AuthFailureCodes.fromThrowable(RuntimeException("HTTP_403 api=dashboard")))
        assertEquals("PAPER_STATE_REQUEST_FAILED", AuthFailureCodes.fromThrowable(RuntimeException("PAPER_STATE_REQUEST_FAILED api=paper/state HTTP 500")))
    }

    @Test fun liquidityRankLabelStillPreventsZeroSlashZero(){
        assertEquals("순위 계산 전", EntryUrgencyAudit.liquidityRankLabel(0, 0))
        assertEquals("1/10", EntryUrgencyAudit.liquidityRankLabel(1, 10))
    }

    @Test fun timestampSecondsNormalizedToMillis(){
        val sec = 1_700_000_000L
        assertEquals(sec * 1000L, TimestampUnits.toEpochMs(sec))
        val ms = 1_700_000_000_000L
        assertEquals(ms, TimestampUnits.toEpochMs(ms))
        val age = TimestampUnits.ageMs(sec, sec * 1000L + 2_000L)
        assertEquals(2_000L, age)
    }

    @Test fun websocketZombieWhenConnectedButSilent(){
        ExecutionDataPipelineDiagnostics.clearForTests()
        val now = System.currentTimeMillis()
        val zombie = ExecutionDataPipelineDiagnostics.webSocketHealth(
            ConnectionStatus.CONNECTED, lastMessageAt = now - 90_000L, now = now, messages = 10
        )
        assertEquals(WebSocketHealth.WEBSOCKET_ZOMBIE, zombie.health)
        val normal = ExecutionDataPipelineDiagnostics.webSocketHealth(
            ConnectionStatus.CONNECTED, lastMessageAt = now - 1_000L, now = now, messages = 100
        )
        assertEquals(WebSocketHealth.NORMAL, normal.health)
    }

    @Test fun executionGapTrackerMarksBugCandidateAfterFiveMinutes(){
        ExecutionDataPipelineDiagnostics.clearForTests()
        val market = "KRW-BTR-TEST"
        val t0 = 1_000_000L
        ExecutionDataPipelineDiagnostics.recordGap(market, ExecutionDataStatus.MISSING, listOf("MISSING_ORDERBOOK"), t0)
        val later = ExecutionDataPipelineDiagnostics.recordGap(
            market, ExecutionDataStatus.MISSING, listOf("INSUFFICIENT_MICRO_SAMPLES"), t0 + 6 * 60_000L
        )
        assertNotNull(later)
        assertTrue(later!!.bugCandidate)
        assertEquals("BUG_CANDIDATE", later.label)
        ExecutionDataPipelineDiagnostics.recordGap(market, ExecutionDataStatus.GOOD, emptyList(), t0 + 7 * 60_000L)
        assertNull(ExecutionDataPipelineDiagnostics.gapTracker(market))
    }

    @Test fun executionInsufficientRateFlagsPipelineDegraded(){
        ExecutionDataPipelineDiagnostics.clearForTests()
        val now = System.currentTimeMillis()
        repeat(6) { ExecutionDataPipelineDiagnostics.recordState(ScalpingExecutionState.DATA_INSUFFICIENT.name, now - 60_000L) }
        repeat(2) { ExecutionDataPipelineDiagnostics.recordState(ScalpingExecutionState.ENTER_NOW.name, now - 30_000L) }
        val stats = ExecutionDataPipelineDiagnostics.rateStats(30 * 60_000L, now, "30M")
        assertTrue(stats.total >= 8)
        assertNotNull(stats.executionDataInsufficientRate)
        assertTrue(stats.executionDataInsufficientRate!! >= 0.50)
        assertTrue(stats.pipelineDegraded)
        assertEquals("EXECUTION_DATA_PIPELINE_DEGRADED", stats.note)
    }

    @Test fun scalpInputSnapshotDoesNotHideNullAges(){
        ExecutionDataPipelineDiagnostics.clearForTests()
        val snap = ExecutionDataPipelineDiagnostics.buildInputSnapshot(
            market = "KRW-BTR",
            now = 1_000L,
            tickerPrice = 10.0,
            tickerAgeMs = null,
            orderbookAgeMs = null,
            bidDepth = null,
            askDepth = null,
            imbalance = 0.5,
            buyPressure = 0.5,
            spread = null,
            samples = emptyList(),
            tradeIntensity = null,
            volumeAcceleration = 1.0,
            flow = MicroFlowMetrics(),
            atr = 0.5,
            momentum = 1.0,
            momentumSlope = 0.1,
            strategyScore = 92.0,
            aiScore = 70.0,
            entryTimingScore = 50.0,
            chaseScore = 0.0,
            shortEdge = null
        )
        assertTrue(snap.formatLog().contains("tickerAgeMs=null"))
        assertTrue(snap.formatLog().contains("orderbookAgeMs=null"))
        assertTrue(snap.formatLog().contains("shortEdge=null"))
        assertEquals(0, snap.microSampleCount1m)
    }

    @Test fun scanStallDetectionTriggersAfterNinetySeconds(){
        val (warn, msg) = NoTradeDiagnostics.stallWarning(true, System.currentTimeMillis() - 120_000L)
        assertTrue(warn)
        assertTrue(msg.contains("ENGINE_STALL_WARNING"))
        assertFalse(NoTradeDiagnostics.stallWarning(false, 0L).first)
        assertFalse(NoTradeDiagnostics.stallWarning(true, System.currentTimeMillis() - 10_000L).first)
    }

    @Test fun gateFunnelCountsRejectsAndTopBlockers(){
        val signals = listOf(
            StrategySignalModel("KRW-A", 80.0, "", aiScore = 70.0, status = CandidateStatus.REJECTED, failureReason = "SCALP_NO_EDGE: SCALP_EDGE_TOO_SMALL", chaseEntryScore = 20.0, entryTimingScore = 70.0, shortHorizonNetEdge = -0.5, scalpExecutionState = ScalpingExecutionState.NO_EDGE.name, liquidityReady = true, liquidityPassed = true, netEdgePassed = true),
            StrategySignalModel("KRW-B", 82.0, "", aiScore = 72.0, status = CandidateStatus.REJECTED, failureReason = "LOW_24H_TRADE_VALUE", chaseEntryScore = 30.0, entryTimingScore = 60.0, shortHorizonNetEdge = 0.4, scalpEntryAllowed = true, scalpExecutionState = ScalpingExecutionState.ENTER_NOW.name, liquidityReady = true, liquidityPassed = false, netEdgePassed = true),
            StrategySignalModel("KRW-C", 90.0, "", aiScore = 80.0, status = CandidateStatus.BUY_READY, failureReason = "매수 예정", chaseEntryScore = 10.0, entryTimingScore = 80.0, shortHorizonNetEdge = 0.5, scalpEntryAllowed = true, scalpExecutionState = ScalpingExecutionState.ENTER_NOW.name, liquidityReady = true, liquidityPassed = true, netEdgePassed = true)
        )
        val funnel = NoTradeDiagnostics.buildFunnel(100, 20, 3, signals, 75.0, 55.0, 80.0, 45.0, 0.15, 0)
        assertEquals(3, funnel.deepCandidates)
        assertEquals(1, funnel.buyReady)
        assertTrue((funnel.rejectCounts["SCALP_EDGE_TOO_SMALL"] ?: 0) >= 1 || (funnel.rejectCounts["LIQUIDITY"] ?: 0) >= 1)
        val blockers = NoTradeDiagnostics.topBlockers(funnel.rejectCounts)
        assertTrue(blockers.isNotEmpty())
    }

    @Test fun paperBuyCandidateIgnoresDisplayTopTenIntersection(){
        val ready = listOf(
            StrategySignalModel("KRW-READY", 78.0, "", status = CandidateStatus.BUY_READY, currentPrice = 100.0)
        )
        assertEquals("KRW-READY", NoTradeDiagnostics.selectPaperBuyCandidate(ready)?.market)
        assertNull(NoTradeDiagnostics.selectPaperBuyCandidate(emptyList()))
    }

    @Test fun fastToDeepCandidatePropagationKeepsSubset(){
        val markets = (1..40).map { MarketModel("KRW-$it", "M$it", "M$it") }
        val tickers = markets.associate { it.market to TickerModel(it.market, 100.0, 1_000_000_000.0, 0.02, 10.0, System.currentTimeMillis()) }
        val fast = FastScanEngine.scan(markets, tickers, listOf("KRW-1", "KRW-2"))
        assertTrue(fast.candidates.isNotEmpty())
        val deepCount = minOf(fast.candidates.size, maxOf(10, minOf(15, fast.candidates.size)))
        assertTrue(deepCount in 1..15)
        assertTrue(deepCount <= fast.candidates.size)
    }

    @Test fun paperRiskShadowModeRemainsTradingActive(){
        val snap = PaperRiskEngine.evaluate(TradeMode.PAPER, 8, false, PerformanceStats(expectedReturnPercent = -1.0, winRate = 0.2), MarketHealthScore(score = 50.0, level = MarketHealthLevel.CAUTION), MarketRegimeSnapshot(regime = MarketRegime.BEAR))
        assertEquals(PaperRiskState.PAPER_SHADOW_MODE, snap.state)
        assertTrue(snap.tradingActive)
        assertEquals(0.1, snap.positionSizeMultiplier, 0.0001)
        assertEquals(10.0, snap.thresholdOffset, 0.0001)
    }

    @Test fun percentUnitAuditFlagsSuspiciousFractions(){
        assertTrue(NoTradeDiagnostics.percentUnitLooksLikeFractionNotPercent(0.0035))
        assertFalse(NoTradeDiagnostics.percentUnitLooksLikeFractionNotPercent(0.35))
        assertFalse(NoTradeDiagnostics.percentUnitLooksLikeFractionNotPercent(35.0))
        assertEquals(0.15, TradingSettings().scalpingMinimumShortNetEdgePercent, 0.0)
        assertEquals(0.35, TradingSettings().minNetEdgeMarginPercent, 0.0)
        assertEquals(0.25, TradingSettings().liquidityPercentileThreshold, 0.0)
    }

    @Test fun noTradeHealthClassSeparatesStallFromBlocked(){
        assertEquals(
            EngineHealthClass.ENGINE_STALLED,
            NoTradeDiagnostics.healthClass(EngineStatus.RUNNING, stalled = true, scansLastHour = 10, buyReadyLastHour = 0, actualBuysLastHour = 0)
        )
        assertEquals(
            EngineHealthClass.ENGINE_RUNNING_BUT_ALL_CANDIDATES_BLOCKED,
            NoTradeDiagnostics.healthClass(EngineStatus.RUNNING, stalled = false, scansLastHour = 10, buyReadyLastHour = 0, actualBuysLastHour = 0)
        )
    }

    @Test fun rejectGateClassifierMapsScalpAndLiquidity(){
        assertEquals("SCALP_EDGE_TOO_SMALL", NoTradeDiagnostics.classifyRejectGate("SCALP_NO_EDGE: SCALP_EDGE_TOO_SMALL"))
        assertEquals("LIQUIDITY", NoTradeDiagnostics.classifyRejectGate("LOW_24H_TRADE_VALUE"))
        assertEquals("PRICE_MOVED_AWAY", NoTradeDiagnostics.classifyRejectGate("PRICE_MOVED_AWAY"))
    }

    private fun learningSample(
        regime: String,
        hard: Boolean = false,
        futureReturn: Double = 1.0,
        source: String = LearningDataSource.HISTORICAL.name
    ) = AiTrainingSampleEntity(
        market = "KRW-$regime",
        time = regime.hashCode().toLong(),
        featuresJson = List(8) { "0.1" }.joinToString(","),
        strategyScore = 80.0,
        aiScoreAtCapture = 70.0,
        buyTradeId = if (source == LearningDataSource.PAPER.name) "trade-$regime" else null,
        outcomeLabel = if (futureReturn > 0.0) 1.0 else 0.0,
        realizedPnlRate = futureReturn,
        marketRegime = regime,
        source = source,
        futureReturn5m = futureReturn,
        lookaheadSafe = true,
        hardExample = hard
    )

    private fun profitAnchor(profit: Double = 1_500.0) = ProfitExitAnchor(
        market = "KRW-BTC",
        exitPrice = 100.0,
        exitTime = 1_000_000L,
        entryPrice = 95.0,
        peakPrice = 102.0,
        realizedProfit = profit,
        realizedProfitPercent = 5.0,
        exitReason = "TAKE PROFIT",
        strategyScoreAtExit = 95.0,
        aiScoreAtExit = 96.0,
        regimeAtExit = MarketRegime.BULL.name,
        marketHealthAtExit = 90.0,
        consumedSignalId = "consumed-signal"
    )

    private fun reentrySignal(
        currentPrice: Double = 98.0,
        signalId: String = "new-signal",
        timestamp: Long = 2_000_000L,
        pullbackState: String = PullbackState.REACCELERATION.name
    ) = StrategySignalModel(
        market = "KRW-BTC",
        score = 70.0,
        reason = "new setup",
        timestamp = timestamp,
        signalId = signalId,
        currentPrice = currentPrice,
        estimatedInvestment = 500.0,
        entryTimingScore = 82.0,
        chaseEntryScore = 15.0,
        scalpExecutionScore = 85.0,
        shortHorizonNetEdge = 0.5,
        pullbackState = pullbackState,
        microMomentumState = MicroMomentumState.ACCELERATING.name,
        entryAtrPercent = 1.0
    )

    private fun derivativeSnapshot(
        timestamp: Long = System.currentTimeMillis(),
        priceChange: Double = 1.0,
        oiChange: Double = 1.0,
        funding: FundingState = FundingState.NEUTRAL,
        longRatio: Double = 0.55,
        shortRatio: Double = 0.45
    ) = DerivativesDataSnapshot(
        market = "KRW-BTC",
        symbol = "BTCUSDT",
        supportStatus = DerivativeSupportStatus.SUPPORTED,
        providerStatus = DerivativesProviderStatus.CONNECTED,
        freshness = DerivativeFreshness.FRESH,
        timestamp = timestamp,
        ageMs = 0L,
        lastPrice = 100.0,
        openInterest = 100.0,
        oiChange5m = oiChange,
        fundingRate = 0.0001,
        fundingState = funding,
        longRatio = longRatio,
        shortRatio = shortRatio,
        priceChangePercent = priceChange,
        liquidationState = LiquidationState.UNAVAILABLE
    )

    private fun scalpInput(
        currentPrice: Double = 100.0,
        currentSignalPrice: Double = currentPrice,
        return30s: Double = 0.3,
        return1m: Double = 0.8,
        return3m: Double = 1.4,
        return5m: Double = 1.8,
        momentumSlope: Double = 0.1,
        momentumAcceleration: Double = 0.1,
        chaseScore: Double = 20.0,
        entryTimingScore: Double = 80.0,
        rsi: Double = 60.0,
        atrPercent: Double = 0.5,
        bidAskSpread: Double = 0.1,
        orderbookStable: Boolean = true,
        depthChangePercent: Double = 5.0,
        netEdge: Double = 1.0,
        pullbackState: PullbackState = PullbackState.NONE,
        retestState: BreakoutRetestState = BreakoutRetestState.NONE,
        marketHealth: Double = 90.0,
        microSampleCount: Int = 20,
        orderbookPresent: Boolean = true,
        usedCandleProxyForMicro: Boolean = false,
        tickerAgeMs: Long = 500L,
        orderbookAgeMs: Long = 200L
    ) = ScalpingExecutionInput(
        market = "KRW-BTC",
        currentPrice = currentPrice,
        return30s = return30s,
        return1m = return1m,
        return3m = return3m,
        return5m = return5m,
        volume10s = 10.0,
        volume30s = 30.0,
        volume1m = 60.0,
        volumeAcceleration = 30.0,
        bidAskSpread = bidAskSpread,
        orderbookImbalance = 0.65,
        bidDepth = 20.0,
        askDepth = 10.0,
        depthChangePercent = depthChangePercent,
        orderbookStable = orderbookStable,
        tradeIntensity = 4.0,
        buyPressure = 0.65,
        sellPressure = 0.35,
        atrPercent = atrPercent,
        shortVolatilityPercent = 0.5,
        momentumSlope = momentumSlope,
        momentumAcceleration = momentumAcceleration,
        rsi = rsi,
        emaDistancePercent = 0.5,
        strategyScore = 95.0,
        aiScore = 90.0,
        marketHealth = marketHealth,
        netEdge = netEdge,
        chaseScore = chaseScore,
        entryTimingScore = entryTimingScore,
        pullbackState = pullbackState,
        retestState = retestState,
        currentSignalPrice = currentSignalPrice,
        microSampleCount = microSampleCount,
        orderbookPresent = orderbookPresent,
        usedCandleProxyForMicro = usedCandleProxyForMicro,
        tickerAgeMs = tickerAgeMs,
        orderbookAgeMs = orderbookAgeMs
    )

    private fun scalpDiagnostic(
        tradeId: String? = null,
        first5mReturn: Double? = null,
        confidence: Double = 70.0,
        outcome: String? = null,
        state: String = ScalpingExecutionState.ENTER_NOW.name
    ) = ScalpingExecutionDiagnosticEntity(
        market = "KRW-BTC",
        time = 1L,
        tradeId = tradeId,
        entryPrice = 100.0,
        strategyScore = 95.0,
        aiScore = 90.0,
        entryTimingScore = 80.0,
        chaseScore = 20.0,
        executionScore = 85.0,
        executionConfidence = confidence,
        state = state,
        marketState = ScalpingMarketState.SCALP_READY.name,
        momentumState = MicroMomentumState.ACCELERATING.name,
        volatilityRegime = MicroVolatilityRegime.NORMAL.name,
        return30s = 0.2,
        return1m = 0.4,
        return3m = 0.7,
        return5m = 1.0,
        volumeAcceleration = 20.0,
        spreadPercent = 0.1,
        orderbookImbalance = 0.65,
        depthChangePercent = 5.0,
        shortNetEdge = 0.5,
        recommendedHorizonSeconds = 60,
        executionCostPercent = 0.35,
        first5mReturn = first5mReturn,
        outcome = outcome,
        decision = state,
        reasonCodes = "test"
    )

    private fun <T> errorResponse(code: Int): Response<List<T>> =
        Response.error(code, "{}".toResponseBody("application/json".toMediaType()))

    private class FakePublicApi(
        private val tickerResponses: ArrayDeque<Response<List<TickerDto>>>
    ) : BithumbPublicApi {
        var tickerCalls = 0

        override suspend fun markets(details: Boolean): Response<List<MarketDto>> =
            Response.success(emptyList())

        override suspend fun ticker(markets: String): Response<List<TickerDto>> {
            tickerCalls += 1
            return tickerResponses.removeFirst()
        }

        override suspend fun orderbook(markets: String): Response<List<OrderbookDto>> =
            Response.success(emptyList())

        override suspend fun minuteCandles(unit: Int, market: String, count: Int): Response<List<CandleDto>> =
            Response.success(emptyList())
    }

    // --- Net Profit After Cost / Fee Drag Protection (CASE A–F) ---

    @Test fun netProfitAfterCost_caseA_gross300_cost50_net250() {
        // Round-trip before margin: fee 0.1+0.1 + slip 0.1+0.1 + spread 0.1 = 0.5%; safety=1 → cost 50 on 10k
        val d = NetProfitAfterCostEngine.evaluate(
            entryPrice = 100.0,
            plannedCapitalKrw = 10_000.0,
            expectedGrossMovePercent = 3.0,
            spreadPercent = 0.1,
            oneWaySlippagePercent = 0.1,
            marketImpactPercent = 0.0,
            safetyMargin = 1.0,
            oneWayFeePercent = 0.1,
            absoluteMinimumNetProfitKrw = 0.0,
            minimumNetProfitPercentOfOrder = 0.0,
            minimumCostCoverageMultiple = 1.0
        )
        assertEquals(300.0, d.expectedGrossProfitKrw, 0.01)
        assertEquals(50.0, d.expectedRoundTripCostKrw, 0.01)
        assertEquals(250.0, d.expectedNetProfitKrw, 0.01)
        assertTrue(d.cost.buyFeeIncluded && d.cost.sellFeeIncluded)
        assertTrue(d.allowed)
        assertEquals("NET_PROFIT_PASS", d.reasonCode)
    }

    @Test fun netProfitAfterCost_caseB_gross300_cost100_net200() {
        // fee 0.25*2 + slip 0.1*2 + spread 0.3 = 1.0%; safety=1 → cost 100 on 10k
        val d = NetProfitAfterCostEngine.evaluate(
            entryPrice = 100.0,
            plannedCapitalKrw = 10_000.0,
            expectedGrossMovePercent = 3.0,
            spreadPercent = 0.3,
            oneWaySlippagePercent = 0.1,
            marketImpactPercent = 0.0,
            safetyMargin = 1.0,
            oneWayFeePercent = 0.25,
            absoluteMinimumNetProfitKrw = 0.0,
            minimumNetProfitPercentOfOrder = 0.0,
            minimumCostCoverageMultiple = 1.0
        )
        assertEquals(300.0, d.expectedGrossProfitKrw, 0.01)
        assertEquals(100.0, d.expectedRoundTripCostKrw, 0.01)
        assertEquals(200.0, d.expectedNetProfitKrw, 0.01)
        assertEquals(100.0 / 300.0, d.costToGrossProfitRatio, 0.001)
        assertEquals(3.0, d.costCoverageMultiple, 0.001)
        assertTrue(d.allowed)
    }

    @Test fun netProfitAfterCost_caseC_gross100_cost120_blocksBuy() {
        val d = NetProfitAfterCostEngine.evaluate(
            entryPrice = 100.0,
            plannedCapitalKrw = 10_000.0,
            expectedGrossMovePercent = 1.0,
            spreadPercent = 0.5,
            oneWaySlippagePercent = 0.1,
            marketImpactPercent = 0.0,
            safetyMargin = 1.0,
            oneWayFeePercent = 0.25, // 0.5+0.2+0.5=1.2%
            absoluteMinimumNetProfitKrw = 0.0,
            minimumNetProfitPercentOfOrder = 0.0,
            minimumCostCoverageMultiple = 1.0
        )
        assertEquals(100.0, d.expectedGrossProfitKrw, 0.01)
        assertEquals(120.0, d.expectedRoundTripCostKrw, 0.01)
        assertEquals(-20.0, d.expectedNetProfitKrw, 0.01)
        assertFalse(d.allowed)
        assertEquals("NET_PROFIT_TOO_SMALL", d.reasonCode)
    }

    @Test fun netProfitAfterCost_caseD_costCoverageTooLow_blocksBuy() {
        // Gross 140 / Cost 100 = 1.4x < minimum 1.5x
        val d = NetProfitAfterCostEngine.evaluate(
            entryPrice = 100.0,
            plannedCapitalKrw = 10_000.0,
            expectedGrossMovePercent = 1.4,
            spreadPercent = 0.3,
            oneWaySlippagePercent = 0.1,
            marketImpactPercent = 0.0,
            safetyMargin = 1.0,
            oneWayFeePercent = 0.25,
            absoluteMinimumNetProfitKrw = 0.0,
            minimumNetProfitPercentOfOrder = 0.0,
            minimumCostCoverageMultiple = 1.5
        )
        assertEquals(140.0, d.expectedGrossProfitKrw, 0.01)
        assertEquals(100.0, d.expectedRoundTripCostKrw, 0.01)
        assertTrue(d.expectedNetProfitKrw > 0.0)
        assertFalse(d.allowed)
        assertEquals("COST_COVERAGE_TOO_LOW", d.reasonCode)
    }

    @Test fun netProfitAfterCost_caseE_sufficientEdge_passesToRiskPath() {
        val d = NetProfitAfterCostEngine.evaluate(
            entryPrice = 200.0,
            plannedCapitalKrw = 20_000.0,
            expectedGrossMovePercent = 2.0, // gross 400
            spreadPercent = 0.2,
            oneWaySlippagePercent = 0.1,
            marketImpactPercent = 0.0,
            safetyMargin = 1.35,
            oneWayFeePercent = NetProfitAfterCostEngine.ONE_WAY_FEE_PERCENT,
            absoluteMinimumNetProfitKrw = 30.0,
            minimumNetProfitPercentOfOrder = 0.05,
            minimumCostCoverageMultiple = 1.5
        )
        // cost% = (0.5+0.2+0.2)*1.35 = 1.215% → cost 243; net 157
        assertTrue(d.expectedGrossProfitKrw > d.expectedRoundTripCostKrw)
        assertTrue(d.expectedNetProfitKrw >= 30.0)
        assertTrue(d.costCoverageMultiple >= 1.5)
        assertTrue(d.allowed)
        assertTrue(d.breakEvenPrice > 200.0)
        assertEquals(NetProfitAfterCostEngine.ROUND_TRIP_FEE_PERCENT, 0.50, 0.0001)
    }

    @Test fun netProfitAfterCost_caseF_aiDoesNotTreatFeeScrapedWinAsStrongSuccess() {
        // Gross positive after sell fee alone, but net <= 0 once buy fee is considered → gross-win/net-loss
        val trades = listOf(
            TradeEntity(time=1, market="KRW-X", side="BUY", amount=10_000.0, quantity=1.0, avgPrice=10_000.0, fee=25.0, realizedPnl=0.0, pnlRate=0.0, reason="BUY"),
            TradeEntity(time=2, market="KRW-X", side="SELL", amount=10_040.0, quantity=1.0, avgPrice=10_040.0, fee=25.0, realizedPnl=-10.0, pnlRate=-0.1, reason="TAKE PROFIT")
        )
        val ledger = NetProfitAfterCostEngine.ledgerFromTrades(trades)
        assertEquals(1, ledger.grossWinNetLossCount)
        assertTrue(ledger.netPnlKrw < 0.0)
        assertTrue(ledger.grossPnlKrw > ledger.netPnlKrw)
        // Meaningful-win threshold: net +0.05% must not be strong positive label
        assertFalse(0.05 >= 0.10)
        assertTrue((-0.1) <= 0.0) // CASE F net loser
    }

    @Test fun netProfitAfterCost_roundTripIncludesBuyAndSellFeeOnceEach() {
        val cost = NetProfitAfterCostEngine.roundTripCost(
            oneWayFeePercent = 0.25,
            spreadPercent = 0.2,
            oneWaySlippagePercent = 0.1,
            marketImpactPercent = 0.05,
            safetyMargin = 1.0
        )
        assertEquals(0.25, cost.buyFeePercent, 0.0)
        assertEquals(0.25, cost.sellFeePercent, 0.0)
        assertEquals(0.2, cost.spreadPercent, 0.0) // once
        assertEquals(0.25 + 0.25 + 0.1 + 0.1 + 0.2 + 0.05, cost.expectedRoundTripCostPercent, 0.0001)
    }

    @Test fun netProfitAfterCost_overtradingCostDragWhenGrossPositiveNetNonPositive() {
        val trades = listOf(
            TradeEntity(time=1, market="KRW-A", side="BUY", amount=1000.0, quantity=1.0, avgPrice=1000.0, fee=20.0, realizedPnl=0.0, pnlRate=0.0, reason="B"),
            TradeEntity(time=2, market="KRW-A", side="SELL", amount=1030.0, quantity=1.0, avgPrice=1030.0, fee=20.0, realizedPnl=-10.0, pnlRate=-1.0, reason="S"),
            TradeEntity(time=3, market="KRW-B", side="BUY", amount=1000.0, quantity=1.0, avgPrice=1000.0, fee=20.0, realizedPnl=0.0, pnlRate=0.0, reason="B"),
            TradeEntity(time=4, market="KRW-B", side="SELL", amount=1020.0, quantity=1.0, avgPrice=1020.0, fee=20.0, realizedPnl=-5.0, pnlRate=-0.5, reason="S")
        )
        val ledger = NetProfitAfterCostEngine.ledgerFromTrades(trades)
        assertTrue(ledger.overtradingCostDrag)
        assertEquals("OVERTRADING_COST_DRAG", ledger.warning)
        assertEquals("OVERTRADING", ledger.status)
    }

    @Test fun takeProfitHoldsWhenEstimatedNetNonPositive() {
        // Nominal TP hits (+0.5%), but elevated sell fee+slip leave estimated net <= 0 → hold.
        val settings = TradingSettings(takeProfitPercent = 0.4, paperFeeRate = 0.01, paperSlippageRate = 0.002)
        val r = RiskManager().shouldSell(
            settings,
            PositionModel("KRW-BTC", 1.0, 100.0, 100.5, 0),
            100.5,
            StrategySignalModel("KRW-BTC", 80.0, "")
        )
        assertFalse(r.first)
        assertEquals("HOLD_NET_TP_INSUFFICIENT", r.second)
    }

    @Test fun feeRateUnits_paperRatioMatchesPercentConvention() {
        // Paper fill uses ratio 0.0025; Short/Net Edge uses percent units 0.25
        assertEquals(0.0025, TradingSettings().paperFeeRate, 0.0)
        assertEquals(0.25, NetProfitAfterCostEngine.ONE_WAY_FEE_PERCENT, 0.0)
        assertEquals(TradingSettings().paperFeeRate * 100.0, NetProfitAfterCostEngine.ONE_WAY_FEE_PERCENT, 0.0000001)
    }

    @Test fun serverPaperTradesMapIntoUiTradeEntities() {
        val remote = listOf(
            RemotePaperTrade(
                id = "t1", time = 2_000L, market = "KRW-BTC", side = "SELL",
                amount = 10_200.0, quantity = 0.01, avgPrice = 1_020_000.0,
                fee = 25.0, realizedPnl = 150.0, pnlRate = 1.5, reason = "TAKE PROFIT"
            ),
            RemotePaperTrade(
                id = "t0", time = 1_000L, market = "KRW-BTC", side = "BUY",
                amount = 10_000.0, quantity = 0.01, avgPrice = 1_000_000.0,
                fee = 25.0, realizedPnl = 0.0, pnlRate = 0.0, reason = "BUY"
            )
        )
        val mapped = ServerPrimaryCoordinator.toTradeEntities(remote)
        assertEquals(2, mapped.size)
        assertEquals("SELL", mapped.first().side)
        assertEquals(150.0, mapped.first().realizedPnl, 0.0)
        assertEquals("PAPER", mapped.first().mode)
        val state = ServerPrimaryCoordinator.withServerPaperTrades(DashboardState(), remote)
        assertEquals(2, state.serverPaperTrades.size)
        assertEquals(1, state.tradingCostLedger.tradeCount)
        assertEquals(150.0, state.tradingCostLedger.netPnlKrw, 0.0)
    }

    // --- Dynamic Portfolio Capacity (CASE A–G) ---

    private fun capacitySettings(
        hardCap: Int = 8,
        maxOpenRisk: Double = 5.0,
        minOrder: Double = 8_000.0,
        minCash: Double = 30.0
    ) = TradingSettings(
        maxPositions = 3,
        maxPositionsHardCap = hardCap,
        dynamicPortfolioCapacityEnabled = true,
        maxOpenRiskPercent = maxOpenRisk,
        minimumViableOrderKrw = minOrder,
        minKrwCashPercent = minCash,
        stopLossPercent = -2.5,
        absoluteMinimumNetProfitKrw = 30.0,
        minimumCostCoverageMultiple = 1.5
    )

    @Test fun dynamicCapacity_caseA_allowsFourthWhenHeatAndCashOk() {
        val held = listOf(
            PositionModel("KRW-AAA", 1.0, 10_000.0, 10_000.0, 0L),
            PositionModel("KRW-BBB", 1.0, 10_000.0, 10_000.0, 0L),
            PositionModel("KRW-CCC", 1.0, 10_000.0, 10_000.0, 0L)
        )
        val heat = PortfolioHeatEngine.evaluate(held, 100_000.0, -2.5)
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(),
            positions = held,
            totalEquityKrw = 100_000.0,
            availableCashKrw = 70_000.0,
            candidateOrderKrw = 15_000.0,
            candidateMarket = "KRW-ZZZ",
            heat = heat,
            netProfitAfterCostPassed = true
        )
        assertTrue(d.reason, d.allowed)
        assertEquals("AVAILABLE", d.reasonCode)
    }

    @Test fun dynamicCapacity_caseB_blocksWhenProjectedHeatExceeds() {
        val held = listOf(
            PositionModel("KRW-AAA", 1.0, 25_000.0, 25_000.0, 0L),
            PositionModel("KRW-BBB", 1.0, 25_000.0, 25_000.0, 0L),
            PositionModel("KRW-CCC", 1.0, 20_000.0, 20_000.0, 0L)
        )
        val heat = PortfolioHeatEngine.evaluate(held, 100_000.0, -2.5)
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(maxOpenRisk = 2.0),
            positions = held,
            totalEquityKrw = 100_000.0,
            availableCashKrw = 30_000.0,
            candidateOrderKrw = 15_000.0,
            candidateMarket = "KRW-ZZZ",
            heat = heat,
            netProfitAfterCostPassed = true
        )
        assertFalse(d.allowed)
        assertTrue(
            d.reason,
            d.reasonCode == "PORTFOLIO_HEAT_LIMIT" ||
                d.reasonCode == "MINIMUM_VIABLE_ORDER" ||
                d.reasonCode == "CASH_RESERVE_LIMIT"
        )
    }

    @Test fun dynamicCapacity_caseC_fiveSmallPositionsAllowedUnderHardCap() {
        val held = (1..5).map { PositionModel("KRW-X$it", 1.0, 5_000.0, 5_000.0, 0L) }
        val heat = PortfolioHeatEngine.evaluate(held, 100_000.0, -2.5)
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(),
            positions = held,
            totalEquityKrw = 100_000.0,
            availableCashKrw = 70_000.0,
            candidateOrderKrw = 10_000.0,
            candidateMarket = "KRW-NEW",
            heat = heat,
            netProfitAfterCostPassed = true
        )
        assertTrue(d.reason, d.allowed)
    }

    @Test fun dynamicCapacity_caseD_twoPositionsButHeatBlocks() {
        val held = listOf(
            PositionModel("KRW-BTC", 1.0, 40_000.0, 40_000.0, 0L),
            PositionModel("KRW-ETH", 1.0, 35_000.0, 35_000.0, 0L)
        )
        val heat = PortfolioHeatEngine.evaluate(held, 100_000.0, -2.5)
        assertEquals(PortfolioHeatLevel.CRITICAL, heat.level)
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(minCash = 10.0),
            positions = held,
            totalEquityKrw = 100_000.0,
            availableCashKrw = 25_000.0,
            candidateOrderKrw = 10_000.0,
            candidateMarket = "KRW-SOL",
            heat = heat,
            netProfitAfterCostPassed = true
        )
        assertFalse(d.allowed)
        assertTrue(
            d.reason,
            d.reasonCode == "PORTFOLIO_HEAT_LIMIT" || d.reasonCode == "CORRELATED_PORTFOLIO_HEAT"
        )
    }

    @Test fun dynamicCapacity_caseE_blocksTinyOrderDespiteRiskRoom() {
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(minOrder = 8_000.0),
            positions = emptyList(),
            totalEquityKrw = 100_000.0,
            availableCashKrw = 100_000.0,
            candidateOrderKrw = 3_000.0,
            candidateMarket = "KRW-NEW",
            heat = PortfolioHeatSnapshot(PortfolioHeatLevel.LOW, 0.0, 0.0, 0.0, "NONE", 0.0, 1.0),
            netProfitAfterCostPassed = true
        )
        assertFalse(d.allowed)
        assertEquals("MINIMUM_VIABLE_ORDER", d.reasonCode)
    }

    @Test fun dynamicCapacity_caseF_correlatedClusterBlocks() {
        val held = listOf(
            PositionModel("KRW-BTC", 1.0, 30_000.0, 30_000.0, 0L),
            PositionModel("KRW-ETH", 1.0, 25_000.0, 25_000.0, 0L)
        )
        val heat = PortfolioHeatEngine.evaluate(held, 100_000.0, -2.5)
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(maxOpenRisk = 10.0),
            positions = held,
            totalEquityKrw = 100_000.0,
            availableCashKrw = 45_000.0,
            candidateOrderKrw = 15_000.0,
            candidateMarket = "KRW-SOL",
            heat = heat,
            netProfitAfterCostPassed = true
        )
        assertFalse(d.allowed)
        assertTrue(
            d.reason,
            d.reasonCode == "CORRELATED_PORTFOLIO_HEAT" || d.reasonCode == "PORTFOLIO_HEAT_LIMIT"
        )
    }

    @Test fun dynamicCapacity_caseG_hardEmergencyCapBlocks() {
        val held = (1..8).map { PositionModel("KRW-Y$it", 1.0, 2_000.0, 2_000.0, 0L) }
        val heat = PortfolioHeatEngine.evaluate(held, 100_000.0, -2.5)
        val d = DynamicPortfolioCapacityEngine.canOpenAdditionalPosition(
            settings = capacitySettings(hardCap = 8),
            positions = held,
            totalEquityKrw = 100_000.0,
            availableCashKrw = 80_000.0,
            candidateOrderKrw = 10_000.0,
            candidateMarket = "KRW-NEW",
            heat = heat,
            netProfitAfterCostPassed = true
        )
        assertFalse(d.allowed)
        assertEquals("HARD_EMERGENCY_POSITION_CAP", d.reasonCode)
    }

    @Test fun riskManagerNoLongerBlocksSolelyOnThreePositions() {
        val heldState = DashboardState(
            lastTickerAt = System.currentTimeMillis(),
            apiStatus = ConnectionStatus.CONNECTED,
            holdingCount = 3,
            coinValue = 30_000.0,
            krwBalance = 70_000.0,
            totalValue = 100_000.0,
            portfolioHeat = PortfolioHeatSnapshot(PortfolioHeatLevel.LOW, 0.75, 30.0, 0.0, "ISOLATED", 1.0, 1.0),
            settings = capacitySettings()
        )
        val ok = RiskManager().canBuy(
            capacitySettings(),
            heldState,
            StrategySignalModel("KRW-NEW", 90.0, ""),
            OrderbookModel("KRW-NEW", 100.0, 99.9, 1.0, 1.0, 0),
            false,
            false,
            15_000.0
        )
        assertTrue(ok.second, ok.first)
    }


    @Test fun exchangeIsolation_positionKeysDiffer() {
        assertEquals("BITHUMB:KRW-BTC", ExchangeIsolation.positionKey(ExchangeId.BITHUMB, "KRW-BTC"))
        assertEquals("UPBIT:KRW-BTC", ExchangeIsolation.positionKey(ExchangeId.UPBIT, "krw-btc"))
        assertNotEquals(
            ExchangeIsolation.positionKey(ExchangeId.BITHUMB, "KRW-XRP"),
            ExchangeIsolation.positionKey(ExchangeId.UPBIT, "KRW-XRP")
        )
    }

    @Test fun exchangeIsolation_holdingDoesNotCrossBlock() {
        val held = setOf(ExchangeIsolation.positionKey(ExchangeId.BITHUMB, "KRW-XRP"))
        assertTrue(ExchangeIsolation.alreadyHoldingBlocks(held, ExchangeId.BITHUMB, "KRW-XRP"))
        assertFalse(ExchangeIsolation.alreadyHoldingBlocks(held, ExchangeId.UPBIT, "KRW-XRP"))
    }

    @Test fun exchangeIsolation_dailyLossIndependent() {
        assertTrue(ExchangeIsolation.dailyLossBlocksSameExchangeOnly(ExchangeId.BITHUMB, ExchangeId.BITHUMB))
        assertFalse(ExchangeIsolation.dailyLossBlocksSameExchangeOnly(ExchangeId.BITHUMB, ExchangeId.UPBIT))
        assertFalse(ExchangeIsolation.dailyLossBlocksSameExchangeOnly(null, ExchangeId.UPBIT))
    }

    @Test fun exchangeIsolation_duplicateAndReentryKeys() {
        assertEquals(
            "UPBIT:KRW-ETH:dec-1",
            ExchangeIsolation.duplicateKey(ExchangeId.UPBIT, "KRW-ETH", "dec-1")
        )
        assertEquals("BITHUMB:KRW-ETH", ExchangeIsolation.reentryKey(ExchangeId.BITHUMB, "KRW-ETH"))
        assertEquals(ExchangeId.UPBIT, ExchangeIsolation.parseExchange("UPBIT:KRW-BTC"))
        assertEquals("KRW-BTC", ExchangeIsolation.parseMarket("UPBIT:KRW-BTC"))
    }



    @Test fun remotePaperTradesParser_handlesNullDecisionIdAndMissingExchange() {
        val body = """
            {"trades":[{"id":"t1","time":1788250626161,"market":"KRW-BTR","side":"SELL","amount":1.0,"quantity":1.0,"avgPrice":1.0,"fee":0.1,"realizedPnl":-1.0,"pnlRate":-1.0,"reason":"TRAILING STOP","decisionId":null},{"id":"t0","time":1788250500000,"market":"KRW-BTR","side":"BUY","amount":1.0,"quantity":1.0,"avgPrice":1.0,"fee":0.1,"realizedPnl":0.0,"pnlRate":0.0,"reason":"BUY","decisionId":"d1"}],"serverTimestamp":1788250627000}
        """.trimIndent()
        val parsed = RemotePaperTradesParser.parse(body)
        assertEquals(2, parsed.trades!!.size)
        assertEquals("SELL", parsed.trades!![0].side)
        assertNull(parsed.trades!![0].decisionId)
        val entities = ServerPrimaryCoordinator.toTradeEntities(parsed.trades!!)
        assertEquals(2, entities.size)
        assertTrue(entities.any { it.side == "SELL" && it.market == "KRW-BTR" })
    }

    @Test fun applyServerPaperMirror_embedsRecentTradesIntoUi() {
        val paper = RemotePaperState(
            paperAuto = true,
            cash = 50_000.0,
            initialCash = 100_000.0,
            coinValue = 0.0,
            totalValue = 50_000.0,
            realizedPnl = -100.0,
            unrealizedPnl = 0.0,
            totalPnl = -100.0,
            positionCount = 0,
            positions = emptyList(),
            recentTrades = listOf(
                RemotePaperTrade(
                    id = "x1", time = 100L, market = "KRW-XRP", side = "BUY",
                    amount = 10_000.0, quantity = 10.0, avgPrice = 1_000.0,
                    fee = 25.0, realizedPnl = 0.0, pnlRate = 0.0, reason = "BUY", exchange = "BITHUMB"
                )
            ),
            tradeCount = 1,
            tickCount = 3
        )
        val state = ServerPrimaryCoordinator.applyServerPaperMirror(DashboardState(), paper, "ONLINE")
        assertEquals(1, state.serverPaperTrades.size)
        assertEquals("OK", state.serverPaperTradesSyncStatus)
        assertTrue(state.serverPaperTrades.first().reason.contains("BITHUMB"))
    }

    @Test fun useServerPaperTradeLedger_whenPrimaryEvenIfNotYetSynced() {
        val state = DashboardState(
            settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = true),
            androidAnalysisMode = "LOCAL_FULL_SCAN",
            serverPaperUiSynced = false,
            serverPaperTrades = emptyList()
        )
        assertTrue(ServerPrimaryCoordinator.useServerPaperTradeLedger(state))
    }

    @Test fun useServerPaperTradeLedger_whenServerTradesAlreadyPresent() {
        val trade = TradeEntity(
            time = 1L, market = "KRW-BTC", side = "BUY", amount = 1.0, quantity = 1.0,
            avgPrice = 1.0, fee = 0.0, realizedPnl = 0.0, pnlRate = 0.0, reason = "x"
        )
        val state = DashboardState(
            settings = TradingSettings(remoteAiEnabled = true, remoteAiPrimary = false),
            androidAnalysisMode = "LOCAL_FULL_SCAN",
            serverPaperUiSynced = false,
            serverPaperTrades = listOf(trade)
        )
        assertTrue(ServerPrimaryCoordinator.useServerPaperTradeLedger(state))
    }

    @Test fun paperTradingMath_economicRealizedIncludesBuyFeeOnce() {
        val buy = PaperTradingMath.buyFill(10_000.0, 100.0, 0.0025, 0.001)!!
        val sell = PaperTradingMath.sellFill(buy.quantity, 100.0, 0.0025, 0.001)!!
        val buyCash = 10_000.0
        val economic = PaperTradingMath.economicRealizedPnl(
            quantity = buy.quantity,
            avgBuyPrice = buy.executionPrice,
            sellGrossAmount = sell.grossAmount,
            sellFee = sell.fee,
            feeRate = 0.0025,
            buyCashSpentOverride = buyCash
        )
        val legacyExBuyFee = sell.grossAmount - sell.fee - buy.quantity * buy.executionPrice
        assertTrue(economic < legacyExBuyFee)
        assertEquals(buyCash, PaperTradingMath.buyCashSpent(buy.quantity, buy.executionPrice, 0.0025), 0.01)
        // Slippage in prices only — economic equals sellNet - buyCash
        assertEquals((sell.grossAmount - sell.fee) - buyCash, economic, 0.0001)
    }

    @Test fun paperLossAutopsy_detectsFeeDragAndAccountingMismatch() {
        val buy = TradeEntity(
            id = "b1", time = 1, market = "KRW-BTC", side = "BUY",
            amount = 10_000.0, quantity = 1.0, avgPrice = 9_975.0, fee = 25.0,
            realizedPnl = 0.0, pnlRate = 0.0, reason = "BUY"
        )
        val sell = TradeEntity(
            id = "s1", time = 2, market = "KRW-BTC", side = "SELL",
            amount = 9_980.0, quantity = 1.0, avgPrice = 10_005.0, fee = 25.0,
            realizedPnl = 5.0, pnlRate = 0.05, reason = "TAKE PROFIT"
        )
        // Legacy reported realized excludes buy fee → mismatch vs cash
        val report = PaperLossAutopsyEngine.analyze(
            trades = listOf(buy, sell),
            initialCapital = 100_000.0,
            cash = 99_980.0, // 100k - 10k + 9.98k
            positionValue = 0.0,
            realizedPnlReported = 5.0,
            unrealizedPnl = 0.0
        )
        assertEquals(1, report.closedRoundTrips)
        assertTrue(report.breakdowns.first().netPnlKrw < 0.0)
        assertEquals(PrimaryLossCause.FEE_DRAG_LOSS, report.breakdowns.first().primaryLossCause)
        assertTrue(report.accounting.accountingMismatch)
        assertTrue(abs(report.accounting.economicMismatchKrw) < 1.0)
    }

    @Test fun paperLossAutopsy_ranksStopLossByKrwContribution() {
        val trades = mutableListOf<TradeEntity>()
        var t = 1L
        fun round(market: String, buyAmt: Double, sellNet: Double, reason: String) {
            trades += TradeEntity(id = "b$t", time = t++, market = market, side = "BUY", amount = buyAmt, quantity = 1.0, avgPrice = buyAmt * 0.9975, fee = buyAmt * 0.0025, realizedPnl = 0.0, pnlRate = 0.0, reason = "BUY")
            trades += TradeEntity(id = "s$t", time = t++, market = market, side = "SELL", amount = sellNet, quantity = 1.0, avgPrice = sellNet + 25.0, fee = 25.0, realizedPnl = sellNet - buyAmt, pnlRate = -2.0, reason = reason)
        }
        repeat(5) { round("KRW-A", 10_000.0, 9_500.0, "STOP LOSS") }
        round("KRW-B", 10_000.0, 10_500.0, "TAKE PROFIT")
        val report = PaperLossAutopsyEngine.analyze(
            trades = trades,
            initialCapital = 100_000.0,
            cash = 90_000.0,
            positionValue = 0.0,
            realizedPnlReported = -2_000.0,
            unrealizedPnl = 0.0
        )
        assertTrue(report.lossCount >= 5)
        assertTrue(report.stopLossTotalLossKrw < -1_000.0)
        // Same-market consecutive stops → exclusive PRIMARY is REENTRY_LOSS (STOP remains contributing).
        assertEquals(PrimaryLossCause.REENTRY_LOSS, report.topCauses.first().cause)
        assertTrue(abs(report.primaryAttributedLossSum - report.netLossKrw) < 1.0)
        assertTrue(report.windows.size == 3)
    }

    @Test fun paperRiskEngine_autopsyDefenseEscalatesWithoutLock() {
        val snap = PaperRiskEngine.evaluate(
            mode = TradeMode.PAPER,
            lossStreak = 0,
            dailyLossLocked = false,
            recentStats = PerformanceStats(expectedReturnPercent = -1.0, winRate = 0.3),
            health = MarketHealthScore(score = 70.0, level = MarketHealthLevel.CAUTION),
            regime = MarketRegimeSnapshot(regime = MarketRegime.SIDEWAYS),
            autopsyHint = "NET_EXPECTANCY_DEFENSE",
            autopsySampleTooSmall = false
        )
        assertEquals(PaperRiskState.PAPER_DEFENSE, snap.state)
        assertTrue(snap.tradingActive)
        assertTrue(snap.remainingRiskBudgetMultiplier < 1.0)
        assertTrue(snap.positionSizeMultiplier < 1.0)
    }

    @Test fun primaryAttributionIsExclusiveAndSumsToNetLoss(){
        val trades = listOf(
            TradeEntity("b1", 1_000L, "KRW-A", "BUY", 10_000.0, 1.0, 100.0, 25.0, 0.0, 0.0, "BUY", "PAPER"),
            TradeEntity("s1", 2_000L, "KRW-A", "SELL", 9_500.0, 1.0, 95.0, 24.0, -500.0, -5.0, "STOP LOSS", "PAPER"),
            TradeEntity("b2", 3_000L, "KRW-A", "BUY", 10_000.0, 1.0, 100.0, 25.0, 0.0, 0.0, "BUY", "PAPER"),
            TradeEntity("s2", 4_000L, "KRW-A", "SELL", 9_400.0, 1.0, 94.0, 24.0, -600.0, -6.0, "STOP LOSS", "PAPER")
        )
        val report = PaperLossAutopsyEngine.analyze(trades, 100_000.0, 98_900.0, 0.0, -1_100.0, 0.0)
        val primarySum = report.primaryAttributedLossSum
        val netLoss = report.netLossKrw
        assertEquals(netLoss, primarySum, 1.0)
        assertTrue(report.topCauses.any { it.cause == PrimaryLossCause.REENTRY_LOSS })
        assertFalse(report.topCauses.any { it.cause == PrimaryLossCause.BUY_FEE })
        assertEquals("OK", report.accounting.accountingStatus)
    }

    @Test fun inflatedPeakOverrideRejected(){
        val trades = emptyList<TradeEntity>()
        val report = PaperLossAutopsyEngine.analyze(
            trades = trades,
            initialCapital = 100_000.0,
            cash = 52_266.0,
            positionValue = 0.0,
            realizedPnlReported = -47_734.0,
            unrealizedPnl = 0.0,
            peakEquityOverride = 123_529.0
        )
        assertFalse(report.peakAudit.peakValid)
        assertTrue(report.peakEquity <= 100_000.0 + 1.0)
    }

    @Test fun partialWindowAttributionGapNotAccountingMismatch(){
        // Full equity at 52k with only one small loss in window → attribution gap, accounting OK if realized matches.
        val trades = listOf(
            TradeEntity("b1", 1_000L, "KRW-A", "BUY", 10_000.0, 1.0, 100.0, 25.0, 0.0, 0.0, "BUY", "PAPER"),
            TradeEntity("s1", 2_000L, "KRW-A", "SELL", 9_500.0, 1.0, 95.0, 24.0, -500.0, -5.0, "STOP LOSS", "PAPER")
        )
        val report = PaperLossAutopsyEngine.analyze(
            trades, 100_000.0, 52_266.0, 0.0, -47_734.0, 0.0
        )
        assertEquals("OK", report.accounting.accountingStatus)
        assertTrue(report.accounting.attributionStatus == "GAP" || report.accounting.attributionStatus == "WINDOW_PARTIAL")
    }

}


===== END FILE: app/src/test/java/com/example/bithumbtrader/CoreUnitTest.kt =====

===== FILE: app/src/test/java/com/example/bithumbtrader/MigrationPreservationTest.kt =====
package com.example.bithumbtrader

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.file.Files
import java.sql.Connection
import java.sql.DriverManager

class MigrationPreservationTest {
    @Test
    fun migration20To21PreservesExistingLearningRowsAndSettings() {
        Class.forName("org.sqlite.JDBC")
        val db = Files.createTempFile("bithumb-v20-", ".db").toFile()
        try {
            DriverManager.getConnection("jdbc:sqlite:${db.absolutePath}").use { connection ->
                createVersion20Fixture(connection)
                val before = counts(connection)
                Migration20To21Sql.statements.forEach { sql -> connection.createStatement().use { it.execute(sql) } }
                connection.createStatement().use { it.execute("PRAGMA user_version = 21") }
                val after = counts(connection)

                assertEquals(mapOf(
                    "samples" to 600,
                    "journal" to 600,
                    "models" to 1,
                    "promotions" to 1,
                    "shadow" to 10,
                    "research" to 3,
                    "reentry" to 2,
                    "scalping" to 4
                ), before)
                assertEquals(before, after)
                assertEquals(600, queryInt(connection, "SELECT COUNT(*) FROM ai_training_samples WHERE source='PAPER'"))
                assertEquals("DL_TEST_7", queryString(connection, "SELECT value FROM settings WHERE key='continuous_learning_model_version'"))
                assertEquals("ABC", queryString(connection, "SELECT value FROM settings WHERE key='learning_lineage_id'"))
                assertEquals(21, queryInt(connection, "PRAGMA user_version"))
            }
        } finally {
            db.delete()
        }
    }

    @Test
    fun migration21To22PreservesLearningAssetsAndAddsRegimeTables() {
        Class.forName("org.sqlite.JDBC")
        val db = Files.createTempFile("bithumb-v21-", ".db").toFile()
        try {
            DriverManager.getConnection("jdbc:sqlite:${db.absolutePath}").use { connection ->
                createVersion20Fixture(connection)
                Migration20To21Sql.statements.forEach { sql -> connection.createStatement().use { it.execute(sql) } }
                connection.createStatement().use { it.execute("PRAGMA user_version = 21") }
                val before = counts(connection)
                Migration21To22Sql.statements.forEach { sql -> connection.createStatement().use { it.execute(sql) } }
                connection.createStatement().use { it.execute("PRAGMA user_version = 22") }
                val after = counts(connection)

                assertEquals(before, after)
                assertEquals(22, queryInt(connection, "PRAGMA user_version"))
                assertEquals(0, queryInt(connection, "SELECT COUNT(*) FROM regime_strategy_set_snapshots"))
                assertEquals(0, queryInt(connection, "SELECT COUNT(*) FROM regime_accuracy_samples"))
                assertEquals(600, queryInt(connection, "SELECT COUNT(*) FROM ai_training_samples"))
                assertEquals(600, queryInt(connection, "SELECT COUNT(*) FROM prediction_journal"))
                assertEquals("DL_TEST_7", queryString(connection, "SELECT value FROM settings WHERE key='continuous_learning_model_version'"))
                assertEquals("ABC", queryString(connection, "SELECT value FROM settings WHERE key='learning_lineage_id'"))
                assertEquals(AppDatabase.SCHEMA_VERSION, 22)
            }
        } finally {
            db.delete()
        }
    }

    @Test
    fun multiStepMigration20To22PreservesAcceptanceCountsExactly() {
        Class.forName("org.sqlite.JDBC")
        val db = Files.createTempFile("bithumb-v20-to-22-", ".db").toFile()
        try {
            DriverManager.getConnection("jdbc:sqlite:${db.absolutePath}").use { connection ->
                createVersion20Fixture(connection)
                val before = invariantProbe(connection)
                Migration20To21Sql.statements.forEach { sql -> connection.createStatement().use { it.execute(sql) } }
                Migration21To22Sql.statements.forEach { sql -> connection.createStatement().use { it.execute(sql) } }
                connection.createStatement().use { it.execute("PRAGMA user_version = 22") }
                val after = invariantProbe(connection)

                assertEquals(
                    mapOf(
                        "totalSamples" to 600,
                        "resolvedPaperTagged" to 100,
                        "predictionJournal" to 600,
                        "journalHistorical" to 500,
                        "journalPaper" to 100,
                        "model" to "DL_TEST_7",
                        "lastCount" to "600",
                        "lineage" to "ABC",
                        "models" to 1,
                        "promotions" to 1,
                        "shadow" to 10,
                        "research" to 3,
                        "reentry" to 2,
                        "scalping" to 4
                    ),
                    before
                )
                assertEquals(before, after)
                assertEquals(22, queryInt(connection, "PRAGMA user_version"))
                // Acceptance contract mirrored in manifest compare (Paper 100 / Historical 500 / Journal 600 / Model DL_TEST_7 / Last 600 / Lineage ABC)
                val acceptance = LearningAssetIntegrity.compare(
                    acceptanceManifest("1.8.0", 100L),
                    acceptanceManifest("1.9.0", 200L)
                )
                assertEquals(LearningAssetPreservationStatus.PRESERVED, acceptance.status)
            }
        } finally {
            db.delete()
        }
    }

    @Test
    fun simulatedApkUpdatePreservesAcceptanceManifestExactly() {
        val before = acceptanceManifest(appVersion = "1.8.0", updatedAt = 100L)
        val after = acceptanceManifest(appVersion = "1.9.0", updatedAt = 200L)
        val comparison = LearningAssetIntegrity.compare(before, after)

        assertEquals(LearningAssetPreservationStatus.PRESERVED, comparison.status)
        assertEquals(100, after.paperSampleCount)
        assertEquals(500, after.historicalSampleCount)
        assertEquals(600, after.predictionJournalCount)
        assertEquals("DL_TEST_7", after.modelVersion)
        assertEquals(600, after.continuousLearningLastCount)
        assertEquals("ABC", after.learningLineageId)
        assertEquals(22, after.schemaVersion)
        assertTrue(comparison.checksumMatches)
        assertTrue(comparison.lineageMatches)
        assertEquals(listOf("LEARNING_ASSETS_PRESERVED"), comparison.reasons)
    }

    @Test
    fun manifestDetectsAnyLearningAssetDecreaseOrLineageReset() {
        val before = acceptanceManifest(appVersion = "1.8.0", updatedAt = 100L)
        val after = acceptanceManifest(appVersion = "1.9.0", updatedAt = 200L).copy(
            aiTrainingSampleCount = 599,
            predictionJournalCount = 590,
            learningLineageId = "RESET"
        )
        val comparison = LearningAssetIntegrity.compare(before, after)
        assertEquals(LearningAssetPreservationStatus.LOSS_DETECTED, comparison.status)
        assertTrue(comparison.reasons.any { it.startsWith("AI_SAMPLES 감소") })
        assertTrue(comparison.reasons.any { it.startsWith("PREDICTION_JOURNAL 감소") })
        assertTrue("LEARNING_LINEAGE_RESET" in comparison.reasons)
    }

    @Test
    fun validatedLocalModelSurvivesNewBundledAndDefersOta() {
        assertTrue(AiModelPrecedencePolicy.shouldRestorePersisted(
            source = "LOCAL_RETRAIN",
            persistedVersion = 7,
            bundledVersion = 20,
            localValidated = true
        ))
        assertFalse(AiModelPrecedencePolicy.shouldApplyOta(
            currentSource = "LOCAL_RETRAIN",
            otaVersion = 30,
            currentVersion = 7,
            localValidated = true
        ))
        assertTrue(AiModelPrecedencePolicy.shouldApplyOta(
            currentSource = "BUNDLED",
            otaVersion = 2,
            currentVersion = 1,
            localValidated = false
        ))
    }

    @Test
    fun modelChecksumAndManifestRoundTripStayStableAcrossRestart() {
        val json = """{"modelVersion":"DL_TEST_7","weights":[1.0,2.0]}"""
        val checksum = LearningAssetIntegrity.checksum(json)
        val before = acceptanceManifest("1.8.0", 100L).copy(modelChecksum = checksum)
        val restored = LearningAssetIntegrity.fromJson(LearningAssetIntegrity.toJson(before))
        assertEquals(before, restored)
        assertEquals(checksum, LearningAssetIntegrity.checksum(json))
    }

    @Test
    fun schemaVersionConstantMatchesRoomDatabaseVersion() {
        assertEquals(22, AppDatabase.SCHEMA_VERSION)
    }

    private fun createVersion20Fixture(connection: Connection) {
        connection.createStatement().use { statement ->
            statement.execute("PRAGMA user_version = 20")
            statement.execute("CREATE TABLE settings (`key` TEXT PRIMARY KEY NOT NULL, `value` TEXT NOT NULL)")
            statement.execute("CREATE TABLE ai_training_samples (`id` TEXT PRIMARY KEY NOT NULL, `market` TEXT NOT NULL, `time` INTEGER NOT NULL, `featuresJson` TEXT NOT NULL, `strategyScore` REAL NOT NULL, `aiScoreAtCapture` REAL NOT NULL, `buyTradeId` TEXT, `outcomeLabel` REAL, `realizedPnlRate` REAL, `resolvedAt` INTEGER, `marketRegime` TEXT NOT NULL DEFAULT 'UNKNOWN')")
            statement.execute("CREATE TABLE prediction_journal (`predictionId` TEXT PRIMARY KEY NOT NULL, `modelVersion` TEXT NOT NULL, `market` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `featuresJson` TEXT NOT NULL, `predictionJson` TEXT NOT NULL, `confidence` REAL NOT NULL, `decision` TEXT NOT NULL, `reason` TEXT NOT NULL, `source` TEXT NOT NULL, `tradeId` TEXT, `expectedReturn5m` REAL NOT NULL DEFAULT 0.0, `outcomeReturn5m` REAL, `outcomeReturn15m` REAL, `predictionError5m` REAL, `resolvedAt` INTEGER)")
            statement.execute("CREATE TABLE strategy_model_versions (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `generation` INTEGER NOT NULL, `source` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `trainingSamples` INTEGER NOT NULL, `validationSamples` INTEGER NOT NULL, `validationAccuracy` REAL NOT NULL, `baselineAccuracy` REAL NOT NULL, `adopted` INTEGER NOT NULL, `reason` TEXT NOT NULL, `modelJson` TEXT NOT NULL)")
            statement.execute("CREATE TABLE strategy_promotions (`id` TEXT PRIMARY KEY NOT NULL, `champion` TEXT NOT NULL, `challenger` TEXT NOT NULL)")
            statement.execute("CREATE TABLE shadow_trades (`id` TEXT PRIMARY KEY NOT NULL)")
            statement.execute("CREATE TABLE research_hypotheses (`id` TEXT PRIMARY KEY NOT NULL)")
            statement.execute("CREATE TABLE smart_reentry_states (`market` TEXT PRIMARY KEY NOT NULL)")
            statement.execute("CREATE TABLE scalping_execution_diagnostics (`id` TEXT PRIMARY KEY NOT NULL)")
            statement.execute("INSERT INTO settings VALUES ('continuous_learning_model_json','{\"modelVersion\":\"DL_TEST_7\"}')")
            statement.execute("INSERT INTO settings VALUES ('continuous_learning_model_version','DL_TEST_7')")
            statement.execute("INSERT INTO settings VALUES ('continuous_learning_last_count','600')")
            statement.execute("INSERT INTO settings VALUES ('continuous_learning_last_at','123456')")
            statement.execute("INSERT INTO settings VALUES ('learning_lineage_id','ABC')")
            statement.execute("INSERT INTO strategy_model_versions (generation,source,createdAt,trainingSamples,validationSamples,validationAccuracy,baselineAccuracy,adopted,reason,modelJson) VALUES (7,'CONTINUOUS_DEEP_LEARNING',1,500,100,0.8,0.7,0,'PROMOTION_CANDIDATE','{}')")
            statement.execute("INSERT INTO strategy_promotions VALUES ('p1','CURRENT_STRATEGY','DL_TEST_7')")
        }
        connection.autoCommit = false
        connection.prepareStatement("INSERT INTO ai_training_samples VALUES (?,?,?,?,?,?,?,?,?,?,?)").use { ps ->
            repeat(600) { index ->
                ps.setString(1, "s$index")
                ps.setString(2, "KRW-BTC")
                ps.setLong(3, index.toLong())
                ps.setString(4, "0,0,0,0,0,0,0,0")
                ps.setDouble(5, 80.0)
                ps.setDouble(6, 70.0)
                ps.setString(7, if (index < 100) "trade-$index" else null)
                ps.setDouble(8, 1.0)
                ps.setDouble(9, 1.0)
                ps.setLong(10, index.toLong())
                ps.setString(11, "BULL")
                ps.addBatch()
            }
            ps.executeBatch()
        }
        connection.prepareStatement("INSERT INTO prediction_journal (predictionId,modelVersion,market,timestamp,featuresJson,predictionJson,confidence,decision,reason,source,expectedReturn5m) VALUES (?,?,?,?,?,?,?,?,?,?,?)").use { ps ->
            repeat(600) { index ->
                ps.setString(1, "p$index")
                ps.setString(2, "DL_TEST_7")
                ps.setString(3, "KRW-BTC")
                ps.setLong(4, index.toLong())
                ps.setString(5, "0,0,0,0,0,0,0,0")
                ps.setString(6, "p5=0.8")
                ps.setDouble(7, 80.0)
                ps.setString(8, "BUY_READY")
                ps.setString(9, "test")
                ps.setString(10, if (index < 100) "PAPER" else "HISTORICAL")
                ps.setDouble(11, 1.0)
                ps.addBatch()
            }
            ps.executeBatch()
        }
        repeat(10) { connection.createStatement().use { s -> s.execute("INSERT INTO shadow_trades VALUES ('sh$it')") } }
        repeat(3) { connection.createStatement().use { s -> s.execute("INSERT INTO research_hypotheses VALUES ('r$it')") } }
        repeat(2) { connection.createStatement().use { s -> s.execute("INSERT INTO smart_reentry_states VALUES ('KRW-R$it')") } }
        repeat(4) { connection.createStatement().use { s -> s.execute("INSERT INTO scalping_execution_diagnostics VALUES ('sc$it')") } }
        connection.commit()
        connection.autoCommit = true
    }

    private fun counts(connection: Connection): Map<String, Int> = mapOf(
        "samples" to queryInt(connection, "SELECT COUNT(*) FROM ai_training_samples"),
        "journal" to queryInt(connection, "SELECT COUNT(*) FROM prediction_journal"),
        "models" to queryInt(connection, "SELECT COUNT(*) FROM strategy_model_versions"),
        "promotions" to queryInt(connection, "SELECT COUNT(*) FROM strategy_promotions"),
        "shadow" to queryInt(connection, "SELECT COUNT(*) FROM shadow_trades"),
        "research" to queryInt(connection, "SELECT COUNT(*) FROM research_hypotheses"),
        "reentry" to queryInt(connection, "SELECT COUNT(*) FROM smart_reentry_states"),
        "scalping" to queryInt(connection, "SELECT COUNT(*) FROM scalping_execution_diagnostics")
    )

    private fun invariantProbe(connection: Connection): Map<String, Any> = mapOf(
        "totalSamples" to queryInt(connection, "SELECT COUNT(*) FROM ai_training_samples"),
        "resolvedPaperTagged" to queryInt(connection, "SELECT COUNT(*) FROM ai_training_samples WHERE buyTradeId IS NOT NULL"),
        "predictionJournal" to queryInt(connection, "SELECT COUNT(*) FROM prediction_journal"),
        "journalHistorical" to queryInt(connection, "SELECT COUNT(*) FROM prediction_journal WHERE source='HISTORICAL'"),
        "journalPaper" to queryInt(connection, "SELECT COUNT(*) FROM prediction_journal WHERE source='PAPER'"),
        "model" to queryString(connection, "SELECT value FROM settings WHERE key='continuous_learning_model_version'"),
        "lastCount" to queryString(connection, "SELECT value FROM settings WHERE key='continuous_learning_last_count'"),
        "lineage" to queryString(connection, "SELECT value FROM settings WHERE key='learning_lineage_id'"),
        "models" to queryInt(connection, "SELECT COUNT(*) FROM strategy_model_versions"),
        "promotions" to queryInt(connection, "SELECT COUNT(*) FROM strategy_promotions"),
        "shadow" to queryInt(connection, "SELECT COUNT(*) FROM shadow_trades"),
        "research" to queryInt(connection, "SELECT COUNT(*) FROM research_hypotheses"),
        "reentry" to queryInt(connection, "SELECT COUNT(*) FROM smart_reentry_states"),
        "scalping" to queryInt(connection, "SELECT COUNT(*) FROM scalping_execution_diagnostics")
    )

    private fun queryInt(connection: Connection, sql: String): Int =
        connection.createStatement().use { statement ->
            statement.executeQuery(sql).use { result -> result.next(); result.getInt(1) }
        }

    private fun queryString(connection: Connection, sql: String): String =
        connection.createStatement().use { statement ->
            statement.executeQuery(sql).use { result -> result.next(); result.getString(1) }
        }

    private fun acceptanceManifest(appVersion: String, updatedAt: Long) = LearningAssetManifest(
        schemaVersion = 22,
        appVersion = appVersion,
        appVersionCode = if (appVersion == "1.8.0") 18 else 19,
        learningLineageId = "ABC",
        aiTrainingSampleCount = 600,
        historicalSampleCount = 500,
        paperSampleCount = 100,
        liveSampleCount = 0,
        resolvedSampleCount = 600,
        predictionJournalCount = 600,
        hardExampleCount = 25,
        modelVersion = "DL_TEST_7",
        modelSource = "CONTINUOUS_LOCAL_VALIDATED",
        modelChecksum = "checksum-7",
        productionModelChecksum = "production-checksum",
        continuousModelChecksum = "continuous-checksum",
        continuousLearningLastCount = 600,
        continuousLearningLastAt = 123456L,
        championVersion = "CURRENT_STRATEGY",
        challengerVersion = "DL_TEST_7",
        shadowTradeCount = 80,
        researchHypothesisCount = 9,
        smartReentryStateCount = 3,
        scalpingDiagnosticCount = 120,
        createdAt = 1L,
        updatedAt = updatedAt
    )
}

===== END FILE: app/src/test/java/com/example/bithumbtrader/MigrationPreservationTest.kt =====

===== FILE: server/ai-brain/app/auth.py =====
from __future__ import annotations

from fastapi import Header, HTTPException

from .config import API_TOKEN


def require_token(authorization: str | None = Header(default=None), x_api_token: ***REDACTED*** | None = Header(default=None)) -> None:
    if not API_TOKEN:
        # Misconfigured server: fail closed for trading endpoints.
        raise HTTPException(status_code=503, detail="API token not configured")
    provided = None
    if x_api_token:
        provided = x_api_token.strip()
    elif authorization and authorization.lower().startswith("bearer "):
        provided = authorization[7:].strip()
    if not provided or provided != API_TOKEN:
        raise HTTPException(status_code=401, detail="Unauthorized")
===== END FILE: server/ai-brain/app/auth.py =====

===== FILE: server/ai-brain/app/autonomous_research.py =====
"""Autonomous Investment Research Cycle orchestrator (Hetzner Brain).

OBSERVE → DIAGNOSE → HYPOTHESIS → CANDIDATE → REPLAY → OOS → SHADOW → COMPARE → PROMOTE/REJECT → LEARN

Does NOT modify Kotlin/Python strategy source. Only bounded parameter weights.
Does NOT force PAPER BUY resume or enable LIVE.
Research failures never stop realtime market / exit loops (caller wraps try/except).
"""
from __future__ import annotations

import hashlib
import json
import time
import uuid
from typing import Any, Callable

from .learning_authenticity import (
    MIN_OOS_PF_FOR_PROMOTE,
    REAL_PRODUCTION_SOURCES,
    RECOVERY_VALIDATION_MODE,
    audit_reported_cycle_m101,
    classify_candidate,
    classify_trade_training_quality,
    dataset_diversity_report,
    dataset_lineage,
    duplicate_sample_count,
    honest_learning_level,
    layer2_status_from_evidence,
    look_ahead_feature_violations,
    overlap_count,
    prediction_transition_matrix,
    production_evidence,
    sample_source,
    temporal_order_ok,
    why_weight_changed,
)
from .parameter_registry import (
    CROSS_EXCHANGE_LEARNING,
    FIXED_SAFETY,
    clamp_candidate,
    default_weights,
    is_ai_modifiable,
    registry_snapshot,
    weights_hash,
)
from .research_store import ResearchStore
from .weighted_policy import (
    SHADOW_HORIZONS_MS,
    calibration_report,
    compare_predictions,
    extract_features,
    regime_replay_metrics,
    replay_metrics,
    score_with_weights,
)

MIN_SAMPLES_TRAIN = 12
MIN_SAMPLES_PROMOTE = 20
MIN_SHADOW_SAMPLES = 10
RESEARCH_COOLDOWN_MS = 15 * 60 * 1000  # avoid weight oscillation


class AutonomousResearchEngine:
    def __init__(
        self,
        exchange: str,
        store: ResearchStore | None = None,
        decision_store: Any | None = None,
        paper_engine: Any | None = None,
    ) -> None:
        self.exchange = (exchange or "BITHUMB").upper()
        if CROSS_EXCHANGE_LEARNING:
            raise RuntimeError("CROSS_EXCHANGE_LEARNING must stay OFF")
        self.store = store or ResearchStore(self.exchange)
        self.decision_store = decision_store
        self.paper = paper_engine
        self.state = "OBSERVING"
        self.last_research_at = 0
        self.last_error: str | None = None
        self._samples_at_last_learn = 0

    # ── public API ──────────────────────────────────────────────────────────

    def status(self) -> dict[str, Any]:
        active = self.store.get_active_model()
        shadow = self.store.get_shadow()
        cycles = self.store.latest_learning_cycles(20)
        samples_valid = self.store.list_samples(2000, "VALID")
        samples_partial = self.store.list_samples(2000, "PARTIAL")
        samples_invalid = self.store.list_samples(500, "INVALID")
        try:
            syn_q = self.store.count_samples("SYNTHETIC")
        except Exception:
            syn_q = 0
        real_samples = sum(1 for s in samples_valid if sample_source(s) in REAL_PRODUCTION_SOURCES)
        partial_real = sum(1 for s in samples_partial if sample_source(s) in REAL_PRODUCTION_SOURCES)
        real_shadow_samples = sum(1 for s in samples_valid if sample_source(s) == "REAL_SHADOW")
        paper_samples = sum(1 for s in samples_valid if sample_source(s) == "REAL_PAPER_OUTCOME")
        synthetic_samples = syn_q + sum(
            1
            for s in (samples_valid + samples_partial)
            if sample_source(s) in {"SYNTHETIC_TEST", "FIXTURE", "DEMO", "HARDCODED", "UNIT_FIXTURE"}
        )
        real_cycles = [c for c in cycles if c.get("learningProofSource") == "REAL_DATA"]
        syn_cycles = [c for c in cycles if c.get("learningProofSource") == "TEST_DATA"]
        shadow_complete = len(
            [x for x in self.store.list_shadow_outcomes(500) if x.get("label") and (x.get("horizons") or {}).get("60m") is not None]
        )
        last_real = real_cycles[0] if real_cycles else None
        evidence = production_evidence(
            real_samples=real_samples,
            synthetic_samples=synthetic_samples,
            real_cycles=len(real_cycles),
            synthetic_cycles=len(syn_cycles),
            active_source=str(active.get("source") or ""),
            shadow_completed=shadow_complete,
            last_real_cycle=last_real,
        )
        if evidence.get("productionEvidence") == "NONE" and partial_real > 0:
            evidence["productionEvidence"] = "PARTIAL"
            evidence["detail"] = "PARTIAL_REAL_OUTCOMES_WITHOUT_VALID_FEATURES"
        last = cycles[0] if cycles else {}
        is_improving = last.get("didIImprove") or "NOT_ENOUGH_EVIDENCE"
        if not last:
            is_improving = "NOT_ENOUGH_EVIDENCE"
        learning_status = honest_learning_level(
            real_samples=real_samples,
            real_cycles=len(real_cycles),
            proof_source=last.get("learningProofSource") or self._proof_source_label(),
            promotion_decision=last.get("promotionDecision"),
            promotion_tier=last.get("promotionTier"),
            shadow_status=last.get("shadowStatus") or (shadow.get("status") if shadow else None),
            is_improving=is_improving if isinstance(is_improving, str) else "NOT_ENOUGH_EVIDENCE",
        )
        health = self._learning_health(real_samples, cycles)
        last_weight_delta = (last_real or last or {}).get("weightDelta") or (last_real or last or {}).get("weightsDelta")
        if not last_weight_delta and (last_real or last):
            last_weight_delta = (last_real or last).get("weightChanges")
        pred_change = (last_real or last or {}).get("predictionChangeRate")
        pc_last = ((last_real or last) or {}).get("predictionCompare") or {}
        if pred_change is None and (last_real or last):
            if pc_last.get("PREDICTION_CHANGED_PERCENT") is not None:
                pred_change = float(pc_last.get("PREDICTION_CHANGED_PERCENT") or 0) / 100.0
            else:
                pred_change = pc_last.get("changeRate")
        score_changed_count = pc_last.get("SCORE_CHANGED_COUNT")
        decision_changed_count = pc_last.get("DECISION_CHANGED_COUNT")
        if decision_changed_count is None:
            decision_changed_count = pc_last.get("PREDICTION_CHANGED_COUNT")
        decision_transitions = ((last_real or last) or {}).get("predictionTransitions")
        why_w = ((last_real or last) or {}).get("whyWeightChanged")
        if not why_w and (last_real or last):
            # Historical REAL cycles (pre-instrumentation) still get structured WHY from stored evidence
            why_w = why_weight_changed(
                ((last_real or last) or {}).get("diagnosis"),
                ((last_real or last) or {}).get("hypothesis")
                or (self.store.list_hypotheses(1) or [None])[0],
                ((last_real or last) or {}).get("weightDelta") or last_weight_delta,
            )
        diversity = dataset_diversity_report(samples_valid[:800], active.get("weights"))
        real_dec_changed = int(decision_changed_count or 0)
        # Aggregate across real cycles for residual Layer-2 status
        any_real_dec = real_dec_changed
        for cyc in real_cycles:
            pc = cyc.get("predictionCompare") or {}
            any_real_dec = max(any_real_dec, int(pc.get("DECISION_CHANGED_COUNT") or pc.get("PREDICTION_CHANGED_COUNT") or 0))
        last_promo = str((last_real or last or {}).get("promotionDecision") or "")
        if last_promo in {"FAILED_OOS", "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED", "REJECTED", "REJECT", "INSUFFICIENT_REAL_DATA", "LOW_SAMPLE", "LOOK_AHEAD_BIAS", "DATA_LEAK", ""}:
            oos_passed = False
        elif last_promo in {"SHADOW_ONLY", "PROMOTION_ELIGIBLE", "PROMOTED", "IMPROVED_BUT_UNPROFITABLE"}:
            oos_passed = True
        else:
            oos_passed = False
        abs_ok = False
        if last_real or last:
            oos_a = (last_real or last).get("oosAfter") or {}
            abs_ok = float(oos_a.get("profitFactor") or 0) >= MIN_OOS_PF_FOR_PROMOTE and float(oos_a.get("netExpectancy") or 0) > 0
        residual_layer2 = layer2_status_from_evidence(
            real_decision_changed=any_real_dec,
            oos_passed=oos_passed,
            shadow_status=(last_real or last or {}).get("shadowStatus") or (shadow.get("status") if shadow else None),
            absolute_ok=abs_ok and last_promo == "PROMOTED",
        )
        candidate_hash = (last_real or last or {}).get("candidateWeightsHash") or (shadow.get("modelHash") if shadow else None)
        candidate_version = (shadow.get("modelVersion") if shadow else None) or (last_real or last or {}).get("candidateModelVersion")
        is_learning_flag = bool(real_samples > 0 or len(real_cycles) > 0)
        # LEARNING ≠ IMPROVING: improving requires a real cycle with positive OOS/shadow proof
        if not real_cycles:
            is_improving_out: Any = "NOT_ENOUGH_EVIDENCE"
        else:
            is_improving_out = is_improving
        return {
            "exchange": self.exchange,
            "mode": "PAPER_RESEARCH",
            "layer": 2,
            "layerStatus": residual_layer2,
            "brainState": self.state if real_samples > 0 or cycles else "WAITING_FOR_DATA",
            "crossExchangeLearning": False,
            "activeModel": active.get("modelVersion"),
            "activeModelHash": active.get("modelHash"),
            "activeModelSource": active.get("source"),
            "learningCycleId": active.get("learningCycleId"),
            "lastLearningAt": (cycles[0].get("completedAt") if cycles else None),
            "lastResearchAt": self.last_research_at or None,
            "lastRealLearningCycle": (last_real or {}).get("learningCycleId"),
            "lastRealPromotion": self._recent_by_status("PROMOTED") if last_real else None,
            "samplesTotal": len(samples_valid),
            "realSampleCount": real_samples,
            "realShadowSampleCount": real_shadow_samples,
            "paperSampleCount": paper_samples,
            "partialRealSampleCount": partial_real,
            "invalidSampleCount": self.store.count_samples("INVALID"),
            "syntheticSampleCount": synthetic_samples,
            "realLearningCycleCount": len(real_cycles),
            "syntheticCycleCount": len(syn_cycles),
            "samplesSinceLastLearning": max(0, len(samples_valid) - self._samples_at_last_learn),
            "championVersion": active.get("modelVersion"),
            "challengerVersion": shadow.get("modelVersion") if shadow else None,
            "candidateModel": candidate_version,
            "candidateModelHash": candidate_hash,
            "challengers": [
                {"slot": s.get("slot"), "modelVersion": s.get("modelVersion"), "status": s.get("status")}
                for s in self.store.list_shadows("SHADOW", limit=3)
            ],
            "shadowStatus": shadow.get("status") if shadow else "NONE",
            "shadowOutcomeCount": len(self.store.list_shadow_outcomes(500)),
            "shadowCompletedSamples": shadow_complete,
            "oosStatus": (last_real or last or {}).get("oosStatus")
            or (("RAN" if (last_real or last or {}).get("oosAfter") else "NONE") if (last_real or last) else "NONE"),
            "lastWeightDelta": last_weight_delta,
            "predictionChangeRate": pred_change,
            "scoreChangedCount": score_changed_count,
            "decisionChangedCount": decision_changed_count,
            "realProductionDecisionChangedCount": any_real_dec,
            "testDecisionChangedCount": 0,  # unit-test flips never counted as production evidence
            "decisionTransitions": decision_transitions,
            "predictionCompare": pc_last or None,
            "whyWeightChanged": why_w,
            "datasetDiversity": diversity,
            "boundaryCoverage": {
                "nearBoundary": diversity.get("nearBoundaryCount"),
                "farBoundary": diversity.get("farBoundaryCount"),
                "buyNearMiss": diversity.get("buyNearMissCount"),
                "warnings": diversity.get("warnings"),
            },
            "currentExperiment": (self.store.list_experiments(1) or [None])[0],
            "lastHypothesis": (self.store.list_hypotheses(1) or [None])[0],
            "lastRejectedExperiment": next(
                (c for c in cycles if c.get("promotionDecision") in {"REJECTED", "REJECT", "FAILED_OOS"}),
                None,
            ),
            "learningStatus": learning_status,
            "learningHealth": health,
            "learningProofSource": self._proof_source_label(),
            "modelStatus": self._model_status_label(active, cycles),
            "productionEvidence": evidence.get("productionEvidence"),
            "productionEvidenceDetail": evidence.get("detail"),
            "isLearning": is_learning_flag,
            "isImproving": is_improving_out,
            "recoveryValidationMode": RECOVERY_VALIDATION_MODE,
            "minOosPfForPromote": MIN_OOS_PF_FOR_PROMOTE,
            "m101Audit": audit_reported_cycle_m101(),
            "NEXT_REQUIREMENT": (
                None
                if real_samples >= MIN_SAMPLES_TRAIN
                else "Need VALID decision-linked REAL_SHADOW/REAL_PAPER samples (PAPER BUY paused → accumulate REAL_SHADOW)"
            ),
            "calibration": self.calibration_snapshot(),
            "selfEval": self.prediction_self_eval(),
            "recentPromotion": self._recent_by_status("PROMOTED"),
            "recentRollback": self._recent_by_status("ROLLBACK"),
            "lastError": self.last_error,
            "paperBuyState": "PAUSED_DIAGNOSTIC",
            "liveTrading": False,
            "parameterRegistry": {
                "aiModifiableCount": sum(1 for p in registry_snapshot() if p["classification"] in {"LEARNABLE", "TUNABLE"}),
                "fixedSafetyCount": sum(1 for p in registry_snapshot() if p["classification"] == FIXED_SAFETY),
            },
        }

    def _proof_source_label(self) -> str:
        samples = self.store.list_samples(100, "VALID")
        if not samples:
            syn = self.store.count_samples("SYNTHETIC")
            return "NO_REAL_PRODUCTION_EVIDENCE" if syn == 0 else "TEST_DATA"
        sources = {sample_source(s) for s in samples}
        if sources <= {"SYNTHETIC_TEST", "FIXTURE", "HARDCODED", "DEMO"}:
            return "TEST_DATA"
        if "REAL_PAPER_OUTCOME" in sources or "REAL_MARKET_DATA" in sources or "SHADOW_OUTCOME" in sources or "REAL_SHADOW" in sources:
            if sources & {"SYNTHETIC_TEST", "FIXTURE", "DEMO", "UNIT_FIXTURE"}:
                return "MIXED"
            return "REAL_DATA"
        return "UNKNOWN"

    def _model_status_label(self, active: dict[str, Any], cycles: list[dict[str, Any]]) -> str:
        if active.get("source") == "BOOTSTRAP":
            return "BOOTSTRAP_UNVERIFIED"
        last = cycles[0] if cycles else {}
        if last.get("promotionDecision") == "PROMOTED" and last.get("learningProofSource") == "TEST_DATA":
            return "QUESTIONABLE"
        if last.get("promotionTier") == "PROMOTION_ELIGIBLE" and last.get("promotionDecision") == "PROMOTED":
            return "VERIFIED"
        if last.get("promotionDecision") in {"SHADOW_HOLD", "SHADOW_ONLY"} or last.get("shadowStatus"):
            return "SHADOW_ONLY"
        if last.get("promotionDecision") == "PROMOTED":
            return "QUESTIONABLE"
        return "UNVERIFIED"

    def _learning_level_label(self) -> str:
        """Map to LOGGING_ONLY / TRAINING_ONLY / SHADOW_LEARNING / ACTIVE_LEARNING."""
        active = self.store.get_active_model()
        cycles = self.store.latest_learning_cycles(5)
        samples = self.store.count_samples("VALID")
        if samples == 0 and not cycles:
            return "LOGGING_ONLY"
        if not cycles:
            return "TRAINING_ONLY" if samples else "LOGGING_ONLY"
        last = cycles[0]
        if last.get("learningProofSource") == "TEST_DATA":
            # Demo/synthetic cycles do not prove production ACTIVE_LEARNING
            if last.get("promotionDecision") in {"SHADOW_ONLY", "SHADOW_HOLD"} or last.get("shadowStatus"):
                return "SHADOW_LEARNING"
            return "TRAINING_ONLY"
        decision = last.get("promotionDecision")
        if (
            decision == "PROMOTED"
            and active.get("source") in {"AUTONOMOUS_LEARNING", "ROLLBACK"}
            and last.get("learningProofSource") == "REAL_DATA"
            and last.get("promotionTier") == "PROMOTION_ELIGIBLE"
        ):
            return "ACTIVE_LEARNING"
        if last.get("shadowStatus") in {"SHADOW", "SHADOW_REGISTERED"} or decision == "SHADOW_ONLY":
            return "SHADOW_LEARNING"
        if last.get("candidateModelVersion"):
            return "TRAINING_ONLY"
        return "LOGGING_ONLY"

    def _learning_health(self, samples: int, cycles: list[dict[str, Any]]) -> str:
        if samples <= 0:
            return "WAITING_FOR_REAL_DATA"
        if samples < MIN_SAMPLES_TRAIN:
            return "INSUFFICIENT_DATA"
        if self.last_error:
            return "BROKEN"
        if not cycles:
            return "STAGNANT"
        last = cycles[0]
        oos_b = last.get("oosBefore") or {}
        oos_a = last.get("oosAfter") or {}
        if (
            last.get("promotionDecision") == "PROMOTED"
            and float(oos_a.get("netExpectancy") or 0) < float(oos_b.get("netExpectancy") or 0) - 5
        ):
            return "DEGRADING"
        age = int(time.time() * 1000) - int(last.get("completedAt") or last.get("startedAt") or 0)
        if age > 7 * 24 * 3600 * 1000 and last.get("promotionDecision") != "PROMOTED":
            return "STAGNANT"
        if last.get("shadowStatus") in {"SHADOW", "SHADOW_REGISTERED"} or last.get("promotionDecision") in {"SHADOW_ONLY", "SHADOW_HOLD"}:
            return "SHADOWING"
        if last.get("candidateModelVersion") and last.get("promotionDecision") not in {"PROMOTED"}:
            return "VALIDATING"
        if samples >= MIN_SAMPLES_TRAIN and any(c.get("learningProofSource") == "REAL_DATA" for c in cycles[:3]):
            return "LEARNING"
        return "HEALTHY"

    def _recent_by_status(self, status: str) -> dict[str, Any] | None:
        for m in self.store.list_lineage(20):
            if m.get("status") == status or (status == "PROMOTED" and m.get("status") == "CHAMPION" and m.get("source") == "AUTONOMOUS_LEARNING"):
                return m
            if status == "ROLLBACK" and m.get("source") == "ROLLBACK":
                return m
        return None

    def ingest_decision_outcome(
        self,
        decision: dict[str, Any] | None,
        outcome: dict[str, Any],
        quality: str = "VALID",
    ) -> str | None:
        """Connect TRADE/OUTCOME → TRAINING SAMPLE with provenance + quality gate.

        Never invents entry features from realized PnL (look-ahead unsafe).
        SYSTEM/EXEC/ACCOUNTING bug trades are stored INVALID and excluded from training.
        """
        auto_q, invalid_reason = classify_trade_training_quality(outcome, decision)
        if quality in {"INVALID", "ACCOUNTING_MISMATCH"} or auto_q == "INVALID":
            reason = invalid_reason or quality
            self.store.add_memory(
                "INVALID_SAMPLE_EXCLUDED",
                {
                    "reason": reason,
                    "outcome": {k: outcome.get(k) for k in ("decisionId", "market", "realizedPnl", "exitReason")},
                },
            )
            trade_id = outcome.get("tradeId") or outcome.get("paperTradeId") or outcome.get("id")
            self.store.add_training_sample(
                {
                    "sampleId": f"paper-sell-{trade_id}" if trade_id else None,
                    "quality": "INVALID",
                    "market": outcome.get("market"),
                    "netPnl": outcome.get("realizedPnl"),
                    "features": extract_features(decision or {}),
                    "label": 0,
                    "meta": {
                        "excludedReason": reason,
                        "invalidReason": reason,
                        "validForTraining": False,
                        "lookAheadSafe": False,
                        "dataSource": "REAL_PAPER_OUTCOME",
                        "dataQuality": "INVALID",
                        "exchange": self.exchange,
                        "decisionId": (decision or {}).get("decisionId") or outcome.get("decisionId"),
                        "tradeId": trade_id,
                        "paperTradeId": trade_id,
                        "featureTimestamp": (decision or {}).get("serverTimestamp"),
                        "decisionTimestamp": (decision or {}).get("serverTimestamp"),
                        "outcomeTimestamp": outcome.get("time") or outcome.get("outcomeTimestamp"),
                    },
                }
            )
            return None
        if auto_q == "PARTIAL" and quality == "VALID":
            quality = "PARTIAL"
        # Features ONLY from decision-time snapshot — never from outcome PnL/MFE/MAE
        if decision is None:
            quality = "PARTIAL"
            invalid_reason = invalid_reason or "MISSING_REQUIRED_FEATURES"
            feats = {}
            look_ahead_safe = False
        else:
            feats = extract_features(decision)
            look_ahead_safe = len(look_ahead_feature_violations([{"features": feats, "meta": {}}])) == 0
            if not look_ahead_safe:
                quality = "INVALID"
                invalid_reason = "LOOKAHEAD_UNSAFE"
        pnl = outcome.get("realizedPnl")
        if pnl is None:
            pnl = outcome.get("realizedPnlPercent")
        # Net label only (cost-adjusted realized). Gross forbidden as success label.
        label = 1 if (pnl is not None and float(pnl) > 0) else 0
        cause = self._infer_loss_cause(decision, outcome)
        trade_id = outcome.get("tradeId") or outcome.get("paperTradeId") or outcome.get("id")
        decision_id = (decision or {}).get("decisionId") or outcome.get("decisionId")
        self.store.add_memory(
            "TradeOutcome",
            {
                "market": outcome.get("market"),
                "netPnl": pnl,
                "mfe": outcome.get("MFE") or outcome.get("mfe"),
                "mae": outcome.get("MAE") or outcome.get("mae"),
                "exitReason": outcome.get("exitReason"),
                "cause": cause,
                "regime": (decision or {}).get("marketRegime") or "UNKNOWN",
                "validForTraining": quality == "VALID",
            },
        )
        if (decision or {}).get("decision") in {"WAIT", "AVOID"} and outcome.get("missedMovePercent") is not None:
            move = float(outcome["missedMovePercent"])
            kind = "MISSED_OPPORTUNITY" if move > 1.0 and decision.get("decision") == "WAIT" else (
                "CORRECT_REJECTION" if move < -1.0 and decision.get("decision") == "AVOID" else "MarketObservation"
            )
            self.store.add_memory(kind, {"market": outcome.get("market"), "move": move, "decision": decision.get("decision")})
        if quality == "INVALID":
            self.store.add_memory("INVALID_SAMPLE_EXCLUDED", {"reason": invalid_reason, "decisionId": decision_id})
            # still persist for research memory
        feature_blob = json.dumps(feats, sort_keys=True, default=str)
        label_blob = json.dumps({"netPnl": pnl, "label": label}, sort_keys=True)

        return self.store.add_training_sample(
            {
                "sampleId": f"paper-sell-{trade_id}" if trade_id else (f"out-{decision_id}" if decision_id else None),
                "quality": quality if quality in {"VALID", "PARTIAL", "INVALID"} else "VALID",
                "market": outcome.get("market") or (decision or {}).get("market"),
                "netPnl": float(pnl) if pnl is not None else None,
                "label": label,
                "features": feats,
                "createdAt": int(outcome.get("time") or outcome.get("outcomeTimestamp") or time.time() * 1000),
                "meta": {
                    "sampleId": f"paper-sell-{trade_id}" if trade_id else None,
                    "decisionId": decision_id,
                    "tradeId": trade_id,
                    "paperTradeId": trade_id,
                    "modelVersion": (decision or {}).get("modelVersion"),
                    "exitReason": outcome.get("exitReason"),
                    "cause": cause,
                    "dataSource": str(outcome.get("dataSource") or "REAL_PAPER_OUTCOME"),
                    "exchange": self.exchange,
                    "market": outcome.get("market") or (decision or {}).get("market"),
                    "featureTimestamp": (decision or {}).get("serverTimestamp"),
                    "decisionTimestamp": (decision or {}).get("serverTimestamp"),
                    "outcomeTimestamp": outcome.get("time") or outcome.get("outcomeTimestamp"),
                    "featureHash": hashlib.sha256(feature_blob.encode()).hexdigest()[:16],
                    "labelHash": hashlib.sha256(label_blob.encode()).hexdigest()[:16],
                    "dataQuality": str((decision or {}).get("dataQuality") or outcome.get("dataQuality") or "UNKNOWN"),
                    "lookAheadSafe": look_ahead_safe,
                    "validForTraining": quality == "VALID" and look_ahead_safe,
                    "invalidReason": invalid_reason,
                },
            }
        )

    def _infer_loss_cause(self, decision: dict[str, Any] | None, outcome: dict[str, Any]) -> str:
        pnl = float(outcome.get("realizedPnl") or 0)
        reason = str(outcome.get("exitReason") or "").upper()
        hold = int(outcome.get("holdingTime") or 0)
        chase = float((decision or {}).get("chaseScore") or 0)
        if pnl >= 0:
            return "PROFIT"
        parts = []
        if "REENTRY" in reason or (decision or {}).get("reentry"):
            parts.append("REENTRY")
        if hold and hold < 60_000:
            parts.append("SHORT_HOLD")
        if chase >= 70:
            parts.append("CHASE")
        if "STOP" in reason:
            parts.append("STOP")
        if "TRAILING" in reason:
            parts.append("TRAILING")
        if not parts:
            parts.append("BAD_TIMING")
        return "+".join(parts)

    def sync_from_paper_and_decisions(self) -> int:
        """Observe: pull recent paper sells + decision store into training samples.

        Does NOT fabricate entry micro features from realized PnL (look-ahead).
        Missing decision → PARTIAL / not validForTraining.
        """
        added = 0
        if self.paper is not None:
            try:
                trades = self.paper.trades(limit=400)
            except Exception:
                trades = []
            sells = [t for t in trades if str(t.get("side") or "").upper() == "SELL"]
            seen = set()
            for q in ("VALID", "PARTIAL", "INVALID"):
                for s in self.store.list_samples(2000, q):
                    meta = s.get("meta") or {}
                    for key in ("paperTradeId", "tradeId"):
                        if meta.get(key):
                            seen.add(str(meta.get(key)))
                    sid = str(s.get("sampleId") or "")
                    if sid.startswith("paper-sell-"):
                        seen.add(sid.replace("paper-sell-", "", 1))
            for sell in sells:
                tid = str(sell.get("id") or sell.get("tradeId") or "")
                if tid and tid in seen:
                    continue
                did = sell.get("decisionId")
                decision = self._find_decision(str(did)) if did else None
                # Never invent return1m from pnlRate — that is look-ahead leakage.
                sample_id = self.ingest_decision_outcome(
                    decision,
                    {
                        "decisionId": did,
                        "market": sell.get("market"),
                        "realizedPnl": sell.get("realizedPnl"),
                        "realizedPnlPercent": sell.get("pnlRate"),
                        "exitReason": sell.get("reason"),
                        "holdingTime": None,
                        "tradeId": tid,
                        "paperTradeId": tid,
                        "time": sell.get("time"),
                        "dataSource": "REAL_PAPER_OUTCOME",
                        "accountingMismatch": bool(sell.get("accountingMismatch")),
                    },
                    quality="VALID" if decision is not None else "PARTIAL",
                )
                if sample_id:
                    self.store.add_memory("TradeOutcome", {"paperTradeId": tid, "sampleId": sample_id})
                    added += 1
                    if tid:
                        seen.add(tid)
        if self.decision_store is not None:
            try:
                with self.decision_store._conn() as conn:
                    rows = conn.execute(
                        "SELECT payload_json FROM outcomes ORDER BY created_at_ms DESC LIMIT 100"
                    ).fetchall()
                for r in rows:
                    payload = json.loads(r["payload_json"])
                    did = payload.get("decisionId")
                    decision = self._find_decision(str(did)) if did else None
                    before = self.store.count_samples(None)
                    self.ingest_decision_outcome(decision, payload, quality="VALID")
                    if self.store.count_samples(None) > before:
                        added += 1
            except Exception as exc:
                self.last_error = f"outcome_sync:{exc}"
        return added

    def _find_decision(self, decision_id: str) -> dict[str, Any] | None:
        if self.decision_store is None:
            return None
        try:
            with self.decision_store._conn() as conn:
                row = conn.execute(
                    "SELECT payload_json FROM decisions WHERE decision_id=?", (decision_id,)
                ).fetchone()
            if row:
                import json

                return json.loads(row["payload_json"])
        except Exception:
            return None
        return None

    def maybe_run_cycle(self, force: bool = False) -> dict[str, Any] | None:
        now = int(time.time() * 1000)
        if not force and self.last_research_at and now - self.last_research_at < RESEARCH_COOLDOWN_MS:
            return None
        samples = self.store.list_samples(800, "VALID")
        if len(samples) < MIN_SAMPLES_TRAIN and not force:
            self.state = "OBSERVING"
            return None
        if (
            not force
            and len(samples) - self._samples_at_last_learn < MIN_SAMPLES_TRAIN
            and self.last_research_at
        ):
            return None
        return self.run_research_cycle(force=force)

    def run_research_cycle(
        self,
        force: bool = False,
        external_hypothesis: dict[str, Any] | None = None,
        allow_synthetic: bool = False,
    ) -> dict[str, Any]:
        """Full AUTONOMOUS_RESEARCH_CYCLE. Never writes LIVE/safety params.

        Production path never pads with synthetic samples. Synthetic is TEST_DATA only
        and can never promote ACTIVE CHAMPION.
        """
        started = int(time.time() * 1000)
        self.state = "LEARNING"
        self.last_error = None
        active = self.store.get_active_model()
        old_weights = dict(active.get("weights") or default_weights())
        old_version = str(active.get("modelVersion") or "M100")
        old_hash = weights_hash(old_weights)
        samples_before = self.store.count_samples("VALID")

        # OBSERVE
        self.state = "OBSERVING"
        self.sync_from_paper_and_decisions()
        samples = self.store.list_samples(800, "VALID")
        samples_added = max(0, len(samples) - samples_before)
        real_samples = [s for s in samples if sample_source(s) in REAL_PRODUCTION_SOURCES]
        syn_samples = [s for s in samples if sample_source(s) in {"SYNTHETIC_TEST", "FIXTURE", "DEMO", "HARDCODED", "UNIT_FIXTURE"}]

        # DIAGNOSE
        self.state = "LEARNING"
        diagnosis = self._diagnose(samples)

        # HYPOTHESIS
        if external_hypothesis:
            hyp = dict(external_hypothesis)
            hyp["source"] = "EXTERNAL_HYPOTHESIS"
            # External suggestions never apply directly — same pipeline
        else:
            hyp = self._build_hypothesis(diagnosis, samples)
        hyp_id = self.store.save_hypothesis(hyp)

        # CANDIDATE (bounded)
        proposed = self._propose_weights(old_weights, hyp, diagnosis)
        candidate_weights, weight_changes = clamp_candidate(old_weights, proposed)
        # Safety: ensure FIXED_SAFETY keys never present as changed
        for ch in weight_changes:
            if ch.get("rejected"):
                continue
            assert is_ai_modifiable(ch["key"])

        cand_version = self.store.next_model_version()

        # Prefer real samples for REAL cycles; synthetic only when explicitly allowed or already present for tests.
        samples_for_split = list(real_samples) if real_samples and not (force and allow_synthetic and not real_samples) else list(samples)
        if real_samples and len(real_samples) >= MIN_SAMPLES_TRAIN:
            samples_for_split = list(real_samples)
        elif syn_samples and (force or allow_synthetic):
            samples_for_split = list(syn_samples) if not real_samples else list(samples)
        n = len(samples_for_split)
        is_synthetic_cycle = (
            n > 0
            and all(sample_source(s) in {"SYNTHETIC_TEST", "FIXTURE", "DEMO", "HARDCODED", "FALLBACK"} for s in samples_for_split)
        ) or (allow_synthetic and len(real_samples) < MIN_SAMPLES_TRAIN)
        cycle_id = (
            f"{self.exchange}-SYN-{uuid.uuid4().hex[:10]}"
            if is_synthetic_cycle
            else f"{self.exchange}-RLC-{uuid.uuid4().hex[:10]}"
        )
        if n < MIN_SAMPLES_TRAIN:
            if force and allow_synthetic:
                pad = self._synthetic_samples(max(MIN_SAMPLES_TRAIN, MIN_SAMPLES_TRAIN - n), old_weights)
                samples_for_split = samples_for_split + pad
                n = len(samples_for_split)
                is_synthetic_cycle = True
                cycle_id = f"{self.exchange}-SYN-{uuid.uuid4().hex[:10]}"
            elif force and syn_samples and len(syn_samples) >= MIN_SAMPLES_TRAIN:
                samples_for_split = list(syn_samples)
                n = len(samples_for_split)
                is_synthetic_cycle = True
                cycle_id = f"{self.exchange}-SYN-{uuid.uuid4().hex[:10]}"
            else:
                self.state = "WAITING_FOR_DATA"
                return self._finish_cycle_reject(
                    cycle_id,
                    started,
                    samples_before,
                    samples_added,
                    old_version,
                    old_hash,
                    cand_version,
                    weights_hash(candidate_weights),
                    weight_changes,
                    "INSUFFICIENT_REAL_DATA" if not samples else "LOW_SAMPLE",
                    hyp_id,
                    diagnosis,
                )

        i1 = max(1, int(n * 0.5))
        i2 = max(i1 + 1, int(n * 0.75))
        train_s = samples_for_split[:i1]
        val_s = samples_for_split[i1:i2]
        oos_s = samples_for_split[i2:] or samples_for_split[-max(1, n // 5) :]
        samples = samples_for_split

        pred_cmp = compare_predictions(val_s or train_s, old_weights, candidate_weights)
        # If weights moved but decisions identical, apply bounded behavior-seeking nudge (still within registry)
        if pred_cmp.get("PREDICTION_CHANGED_COUNT", 0) == 0 and any(not c.get("rejected") for c in weight_changes):
            nudge = {
                "thr_ai_buy": float(candidate_weights.get("thr_ai_buy", 55.0)) + 3.0,
                "thr_exec_buy": float(candidate_weights.get("thr_exec_buy", 60.0)) + 3.0,
                "thr_strategy_buy": float(candidate_weights.get("thr_strategy_buy", 75.0)) + 2.0,
            }
            candidate_weights, nudge_changes = clamp_candidate(candidate_weights, nudge)
            for nc in nudge_changes:
                if not nc.get("rejected"):
                    nc["note"] = "BEHAVIOR_SEEKING_NUDGE"
                    weight_changes.append(nc)
            pred_cmp = compare_predictions(val_s or train_s, old_weights, candidate_weights)

        cand_hash = weights_hash(candidate_weights)
        changed_count = sum(1 for c in weight_changes if not c.get("rejected"))
        deltas = [abs(float(c.get("delta") or 0)) for c in weight_changes if not c.get("rejected")]
        max_delta = max(deltas) if deltas else 0.0
        mean_delta = (sum(deltas) / len(deltas)) if deltas else 0.0

        self.state = "REPLAYING"
        replay_before = replay_metrics(train_s, old_weights)
        replay_after = replay_metrics(train_s, candidate_weights)

        self.state = "VALIDATING"
        oos_before = replay_metrics(oos_s, old_weights)
        oos_after = replay_metrics(oos_s, candidate_weights)
        val_before = replay_metrics(val_s, old_weights)
        val_after = replay_metrics(val_s, candidate_weights)
        regime_oos_before = regime_replay_metrics(oos_s, old_weights)
        regime_oos_after = regime_replay_metrics(oos_s, candidate_weights)
        # Split-level behavior evidence (validation ≠ OOS). Does not relax promotion gates.
        oos_pred_cmp = compare_predictions(oos_s, old_weights, candidate_weights)
        oos_transitions = prediction_transition_matrix(oos_s, old_weights, candidate_weights, score_with_weights)
        oos_behavior_change_not_observed = int(pred_cmp.get("DECISION_CHANGED_COUNT") or pred_cmp.get("PREDICTION_CHANGED_COUNT") or 0) > 0 and int(
            oos_pred_cmp.get("DECISION_CHANGED_COUNT") or oos_pred_cmp.get("PREDICTION_CHANGED_COUNT") or 0
        ) == 0

        # Multi-objective gate + authenticity classification (absolute vs relative)
        promote_ok, reject_reason = self._promotion_gate(
            replay_before, replay_after, oos_before, oos_after, val_before, val_after, pred_cmp, len(samples)
        )

        train_lineage = dataset_lineage(train_s, self.exchange, "TRAIN")
        val_lineage = dataset_lineage(val_s, self.exchange, "VALIDATION")
        oos_lineage = dataset_lineage(oos_s, self.exchange, "OOS")
        ov_tv = overlap_count(train_s, val_s)
        ov_to = overlap_count(train_s, oos_s)
        ov_vo = overlap_count(val_s, oos_s)
        temporal = temporal_order_ok(train_s, val_s, oos_s)
        leaks = look_ahead_feature_violations(samples)
        dups = duplicate_sample_count(samples)
        transitions = prediction_transition_matrix(val_s or train_s, old_weights, candidate_weights, score_with_weights)
        primary_source = train_lineage.get("primarySource") or "UNKNOWN"
        # All sources across splits
        all_sources = set()
        for lin in (train_lineage, val_lineage, oos_lineage):
            all_sources.update((lin.get("source") or {}).keys())
        if all_sources <= {"SYNTHETIC_TEST", "FIXTURE", "HARDCODED", "DEMO", "FALLBACK"}:
            primary_source = "SYNTHETIC_TEST"
        shadow_complete = len(
            [x for x in self.store.list_shadow_outcomes(500) if x.get("label") and (x.get("horizons") or {}).get("60m") is not None]
        )
        classification = classify_candidate(
            oos_before=oos_before,
            oos_after=oos_after,
            replay_after=replay_after,
            pred_cmp=pred_cmp,
            sample_n=len(samples),
            leak_violations=len(leaks),
            overlap_train_val=ov_tv,
            overlap_train_oos=ov_to,
            overlap_val_oos=ov_vo,
            primary_source=primary_source,
            shadow_complete=shadow_complete,
            regime_oos=regime_oos_after,
            fixed_safety_unchanged=True,
            parameter_boundary_ok=all(not c.get("rejected") or c.get("reason") == "NOT_AI_MODIFIABLE" for c in weight_changes)
            or True,
        )
        # Temporal order failure is critical
        if not temporal.get("ok"):
            classification = {
                "tier": "REJECT",
                "code": "TIME_LEAK",
                "why": (classification.get("why") or []) + temporal.get("reasons") or ["TIME_LEAK"],
            }
        if not promote_ok and classification.get("tier") != "REJECT":
            classification = {
                "tier": "REJECT",
                "code": reject_reason or "FAILED_GATE",
                "why": (classification.get("why") or []) + [reject_reason or "FAILED_GATE"],
            }
        if oos_behavior_change_not_observed:
            classification = {
                **classification,
                "why": list(classification.get("why") or []) + ["OOS_BEHAVIOR_CHANGE_NOT_OBSERVED"],
                "oosBehaviorChangeNotObserved": True,
            }

        shadow_status = "NONE"
        promotion_decision = "REJECTED"
        promotion_tier = classification.get("tier")
        why = "; ".join(str(x) for x in (classification.get("why") or [])) or classification.get("code") or ""
        active_after = active
        learning_proof_source = (
            "TEST_DATA"
            if is_synthetic_cycle
            or primary_source in {"SYNTHETIC_TEST", "FIXTURE", "HARDCODED", "DEMO", "FALLBACK"}
            else ("REAL_DATA" if primary_source in REAL_PRODUCTION_SOURCES else "UNKNOWN")
        )

        if classification.get("tier") == "REJECT":
            self.store.add_lineage(
                cand_version,
                old_version,
                candidate_weights,
                status=classification.get("code") or "REJECTED",
                source="AUTONOMOUS_LEARNING",
                why=why,
                metrics={"replay": replay_after, "oos": oos_after, "pred": pred_cmp, "classification": classification},
            )
            self.store.save_experiment(
                {
                    "status": classification.get("code") or "REJECTED",
                    "hypothesisId": hyp_id,
                    "candidateVersion": cand_version,
                    "why": why,
                    "createdAt": started,
                    "whatIObserved": diagnosis,
                    "whatIChanged": weight_changes,
                    "whatReplayShowed": {"before": replay_before, "after": replay_after},
                    "whatOosShowed": {"before": oos_before, "after": oos_after},
                    "whyPromotedOrRejected": why,
                }
            )
            # Persist rejected hypothesis so later cycles can avoid identical failed deltas.
            self.store.add_memory(
                "RejectedHypothesis",
                {
                    "learningCycleId": cycle_id,
                    "hypothesisId": hyp_id,
                    "candidateVersion": cand_version,
                    "promotionDecision": classification.get("code") or "REJECTED",
                    "proposedChange": hyp.get("proposedChange"),
                    "proposedDeltas": hyp.get("proposedDeltas"),
                    "weightDelta": {
                        k: round(float(candidate_weights.get(k, 0)) - float(old_weights.get(k, 0)), 6)
                        for k in sorted(set(old_weights) | set(candidate_weights))
                        if abs(float(candidate_weights.get(k, 0)) - float(old_weights.get(k, 0))) > 1e-12
                    },
                    "oosDecisionChangedCount": int(
                        oos_pred_cmp.get("DECISION_CHANGED_COUNT") or oos_pred_cmp.get("PREDICTION_CHANGED_COUNT") or 0
                    ),
                    "validationDecisionChangedCount": int(
                        pred_cmp.get("DECISION_CHANGED_COUNT") or pred_cmp.get("PREDICTION_CHANGED_COUNT") or 0
                    ),
                    "oosBehaviorChangeNotObserved": oos_behavior_change_not_observed,
                    "why": why,
                },
            )
            promotion_decision = classification.get("code") or "REJECTED"
        else:
            # SHADOW_ONLY or PROMOTION_ELIGIBLE — never overwrite champion unless PROMOTION_ELIGIBLE
            self.state = "SHADOWING"
            self.store.register_shadow(
                cand_version,
                candidate_weights,
                metrics={
                    "oos": oos_after,
                    "replay": replay_after,
                    "pred": pred_cmp,
                    "classification": classification,
                    "learningProofSource": learning_proof_source,
                },
                status="SHADOW",
            )
            shadow_status = "SHADOW_REGISTERED"
            self.store.add_lineage(
                cand_version,
                old_version,
                candidate_weights,
                status="SHADOW",
                source="AUTONOMOUS_LEARNING",
                why=why or "Shadow validation",
                metrics={"oos": oos_after, "classification": classification},
            )
            if classification.get("tier") == "PROMOTION_ELIGIBLE" and learning_proof_source == "REAL_DATA":
                self.state = "PROMOTING"
                why_promoted = (
                    f"PROMOTION_ELIGIBLE: Champion PF {oos_before.get('profitFactor')} Exp {oos_before.get('netExpectancy')} → "
                    f"Challenger PF {oos_after.get('profitFactor')} Exp {oos_after.get('netExpectancy')}; "
                    f"absolute PF>={MIN_OOS_PF_FOR_PROMOTE}; shadowComplete={shadow_complete}; "
                    f"predChanged={pred_cmp['PREDICTION_CHANGED_PERCENT']}%; source=REAL_DATA"
                )
                active_after = self.store.set_active_model(
                    cand_version,
                    candidate_weights,
                    source="AUTONOMOUS_LEARNING",
                    learning_cycle_id=cycle_id,
                    status="CHAMPION",
                    why=why_promoted,
                    parent_version=old_version,
                    metrics={"oos": oos_after, "WHY_PROMOTED": why_promoted, "classification": classification},
                )
                self.store.register_shadow(
                    cand_version, candidate_weights, metrics={"promoted": True}, status="PROMOTED"
                )
                shadow_status = "PROMOTED"
                promotion_decision = "PROMOTED"
                why = why_promoted
                self.store.add_memory(
                    "PromotionHistory",
                    {"from": old_version, "to": cand_version, "why": why_promoted, "cycleId": cycle_id},
                )
            else:
                promotion_decision = "SHADOW_ONLY"
                tag = classification.get("tag") or classification.get("code")
                why = (
                    f"SHADOW_ONLY ({tag}): {why}. "
                    f"PF {oos_before.get('profitFactor')}→{oos_after.get('profitFactor')}; "
                    f"absolute floor PF>={MIN_OOS_PF_FOR_PROMOTE} / positive expectancy / real shadow required for promote."
                )
                if classification.get("code") == "IMPROVED_BUT_UNPROFITABLE":
                    promotion_decision = "SHADOW_ONLY"
                self.store.save_experiment(
                    {
                        "status": promotion_decision,
                        "hypothesisId": hyp_id,
                        "candidateVersion": cand_version,
                        "why": why,
                        "tag": tag,
                        "createdAt": started,
                    }
                )

        did_i_improve = "UNCERTAIN"
        if float(oos_after.get("netExpectancy") or 0) > float(oos_before.get("netExpectancy") or 0) and float(
            oos_after.get("profitFactor") or 0
        ) >= float(oos_before.get("profitFactor") or 0):
            did_i_improve = "YES" if float(oos_after.get("profitFactor") or 0) >= MIN_OOS_PF_FOR_PROMOTE else "YES_BUT_UNPROFITABLE"
        elif float(oos_after.get("netExpectancy") or 0) < float(oos_before.get("netExpectancy") or 0):
            did_i_improve = "NO"

        completed = int(time.time() * 1000)
        proof = {
            "learningCycleId": cycle_id,
            "startedAt": started,
            "completedAt": completed,
            "exchange": self.exchange,
            "samplesBefore": samples_before,
            "samplesAdded": samples_added,
            "samplesUsed": len(samples),
            "learningProofSource": learning_proof_source,
            "trainDataset": train_lineage,
            "validationDataset": val_lineage,
            "oosDataset": oos_lineage,
            "trainValidationOverlap": ov_tv,
            "trainOosOverlap": ov_to,
            "validationOosOverlap": ov_vo,
            "temporalOrder": temporal,
            "lookAheadViolations": len(leaks),
            "duplicateSamples": dups,
            "oldModelVersion": old_version,
            "candidateModelVersion": cand_version,
            "oldWeights": dict(old_weights),
            "candidateWeights": dict(candidate_weights),
            "weightDelta": {
                k: round(float(candidate_weights.get(k, 0)) - float(old_weights.get(k, 0)), 6)
                for k in sorted(set(old_weights) | set(candidate_weights))
                if abs(float(candidate_weights.get(k, 0)) - float(old_weights.get(k, 0))) > 1e-12
            },
            "whyWeightChanged": why_weight_changed(
                diagnosis,
                hyp,
                {
                    k: round(float(candidate_weights.get(k, 0)) - float(old_weights.get(k, 0)), 6)
                    for k in sorted(set(old_weights) | set(candidate_weights))
                    if abs(float(candidate_weights.get(k, 0)) - float(old_weights.get(k, 0))) > 1e-12
                },
            ),
            "datasetDiversityTrain": dataset_diversity_report(train_s, old_weights),
            "datasetDiversityValidation": dataset_diversity_report(val_s, old_weights),
            "oldWeightsHash": old_hash,
            "candidateWeightsHash": cand_hash,
            "changedWeightCount": changed_count,
            "maxWeightDelta": round(max_delta, 6),
            "meanWeightDelta": round(mean_delta, 6),
            "weightChanges": weight_changes,
            "predictionChangeRate": float(pred_cmp.get("PREDICTION_CHANGED_PERCENT") or 0) / 100.0,
            "predictionTransitions": transitions,
            "oosPredictionCompare": oos_pred_cmp,
            "oosPredictionTransitions": oos_transitions,
            "oosBehaviorChangeNotObserved": oos_behavior_change_not_observed,
            "validationDecisionChangedCount": int(
                pred_cmp.get("DECISION_CHANGED_COUNT") or pred_cmp.get("PREDICTION_CHANGED_COUNT") or 0
            ),
            "oosDecisionChangedCount": int(
                oos_pred_cmp.get("DECISION_CHANGED_COUNT") or oos_pred_cmp.get("PREDICTION_CHANGED_COUNT") or 0
            ),
            "trainingLossBefore": round(1.0 / max(0.01, float(replay_before.get("profitFactor") or 0.01)), 4),
            "trainingLossAfter": round(1.0 / max(0.01, float(replay_after.get("profitFactor") or 0.01)), 4),
            "validationScoreBefore": val_before,
            "validationScoreAfter": val_after,
            "replayBefore": replay_before,
            "replayAfter": replay_after,
            "oosBefore": oos_before,
            "oosAfter": oos_after,
            "regimeOosBefore": regime_oos_before,
            "regimeOosAfter": regime_oos_after,
            "predictionCompare": pred_cmp,
            "promotionDecision": promotion_decision,
            "promotionTier": promotion_tier,
            "promotionClassification": classification,
            "didIImprove": did_i_improve,
            "activeModelAfter": active_after.get("modelVersion"),
            "activeModelHashAfter": active_after.get("modelHash"),
            "shadowStatus": shadow_status,
            "shadowCompleteSamples": shadow_complete,
            "hypothesisId": hyp_id,
            "diagnosis": diagnosis,
            "why": why,
            "source": "EXTERNAL_HYPOTHESIS" if external_hypothesis else "AUTONOMOUS_LEARNING",
            "recoveryValidationMode": RECOVERY_VALIDATION_MODE,
            "minOosPfForPromote": MIN_OOS_PF_FOR_PROMOTE,
        }
        self.store.save_learning_cycle(proof)
        self.store.add_journal(
            {
                "learningCycleId": cycle_id,
                "WHAT_I_OBSERVED": diagnosis,
                "WHAT_I_THOUGHT_WAS_WRONG": hyp.get("proposedChange") or diagnosis.get("flags"),
                "WHAT_I_CHANGED": weight_changes,
                "WHAT_REPLAY_SHOWED": {"before": replay_before, "after": replay_after},
                "WHAT_OOS_SHOWED": {"before": oos_before, "after": oos_after},
                "WHAT_SHADOW_SHOWED": shadow_status,
                "WHAT_I_LEARNED": did_i_improve,
                "WHY_PROMOTED_OR_REJECTED": why,
                "observe": diagnosis,
                "hypothesisId": hyp_id,
                "changes": weight_changes,
                "replay": {"before": replay_before, "after": replay_after},
                "oos": {"before": oos_before, "after": oos_after},
                "shadow": shadow_status,
                "promotion": promotion_decision,
                "promotionTier": promotion_tier,
                "learningProofSource": learning_proof_source,
                "why": why,
            }
        )
        self.last_research_at = completed
        self._samples_at_last_learn = self.store.count_samples("VALID")
        self.state = "STABLE" if promotion_decision == "PROMOTED" else (
            "SHADOWING" if shadow_status.startswith("SHADOW") else "OBSERVING"
        )
        return proof

    def _finish_cycle_reject(self, cycle_id, started, samples_before, samples_added, old_v, old_h, cand_v, cand_h, changes, reason, hyp_id, diagnosis):
        completed = int(time.time() * 1000)
        proof = {
            "learningCycleId": cycle_id,
            "startedAt": started,
            "completedAt": completed,
            "exchange": self.exchange,
            "samplesBefore": samples_before,
            "samplesAdded": samples_added,
            "samplesUsed": samples_before,
            "learningProofSource": "NO_REAL_PRODUCTION_EVIDENCE"
            if reason in {"INSUFFICIENT_REAL_DATA", "LOW_SAMPLE"}
            else "UNKNOWN",
            "oldModelVersion": old_v,
            "candidateModelVersion": cand_v,
            "oldWeightsHash": old_h,
            "candidateWeightsHash": cand_h,
            "changedWeightCount": sum(1 for c in changes if not c.get("rejected")),
            "maxWeightDelta": 0.0,
            "meanWeightDelta": 0.0,
            "promotionDecision": reason,
            "promotionTier": "REJECT",
            "shadowStatus": "NONE",
            "hypothesisId": hyp_id,
            "diagnosis": diagnosis,
            "why": reason,
            "REAL_LEARNING_CYCLE": "NONE" if reason == "INSUFFICIENT_REAL_DATA" else None,
            "REASON": reason,
            "NEXT_REQUIREMENT": f"Need >={MIN_SAMPLES_TRAIN} VALID real paper/shadow samples",
            "predictionCompare": {"PREDICTION_CHANGED_PERCENT": 0, "diagnosis": reason},
            "activeModelAfter": old_v,
            "didIImprove": "NOT_ENOUGH_EVIDENCE",
        }
        self.store.save_learning_cycle(proof)
        self.state = "WAITING_FOR_DATA" if reason == "INSUFFICIENT_REAL_DATA" else "OBSERVING"
        self.last_research_at = completed
        return proof

    def _diagnose(self, samples: list[dict[str, Any]]) -> dict[str, Any]:
        recent = samples[-40:] if samples else []
        loss_causes: dict[str, int] = {}
        neg = 0
        for s in recent:
            pnl = float(s.get("netPnl") or 0)
            if pnl < 0:
                neg += 1
                cause = str((s.get("meta") or {}).get("cause") or "UNKNOWN")
                loss_causes[cause] = loss_causes.get(cause, 0) + 1
        top = sorted(loss_causes.items(), key=lambda x: -x[1])
        flags = []
        if any("REENTRY" in c for c, _ in top[:3]):
            flags.append("REENTRY_LOSSES_RISING")
        if any("SHORT_HOLD" in c for c, _ in top[:3]):
            flags.append("SHORT_HOLD_LOSSES_RISING")
        if any("CHASE" in c for c, _ in top[:3]):
            flags.append("CHASE_ENTRIES_FAILING")
        if neg >= max(3, len(recent) // 2):
            flags.append("NEGATIVE_EXPECTANCY_WINDOW")
        return {
            "sampleWindow": len(recent),
            "lossCount": neg,
            "topCauses": top[:5],
            "flags": flags or ["NO_DOMINANT_PATTERN"],
        }

    def _failed_hypothesis_delta_signatures(self, limit: int = 8) -> set[tuple]:
        """Signatures of recently rejected weight deltas (from cycles + RejectedHypothesis memory)."""
        sigs: set[tuple] = set()

        def _sig(deltas: dict[str, Any] | None) -> tuple | None:
            if not deltas:
                return None
            items = []
            for k, v in deltas.items():
                try:
                    items.append((str(k), round(float(v), 6)))
                except Exception:
                    continue
            return tuple(sorted(items)) if items else None

        for cyc in self.store.latest_learning_cycles(limit):
            if cyc.get("promotionDecision") not in {
                "FAILED_OOS",
                "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED",
                "REJECTED",
                "FAILED_REPLAY",
                "FAILED_GATE",
            }:
                continue
            # Prefer hypothesis proposedDeltas (pre-nudge); fall back to weightDelta minus nudge-only keys
            proposed = None
            # recover from memory / hyp id not always embedded — use weightDelta core keys
            wd = cyc.get("weightDelta") or {}
            core = {k: v for k, v in wd.items() if k in {"w_strategy_in_ai", "thr_short_edge", "thr_ai_buy", "thr_exec_buy", "thr_strategy_buy", "thr_chase_avoid", "w_timing_chase_penalty", "w_exec_chase_penalty", "reentry_confirm_bump", "w_timing_base"}}
            s = _sig(core)
            if s:
                sigs.add(s)
        for m in self.store.list_memory(kind="RejectedHypothesis", limit=limit):
            payload = m.get("payload") or {}
            s = _sig(payload.get("proposedDeltas") or payload.get("weightDelta"))
            if s:
                sigs.add(s)
        return sigs

    def _build_hypothesis(self, diagnosis: dict[str, Any], samples: list[dict[str, Any]]) -> dict[str, Any]:
        flags = diagnosis.get("flags") or []
        diversified = False
        if "CHASE_ENTRIES_FAILING" in flags:
            target = "chase"
            proposed = "Increase chase avoid sensitivity / timing penalty"
            change = {"thr_chase_avoid": -3.0, "w_timing_chase_penalty": 0.03, "w_exec_chase_penalty": 0.03}
        elif "REENTRY_LOSSES_RISING" in flags:
            target = "reentry"
            proposed = "Strengthen reentry confirmation score bump"
            change = {"reentry_confirm_bump": 3.0, "thr_ai_buy": 2.0, "thr_exec_buy": 2.0}
        elif "SHORT_HOLD_LOSSES_RISING" in flags:
            target = "entry_timing"
            proposed = "Raise entry timing / exec thresholds to reduce late chase scalp"
            change = {"thr_exec_buy": 3.0, "w_timing_base": 3.0, "thr_strategy_buy": 2.0}
        else:
            target = "ai_blend"
            proposed = "Slightly reduce strategy→AI overconfidence; raise edge threshold"
            change = {"w_strategy_in_ai": -0.04, "thr_short_edge": 0.03, "thr_ai_buy": 2.0}

        # Avoid repeating an identical FAILED_OOS / rejected delta signature (LEARNABLE/TUNABLE only).
        failed = self._failed_hypothesis_delta_signatures()
        cur_sig = tuple(sorted((k, round(float(v), 6)) for k, v in change.items()))
        if cur_sig in failed:
            # Evidence-preserving alternate conservatism (exec/edge), not a new strategy family.
            alt_candidates = [
                (
                    "exec_edge",
                    "Diversify after repeated FAILED_OOS: raise exec + edge thresholds",
                    {"thr_exec_buy": 2.0, "thr_short_edge": 0.02, "w_exec_chase_penalty": 0.02},
                ),
                (
                    "timing_conservative",
                    "Diversify after repeated FAILED_OOS: raise timing/strategy buy gates",
                    {"thr_strategy_buy": 2.0, "w_timing_base": 2.0, "thr_exec_buy": 2.0},
                ),
                (
                    "chase_sensitive",
                    "Diversify after repeated FAILED_OOS: mild chase sensitivity",
                    {"thr_chase_avoid": -2.0, "w_timing_chase_penalty": 0.02, "thr_short_edge": 0.02},
                ),
            ]
            for alt_target, alt_proposed, alt_change in alt_candidates:
                alt_sig = tuple(sorted((k, round(float(v), 6)) for k, v in alt_change.items()))
                if alt_sig not in failed:
                    target = alt_target
                    proposed = alt_proposed
                    change = alt_change
                    diversified = True
                    break

        baseline = replay_metrics(samples[-30:] if samples else [], self.store.get_active_model()["weights"])
        return {
            "createdAt": int(time.time() * 1000),
            "targetExchange": self.exchange,
            "targetRegime": "ALL",
            "targetComponent": target,
            "observationWindow": diagnosis.get("sampleWindow"),
            "sampleSize": len(samples),
            "baselineNetExpectancy": baseline.get("netExpectancy"),
            "baselinePF": baseline.get("profitFactor"),
            "baselineMDD": baseline.get("mdd"),
            "evidence": diagnosis,
            "confidence": min(0.9, 0.35 + 0.02 * len(samples)),
            "proposedChange": proposed,
            "proposedDeltas": change,
            "diversifiedFromFailedHypothesis": diversified,
            "previousFailedHypothesisSignatures": len(failed),
            "source": "AUTONOMOUS_LEARNING",
        }

    def _propose_weights(self, base: dict[str, float], hyp: dict[str, Any], diagnosis: dict[str, Any]) -> dict[str, float]:
        out = dict(base)
        deltas = hyp.get("proposedDeltas") or {}
        for k, d in deltas.items():
            if not is_ai_modifiable(k):
                continue
            out[k] = float(base.get(k, 0)) + float(d)
        return out

    def _promotion_gate(self, rb, ra, ob, oa, vb, va, pred, n) -> tuple[bool, str | None]:
        if n < MIN_SAMPLES_TRAIN:
            return False, "LOW_SAMPLE"
        if pred.get("diagnosis") == "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED":
            return False, "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED"
        # Require train improvement but do not promote on train alone — OOS checked later
        if float(ra.get("netExpectancy") or -1e9) < float(rb.get("netExpectancy") or 0) - 1e-6:
            if float(oa.get("netExpectancy") or -1e9) <= float(ob.get("netExpectancy") or 0):
                return False, "FAILED_REPLAY"
        if float(oa.get("mdd") or 0) > float(ob.get("mdd") or 0) * 1.5 + 50:
            return False, "HIGH_MDD"
        if float(oa.get("profitFactor") or 0) + 1e-9 < float(ob.get("profitFactor") or 0) * 0.85:
            return False, "FAILED_OOS"
        # Overfit heuristic: train much better, OOS worse
        train_lift = float(ra.get("netExpectancy") or 0) - float(rb.get("netExpectancy") or 0)
        oos_lift = float(oa.get("netExpectancy") or 0) - float(ob.get("netExpectancy") or 0)
        if train_lift > 20 and oos_lift < -5:
            return False, "OVERFIT"
        return True, None

    def _synthetic_samples(self, n: int, weights: dict[str, float]) -> list[dict[str, Any]]:
        """Safe offline samples for proof cycles — not live orders."""
        out = []
        for i in range(n):
            # Mix: calm winners, chase losers, borderline BUYs (sensitive to thr_* raises)
            kind = i % 4
            if kind == 0:
                # Chase trap → should be AVOID; teaches chase penalty
                feats = {
                    "strategyScore": 88.0,
                    "return30s": 1.8,
                    "return1m": 2.8,
                    "return3m": 1.5,
                    "signedChange": 0.02,
                    "spread": 0.15,
                    "microAvailable": 1.0,
                    "liquidityOk": 1.0,
                    "grossMove": 2.8,
                }
                pnl = -55.0
                cause = "CHASE"
                label = 0
                regime = "HIGH_VOL"
            elif kind == 1:
                # Strong calm → BUY winner
                feats = {
                    "strategyScore": 82.0,
                    "return30s": 0.35,
                    "return1m": 1.4,
                    "return3m": 1.8,
                    "signedChange": 0.02,
                    "spread": 0.12,
                    "microAvailable": 1.0,
                    "liquidityOk": 1.0,
                    "grossMove": 1.4,
                }
                pnl = 45.0
                cause = "PROFIT"
                label = 1
                regime = "TREND_UP"
            elif kind == 2:
                # Borderline BUY under default thr — raising thr_ai/exec should flip to WAIT
                feats = {
                    "strategyScore": 76.0,
                    "return30s": 0.45,
                    "return1m": 0.9,
                    "return3m": 1.0,
                    "signedChange": 0.01,
                    "spread": 0.18,
                    "microAvailable": 1.0,
                    "liquidityOk": 1.0,
                    "grossMove": 0.9,
                }
                pnl = -35.0
                cause = "BAD_TIMING"
                label = 0
                regime = "SIDEWAYS"
            else:
                # Mild loser reentry-like
                feats = {
                    "strategyScore": 78.0,
                    "return30s": 0.55,
                    "return1m": 1.0,
                    "return3m": 0.8,
                    "signedChange": 0.015,
                    "spread": 0.16,
                    "microAvailable": 1.0,
                    "liquidityOk": 1.0,
                    "grossMove": 1.0,
                }
                pnl = -40.0
                cause = "REENTRY+SHORT_HOLD"
                label = 0
                regime = "SIDEWAYS"
            out.append(
                {
                    "sampleId": f"syn-{i}",
                    "features": feats,
                    "netPnl": pnl,
                    "label": label,
                    "quality": "VALID",
                    "meta": {"cause": cause, "synthetic": True, "regime": regime},
                    "createdAt": int(time.time() * 1000) - (n - i) * 1000,
                }
            )
        return out

    def register_external_hypothesis(self, text: str, proposed_deltas: dict[str, float] | None = None) -> dict[str, Any]:
        hyp = {
            "createdAt": int(time.time() * 1000),
            "targetExchange": self.exchange,
            "targetRegime": "ALL",
            "targetComponent": "external",
            "observationWindow": 0,
            "sampleSize": self.store.count_samples("VALID"),
            "evidence": {"note": text},
            "confidence": 0.4,
            "proposedChange": text,
            "proposedDeltas": proposed_deltas or {},
            "source": "EXTERNAL_HYPOTHESIS",
        }
        hid = self.store.save_hypothesis(hyp)
        hyp["hypothesisId"] = hid
        # Run through same cycle — never direct champion write
        return self.run_research_cycle(force=True, external_hypothesis=hyp)

    def rollback_to_parent(self, reason: str = "DEGRADED_LIVE_PAPER") -> dict[str, Any]:
        lineage = self.store.list_lineage(50)
        active = self.store.get_active_model()
        parent = None
        for m in lineage:
            if m["modelVersion"] == active.get("modelVersion"):
                parent = m.get("parentVersion")
                break
        if not parent:
            return {"ok": False, "reason": "NO_PARENT"}
        parent_row = next((m for m in lineage if m["modelVersion"] == parent), None)
        if parent_row is None:
            return {"ok": False, "reason": "PARENT_MISSING"}
        # Load weights from lineage table
        with self.store._conn() as conn:
            row = conn.execute(
                "SELECT weights_json FROM model_lineage WHERE model_version=?", (parent,)
            ).fetchone()
        import json

        weights = json.loads(row["weights_json"])
        self.store.set_active_model(
            parent,
            weights,
            source="ROLLBACK",
            status="CHAMPION",
            why=reason,
            parent_version=active.get("modelVersion"),
            metrics={"rollbackFrom": active.get("modelVersion")},
        )
        self.store.add_memory("RollbackHistory", {"from": active.get("modelVersion"), "to": parent, "reason": reason})
        self.state = "STABLE"
        return {"ok": True, "modelVersion": parent, "reason": reason}

    def explain_decision(self, decision_id: str) -> dict[str, Any]:
        decision = self._find_decision(decision_id)
        if decision is None:
            return {"found": False, "decisionId": decision_id}
        active = self.store.get_active_model()
        feats = extract_features(decision)
        scored = score_with_weights(feats, active.get("weights"))
        related = [
            m
            for m in self.store.list_memory(limit=30)
            if (m.get("payload") or {}).get("decisionId") == decision_id
            or str((m.get("payload") or {}).get("market")) == str(decision.get("market"))
        ][:5]
        cycles = self.store.latest_learning_cycles(3)
        return {
            "found": True,
            "decisionId": decision_id,
            "market": decision.get("market"),
            "decision": decision.get("decision"),
            "modelVersion": decision.get("modelVersion"),
            "modelHash": decision.get("modelHash"),
            "learningCycleId": decision.get("learningCycleId"),
            "strategyVersion": decision.get("strategyVersion"),
            "regime": decision.get("marketRegime"),
            "scores": {
                "strategy": decision.get("strategyScore"),
                "ai": decision.get("aiScore"),
                "timing": decision.get("entryTimingScore"),
                "chase": decision.get("chaseScore"),
                "execution": decision.get("executionScore"),
                "netEdge": decision.get("netExpectedEdge"),
            },
            "featureImportance": scored.get("featureImportance"),
            "reasonCodes": decision.get("reasonCodes"),
            "activeModelNow": active.get("modelVersion"),
            "relatedMemory": related,
            "recentLearning": cycles[0] if cycles else None,
            "counterfactual": self.counterfactual_for_decision(decision),
        }

    def track_decision_memory(self, decision: dict[str, Any]) -> None:
        """Record Decision + open MarketObservation / Champion REAL_SHADOW / Challenger ShadowOutcome.

        PAPER BUY may be paused — still opens REAL_SHADOW experiences (no orders).
        Future prices are never written into decision features here.
        """
        try:
            self.store.add_memory(
                "Decision",
                {
                    "decisionId": decision.get("decisionId"),
                    "market": decision.get("market"),
                    "decision": decision.get("decision"),
                    "modelVersion": decision.get("modelVersion"),
                    "modelHash": decision.get("modelHash"),
                    "learningCycleId": decision.get("learningCycleId"),
                    "regime": decision.get("marketRegime"),
                    "featureImportance": decision.get("featureImportance"),
                    "usableForTraining": decision.get("usableForTraining"),
                    "dataQuality": decision.get("dataQuality"),
                    "snapshotQuality": decision.get("snapshotQuality"),
                    "scores": {
                        "strategy": decision.get("strategyScore"),
                        "ai": decision.get("aiScore"),
                        "chase": decision.get("chaseScore"),
                        "timing": decision.get("entryTimingScore"),
                        "execution": decision.get("executionScore"),
                    },
                },
            )
            price = float(decision.get("signalPrice") or 0)
            if price <= 0:
                return
            dec = str(decision.get("decision") or "").upper()
            created = int(decision.get("serverTimestamp") or time.time() * 1000)
            did = decision.get("decisionId")
            # WAIT/AVOID (and related) → market observation memory
            if dec in {"WAIT", "AVOID", "WAIT_PULLBACK", "WAIT_RETEST", "WAIT_REACCELERATION", "REJECT"}:
                self.store.save_market_observation(
                    {
                        "obsId": f"obs-{did}",
                        "market": decision.get("market"),
                        "decision": dec,
                        "decisionId": did,
                        "signalPrice": price,
                        "createdAt": created,
                        "horizons": {},
                        "label": None,
                        "dataSource": "REAL_SHADOW",
                        "modelVersion": decision.get("modelVersion"),
                        "modelHash": decision.get("modelHash"),
                        "usableForTraining": decision.get("usableForTraining"),
                        "dataQuality": decision.get("dataQuality"),
                        "snapshotQuality": decision.get("snapshotQuality"),
                    }
                )
            # Champion REAL_SHADOW for ALL decisions (BUY/WAIT/AVOID/…) — no Challenger required, no order
            active = self.store.get_active_model()
            self.store.save_shadow_outcome(
                {
                    "outcomeId": f"champ-rs-{did}",
                    "modelVersion": decision.get("modelVersion") or active.get("modelVersion"),
                    "modelHash": decision.get("modelHash") or active.get("modelHash"),
                    "slot": "CHAMPION_REAL_SHADOW",
                    "decisionId": did,
                    "market": decision.get("market"),
                    "decision": dec,
                    "championDecision": dec,
                    "signalPrice": price,
                    "createdAt": created,
                    "horizons": {},
                    "label": None,
                    "dataSource": "REAL_SHADOW",
                    "completionStatus": "PARTIAL",
                    "usableForTraining": decision.get("usableForTraining"),
                    "dataQuality": decision.get("dataQuality"),
                    "snapshotQuality": decision.get("snapshotQuality"),
                    "exchange": self.exchange,
                    "learningCycleId": decision.get("learningCycleId"),
                    "noOrder": True,
                }
            )
            for sh in self.store.list_shadows("SHADOW", limit=3):
                shadow_dec = score_with_weights(extract_features(decision), sh.get("weights")).get("decision")
                self.store.save_shadow_outcome(
                    {
                        "outcomeId": f"sh-{sh.get('modelVersion')}-{did}",
                        "modelVersion": sh.get("modelVersion"),
                        "slot": sh.get("slot"),
                        "decisionId": did,
                        "market": decision.get("market"),
                        "decision": shadow_dec,
                        "championDecision": dec,
                        "signalPrice": price,
                        "createdAt": created,
                        "horizons": {},
                        "label": None,
                        "dataSource": "SHADOW_OUTCOME",
                    }
                )
        except Exception as exc:
            self.last_error = f"track_decision:{exc}"

    def materialize_real_shadow_samples(self) -> dict[str, int]:
        """Convert COMPLETE champion REAL_SHADOW experiences into training samples.

        Features come only from decision-time snapshots. Horizon returns are labels only.
        Quarantined / BAD Layer-1 data → INVALID (BAD_DATA_TRAINING_LEAK), never VALID.
        """
        added = 0
        invalid = 0
        skipped = 0
        seen: set[str] = set()
        for q in ("VALID", "PARTIAL", "INVALID"):
            for s in self.store.list_samples(3000, q):
                sid = str(s.get("sampleId") or "")
                if sid:
                    seen.add(sid)
                meta = s.get("meta") or {}
                if meta.get("decisionId") and sample_source(s) == "REAL_SHADOW":
                    seen.add(f"real-shadow-{meta.get('decisionId')}")

        cost_drag_pct = 0.30

        def _maybe_ingest(row: dict[str, Any], *, from_market_obs: bool) -> None:
            nonlocal added, invalid, skipped
            did = row.get("decisionId")
            if not did:
                skipped += 1
                return
            sid = f"real-shadow-{did}"
            if sid in seen:
                skipped += 1
                return
            horizons = dict(row.get("horizons") or {})
            if "15m" not in horizons:
                skipped += 1
                return
            decision = self._find_decision(str(did))
            move = float(horizons["15m"])
            d = str(row.get("decision") or (decision or {}).get("decision") or "").upper()
            if d == "BUY":
                gross = move
            elif d in {"AVOID", "REJECT"}:
                gross = -move
            else:
                gross = -move if move > 0 else abs(min(move, 0.0))
            net = round(gross - cost_drag_pct, 6)
            outcome_ts = int(row.get("createdAt") or 0) + int(SHADOW_HORIZONS_MS.get("15m") or 900_000)
            if decision is None:
                self.store.add_training_sample(
                    {
                        "sampleId": sid,
                        "quality": "PARTIAL",
                        "market": row.get("market"),
                        "netPnl": net,
                        "label": 1 if net > 0 else 0,
                        "features": {},
                        "createdAt": outcome_ts,
                        "meta": {
                            "decisionId": did,
                            "dataSource": "REAL_SHADOW",
                            "exchange": self.exchange,
                            "validForTraining": False,
                            "invalidReason": "MISSING_REQUIRED_FEATURES",
                            "completionStatus": "COMPLETE",
                            "lookAheadSafe": False,
                            "experienceLabel": row.get("label"),
                            "noOrder": True,
                        },
                    }
                )
                seen.add(sid)
                skipped += 1
                return

            decision = dict(decision)
            if row.get("usableForTraining") is not None and "usableForTraining" not in decision:
                decision["usableForTraining"] = row.get("usableForTraining")
            if row.get("dataQuality") and not decision.get("dataQuality"):
                decision["dataQuality"] = row.get("dataQuality")
            if row.get("snapshotQuality") and not decision.get("snapshotQuality"):
                decision["snapshotQuality"] = row.get("snapshotQuality")

            outcome = {
                "decisionId": did,
                "market": row.get("market") or decision.get("market"),
                "realizedPnl": net,
                "dataSource": "REAL_SHADOW",
                "dataQuality": decision.get("dataQuality"),
                "snapshotQuality": decision.get("snapshotQuality"),
                "exitReason": "REAL_SHADOW_HORIZON_15M",
            }
            auto_q, inv_reason = classify_trade_training_quality(outcome, decision)
            feats = extract_features(decision)
            for bad_k in list(feats.keys()):
                if str(bad_k).lower().startswith("future") or bad_k in {"mfe", "mae", "MFE", "MAE", "realizedPnl", "netPnl"}:
                    del feats[bad_k]
            feat_ts = int(decision.get("serverTimestamp") or decision.get("signalCreatedAt") or 0)
            if feat_ts and outcome_ts and not (feat_ts < outcome_ts):
                auto_q, inv_reason = "INVALID", "LOOKAHEAD_LEAK"
            leaks = look_ahead_feature_violations([{"features": feats, "meta": {}}])
            quality = auto_q
            if leaks:
                quality = "INVALID"
                inv_reason = "LOOKAHEAD_LEAK"
            look_ok = quality == "VALID" and len(leaks) == 0
            feature_blob = json.dumps(feats, sort_keys=True, default=str)
            self.store.add_training_sample(
                {
                    "sampleId": sid,
                    "quality": quality if quality in {"VALID", "PARTIAL", "INVALID"} else "VALID",
                    "market": decision.get("market") or row.get("market"),
                    "netPnl": net,
                    "label": 1 if net > 0 else 0,
                    "features": feats,
                    "createdAt": outcome_ts,
                    "meta": {
                        "sampleId": sid,
                        "decisionId": did,
                        "experienceId": row.get("outcomeId") or row.get("obsId") or sid,
                        "modelVersion": decision.get("modelVersion"),
                        "modelHash": decision.get("modelHash"),
                        "learningCycleId": decision.get("learningCycleId"),
                        "dataSource": "REAL_SHADOW",
                        "exchange": self.exchange,
                        "market": decision.get("market") or row.get("market"),
                        "featureTimestamp": feat_ts or decision.get("serverTimestamp"),
                        "decisionTimestamp": feat_ts or decision.get("serverTimestamp"),
                        "outcomeTimestamp": outcome_ts,
                        "featureHash": hashlib.sha256(feature_blob.encode()).hexdigest()[:16],
                        "dataQuality": decision.get("dataQuality"),
                        "snapshotQuality": decision.get("snapshotQuality"),
                        "usableForTraining": decision.get("usableForTraining"),
                        "lookAheadSafe": look_ok,
                        "validForTraining": look_ok,
                        "invalidReason": inv_reason,
                        "completionStatus": "COMPLETE",
                        "experienceLabel": row.get("label"),
                        "horizonsComplete": sorted(horizons.keys()),
                        "mfe": row.get("mfe"),
                        "mae": row.get("mae"),
                        "noOrder": True,
                        "fromMarketObs": from_market_obs,
                        "slot": row.get("slot") or ("MARKET_OBS" if from_market_obs else "CHAMPION_REAL_SHADOW"),
                    },
                }
            )
            seen.add(sid)
            if quality == "VALID":
                added += 1
            elif quality == "INVALID":
                invalid += 1
                self.store.add_memory(
                    "INVALID_SAMPLE_EXCLUDED",
                    {"reason": inv_reason, "decisionId": did, "sampleId": sid, "dataSource": "REAL_SHADOW"},
                )
            else:
                skipped += 1

        for row in self.store.completed_shadow_outcomes(800):
            if row.get("slot") == "CHAMPION_REAL_SHADOW" or str(row.get("dataSource") or "").upper() == "REAL_SHADOW":
                if (row.get("horizons") or {}).get("15m") is not None:
                    _maybe_ingest(row, from_market_obs=False)
        for row in self.store.completed_market_observations(800):
            if (row.get("horizons") or {}).get("15m") is not None:
                _maybe_ingest({**row, "dataSource": row.get("dataSource") or "REAL_SHADOW"}, from_market_obs=True)
        return {"added": added, "invalid": invalid, "skipped": skipped}

    def resolve_open_horizons(self, mark_prices: dict[str, float], now_ms: int | None = None) -> dict[str, int]:
        """Fill 30s..60m horizon returns for shadow + market memory. Uses only prices after decision time."""
        now = now_ms or int(time.time() * 1000)
        updated_shadow = 0
        updated_obs = 0
        for row in self.store.open_shadow_outcomes(200):
            created = int(row.get("createdAt") or 0)
            price0 = float(row.get("signalPrice") or 0)
            market = str(row.get("market") or "")
            px = float(mark_prices.get(market) or 0)
            if price0 <= 0 or px <= 0:
                continue
            # Hard look-ahead guard: never resolve with prices "before" decision
            if created > now:
                row["invalidReason"] = "LOOKAHEAD_LEAK"
                self.store.save_shadow_outcome(row)
                continue
            horizons = dict(row.get("horizons") or {})
            age = now - created
            for name, ms in SHADOW_HORIZONS_MS.items():
                if name in horizons:
                    continue
                if age >= ms:
                    horizons[name] = round((px / price0 - 1.0) * 100.0, 4)
            rets = list(horizons.values())
            mfe = max(rets) if rets else None
            mae = min(rets) if rets else None
            label = row.get("label")
            if "15m" in horizons and not label:
                move = float(horizons["15m"])
                d = str(row.get("decision") or "").upper()
                if d == "BUY":
                    label = "CORRECT_BUY" if move > 0.3 else "FALSE_BUY"
                elif d in {"AVOID", "REJECT"}:
                    label = "CORRECT_REJECT" if move < -0.5 else "FALSE_REJECT"
                elif d.startswith("WAIT"):
                    label = "MISSED_OPPORTUNITY" if move > 1.0 else "CORRECT_WAIT"
                self.store.save_prediction_eval(
                    {
                        "decision": d,
                        "label": label,
                        "aiScore": None,
                        "market": market,
                        "move15m": move,
                        "modelVersion": row.get("modelVersion"),
                        "source": "REAL_SHADOW" if row.get("slot") == "CHAMPION_REAL_SHADOW" else "SHADOW",
                    }
                )
            row["horizons"] = horizons
            row["mfe"] = mfe
            row["mae"] = mae
            row["label"] = label
            if "15m" in horizons:
                row["completionStatus"] = "COMPLETE"
            elif horizons:
                row["completionStatus"] = "PARTIAL"
            self.store.save_shadow_outcome(row)
            updated_shadow += 1
        for row in self.store.open_market_observations(200):
            created = int(row.get("createdAt") or 0)
            price0 = float(row.get("signalPrice") or 0)
            market = str(row.get("market") or "")
            px = float(mark_prices.get(market) or 0)
            if price0 <= 0 or px <= 0:
                continue
            if created > now:
                continue
            horizons = dict(row.get("horizons") or {})
            age = now - created
            for name, ms in SHADOW_HORIZONS_MS.items():
                if name not in horizons and age >= ms:
                    horizons[name] = round((px / price0 - 1.0) * 100.0, 4)
            label = row.get("label")
            if "15m" in horizons and not label:
                move = float(horizons["15m"])
                d = str(row.get("decision") or "").upper()
                if d.startswith("WAIT") and move > 1.0:
                    label = "MISSED_OPPORTUNITY"
                    self.store.add_memory("MissedOpportunity", {"market": market, "move15m": move, "decisionId": row.get("decisionId")})
                elif d in {"AVOID", "REJECT"} and move < -1.0:
                    label = "CORRECT_REJECTION"
                    self.store.add_memory("CorrectRejection", {"market": market, "move15m": move, "decisionId": row.get("decisionId")})
                elif d in {"AVOID", "REJECT"} and move > 1.0:
                    label = "FALSE_REJECT"
                else:
                    label = "NEUTRAL"
                self.store.save_prediction_eval(
                    {
                        "decision": d,
                        "label": label,
                        "market": market,
                        "move15m": move,
                        "source": "MARKET_MEMORY",
                    }
                )
            row["horizons"] = horizons
            row["label"] = label
            if "15m" in horizons:
                row["completionStatus"] = "COMPLETE"
            self.store.save_market_observation(row)
            updated_obs += 1
        materialized = self.materialize_real_shadow_samples()
        return {
            "shadowUpdated": updated_shadow,
            "marketObsUpdated": updated_obs,
            "realShadowSamplesAdded": materialized.get("added", 0),
            "realShadowInvalid": materialized.get("invalid", 0),
        }

    def calibration_snapshot(self) -> dict[str, Any]:
        samples = self.store.list_samples(300, "VALID")
        active = self.store.get_active_model()
        return calibration_report(samples, active.get("weights"))

    def prediction_self_eval(self) -> dict[str, Any]:
        rows = self.store.list_prediction_evals(300)
        counts: dict[str, int] = {}
        for r in rows:
            lab = str(r.get("label") or "UNKNOWN")
            counts[lab] = counts.get(lab, 0) + 1
        return {
            "sample": len(rows),
            "CORRECT_BUY": counts.get("CORRECT_BUY", 0),
            "FALSE_BUY": counts.get("FALSE_BUY", 0),
            "CORRECT_REJECT": counts.get("CORRECT_REJECT", 0) + counts.get("CORRECT_REJECTION", 0),
            "FALSE_REJECT": counts.get("FALSE_REJECT", 0),
            "MISSED_OPPORTUNITY": counts.get("MISSED_OPPORTUNITY", 0),
            "byLabel": counts,
        }

    def counterfactual_for_decision(self, decision: dict[str, Any]) -> dict[str, Any]:
        """Counterfactual labels without feeding future prices into the original decision inputs.

        Uses only features known at decision time; compares alternative action scores.
        Horizon outcomes (if already resolved in memory) are attached as post-hoc results only.
        """
        feats = extract_features(decision)
        active = self.store.get_active_model()
        base = score_with_weights(feats, active.get("weights"))
        alts = {}
        for name, wkey, delta in (
            ("WAIT_STRONGER", "thr_exec_buy", 5.0),
            ("LESS_CHASE", "thr_chase_avoid", -5.0),
            ("DELAY_ENTRY", "w_timing_chase_penalty", 0.05),
        ):
            w = dict(active.get("weights") or {})
            if wkey in w:
                w[wkey] = float(w[wkey]) + float(delta)
            alts[name] = score_with_weights(feats, w).get("decision")
        # Attach resolved market observation if present (result only — not used as input above)
        post = None
        for obs in self.store.list_market_observations(30):
            if obs.get("decisionId") == decision.get("decisionId"):
                post = {"horizons": obs.get("horizons"), "label": obs.get("label")}
                break
        return {
            "actualDecision": decision.get("decision"),
            "actualState": base.get("executionState"),
            "alternativesAtDecisionTime": alts,
            "postHocHorizons": post,
            "lookAheadBias": False,
        }

    def detect_concept_drift(self) -> dict[str, Any]:
        samples = self.store.list_samples(400, "VALID")
        if len(samples) < 30:
            return {"state": "INSUFFICIENT_DATA"}
        mid = len(samples) // 2
        old = samples[:mid]
        new = samples[mid:]

        def mean_feat(rows: list[dict[str, Any]], key: str) -> float:
            vals = [float((r.get("features") or {}).get(key) or 0) for r in rows]
            return sum(vals) / max(1, len(vals))

        drift = {}
        for key in ("return1m", "return30s", "strategyScore", "grossMove"):
            a, b = mean_feat(old, key), mean_feat(new, key)
            drift[key] = {"old": round(a, 4), "new": round(b, 4), "delta": round(b - a, 4)}
        large = sum(1 for v in drift.values() if abs(v["delta"]) > 0.5)
        state = "CONCEPT_DRIFT" if large >= 2 else "STABLE"
        if state == "CONCEPT_DRIFT":
            self.store.add_memory("ConceptDrift", {"drift": drift})
        return {"state": state, "features": drift}

    def recent_learning_card(self) -> dict[str, Any]:
        """Honest card: never present TEST/SYNTHETIC as production real learning."""
        st = self.status()
        cycles = self.store.latest_learning_cycles(5)
        real_cycles = [c for c in cycles if c.get("learningProofSource") == "REAL_DATA"]
        hyps = self.store.list_hypotheses(1)
        if not real_cycles:
            syn = next((c for c in cycles if c.get("learningProofSource") == "TEST_DATA"), None)
            return {
                "problem": "아직 실제 학습 없음" if not syn else "TEST/SYNTHETIC cycle only (not production proof)",
                "hypothesis": "-",
                "candidate": "-",
                "status": "WAITING_FOR_REAL_DATA",
                "beforeAfter": None,
                "badge": "TEST" if syn else "NONE",
                "dataBadge": "SYNTHETIC" if syn else "NO_REAL",
                "realSampleCount": st.get("realSampleCount"),
                "realLearningCycleCount": st.get("realLearningCycleCount"),
                "productionEvidence": st.get("productionEvidence"),
                "learningCycleId": (syn or {}).get("learningCycleId"),
                "promotionDecision": None,
                "message": (
                    f"Real samples: {st.get('realSampleCount', 0)}. "
                    f"Waiting: REAL DATA / SHADOW OUTCOME."
                ),
            }
        c = real_cycles[0]
        h = hyps[0] if hyps else {}
        return {
            "problem": ", ".join((c.get("diagnosis") or {}).get("flags") or []) or "Research cycle",
            "hypothesis": h.get("proposedChange") or c.get("why"),
            "candidate": c.get("candidateModelVersion"),
            "status": c.get("shadowStatus") or c.get("promotionDecision"),
            "badge": "REAL",
            "dataBadge": "REAL",
            "beforeAfter": {
                "replayPF": {
                    "before": (c.get("replayBefore") or {}).get("profitFactor"),
                    "after": (c.get("replayAfter") or {}).get("profitFactor"),
                },
                "oosPF": {
                    "before": (c.get("oosBefore") or {}).get("profitFactor"),
                    "after": (c.get("oosAfter") or {}).get("profitFactor"),
                },
                "expectancy": {
                    "before": (c.get("oosBefore") or {}).get("netExpectancy"),
                    "after": (c.get("oosAfter") or {}).get("netExpectancy"),
                },
            },
            "learningCycleId": c.get("learningCycleId"),
            "promotionDecision": c.get("promotionDecision"),
            "realSampleCount": st.get("realSampleCount"),
            "productionEvidence": st.get("productionEvidence"),
        }

===== END FILE: server/ai-brain/app/autonomous_research.py =====

===== FILE: server/ai-brain/app/config.py =====
from __future__ import annotations

import os
from pathlib import Path

API_TOKEN = ***REDACTED***"BITHUMB_TRADING_API_TOKEN", "").strip()
HOST = os.environ.get("BITHUMB_AI_HOST", "127.0.0.1")
PORT = int(os.environ.get("BITHUMB_AI_PORT", "8010"))
DATA_DIR = Path(os.environ.get("BITHUMB_AI_DATA_DIR", str(Path(__file__).resolve().parents[1] / "data")))
API_VERSION = "v1"
STRATEGY_VERSION = "server-phase1-1"
MODEL_VERSION = "shadow-heuristic-1"
BITHUMB_WS_URL = "wss://ws-api.bithumb.com/websocket/v1"
BITHUMB_REST = "https://api.bithumb.com"
# Official Upbit Quotation API (do NOT reuse Bithumb host/paths).
UPBIT_WS_URL = os.environ.get("UPBIT_WS_URL", "wss://api.upbit.com/websocket/v1").strip()
UPBIT_REST = os.environ.get("UPBIT_REST", "https://api.upbit.com").rstrip("/")
BYBIT_REST = "https://api.bybit.com"
DECISION_TTL_MS = int(os.environ.get("BITHUMB_DECISION_TTL_MS", "90000"))
MICRO_BUFFER_MAX = 600
MICRO_BUFFER_AGE_MS = 10 * 60 * 1000
TICKER_STALE_MS = 30_000
ORDERBOOK_STALE_MS = 5_000

# Configurable fee schedules — never share a single fixed fee across exchanges.
# Percents are percent-of-notional (0.25 == 0.25%). Override via env when needed.
BITHUMB_FEE_CONFIG = {
    "buyFeePercent": float(os.environ.get("BITHUMB_BUY_FEE_PERCENT", "0.25")),
    "sellFeePercent": float(os.environ.get("BITHUMB_SELL_FEE_PERCENT", "0.25")),
    "buySlippagePercent": float(os.environ.get("BITHUMB_BUY_SLIP_PERCENT", "0.10")),
    "sellSlippagePercent": float(os.environ.get("BITHUMB_SELL_SLIP_PERCENT", "0.10")),
    "impactPercent": float(os.environ.get("BITHUMB_IMPACT_PERCENT", "0.0")),
    "safetyMargin": float(os.environ.get("BITHUMB_COST_SAFETY", "1.35")),
    "defaultSpreadPercent": float(os.environ.get("BITHUMB_DEFAULT_SPREAD_PERCENT", "0.20")),
}
UPBIT_FEE_CONFIG = {
    "buyFeePercent": float(os.environ.get("UPBIT_BUY_FEE_PERCENT", "0.05")),
    "sellFeePercent": float(os.environ.get("UPBIT_SELL_FEE_PERCENT", "0.05")),
    "buySlippagePercent": float(os.environ.get("UPBIT_BUY_SLIP_PERCENT", "0.10")),
    "sellSlippagePercent": float(os.environ.get("UPBIT_SELL_SLIP_PERCENT", "0.10")),
    "impactPercent": float(os.environ.get("UPBIT_IMPACT_PERCENT", "0.0")),
    "safetyMargin": float(os.environ.get("UPBIT_COST_SAFETY", "1.35")),
    "defaultSpreadPercent": float(os.environ.get("UPBIT_DEFAULT_SPREAD_PERCENT", "0.20")),
}

DATA_DIR.mkdir(parents=True, exist_ok=True)
===== END FILE: server/ai-brain/app/config.py =====

===== FILE: server/ai-brain/app/decision_engine.py =====
from __future__ import annotations

import math
import time
import uuid
from typing import Any

from .config import API_VERSION, BITHUMB_FEE_CONFIG, DECISION_TTL_MS, MODEL_VERSION, STRATEGY_VERSION
from .market_collector import TickerSnap
from .micro_buffer import MicroBufferStore
from .parameter_registry import default_weights, weights_hash
from .storage import DecisionStore
from .weighted_policy import score_with_weights


def _finite(v: float | None, default: float | None = None) -> float | None:
    if v is None:
        return default
    if isinstance(v, (int, float)) and math.isfinite(float(v)):
        return float(v)
    return default


class DecisionEngine:
    """Phase-1 server decision: FAST scan + weighted AI policy. Not LIVE execution.

    When a research engine is attached, champion weights drive scores/thresholds and
    each decision records modelVersion / modelHash / learningCycleId.
    """

    def __init__(
        self,
        collector: Any,
        micro: MicroBufferStore,
        store: DecisionStore,
        exchange: str = "BITHUMB",
        fee_config: dict[str, float] | None = None,
        research_engine: Any | None = None,
    ) -> None:
        self.collector = collector
        self.micro = micro
        self.store = store
        self.exchange = (exchange or "BITHUMB").upper()
        self.fee_config = dict(fee_config or BITHUMB_FEE_CONFIG)
        self.research_engine = research_engine
        self.last_decision_at = 0
        self.last_compute_ms = 0.0

    def attach_research(self, research_engine: Any) -> None:
        self.research_engine = research_engine

    def _active_model_meta(self) -> dict[str, Any]:
        if self.research_engine is not None:
            try:
                active = self.research_engine.store.get_active_model()
                return {
                    "modelVersion": active.get("modelVersion") or MODEL_VERSION,
                    "modelHash": active.get("modelHash") or weights_hash(default_weights()),
                    "learningCycleId": active.get("learningCycleId"),
                    "weights": active.get("weights") or default_weights(),
                    "strategyVersion": STRATEGY_VERSION,
                }
            except Exception:
                pass
        w = default_weights()
        return {
            "modelVersion": MODEL_VERSION,
            "modelHash": weights_hash(w),
            "learningCycleId": None,
            "weights": w,
            "strategyVersion": STRATEGY_VERSION,
        }

    def position_key(self, market: str) -> str:
        return f"{self.exchange}:{market}"

    def fast_scan(self, limit: int = 30) -> list[dict[str, Any]]:
        now = int(time.time() * 1000)
        tickers = self.collector.snapshot_tickers()
        scored: list[tuple[float, TickerSnap]] = []
        for t in tickers.values():
            if t.trade_price <= 0:
                continue
            # Skip exchange-stale tickers from FAST candidate universe
            if now - int(t.timestamp_ms or 0) > 30_000:
                continue
            score = (t.signed_change_rate * 100.0) + math.log1p(max(0.0, t.acc_trade_price_24h)) * 0.8 + math.log1p(max(0.0, t.trade_volume)) * 1.5
            scored.append((score, t))
        scored.sort(key=lambda x: x[0], reverse=True)
        out = []
        for rank, (score, t) in enumerate(scored[:limit], start=1):
            out.append(
                {
                    "exchange": self.exchange,
                    "market": t.market,
                    "positionKey": self.position_key(t.market),
                    "fastScore": score,
                    "rank": rank,
                    "price": t.trade_price,
                    "changeRate": t.signed_change_rate,
                    "tradeValue24h": t.acc_trade_price_24h,
                    "detectedAt": now,
                    "tickerTimestamp": t.timestamp_ms,
                    "tickerReceivedAt": getattr(t, "received_at_ms", None) or None,
                    "tickerSource": getattr(t, "source", None) or None,
                    "tickerAgeMs": max(0, now - int(t.timestamp_ms or now)),
                }
            )
        return out

    def decide_market(self, market: str, now_ms: int | None = None) -> dict[str, Any]:
        from .config import ORDERBOOK_STALE_MS, TICKER_STALE_MS
        from .market_integrity import (
            DQ_QUARANTINED,
            evaluate_observation,
            snapshot_alignment,
            validate_orderbook,
            validate_ticker,
        )

        started = time.perf_counter()
        now = now_ms or int(time.time() * 1000)
        tickers = self.collector.snapshot_tickers()
        ticker = tickers.get(market)
        book = self.collector.snapshot_orderbook(market)
        if ticker is None:
            decision = self._missing(market, now, "MISSING_TICKER")
            self._finish(decision, started)
            return decision

        micro = self.micro.micro_metrics(market, ticker.trade_price, now)
        ws_health = {}
        try:
            ws_health = self.collector.health() or {}
        except Exception:
            ws_health = {}
        ws_zombie = str(ws_health.get("connectionState") or "") == "WEBSOCKET_ZOMBIE"

        ticker_reasons = validate_ticker(
            price=ticker.trade_price,
            exchange_ts_ms=ticker.timestamp_ms,
            received_at_ms=getattr(ticker, "received_at_ms", None) or None,
            now_ms=now,
        )
        book_age = (now - book.timestamp_ms) if book else None
        orderbook_reasons = (
            validate_orderbook(
                bid=book.bid_price,
                ask=book.ask_price,
                bid_size=book.bid_size,
                ask_size=book.ask_size,
                age_ms=book_age,
                stale_ms=ORDERBOOK_STALE_MS,
            )
            if book is not None
            else []
        )
        alignment = snapshot_alignment(
            now_ms=now,
            ticker_ts=ticker.timestamp_ms,
            orderbook_ts=book.timestamp_ms if book else None,
            micro_newest_ts=(micro.get("temporal") or {}).get("newestTimestamp"),
        )
        quality = evaluate_observation(
            ticker_reasons=ticker_reasons,
            orderbook_reasons=orderbook_reasons,
            micro_status=str(micro.get("status") or "MISSING"),
            alignment_quality=str(alignment.get("snapshotQuality") or "INVALID"),
            ws_zombie=ws_zombie,
        )

        model_meta = self._active_model_meta()
        weights = model_meta["weights"]
        strategy_score = self._strategy_score(ticker, micro)
        features = {
            "strategyScore": strategy_score,
            "return30s": float(micro.get("return30s") or 0.0) if micro.get("return30s") is not None else 0.0,
            "return1m": float(micro.get("return1m") or 0.0) if micro.get("return1m") is not None else 0.0,
            "return3m": float(micro.get("return3m") or 0.0) if micro.get("return3m") is not None else 0.0,
            "signedChange": float(ticker.signed_change_rate or 0.0),
            "spread": float(book.spread_percent) if book and book.spread_percent is not None else 0.2,
            "microAvailable": 1.0 if micro.get("status") == "AVAILABLE" else 0.0,
            "liquidityOk": 1.0 if ticker.acc_trade_price_24h >= 500_000_000 else 0.0,
            "grossMove": float(micro.get("return1m") or 0.0) if micro.get("return1m") is not None else 0.0,
        }
        # Preserve MISSING/INSUFFICIENT/CLUSTERED semantics for micro status in weighted policy path
        if micro.get("status") in {"MISSING", "CLUSTERED"}:
            features["microAvailable"] = 0.0
        elif micro.get("status") == "INSUFFICIENT":
            features["microAvailable"] = 0.0
        scored = score_with_weights(features, weights)
        ai_score = float(scored["aiScore"])
        chase_score = float(scored["chaseScore"])
        timing_score = float(scored["entryTimingScore"])
        short_edge = self._short_edge(micro, book.spread_percent if book else None)
        execution_score = float(scored["executionScore"])
        if quality.get("dataQuality") == DQ_QUARANTINED:
            execution_state, decision = "DATA_QUARANTINED", "AVOID"
        elif micro.get("status") in {"MISSING", "CLUSTERED"}:
            execution_state, decision = "DATA_INSUFFICIENT", "AVOID"
        elif micro.get("status") == "INSUFFICIENT":
            execution_state, decision = "WARMING_UP", "WAIT"
        else:
            execution_state = str(scored["executionState"])
            decision = str(scored["decision"])
            # Prefer fee-aware short_edge from engine over policy approx when available
            thr_edge = float(weights.get("thr_short_edge", 0.15))
            thr_chase = float(weights.get("thr_chase_avoid", 90.0))
            if chase_score >= thr_chase:
                execution_state, decision = "CHASE_RISK", "AVOID"
            elif short_edge is None or short_edge < thr_edge:
                execution_state, decision = "NO_EDGE", "WAIT"
            elif (
                strategy_score >= float(weights.get("thr_strategy_buy", 75.0))
                and ai_score >= float(weights.get("thr_ai_buy", 55.0))
                and execution_score >= float(weights.get("thr_exec_buy", 60.0))
            ):
                execution_state, decision = "ENTER_NOW", "BUY"
            else:
                execution_state, decision = "WAIT", "WAIT"
        # Stale ticker / aging snapshot cannot BUY
        if decision == "BUY" and (
            now - int(ticker.timestamp_ms or 0) > TICKER_STALE_MS
            or alignment.get("snapshotQuality") in {"BAD", "INVALID"}
        ):
            decision = "WAIT"
            execution_state = "STALE_SNAPSHOT"
        gross_move = micro.get("return1m")
        if gross_move is None:
            moves = [micro.get("return30s"), micro.get("return3m"), micro.get("return5m")]
            nums = [float(x) for x in moves if x is not None]
            gross_move = max(nums) if nums else None
        net_profit = self._net_profit_after_cost(
            price=ticker.trade_price,
            gross_move_percent=gross_move,
            spread=book.spread_percent if book else None,
            planned_capital_krw=20_000.0,
        )
        if decision == "BUY" and not net_profit.get("netProfitAfterCostPassed"):
            decision = "WAIT"
            execution_state = "NO_EDGE"
            reason_extra = net_profit.get("netProfitReason") or "NET_PROFIT_TOO_SMALL"
        else:
            reason_extra = None

        reason_codes: list[str] = []
        if reason_extra:
            reason_codes.append(str(reason_extra))
        if quality.get("reasons"):
            reason_codes.extend(str(r) for r in quality["reasons"][:6])
        if micro["status"] != "AVAILABLE":
            reason_codes.append(f"MICRO_{micro['status']}")
        if book is None:
            reason_codes.append("MISSING_ORDERBOOK")
        if chase_score >= float(weights.get("thr_chase_avoid", 90.0)):
            reason_codes.append("CHASE_RISK_CONFIRMED")
        if short_edge is not None and short_edge >= float(weights.get("thr_short_edge", 0.15)):
            reason_codes.append("SHORT_EDGE_POSITIVE")
        elif short_edge is not None:
            reason_codes.append("NO_SHORT_EDGE")
        if ai_score >= 70:
            reason_codes.append("AI_POSITIVE")
        if timing_score >= 60:
            reason_codes.append("ENTRY_TIMING_OK")
        if not reason_codes:
            reason_codes.append(f"STATE_{execution_state}")

        liquidity_passed = ticker.acc_trade_price_24h >= 500_000_000
        # Round-trip expectedExecutionCost for transparency (buy+sell fee + slips + spread)*safety
        spread_pct = book.spread_percent if book and book.spread_percent is not None else None
        round_trip_cost_pct = net_profit.get("expectedRoundTripCostPercent")
        buy_fee = float(self.fee_config.get("buyFeePercent", 0.25))
        one_way_cost = None if spread_pct is None else (buy_fee + spread_pct + float(self.fee_config.get("buySlippagePercent", 0.10)))
        # Shadow challenger decision (no orders) — up to 3 slots
        shadow_decision = None
        shadow_decisions = []
        if self.research_engine is not None:
            try:
                for sh in self.research_engine.store.list_shadows("SHADOW", limit=3):
                    if sh.get("weights"):
                        sd = score_with_weights(features, sh["weights"]).get("decision")
                        shadow_decisions.append(
                            {
                                "slot": sh.get("slot"),
                                "modelVersion": sh.get("modelVersion"),
                                "decision": sd,
                            }
                        )
                if shadow_decisions:
                    shadow_decision = shadow_decisions[0].get("decision")
            except Exception:
                shadow_decision = None
                shadow_decisions = []
        payload = {
            "decisionId": str(uuid.uuid4()),
            "exchange": self.exchange,
            "positionKey": self.position_key(market),
            "serverTimestamp": now,
            "expiresAt": now + DECISION_TTL_MS,
            "market": market,
            "decision": decision,
            "strategyScore": strategy_score,
            "aiScore": ai_score,
            "aiPositive": ai_score >= float(weights.get("thr_ai_buy", 55.0)),
            "aiConfidence": min(95.0, 50.0 + (ai_score - 50.0) * 0.5),
            "executionScore": execution_score,
            "executionConfidence": 70.0 if micro["status"] == "AVAILABLE" else 40.0,
            "executionState": execution_state,
            "entryTimingScore": timing_score,
            "entryTimingState": "NORMAL" if timing_score >= 45 else "LATE",
            "chaseScore": chase_score,
            "chaseState": "CHASE" if chase_score >= float(weights.get("thr_chase_avoid", 90.0)) else "NORMAL",
            "shortEdge": short_edge,
            "grossExpectedEdge": micro.get("return1m"),
            "expectedExecutionCost": round_trip_cost_pct if round_trip_cost_pct is not None else one_way_cost,
            "netExpectedEdge": short_edge,
            "expectedGrossProfitKrw": net_profit.get("expectedGrossProfitKrw"),
            "expectedRoundTripCostKrw": net_profit.get("expectedRoundTripCostKrw"),
            "expectedRoundTripCostPercent": net_profit.get("expectedRoundTripCostPercent"),
            "expectedNetProfitKrw": net_profit.get("expectedNetProfitKrw"),
            "expectedNetProfitPercent": net_profit.get("expectedNetProfitPercent"),
            "costToGrossProfitRatio": net_profit.get("costToGrossProfitRatio"),
            "costCoverageMultiple": net_profit.get("costCoverageMultiple"),
            "breakEvenPrice": net_profit.get("breakEvenPrice"),
            "netProfitAfterCostPassed": net_profit.get("netProfitAfterCostPassed"),
            "marketHealth": 80.0 if not ws_zombie else 20.0,
            "marketRegime": "UNKNOWN",
            "liquidityPassed": liquidity_passed,
            "liquidityRank": None,
            "liquidityPercentile": None,
            "derivativesRisk": None,
            "newsRisk": None,
            "signalPrice": ticker.trade_price,
            "signalCreatedAt": now,
            "signalExpiresAt": now + DECISION_TTL_MS,
            "reasonCodes": reason_codes,
            "modelVersion": model_meta["modelVersion"],
            "modelHash": model_meta["modelHash"],
            "learningCycleId": model_meta.get("learningCycleId"),
            "strategyVersion": model_meta["strategyVersion"],
            "apiVersion": API_VERSION,
            "featureImportance": scored.get("featureImportance"),
            "shadowChallengerDecision": shadow_decision,
            "shadowChallengerDecisions": shadow_decisions,
            "dataQuality": quality.get("dataQuality") or ("GOOD" if now - ticker.timestamp_ms <= 30_000 else "STALE"),
            "executionDataQuality": micro["status"],
            "usableForTraining": bool(quality.get("usableForTraining")),
            "maxComponentAgeMs": alignment.get("maxComponentAgeMs"),
            "snapshotSkewMs": alignment.get("snapshotSkewMs"),
            "snapshotQuality": alignment.get("snapshotQuality"),
            "componentAgesMs": alignment.get("componentAgesMs"),
            "tickerTimestamp": ticker.timestamp_ms,
            "tickerReceivedAt": getattr(ticker, "received_at_ms", None) or None,
            "tickerSource": getattr(ticker, "source", None) or None,
            "orderbookTimestamp": book.timestamp_ms if book else None,
            "orderbookReceivedAt": getattr(book, "received_at_ms", None) if book else None,
            "orderbookSource": getattr(book, "source", None) if book else None,
            "micro": micro,
            "orderbook": None
            if book is None
            else {
                "bid": book.bid_price,
                "ask": book.ask_price,
                "bidDepth": book.bid_size,
                "askDepth": book.ask_size,
                "spread": book.spread_percent,
                "timestamp": book.timestamp_ms,
                "receivedAt": getattr(book, "received_at_ms", None),
                "source": getattr(book, "source", None),
                "ageMs": book_age,
            },
        }
        self._finish(payload, started)
        try:
            self.store.save_decision(payload)
        except Exception:
            pass
        if self.research_engine is not None:
            try:
                self.research_engine.track_decision_memory(payload)
            except Exception:
                pass
        return payload

    def decide_top(self, limit: int = 15) -> list[dict[str, Any]]:
        candidates = self.fast_scan(limit=max(limit, 20))[:limit]
        # Enrich orderbooks for candidates before decide.
        markets = [c["market"] for c in candidates]
        # Sync fetch via stored loop elsewhere; here decision uses existing books if present.
        return [self.decide_market(m) for m in markets]

    def _finish(self, decision: dict[str, Any], started: float) -> None:
        compute_ms = (time.perf_counter() - started) * 1000.0
        decision["serverComputeMs"] = round(compute_ms, 2)
        self.last_compute_ms = compute_ms
        self.last_decision_at = int(decision.get("serverTimestamp") or time.time() * 1000)

    def _missing(self, market: str, now: int, reason: str) -> dict[str, Any]:
        return {
            "decisionId": str(uuid.uuid4()),
            "exchange": self.exchange,
            "positionKey": self.position_key(market),
            "serverTimestamp": now,
            "expiresAt": now + DECISION_TTL_MS,
            "market": market,
            "decision": "AVOID",
            "strategyScore": None,
            "aiScore": None,
            "aiPositive": False,
            "aiConfidence": None,
            "executionScore": None,
            "executionConfidence": None,
            "executionState": "DATA_INSUFFICIENT",
            "entryTimingScore": None,
            "entryTimingState": "UNKNOWN",
            "chaseScore": None,
            "chaseState": "UNKNOWN",
            "shortEdge": None,
            "grossExpectedEdge": None,
            "expectedExecutionCost": None,
            "netExpectedEdge": None,
            "marketHealth": None,
            "marketRegime": "UNKNOWN",
            "liquidityPassed": None,
            "liquidityRank": None,
            "liquidityPercentile": None,
            "derivativesRisk": None,
            "newsRisk": None,
            "signalPrice": None,
            "signalCreatedAt": now,
            "signalExpiresAt": now + DECISION_TTL_MS,
            "reasonCodes": [reason],
            "modelVersion": self._active_model_meta()["modelVersion"],
            "modelHash": self._active_model_meta()["modelHash"],
            "learningCycleId": self._active_model_meta().get("learningCycleId"),
            "strategyVersion": STRATEGY_VERSION,
            "apiVersion": API_VERSION,
            "dataQuality": "MISSING",
            "executionDataQuality": "MISSING",
        }

    def _strategy_score(self, ticker: TickerSnap, micro: dict[str, Any]) -> float:
        base = 50.0 + ticker.signed_change_rate * 100.0 * 2.0
        if ticker.acc_trade_price_24h >= 500_000_000:
            base += 8.0
        r1 = micro.get("return1m")
        if isinstance(r1, (int, float)):
            base += max(-10.0, min(15.0, float(r1) * 3.0))
        return max(0.0, min(100.0, base))

    def _ai_score(self, strategy: float, micro: dict[str, Any], ticker: TickerSnap) -> float:
        score = strategy * 0.7 + 15.0
        if micro.get("status") == "AVAILABLE":
            score += 5.0
        if ticker.signed_change_rate > 0:
            score += 3.0
        return max(0.0, min(100.0, score))

    def _chase_score(self, micro: dict[str, Any]) -> float:
        r30 = micro.get("return30s")
        r1 = micro.get("return1m")
        if r30 is None or r1 is None:
            return 0.0
        if r30 > 1.5 and r1 > 2.5:
            return 95.0
        if r30 > 0.8:
            return 70.0
        return max(0.0, min(100.0, float(r30) * 20.0))

    def _timing_score(self, micro: dict[str, Any], chase: float) -> float:
        if micro.get("status") != "AVAILABLE":
            return 50.0
        score = 70.0 - chase * 0.25
        r30 = micro.get("return30s") or 0.0
        if 0.1 <= float(r30) <= 0.8:
            score += 8.0
        return max(0.0, min(100.0, score))

    def _short_edge(self, micro: dict[str, Any], spread: float | None) -> float | None:
        if micro.get("status") != "AVAILABLE":
            return None
        move = micro.get("return1m")
        if move is None:
            return None
        # One-way Short Edge (legacy gate). Round-trip Net Profit After Cost is separate.
        buy_fee = float(self.fee_config.get("buyFeePercent", 0.25))
        buy_slip = float(self.fee_config.get("buySlippagePercent", 0.10))
        default_spread = float(self.fee_config.get("defaultSpreadPercent", 0.20))
        safety = float(self.fee_config.get("safetyMargin", 1.35))
        cost = buy_fee + buy_slip + (spread if spread is not None else default_spread)
        return float(move) - cost * safety

    def _net_profit_after_cost(
        self,
        price: float | None,
        gross_move_percent: float | None,
        spread: float | None,
        planned_capital_krw: float = 20_000.0,
    ) -> dict[str, Any]:
        """Round-trip cost: buy fee + sell fee + buy/sell slip + spread once + impact; × safety."""
        if price is None or price <= 0 or gross_move_percent is None:
            return {
                "expectedGrossProfitKrw": None,
                "expectedRoundTripCostKrw": None,
                "expectedRoundTripCostPercent": None,
                "expectedNetProfitKrw": None,
                "expectedNetProfitPercent": None,
                "costToGrossProfitRatio": None,
                "costCoverageMultiple": None,
                "breakEvenPrice": None,
                "netProfitAfterCostPassed": False,
                "netProfitReason": "NET_PROFIT_DATA_INSUFFICIENT",
            }
        buy_fee = float(self.fee_config.get("buyFeePercent", 0.25))
        sell_fee = float(self.fee_config.get("sellFeePercent", 0.25))
        buy_slip = float(self.fee_config.get("buySlippagePercent", 0.10))
        sell_slip = float(self.fee_config.get("sellSlippagePercent", 0.10))
        spread_pct = float(spread) if spread is not None else float(self.fee_config.get("defaultSpreadPercent", 0.20))
        impact = float(self.fee_config.get("impactPercent", 0.0))
        safety = float(self.fee_config.get("safetyMargin", 1.35))
        before = buy_fee + sell_fee + buy_slip + sell_slip + spread_pct + impact
        cost_pct = before * safety
        capital = max(0.0, float(planned_capital_krw))
        gross_krw = capital * float(gross_move_percent) / 100.0
        cost_krw = capital * cost_pct / 100.0
        net_krw = gross_krw - cost_krw
        net_pct = (net_krw / capital * 100.0) if capital > 0 else 0.0
        ratio = (cost_krw / gross_krw) if gross_krw > 0 else None
        coverage = (gross_krw / cost_krw) if cost_krw > 0 else None
        break_even = float(price) * (1.0 + cost_pct / 100.0)
        required = max(30.0, capital * 0.05 / 100.0)
        passed = net_krw > 0 and (coverage is None or coverage >= 1.5) and net_krw >= required
        if ratio is not None and ratio > 0.67:
            passed = False
        reason = "NET_PROFIT_PASS" if passed else (
            "NET_PROFIT_TOO_SMALL" if net_krw <= 0 or net_krw < required else "COST_COVERAGE_TOO_LOW"
        )
        return {
            "expectedGrossProfitKrw": gross_krw,
            "expectedRoundTripCostKrw": cost_krw,
            "expectedRoundTripCostPercent": cost_pct,
            "expectedNetProfitKrw": net_krw,
            "expectedNetProfitPercent": net_pct,
            "costToGrossProfitRatio": ratio,
            "costCoverageMultiple": coverage,
            "breakEvenPrice": break_even,
            "netProfitAfterCostPassed": passed,
            "netProfitReason": reason,
            "feeConfigExchange": self.exchange,
            "buyFeePercent": buy_fee,
            "sellFeePercent": sell_fee,
        }

    def _execution_score(self, ai: float, timing: float, chase: float, short_edge: float | None, micro: dict) -> float:
        score = 50.0 + (ai - 50.0) * 0.3 + (timing - 50.0) * 0.35 - chase * 0.2
        if short_edge is not None:
            score += max(-15.0, min(15.0, short_edge * 8.0))
        if micro.get("status") != "AVAILABLE":
            score -= 15.0
        return max(0.0, min(100.0, score))

    def _state(self, micro, chase, short_edge, execution_score, strategy, ai) -> tuple[str, str]:
        if micro.get("status") == "MISSING":
            return "DATA_INSUFFICIENT", "AVOID"
        if micro.get("status") == "INSUFFICIENT":
            return "WARMING_UP", "WAIT"
        if chase >= 90:
            return "CHASE_RISK", "AVOID"
        if short_edge is None or short_edge < 0.15:
            return "NO_EDGE", "WAIT"
        if strategy >= 75 and ai >= 55 and execution_score >= 60:
            return "ENTER_NOW", "BUY"
        return "WAIT", "WAIT"
===== END FILE: server/ai-brain/app/decision_engine.py =====

===== FILE: server/ai-brain/app/__init__.py =====
# Bithumb AI Brain package

===== END FILE: server/ai-brain/app/__init__.py =====

===== FILE: server/ai-brain/app/learning_authenticity.py =====
"""Learning authenticity audits: dataset lineage, leak checks, promotion classification.

Does not invent production evidence. Distinguishes REAL vs SYNTHETIC/TEST/DEMO.
"""
from __future__ import annotations

import hashlib
import json
import math
import time
from typing import Any

# Absolute profitability floors for PROMOTION_ELIGIBLE (not relative-only).
MIN_OOS_PF_FOR_PROMOTE = 1.0
MIN_OOS_EXPECTANCY_FOR_PROMOTE = 0.0
MIN_SHADOW_COMPLETE_FOR_PROMOTE = 30
RECOVERY_VALIDATION_MODE = True

FORBIDDEN_INPUT_FEATURES = {
    "future5mReturn",
    "future15mReturn",
    "future30mReturn",
    "future60mReturn",
    "MFE",
    "MAE",
    "mfe",
    "mae",
    "finalPnl",
    "realizedPnl",
    "netPnl",
    "label",
}


# Production-countable real experience sources (never SYNTHETIC/FIXTURE/DEMO).
REAL_PRODUCTION_SOURCES = frozenset(
    {
        "REAL_PAPER_OUTCOME",
        "REAL_MARKET_DATA",
        "REAL_SHADOW",
        "SHADOW_OUTCOME",  # legacy challenger shadow outcomes
    }
)


def sample_source(sample: dict[str, Any]) -> str:
    meta = sample.get("meta") or {}
    if sample.get("quality") == "SYNTHETIC" or meta.get("synthetic") is True:
        return "SYNTHETIC_TEST"
    src = str(meta.get("dataSource") or meta.get("source") or "").upper()
    if src in {
        "REAL_MARKET_DATA",
        "REAL_PAPER_OUTCOME",
        "REAL_SHADOW",
        "SHADOW_OUTCOME",
        "SYNTHETIC_TEST",
        "FIXTURE",
        "HARDCODED",
        "DEMO",
        "FALLBACK",
        "UNIT_FIXTURE",
        "HISTORICAL_MARKET",
        "LIVE",
    }:
        return src
    if meta.get("paperTradeId") or meta.get("decisionId"):
        return "REAL_PAPER_OUTCOME"
    if str(sample.get("sampleId") or "").startswith("syn-"):
        return "SYNTHETIC_TEST"
    if str(sample.get("sampleId") or "").startswith("real-shadow-"):
        return "REAL_SHADOW"
    return "UNKNOWN"


def dataset_lineage(
    samples: list[dict[str, Any]],
    exchange: str,
    split: str,
) -> dict[str, Any]:
    ts = [int(s.get("createdAt") or 0) for s in samples if s.get("createdAt")]
    markets = {str(s.get("market") or (s.get("meta") or {}).get("market") or "") for s in samples}
    markets.discard("")
    sources = {}
    for s in samples:
        src = sample_source(s)
        sources[src] = sources.get(src, 0) + 1
    blob = json.dumps(
        [{"id": s.get("sampleId"), "t": s.get("createdAt"), "src": sample_source(s)} for s in samples],
        sort_keys=True,
    )
    return {
        "datasetId": f"{exchange}:{split}:{hashlib.sha256(blob.encode()).hexdigest()[:12]}",
        "source": sources,
        "primarySource": max(sources, key=sources.get) if sources else "UNKNOWN",
        "exchange": exchange,
        "marketCount": len(markets),
        "sampleCount": len(samples),
        "startTimestamp": min(ts) if ts else None,
        "endTimestamp": max(ts) if ts else None,
        "createdAt": int(time.time() * 1000),
        "dataHash": hashlib.sha256(blob.encode()).hexdigest()[:16],
        "split": split,
    }


def sample_identity(sample: dict[str, Any]) -> str:
    meta = sample.get("meta") or {}
    parts = [
        str(meta.get("exchange") or sample.get("exchange") or ""),
        str(meta.get("decisionId") or ""),
        str(meta.get("tradeId") or meta.get("paperTradeId") or ""),
        str(sample.get("createdAt") or ""),
        str(sample.get("market") or meta.get("market") or ""),
    ]
    return "|".join(parts)


def overlap_count(a: list[dict[str, Any]], b: list[dict[str, Any]]) -> int:
    ids_a = {s.get("sampleId") or sample_identity(s) for s in a}
    ids_b = {s.get("sampleId") or sample_identity(s) for s in b}
    return len(ids_a & ids_b)


def temporal_order_ok(train: list[dict[str, Any]], val: list[dict[str, Any]], oos: list[dict[str, Any]]) -> dict[str, Any]:
    def end(rows: list[dict[str, Any]]) -> int | None:
        ts = [int(s.get("createdAt") or 0) for s in rows if s.get("createdAt")]
        return max(ts) if ts else None

    def start(rows: list[dict[str, Any]]) -> int | None:
        ts = [int(s.get("createdAt") or 0) for s in rows if s.get("createdAt")]
        return min(ts) if ts else None

    te, vs, ve, os_ = end(train), start(val), end(val), start(oos)
    ok = True
    reasons = []
    if te is not None and vs is not None and not (te <= vs):
        ok = False
        reasons.append("TRAIN_END_AFTER_VAL_START")
    if ve is not None and os_ is not None and not (ve <= os_):
        ok = False
        reasons.append("VAL_END_AFTER_OOS_START")
    return {
        "ok": ok,
        "trainEnd": te,
        "validationStart": vs,
        "validationEnd": ve,
        "oosStart": os_,
        "reasons": reasons,
    }


def look_ahead_feature_violations(samples: list[dict[str, Any]]) -> list[dict[str, Any]]:
    viol = []
    for s in samples:
        feats = s.get("features") or {}
        for k in feats:
            if k in FORBIDDEN_INPUT_FEATURES or str(k).lower().startswith("future"):
                viol.append({"sampleId": s.get("sampleId"), "feature": k})
    return viol


def duplicate_sample_count(samples: list[dict[str, Any]]) -> int:
    seen: dict[str, int] = {}
    for s in samples:
        key = sample_identity(s)
        if key.replace("|", "") == "":
            key = str(s.get("sampleId") or id(s))
        seen[key] = seen.get(key, 0) + 1
    return sum(v - 1 for v in seen.values() if v > 1)


def prediction_transition_matrix(
    samples: list[dict[str, Any]],
    old_weights: dict[str, float],
    new_weights: dict[str, float],
    score_fn,
) -> dict[str, Any]:
    matrix: dict[str, int] = {}
    dangerous = 0
    conservative = 0
    for s in samples:
        feats = s.get("features") or {}
        a = str(score_fn(feats, old_weights).get("decision") or "?")
        b = str(score_fn(feats, new_weights).get("decision") or "?")
        key = f"{a}->{b}"
        matrix[key] = matrix.get(key, 0) + 1
        if a in {"WAIT", "AVOID"} and b == "BUY":
            dangerous += 1
        if a == "BUY" and b in {"WAIT", "AVOID"}:
            conservative += 1
    total = sum(matrix.values()) or 1
    changed = sum(v for k, v in matrix.items() if "->" in k and k.split("->")[0] != k.split("->")[1])
    stance = "BALANCED"
    if conservative > dangerous * 1.5:
        stance = "MORE_CONSERVATIVE"
    elif dangerous > conservative * 1.5:
        stance = "MORE_AGGRESSIVE"
    return {
        "transitions": matrix,
        "changedCount": changed,
        "changedPercent": round(100.0 * changed / total, 3),
        "dangerousToBuy": dangerous,
        "conservativeFromBuy": conservative,
        "stance": stance,
        "sampleCount": total,
    }


def is_finite_metric(v: Any) -> bool:
    try:
        x = float(v)
        return math.isfinite(x)
    except Exception:
        return False


def classify_candidate(
    *,
    oos_before: dict[str, float],
    oos_after: dict[str, float],
    replay_after: dict[str, float],
    pred_cmp: dict[str, Any],
    sample_n: int,
    leak_violations: int,
    overlap_train_val: int,
    overlap_train_oos: int,
    overlap_val_oos: int,
    primary_source: str,
    shadow_complete: int = 0,
    regime_oos: dict[str, Any] | None = None,
    fixed_safety_unchanged: bool = True,
    parameter_boundary_ok: bool = True,
) -> dict[str, Any]:
    """Return REJECT / SHADOW_ONLY / PROMOTION_ELIGIBLE with reasons."""
    reasons: list[str] = []
    pf_a = float(oos_after.get("profitFactor") or 0)
    pf_b = float(oos_before.get("profitFactor") or 0)
    exp_a = float(oos_after.get("netExpectancy") or 0)
    exp_b = float(oos_before.get("netExpectancy") or 0)
    mdd_a = float(oos_after.get("mdd") or 0)
    mdd_b = float(oos_before.get("mdd") or 0)

    if not fixed_safety_unchanged:
        return {"tier": "REJECT", "code": "FIXED_SAFETY_CHANGED", "why": reasons + ["FIXED_SAFETY_CHANGED"]}
    if not parameter_boundary_ok:
        return {"tier": "REJECT", "code": "PARAMETER_BOUNDARY", "why": reasons + ["PARAMETER_BOUNDARY"]}
    if leak_violations > 0:
        return {"tier": "REJECT", "code": "LOOK_AHEAD_BIAS", "why": reasons + [f"LOOK_AHEAD={leak_violations}"]}
    if overlap_train_val or overlap_train_oos or overlap_val_oos:
        return {
            "tier": "REJECT",
            "code": "DATA_LEAK",
            "why": reasons
            + [
                f"OVERLAP tv={overlap_train_val} to={overlap_train_oos} vo={overlap_val_oos}",
            ],
        }
    if primary_source in {"SYNTHETIC_TEST", "FIXTURE", "HARDCODED", "DEMO", "FALLBACK"}:
        return {
            "tier": "SHADOW_ONLY",
            "code": "TEST_DATA",
            "learningProofSource": "TEST_DATA",
            "why": reasons + [f"primarySource={primary_source} not eligible for production promotion"],
            "tag": "IMPROVED_BUT_UNPROFITABLE" if exp_a > exp_b and pf_a < MIN_OOS_PF_FOR_PROMOTE else "TEST_ONLY",
        }
    if not is_finite_metric(pf_a) or not is_finite_metric(exp_a) or pf_a > 1e6:
        return {"tier": "REJECT", "code": "NAN_OR_INFINITY", "why": reasons + ["NaN/Infinity PF blocked"]}
    if sample_n < 20:
        return {"tier": "SHADOW_ONLY", "code": "LOW_SAMPLE", "why": reasons + [f"sample_n={sample_n}"]}
    if pred_cmp.get("PREDICTION_CHANGED_COUNT", 0) <= 0:
        return {"tier": "REJECT", "code": "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED", "why": reasons}

    relative_better = exp_a > exp_b and pf_a >= pf_b * 0.999 and mdd_a <= mdd_b * 1.15 + 1e-9
    absolute_ok = pf_a >= MIN_OOS_PF_FOR_PROMOTE and exp_a > MIN_OOS_EXPECTANCY_FOR_PROMOTE

    # Regime: if only one regime positive while others negative → specialist, not global promote
    regime_tag = None
    if regime_oos:
        pos = []
        neg = []
        insuf = []
        for reg, m in regime_oos.items():
            n = int(m.get("sampleSize") or m.get("tradeCount") or 0)
            if n < 3:
                insuf.append(reg)
                continue
            if float(m.get("netExpectancy") or 0) > 0 and float(m.get("profitFactor") or 0) >= 1.0:
                pos.append(reg)
            else:
                neg.append(reg)
        if pos and neg:
            regime_tag = "REGIME_SPECIALIST_CANDIDATE"
            reasons.append(f"regime_pos={pos} regime_neg={neg}")
        for missing in ("TREND_DOWN", "CRASH"):
            if missing not in (regime_oos or {}) or int((regime_oos.get(missing) or {}).get("sampleSize") or 0) < 3:
                reasons.append(f"{missing}=INSUFFICIENT_DATA")

    if not relative_better and exp_a <= exp_b:
        return {"tier": "REJECT", "code": "FAILED_OOS", "why": reasons + ["OOS not improved"]}

    if relative_better and not absolute_ok:
        return {
            "tier": "SHADOW_ONLY",
            "code": "IMPROVED_BUT_UNPROFITABLE",
            "tag": "IMPROVED_BUT_UNPROFITABLE",
            "why": reasons
            + [
                f"PF {pf_b}->{pf_a} improved but absolute PF<{MIN_OOS_PF_FOR_PROMOTE} or expectancy<=0",
            ],
        }

    if regime_tag == "REGIME_SPECIALIST_CANDIDATE":
        return {
            "tier": "SHADOW_ONLY",
            "code": "REGIME_SPECIALIST_CANDIDATE",
            "tag": regime_tag,
            "why": reasons,
        }

    if RECOVERY_VALIDATION_MODE and shadow_complete < MIN_SHADOW_COMPLETE_FOR_PROMOTE:
        return {
            "tier": "SHADOW_ONLY",
            "code": "RECOVERY_VALIDATION_MODE",
            "why": reasons
            + [
                f"absolute OK but shadow_complete={shadow_complete}<{MIN_SHADOW_COMPLETE_FOR_PROMOTE}; stay SHADOW",
            ],
        }

    if absolute_ok and relative_better and shadow_complete >= MIN_SHADOW_COMPLETE_FOR_PROMOTE:
        return {
            "tier": "PROMOTION_ELIGIBLE",
            "code": "PASS",
            "why": reasons
            + [
                f"OOS PF {pf_a}>= {MIN_OOS_PF_FOR_PROMOTE}, exp {exp_a}>0, shadow={shadow_complete}",
            ],
        }

    return {
        "tier": "SHADOW_ONLY",
        "code": "AWAITING_SHADOW",
        "why": reasons + ["absolute/relative ok pending shadow graduation"],
    }


def classify_trade_training_quality(outcome: dict[str, Any], decision: dict[str, Any] | None = None) -> tuple[str, str | None]:
    """Return (quality, invalidReason). Net PnL must be cost-adjusted realized."""
    reason = str(outcome.get("exitReason") or outcome.get("reason") or "").upper()
    cause = str(outcome.get("cause") or "").upper()
    blob = f"{reason} {cause}"
    if any(x in blob for x in ("SYSTEM_BUG", "EXECUTION_BUG", "ACCOUNTING_BUG", "DUPLICATE_ORDER", "KNOWN_FIXED_BUG", "EXECUTION_SYSTEM_FAILURE")):
        return "INVALID", "SYSTEM_OR_EXEC_BUG"
    if outcome.get("accountingMismatch") is True:
        return "INVALID", "ACCOUNTING_MISMATCH"
    src = str(outcome.get("dataSource") or "").upper()
    if src in {"SYNTHETIC_TEST", "UNIT_FIXTURE", "DEMO", "FIXTURE", "HARDCODED"} and outcome.get("forceValidSynthetic") is not True:
        # Synthetic may still be stored for pipeline tests, but never auto-VALID for production path
        # (callers that intentionally train on SYNTHETIC set quality explicitly).
        pass
    dq = str((decision or {}).get("dataQuality") or outcome.get("dataQuality") or "").upper()
    sq = str((decision or {}).get("snapshotQuality") or outcome.get("snapshotQuality") or "").upper()
    if dq in {"STALE", "MISSING", "POOR", "BAD", "QUARANTINED", "INVALID"} or sq in {"BAD", "INVALID", "QUARANTINED"}:
        return "INVALID", "BAD_DATA_TRAINING_LEAK" if dq in {"BAD", "QUARANTINED", "INVALID"} or sq in {"BAD", "INVALID", "QUARANTINED"} else "STALE_OR_MISSING_DATA"
    if decision is not None and decision.get("usableForTraining") is False:
        return "INVALID", "BAD_DATA_TRAINING_LEAK"
    if (decision or {}).get("lookAheadUnsafe") is True:
        return "INVALID", "LOOKAHEAD_UNSAFE"
    # Incomplete features → still store as PARTIAL, not champion training
    feats = (decision or {}).get("micro") or {}
    if decision and feats.get("status") not in {None, "AVAILABLE"} and str(decision.get("decision") or "").upper() == "BUY":
        return "PARTIAL", "INSUFFICIENT_MICRO_AT_ENTRY"
    return "VALID", None


def production_evidence(
    *,
    real_samples: int,
    synthetic_samples: int,
    real_cycles: int,
    synthetic_cycles: int,
    active_source: str,
    shadow_completed: int,
    last_real_cycle: dict[str, Any] | None,
) -> dict[str, Any]:
    """NONE / PARTIAL / VERIFIED — never invent evidence."""
    if real_samples <= 0 and real_cycles <= 0:
        level = "NONE"
        detail = "NO_REAL_PRODUCTION_EVIDENCE"
    elif real_samples > 0 and real_cycles == 0:
        level = "PARTIAL"
        detail = "REAL_SAMPLES_WITHOUT_REAL_CYCLE"
    elif real_cycles > 0 and (last_real_cycle or {}).get("learningProofSource") != "REAL_DATA":
        level = "PARTIAL"
        detail = "CYCLE_NOT_MARKED_REAL_DATA"
    elif (
        real_cycles > 0
        and shadow_completed > 0
        and (last_real_cycle or {}).get("promotionTier") == "PROMOTION_ELIGIBLE"
        and (last_real_cycle or {}).get("promotionDecision") == "PROMOTED"
        and active_source == "AUTONOMOUS_LEARNING"
    ):
        level = "VERIFIED"
        detail = "REAL_CYCLE_SHADOW_AND_SAFE_PROMOTION"
    elif real_cycles > 0:
        level = "PARTIAL"
        detail = "REAL_CYCLE_WITHOUT_VERIFIED_PROMOTION"
    else:
        level = "NONE"
        detail = "UNKNOWN"
    return {
        "productionEvidence": level,
        "detail": detail,
        "realSampleCount": real_samples,
        "syntheticSampleCount": synthetic_samples,
        "realLearningCycleCount": real_cycles,
        "syntheticCycleCount": synthetic_cycles,
        "shadowCompletedSamples": shadow_completed,
        "activeModelSource": active_source,
    }


def honest_learning_level(
    *,
    real_samples: int,
    real_cycles: int,
    proof_source: str | None,
    promotion_decision: str | None,
    promotion_tier: str | None,
    shadow_status: str | None,
    is_improving: str | None,
) -> str:
    if real_samples <= 0 and real_cycles <= 0:
        return "WAITING_FOR_REAL_DATA" if proof_source != "TEST_DATA" else "LOGGING_ONLY"
    if proof_source == "TEST_DATA":
        return "TRAINING_WITHOUT_REAL_VALIDATION"
    if real_cycles <= 0:
        return "WAITING_FOR_REAL_DATA"
    if promotion_decision == "PROMOTED" and promotion_tier == "PROMOTION_ELIGIBLE" and proof_source == "REAL_DATA":
        if is_improving == "YES":
            return "REAL_LEARNING_IMPROVING"
        return "REAL_LEARNING_SHADOW_VALIDATION"
    if promotion_decision in {"SHADOW_ONLY", "SHADOW_HOLD"} or (shadow_status or "").startswith("SHADOW"):
        return "REAL_LEARNING_SHADOW_VALIDATION"
    if is_improving == "NO":
        return "REAL_LEARNING_NO_IMPROVEMENT_YET"
    if is_improving == "YES_BUT_UNPROFITABLE":
        return "REAL_LEARNING_NO_IMPROVEMENT_YET"
    return "REAL_LEARNING_NO_IMPROVEMENT_YET"


def audit_reported_cycle_m101() -> dict[str, Any]:
    """Static audit of the advertised LC-00501d30ba / M100→M101 claim."""
    return {
        "CYCLE": "LC-00501d30ba",
        "DATA_SOURCE": "SYNTHETIC_TEST",
        "LEARNING_PROOF_SOURCE": "TEST_DATA",
        "FOUND_IN_PRODUCTION_DB": False,
        "evidence": (
            "Cycle was produced by local tempfile demo using AutonomousResearchEngine._synthetic_samples(40); "
            "workspace research_bithumb.sqlite3 has 0 learning_cycles and active model remains M100 BOOTSTRAP. "
            "Hetzner production (pre-sync) had no research modules and 0 learning cycles."
        ),
        "PROMOTION_BUG": (
            "Code promoted on relative OOS improvement only (exp↑ and pf≥prior) without absolute PF>=1.0 gate; "
            "OOS PF 0.75 < 1.0 ⇒ IMPROVED_BUT_UNPROFITABLE should be SHADOW_ONLY."
        ),
        "M101_PROMOTION_AUDIT": "INVALID_PROMOTION",
        "WHY": "TEST/SYNTHETIC data + unprofitable absolute OOS PF 0.75 promoted via relative-only gate",
    }


def boundary_distances(features: dict[str, Any], weights: dict[str, float] | None = None) -> dict[str, Any]:
    """Research-only distances to Champion decision boundaries. Never used to auto-lower thresholds."""
    from .weighted_policy import score_with_weights
    from .parameter_registry import default_weights

    w = dict(default_weights())
    if weights:
        w.update({k: float(v) for k, v in weights.items() if k in w or True})
        base = default_weights()
        w = {**base, **{k: float(weights[k]) for k in base if k in weights}}
    scored = score_with_weights(features, w)
    strategy = float(features.get("strategyScore") or scored.get("strategyScore") or 0)
    ai = float(scored.get("aiScore") or 0)
    exec_s = float(scored.get("executionScore") or 0)
    chase = float(scored.get("chaseScore") or 0)
    edge = scored.get("shortEdge")
    thr_s = float(w.get("thr_strategy_buy", 75))
    thr_ai = float(w.get("thr_ai_buy", 55))
    thr_e = float(w.get("thr_exec_buy", 60))
    thr_edge = float(w.get("thr_short_edge", 0.15))
    thr_chase = float(w.get("thr_chase_avoid", 90))
    gaps = {
        "strategyGap": round(thr_s - strategy, 4),
        "aiGap": round(thr_ai - ai, 4),
        "execGap": round(thr_e - exec_s, 4),
        "edgeGap": round(thr_edge - float(edge if edge is not None else thr_edge - 1), 4),
        "chaseHeadroom": round(thr_chase - chase, 4),
    }
    decision = str(scored.get("decision") or "?")
    # Near-buy miss: not BUY, but at most 2 buy-path gaps within 5 points / 0.05 edge
    buy_gaps = [gaps["strategyGap"], gaps["aiGap"], gaps["execGap"]]
    failing = sum(1 for g in buy_gaps if g > 0)
    edge_fail = gaps["edgeGap"] > 0
    chase_block = gaps["chaseHeadroom"] <= 0 or float(features.get("microAvailable") or 0) < 0.5
    near_buy = (
        decision in {"WAIT", "AVOID", "REJECT"}
        and not chase_block
        and failing <= 2
        and all(g <= 5.0 for g in buy_gaps if g > 0)
        and (not edge_fail or gaps["edgeGap"] <= 0.05)
    )
    # Bucket by min positive buy-path gap (or 0 if BUY)
    if decision == "BUY":
        bucket = "ON_BUY"
        dist = 0.0
    else:
        pos = [g for g in buy_gaps + ([gaps["edgeGap"] * 100] if edge_fail else []) if g > 0]
        dist = min(pos) if pos else abs(min(buy_gaps))
        if near_buy or dist <= 5:
            bucket = "NEAR_BOUNDARY"
        elif dist <= 15:
            bucket = "MID_BOUNDARY"
        else:
            bucket = "FAR_BOUNDARY"
    return {
        "decision": decision,
        "executionState": scored.get("executionState"),
        "gaps": gaps,
        "distanceToBuyBoundary": round(float(dist), 4),
        "bucket": bucket,
        "buyNearMiss": bool(near_buy),
        "microAvailable": float(features.get("microAvailable") or 0) >= 0.5,
        "chaseBlocked": bool(chase_block),
    }


def dataset_diversity_report(
    samples: list[dict[str, Any]],
    weights: dict[str, float] | None = None,
) -> dict[str, Any]:
    """Dataset diversity + boundary coverage for REAL training/validation research."""
    from collections import Counter
    from .weighted_policy import extract_features

    decisions = Counter()
    markets = Counter()
    regimes = Counter()
    outcomes = Counter()
    buckets = Counter()
    micro = Counter()
    chase_bins = Counter()
    buy_near_miss = 0
    warnings: list[str] = []
    for s in samples:
        feats = s.get("features") or extract_features(s)
        bd = boundary_distances(feats, weights)
        decisions[bd["decision"]] += 1
        markets[str(s.get("market") or (s.get("meta") or {}).get("market") or "?")] += 1
        regimes[str((s.get("meta") or {}).get("regime") or "UNKNOWN")] += 1
        pnl = s.get("netPnl")
        if pnl is None:
            outcomes["UNKNOWN"] += 1
        elif float(pnl) > 0:
            outcomes["POSITIVE"] += 1
        elif float(pnl) < 0:
            outcomes["NEGATIVE"] += 1
        else:
            outcomes["NEUTRAL"] += 1
        buckets[bd["bucket"]] += 1
        micro["MICRO_AVAILABLE" if bd["microAvailable"] else "MICRO_INSUFFICIENT"] += 1
        chase_bins["CHASE_BLOCKED" if bd["chaseBlocked"] else "CHASE_OK"] += 1
        if bd["buyNearMiss"]:
            buy_near_miss += 1
    n = max(1, len(samples))
    top_m = markets.most_common(1)
    if top_m and top_m[0][1] / n >= 0.5:
        warnings.append("MARKET_CONCENTRATION")
        warnings.append("DATASET_BIAS_WARNING")
    if regimes.get("UNKNOWN", 0) / n >= 0.8:
        warnings.append("REGIME_CONCENTRATION")
    buy_n = decisions.get("BUY", 0)
    wait_n = decisions.get("WAIT", 0)
    avoid_n = decisions.get("AVOID", 0)
    if buy_n == 0 and (wait_n + avoid_n) == len(samples) and len(samples) >= 20:
        warnings.append("VALIDATION_DECISION_IMBALANCE")
    if buckets.get("NEAR_BOUNDARY", 0) + buy_near_miss < max(3, int(0.05 * n)):
        warnings.append("BOUNDARY_SAMPLE_INSUFFICIENT")
    if micro.get("MICRO_INSUFFICIENT", 0) / n >= 0.45:
        warnings.append("MICRO_DATA_DOMINANCE")
    if chase_bins.get("CHASE_BLOCKED", 0) / n >= 0.45:
        warnings.append("CHASE_STATE_DOMINANCE")
    return {
        "totalValid": len(samples),
        "decisions": dict(decisions),
        "outcomes": dict(outcomes),
        "regimes": dict(regimes),
        "marketTop5": markets.most_common(5),
        "marketCount": len([m for m in markets if m != "?"]),
        "boundaryBuckets": dict(buckets),
        "buyNearMissCount": buy_near_miss,
        "micro": dict(micro),
        "chase": dict(chase_bins),
        "warnings": warnings,
        "nearBoundaryCount": buckets.get("NEAR_BOUNDARY", 0),
        "farBoundaryCount": buckets.get("FAR_BOUNDARY", 0),
    }


def why_weight_changed(
    diagnosis: dict[str, Any] | None,
    hyp: dict[str, Any] | None,
    weight_delta: dict[str, float] | None,
) -> dict[str, Any]:
    """Structured WHY_WEIGHT_CHANGED for Memory / future conversational AI. No invention."""
    diagnosis = diagnosis or {}
    hyp = hyp or {}
    flags = list(diagnosis.get("flags") or [])
    causes = diagnosis.get("topCauses") or []
    reasons: list[str] = []
    if "CHASE_ENTRIES_FAILING" in flags:
        reasons.append("chase losses / chase entries failing in recent window")
    if "REENTRY_LOSSES_RISING" in flags:
        reasons.append("reentry losses rising")
    if "SHORT_HOLD_LOSSES_RISING" in flags:
        reasons.append("short-hold losses rising")
    if "NEGATIVE_EXPECTANCY_WINDOW" in flags:
        reasons.append("negative expectancy window on recent labeled samples")
    if hyp.get("proposedChange"):
        reasons.append(str(hyp.get("proposedChange")))
    if not reasons and not (weight_delta or {}):
        return {"code": "INSUFFICIENT_EVIDENCE", "reasons": [], "flags": flags, "topCauses": causes}
    if not reasons:
        reasons.append("hypothesis deltas applied without dominant named pattern")
        code = "INSUFFICIENT_EVIDENCE"
    else:
        code = "EVIDENCE_LINKED"
    return {
        "code": code,
        "reasons": reasons,
        "flags": flags,
        "topCauses": causes,
        "proposedChange": hyp.get("proposedChange"),
        "proposedDeltas": hyp.get("proposedDeltas"),
        "weightDelta": weight_delta or {},
    }


def layer2_status_from_evidence(
    *,
    real_decision_changed: int,
    oos_passed: bool,
    shadow_status: str | None,
    absolute_ok: bool,
) -> str:
    """CASE A–D residual Layer-2 status (honest)."""
    shadow = str(shadow_status or "NONE").upper()
    if real_decision_changed <= 0:
        return "PARTIAL_WAITING_FOR_REAL_DATA"
    if not oos_passed:
        return "PARTIAL_LEARNING_NOT_IMPROVING"
    if shadow in {"", "NONE"} or "INSUFFICIENT" in shadow:
        return "PARTIAL_AWAITING_SHADOW"
    if absolute_ok and shadow in {"PASS", "SHADOW_PASS", "PROMOTED", "SHADOW_COMPLETE"}:
        return "PASS"
    if not absolute_ok:
        return "PARTIAL_LEARNING_NOT_IMPROVING"
    return "PARTIAL_AWAITING_SHADOW"

===== END FILE: server/ai-brain/app/learning_authenticity.py =====

===== FILE: server/ai-brain/app/main.py =====
from __future__ import annotations

import asyncio
import time
from collections import deque
from contextlib import asynccontextmanager
from typing import Any

from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field

from .auth import require_token
from .autonomous_research import AutonomousResearchEngine
from .config import (
    API_VERSION,
    BITHUMB_FEE_CONFIG,
    DATA_DIR,
    DECISION_TTL_MS,
    MODEL_VERSION,
    STRATEGY_VERSION,
    UPBIT_FEE_CONFIG,
)
from .decision_engine import DecisionEngine
from .market_collector import MarketCollector
from .micro_buffer import MicroBufferStore
from .paper_engine import UPBIT_DEFAULT_SETTINGS, PaperTradingEngine
from .research_store import ResearchStore
from .storage import DecisionStore
from .upbit_collector import UpbitMarketCollector

STARTED_AT = int(time.time() * 1000)

# ─── BITHUMB ENGINE (preserved) ───────────────────────────────────────────────
bithumb_micro = MicroBufferStore()
bithumb_store = DecisionStore()
bithumb_collector = MarketCollector(bithumb_micro)
bithumb_engine = DecisionEngine(
    bithumb_collector, bithumb_micro, bithumb_store, exchange="BITHUMB", fee_config=BITHUMB_FEE_CONFIG
)
bithumb_paper = PaperTradingEngine(exchange="BITHUMB")
bithumb_research = AutonomousResearchEngine(
    "BITHUMB",
    store=ResearchStore("BITHUMB", DATA_DIR / "research_bithumb.sqlite3"),
    decision_store=bithumb_store,
    paper_engine=bithumb_paper,
)
bithumb_engine.attach_research(bithumb_research)

# Backward-compatible aliases used by existing tests / callers
micro_buffer = bithumb_micro
store = bithumb_store
collector = bithumb_collector
engine = bithumb_engine
paper = bithumb_paper

# ─── UPBIT ENGINE (fully independent) ─────────────────────────────────────────
upbit_micro = MicroBufferStore()
upbit_store = DecisionStore(DATA_DIR / "ai_brain_upbit.sqlite3")
upbit_collector = UpbitMarketCollector(upbit_micro)
upbit_engine = DecisionEngine(
    upbit_collector, upbit_micro, upbit_store, exchange="UPBIT", fee_config=UPBIT_FEE_CONFIG
)
upbit_paper = PaperTradingEngine(
    path=DATA_DIR / "paper_upbit.sqlite3",
    exchange="UPBIT",
    default_settings=UPBIT_DEFAULT_SETTINGS,
)
upbit_research = AutonomousResearchEngine(
    "UPBIT",
    store=ResearchStore("UPBIT", DATA_DIR / "research_upbit.sqlite3"),
    decision_store=upbit_store,
    paper_engine=upbit_paper,
)
upbit_engine.attach_research(upbit_research)

_bg_tasks: list[asyncio.Task] = []
_latest_dashboard: dict[str, Any] = {
    "serverTimestamp": STARTED_AT,
    "fastScanCount": 0,
    "deepScanCount": 0,
    "candidates": [],
    "marketRegime": "UNKNOWN",
    "marketHealth": None,
}
_latest_upbit_dashboard: dict[str, Any] = {
    "serverTimestamp": STARTED_AT,
    "fastScanCount": 0,
    "deepScanCount": 0,
    "candidates": [],
    "marketRegime": "UNKNOWN",
    "marketHealth": None,
}
# PHASE6 diagnostic only — does not affect trading.
_device_verifies: deque[dict[str, Any]] = deque(maxlen=200)


def _mark_prices() -> dict[str, float]:
    return {m: t.trade_price for m, t in bithumb_collector.snapshot_tickers().items() if t.trade_price > 0}


def _upbit_mark_prices() -> dict[str, float]:
    return {m: t.trade_price for m, t in upbit_collector.snapshot_tickers().items() if t.trade_price > 0}


async def _orderbook_loop() -> None:
    while True:
        try:
            tops = [c["market"] for c in bithumb_engine.fast_scan(limit=20)]
            held = [p["market"] for p in bithumb_paper.positions()]
            markets = list(dict.fromkeys(tops + held))
            if markets:
                await bithumb_collector.fetch_orderbooks(markets)
        except Exception as exc:
            print(f"[BITHUMB][ORDERBOOK] ERROR detail={exc}", flush=True)
        await asyncio.sleep(2.0)


async def _upbit_orderbook_loop() -> None:
    while True:
        try:
            tops = [c["market"] for c in upbit_engine.fast_scan(limit=20)]
            held = [p["market"] for p in upbit_paper.positions()]
            markets = list(dict.fromkeys(tops + held))
            if markets:
                await upbit_collector.fetch_orderbooks(markets)
        except Exception as exc:
            print(f"[UPBIT][ORDERBOOK] ERROR detail={exc}", flush=True)
        await asyncio.sleep(2.0)


async def _analysis_loop() -> None:
    """Phone-independent continuous FAST/DEEP/decision + PAPER trading (Bithumb)."""
    while True:
        try:
            started = time.perf_counter()
            fast = bithumb_engine.fast_scan(limit=30)
            deep_markets = [c["market"] for c in fast[:15]]
            held = [p["market"] for p in bithumb_paper.positions()]
            for m in held:
                if m not in deep_markets:
                    deep_markets.append(m)
            if deep_markets:
                await bithumb_collector.fetch_orderbooks(deep_markets)
            decisions = [bithumb_engine.decide_market(m) for m in deep_markets]
            now = int(time.time() * 1000)
            marks = _mark_prices()
            paper_tick = bithumb_paper.tick(decisions, marks)
            paper_state = bithumb_paper.state(marks)
            _latest_dashboard.update(
                {
                    "serverTimestamp": now,
                    "serverComputeMs": round((time.perf_counter() - started) * 1000.0, 2),
                    "fastScanCount": len(fast),
                    "deepScanCount": len(decisions),
                    "fastCandidates": fast,
                    "candidates": [_candidate_view(d) for d in decisions],
                    "marketRegime": "UNKNOWN",
                    "marketHealth": 80.0 if bithumb_collector.health().get("connectionState") == "CONNECTED" else 40.0,
                    "modelVersion": MODEL_VERSION,
                    "strategyVersion": STRATEGY_VERSION,
                    "apiVersion": API_VERSION,
                    "paper": paper_state,
                    "paperTick": paper_tick,
                    "exchange": "BITHUMB",
                }
            )
            print(
                f"[BITHUMB][FAST_SCAN] fast={len(fast)} deep={len(decisions)} ts={now} "
                f"computeMs={_latest_dashboard.get('serverComputeMs')} "
                f"paperAuto={'ON' if paper_state.get('paperAuto') else 'OFF'}",
                flush=True,
            )
        except Exception as exc:
            print(f"[BITHUMB][DECISION] ERROR detail={exc}", flush=True)
        await asyncio.sleep(5.0)


async def _upbit_analysis_loop() -> None:
    """Independent Upbit FAST/DEEP/decision + PAPER. Isolated from Bithumb failures."""
    while True:
        try:
            started = time.perf_counter()
            fast = upbit_engine.fast_scan(limit=30)
            deep_markets = [c["market"] for c in fast[:15]]
            held = [p["market"] for p in upbit_paper.positions()]
            for m in held:
                if m not in deep_markets:
                    deep_markets.append(m)
            if deep_markets:
                await upbit_collector.fetch_orderbooks(deep_markets)
            decisions = [upbit_engine.decide_market(m) for m in deep_markets]
            now = int(time.time() * 1000)
            marks = _upbit_mark_prices()
            paper_tick = upbit_paper.tick(decisions, marks)
            paper_state = upbit_paper.state(marks)
            _latest_upbit_dashboard.update(
                {
                    "serverTimestamp": now,
                    "serverComputeMs": round((time.perf_counter() - started) * 1000.0, 2),
                    "fastScanCount": len(fast),
                    "deepScanCount": len(decisions),
                    "fastCandidates": fast,
                    "candidates": [_candidate_view(d) for d in decisions],
                    "marketRegime": "UNKNOWN",
                    "marketHealth": 80.0 if upbit_collector.health().get("connectionState") == "CONNECTED" else 40.0,
                    "modelVersion": MODEL_VERSION,
                    "strategyVersion": STRATEGY_VERSION,
                    "apiVersion": API_VERSION,
                    "paper": paper_state,
                    "paperTick": paper_tick,
                    "exchange": "UPBIT",
                }
            )
            print(
                f"[UPBIT][FAST_SCAN] fast={len(fast)} deep={len(decisions)} ts={now} "
                f"computeMs={_latest_upbit_dashboard.get('serverComputeMs')} "
                f"paperAuto={'ON' if paper_state.get('paperAuto') else 'OFF'}",
                flush=True,
            )
        except Exception as exc:
            print(f"[UPBIT][DECISION] ERROR detail={exc}", flush=True)
        await asyncio.sleep(5.0)


def _candidate_view(d: dict[str, Any]) -> dict[str, Any]:
    return {
        "exchange": d.get("exchange"),
        "positionKey": d.get("positionKey"),
        "market": d.get("market"),
        "price": d.get("signalPrice"),
        "strategyScore": d.get("strategyScore"),
        "aiScore": d.get("aiScore"),
        "aiConfidence": d.get("aiConfidence"),
        "aiPositive": d.get("aiPositive"),
        "entryTimingScore": d.get("entryTimingScore"),
        "entryTimingState": d.get("entryTimingState"),
        "chaseScore": d.get("chaseScore"),
        "chaseState": d.get("chaseState"),
        "executionScore": d.get("executionScore"),
        "executionConfidence": d.get("executionConfidence"),
        "executionState": d.get("executionState"),
        "shortEdge": d.get("shortEdge"),
        "grossExpectedEdge": d.get("grossExpectedEdge"),
        "executionCost": d.get("expectedExecutionCost"),
        "netExpectedEdge": d.get("netExpectedEdge"),
        "expectedGrossProfitKrw": d.get("expectedGrossProfitKrw"),
        "expectedRoundTripCostKrw": d.get("expectedRoundTripCostKrw"),
        "expectedRoundTripCostPercent": d.get("expectedRoundTripCostPercent"),
        "expectedNetProfitKrw": d.get("expectedNetProfitKrw"),
        "expectedNetProfitPercent": d.get("expectedNetProfitPercent"),
        "costToGrossProfitRatio": d.get("costToGrossProfitRatio"),
        "costCoverageMultiple": d.get("costCoverageMultiple"),
        "breakEvenPrice": d.get("breakEvenPrice"),
        "netProfitAfterCostPassed": d.get("netProfitAfterCostPassed"),
        "liquidityPassed": d.get("liquidityPassed"),
        "liquidityRank": d.get("liquidityRank"),
        "liquidityTotal": d.get("liquidityTotal"),
        "liquidityPercentile": d.get("liquidityPercentile"),
        "dataQuality": d.get("dataQuality"),
        "executionDataQuality": d.get("executionDataQuality"),
        "derivativesState": d.get("derivativesRisk"),
        "newsRisk": d.get("newsRisk"),
        "decision": d.get("decision"),
        "decisionId": d.get("decisionId"),
        "reasonCodes": d.get("reasonCodes") or [],
        "signalCreatedAt": d.get("signalCreatedAt"),
        "signalExpiresAt": d.get("signalExpiresAt") or d.get("expiresAt"),
        "serverTimestamp": d.get("serverTimestamp"),
        "modelVersion": d.get("modelVersion"),
        "modelHash": d.get("modelHash"),
        "learningCycleId": d.get("learningCycleId"),
        "strategyVersion": d.get("strategyVersion"),
        "apiVersion": d.get("apiVersion"),
        "featureImportance": d.get("featureImportance"),
        "shadowChallengerDecision": d.get("shadowChallengerDecision"),
        "microSampleCount": (d.get("micro") or {}).get("microSampleCount"),
        "usableForTraining": d.get("usableForTraining"),
        "maxComponentAgeMs": d.get("maxComponentAgeMs"),
        "snapshotSkewMs": d.get("snapshotSkewMs"),
        "snapshotQuality": d.get("snapshotQuality"),
        "componentAgesMs": d.get("componentAgesMs"),
        "tickerTimestamp": d.get("tickerTimestamp"),
        "tickerReceivedAt": d.get("tickerReceivedAt"),
        "tickerSource": d.get("tickerSource"),
        "orderbookTimestamp": d.get("orderbookTimestamp"),
        "orderbookReceivedAt": d.get("orderbookReceivedAt"),
        "orderbookSource": d.get("orderbookSource"),
    }


def _engine_status(ws_health: dict[str, Any], market_count: int) -> str:
    state = ws_health.get("connectionState")
    if state == "WEBSOCKET_ZOMBIE":
        return "DEGRADED"
    if state in {"CONNECTED"} or market_count > 0:
        return "ONLINE"
    if state in {"CONNECTING"}:
        return "DEGRADED"
    return "OFFLINE" if state in {"DISCONNECTED", "ERROR"} else "DEGRADED"


async def _research_loop() -> None:
    """Low-priority autonomous research. Failures must not stop realtime loops."""
    await asyncio.sleep(45.0)
    while True:
        try:
            bithumb_research.sync_from_paper_and_decisions()
            bithumb_research.resolve_open_horizons(_mark_prices())
            bithumb_research.detect_concept_drift()
            bithumb_research.maybe_run_cycle(force=False)
        except Exception as exc:
            bithumb_research.last_error = str(exc)
            bithumb_research.state = "DEGRADED"
            print(f"[BITHUMB][RESEARCH] ERROR detail={exc}", flush=True)
        try:
            upbit_research.sync_from_paper_and_decisions()
            upbit_research.resolve_open_horizons(_upbit_mark_prices())
            upbit_research.detect_concept_drift()
            upbit_research.maybe_run_cycle(force=False)
        except Exception as exc:
            upbit_research.last_error = str(exc)
            upbit_research.state = "DEGRADED"
            print(f"[UPBIT][RESEARCH] ERROR detail={exc}", flush=True)
        await asyncio.sleep(120.0)


@asynccontextmanager
async def lifespan(app: FastAPI):
    store.upsert_model("shadow-heuristic", MODEL_VERSION, "CANDIDATE", {"phase": 4, "exchange": "BITHUMB"})
    upbit_store.upsert_model(
        "shadow-heuristic-upbit", MODEL_VERSION, "CANDIDATE", {"phase": 4, "exchange": "UPBIT"}
    )
    # Start both engines independently — one failure must not stop the other.
    try:
        bithumb_collector.start()
    except Exception as exc:
        print(f"[BITHUMB][BOOT] collector start failed: {exc}", flush=True)
    try:
        upbit_collector.start()
    except Exception as exc:
        print(f"[UPBIT][BOOT] collector start failed: {exc}", flush=True)
    _bg_tasks.append(asyncio.create_task(_orderbook_loop()))
    _bg_tasks.append(asyncio.create_task(_analysis_loop()))
    _bg_tasks.append(asyncio.create_task(_upbit_orderbook_loop()))
    _bg_tasks.append(asyncio.create_task(_upbit_analysis_loop()))
    _bg_tasks.append(asyncio.create_task(_research_loop()))
    print(
        f"[BITHUMB][BOOT] paperAuto={'ON' if bithumb_paper.auto_enabled() else 'OFF'} "
        f"cash={bithumb_paper.state().get('cash')} positions={bithumb_paper.state().get('positionCount')}",
        flush=True,
    )
    print(
        f"[UPBIT][BOOT] paperAuto={'ON' if upbit_paper.auto_enabled() else 'OFF'} "
        f"cash={upbit_paper.state().get('cash')} positions={upbit_paper.state().get('positionCount')} "
        f"live=DISABLED",
        flush=True,
    )
    print(
        f"[RESEARCH][BOOT] bithumb={bithumb_research.status().get('learningStatus')} "
        f"upbit={upbit_research.status().get('learningStatus')} crossExchange=OFF",
        flush=True,
    )
    yield
    for t in _bg_tasks:
        t.cancel()
    await bithumb_collector.stop()
    await upbit_collector.stop()


app = FastAPI(title="Bithumb+Upbit AI Brain", version=API_VERSION, lifespan=lifespan)


class OutcomeBody(BaseModel):
    decisionId: str
    market: str
    exchange: str | None = "BITHUMB"
    entryTime: int | None = None
    entryPrice: float | None = None
    exitTime: int | None = None
    exitPrice: float | None = None
    realizedPnl: float | None = None
    realizedPnlPercent: float | None = None
    exitReason: str | None = None
    mfe: float | None = Field(default=None, alias="MFE")
    mae: float | None = Field(default=None, alias="MAE")
    holdingTime: int | None = None
    fees: float | None = None
    slippage: float | None = None
    tradeId: str | None = None
    outcomeId: str | None = None

    class Config:
        populate_by_name = True


class PaperAutoBody(BaseModel):
    enabled: bool
    source: str | None = "ANDROID"


class PaperSettingsBody(BaseModel):
    """Patch paper settings (pause/resume ladder). Does not force-close positions."""
    newBuyPaused: bool | None = None
    paperBuyResumeMode: str | None = None
    pauseReason: str | None = None


class PaperVerifyBuyBody(BaseModel):
    """Verification-only: run the SAME try_buy path with a synthetic BUY decision on a live market."""
    market: str | None = None


class DeviceVerifyBody(BaseModel):
    """PHASE6: Android device verification diagnostic upload. Trading-neutral."""
    deviceSessionId: str
    appVersion: str
    timestamp: int
    event: str
    decision: str
    serverStateTimestamp: int | None = None
    reason: str | None = None
    expected: dict[str, Any] | None = None
    actual: dict[str, Any] | None = None


@app.get("/api/trading/v1/health")
async def health() -> dict[str, Any]:
    now = int(time.time() * 1000)
    ws = bithumb_collector.health()
    ups = upbit_collector.health()
    ps = bithumb_paper.state(_mark_prices())
    ups_paper = upbit_paper.state(_upbit_mark_prices())
    bithumb_status = _engine_status(ws, int(ws.get("marketCount") or 0))
    upbit_status = _engine_status(ups, int(ups.get("marketCount") or 0))
    return {
        "service": "bithumb-ai-brain",
        "status": bithumb_status,
        "bithumbStatus": bithumb_status,
        "upbitStatus": upbit_status,
        "uptimeMs": now - STARTED_AT,
        "serverTime": now,
        "apiVersion": API_VERSION,
        "strategyVersion": STRATEGY_VERSION,
        "modelVersion": MODEL_VERSION,
        "bithumbWs": ws,
        "upbitWs": ups,
        "bybitWs": {"connectionState": "NOT_STARTED_PHASE1"},
        "lastTickerAt": ws.get("lastMessageAt"),
        "lastDecisionAt": bithumb_engine.last_decision_at or None,
        "marketCount": ws.get("marketCount") or 0,
        "microBufferReadyMarkets": bithumb_micro.ready_markets(),
        "executionDataReadyMarketCount": bithumb_micro.ready_markets(),
        "learningStatus": bithumb_research.status().get("learningStatus"),
        "autonomousLearning": bithumb_research.status(),
        "processHealth": bithumb_status,
        "marketDataHealth": (
            "ZOMBIE"
            if ws.get("connectionState") == "WEBSOCKET_ZOMBIE"
            else ("DEGRADED" if int(ws.get("staleMarketCount") or 0) > int(ws.get("marketCount") or 1) * 0.9 else "OK")
        ),
        "analysisHealth": "OK" if bithumb_engine.last_decision_at else "WARMING",
        "layer1": {
            "bithumb": {
                "marketCount": ws.get("marketCount") or 0,
                "wsState": ws.get("connectionState"),
                "lastMessageAgeMs": ws.get("lastMessageAgeMs"),
                "messageRate": ws.get("messageRate"),
                "restFallbackSuccess": ws.get("restFallbackSuccess"),
                "zombieReason": ws.get("zombieReason"),
                "microReady": bithumb_micro.ready_markets(),
            },
            "upbit": {
                "marketCount": ups.get("marketCount") or 0,
                "wsState": ups.get("connectionState"),
                "lastMessageAgeMs": ups.get("lastMessageAgeMs"),
                "messageRate": ups.get("messageRate"),
                "restFallbackSuccess": ups.get("restFallbackSuccess"),
                "zombieReason": ups.get("zombieReason"),
                "microReady": upbit_micro.ready_markets(),
            },
        },
        "queueLagMs": 0,
        "decisionTtlMs": DECISION_TTL_MS,
        "lastComputeMs": round(bithumb_engine.last_compute_ms, 2),
        "paperAuto": ps.get("paperAuto"),
        "paperCash": ps.get("cash"),
        "paperPositions": ps.get("positionCount"),
        "paperTickCount": bithumb_paper.tick_count,
        "androidIndependentPaper": True,
        "upbit": {
            "status": upbit_status,
            "marketCount": ups.get("marketCount") or 0,
            "ws": ups,
            "microBufferReadyMarkets": upbit_micro.ready_markets(),
            "lastDecisionAt": upbit_engine.last_decision_at or None,
            "paperAuto": ups_paper.get("paperAuto"),
            "paperCash": ups_paper.get("cash"),
            "paperPositions": ups_paper.get("positionCount"),
            "liveTrading": False,
            "learningStatus": upbit_research.status().get("learningStatus"),
            "autonomousLearning": upbit_research.status(),
        },
        "engines": {
            "BITHUMB": bithumb_status,
            "UPBIT": upbit_status,
        },
    }


@app.get("/api/trading/v1/upbit/health")
async def upbit_health() -> dict[str, Any]:
    now = int(time.time() * 1000)
    ws = upbit_collector.health()
    status = _engine_status(ws, int(ws.get("marketCount") or 0))
    ps = upbit_paper.state(_upbit_mark_prices())
    return {
        "exchange": "UPBIT",
        "service": "upbit-ai-brain",
        "status": status,
        "serverTime": now,
        "uptimeMs": now - STARTED_AT,
        "apiVersion": API_VERSION,
        "strategyVersion": STRATEGY_VERSION,
        "modelVersion": MODEL_VERSION,
        "marketCount": ws.get("marketCount") or 0,
        "wsState": ws.get("wsState") or ws.get("connectionState"),
        "lastWsMessageAt": ws.get("lastWsMessageAt") or ws.get("lastMessageAt"),
        "messageRate": ws.get("messageRate"),
        "reconnectCount": ws.get("reconnectCount"),
        "staleMarketCount": ws.get("staleMarketCount"),
        "zombieReason": ws.get("zombieReason"),
        "lastTickerAt": ws.get("lastMessageAt"),
        "lastOrderbookAt": None,
        "fastScan": _latest_upbit_dashboard.get("fastScanCount") or 0,
        "deepScan": _latest_upbit_dashboard.get("deepScanCount") or 0,
        "microReadyMarkets": upbit_micro.ready_markets(),
        "executionReadyMarkets": upbit_micro.ready_markets(),
        "lastDecisionAt": upbit_engine.last_decision_at or None,
        "lastComputeMs": round(upbit_engine.last_compute_ms, 2),
        "paperAuto": ps.get("paperAuto"),
        "paperCash": ps.get("cash"),
        "paperPositions": ps.get("positionCount"),
        "paperInitialCash": ps.get("initialCash"),
        "liveTrading": False,
        "upbitWs": ws,
    }


@app.get("/api/trading/v1/state", dependencies=[Depends(require_token)])
async def state() -> dict[str, Any]:
    h = await health()
    return {
        **h,
        "phase": 4,
        "mode": "SERVER_BRAIN_ANDROID_REMOTE_CONTROL",
        "liveTrading": False,
        "latestDashboardAt": _latest_dashboard.get("serverTimestamp"),
        "fastScanCount": _latest_dashboard.get("fastScanCount"),
        "deepScanCount": _latest_dashboard.get("deepScanCount"),
        "paper": bithumb_paper.state(_mark_prices()),
        "upbitPaper": upbit_paper.state(_upbit_mark_prices()),
    }


@app.get("/api/trading/v1/dashboard", dependencies=[Depends(require_token)])
async def dashboard(limit: int = 15, refresh: bool = False) -> dict[str, Any]:
    h = await health()
    if refresh or not _latest_dashboard.get("candidates"):
        fast = bithumb_engine.fast_scan(limit=max(limit, 20))
        deep = [c["market"] for c in fast[:limit]]
        if deep:
            await bithumb_collector.fetch_orderbooks(deep)
        decisions = [bithumb_engine.decide_market(m) for m in deep]
        now = int(time.time() * 1000)
        marks = _mark_prices()
        bithumb_paper.tick(decisions, marks)
        _latest_dashboard.update(
            {
                "serverTimestamp": now,
                "fastScanCount": len(fast),
                "deepScanCount": len(decisions),
                "fastCandidates": fast,
                "candidates": [_candidate_view(d) for d in decisions],
                "marketHealth": 80.0 if h.get("bithumbWs", {}).get("connectionState") == "CONNECTED" else 40.0,
                "paper": bithumb_paper.state(marks),
                "exchange": "BITHUMB",
            }
        )
    marks = _mark_prices()
    paper_state = bithumb_paper.state(marks)
    return {
        "exchange": "BITHUMB",
        "serverTimestamp": _latest_dashboard.get("serverTimestamp") or int(time.time() * 1000),
        "serverHealth": h.get("status"),
        "health": h,
        "marketCount": h.get("marketCount"),
        "marketRegime": _latest_dashboard.get("marketRegime") or "UNKNOWN",
        "marketHealth": _latest_dashboard.get("marketHealth"),
        "fastScanCount": _latest_dashboard.get("fastScanCount") or 0,
        "deepScanCount": _latest_dashboard.get("deepScanCount") or 0,
        "microBufferReadyMarkets": h.get("microBufferReadyMarkets"),
        "modelVersion": h.get("modelVersion"),
        "strategyVersion": h.get("strategyVersion"),
        "apiVersion": h.get("apiVersion"),
        "learningStatus": h.get("learningStatus"),
        "autonomousLearning": bithumb_research.status(),
        "recentLearning": bithumb_research.recent_learning_card(),
        "candidates": (_latest_dashboard.get("candidates") or [])[:limit],
        "serverComputeMs": _latest_dashboard.get("serverComputeMs"),
        "paper": paper_state,
        # Duplicate SoT path: trades must arrive even if /paper/trades is skipped on device.
        "recentTrades": paper_state.get("recentTrades") or [],
        "tradeCount": paper_state.get("tradeCount") or 0,
    }


@app.get("/api/trading/v1/upbit/dashboard", dependencies=[Depends(require_token)])
async def upbit_dashboard(limit: int = 15, refresh: bool = False) -> dict[str, Any]:
    h = await upbit_health()
    if refresh or not _latest_upbit_dashboard.get("candidates"):
        fast = upbit_engine.fast_scan(limit=max(limit, 20))
        deep = [c["market"] for c in fast[:limit]]
        if deep:
            await upbit_collector.fetch_orderbooks(deep)
        decisions = [upbit_engine.decide_market(m) for m in deep]
        now = int(time.time() * 1000)
        marks = _upbit_mark_prices()
        upbit_paper.tick(decisions, marks)
        _latest_upbit_dashboard.update(
            {
                "serverTimestamp": now,
                "fastScanCount": len(fast),
                "deepScanCount": len(decisions),
                "fastCandidates": fast,
                "candidates": [_candidate_view(d) for d in decisions],
                "marketHealth": 80.0 if h.get("wsState") == "CONNECTED" else 40.0,
                "paper": upbit_paper.state(marks),
                "exchange": "UPBIT",
            }
        )
    marks = _upbit_mark_prices()
    paper_state = upbit_paper.state(marks)
    return {
        "exchange": "UPBIT",
        "serverTimestamp": _latest_upbit_dashboard.get("serverTimestamp") or int(time.time() * 1000),
        "serverHealth": h.get("status"),
        "health": h,
        "marketCount": h.get("marketCount"),
        "marketRegime": _latest_upbit_dashboard.get("marketRegime") or "UNKNOWN",
        "marketHealth": _latest_upbit_dashboard.get("marketHealth"),
        "fastScanCount": _latest_upbit_dashboard.get("fastScanCount") or 0,
        "deepScanCount": _latest_upbit_dashboard.get("deepScanCount") or 0,
        "microBufferReadyMarkets": h.get("microReadyMarkets"),
        "modelVersion": h.get("modelVersion"),
        "strategyVersion": h.get("strategyVersion"),
        "apiVersion": h.get("apiVersion"),
        "learningStatus": upbit_research.status().get("learningStatus"),
        "autonomousLearning": upbit_research.status(),
        "recentLearning": upbit_research.recent_learning_card(),
        "candidates": (_latest_upbit_dashboard.get("candidates") or [])[:limit],
        "serverComputeMs": _latest_upbit_dashboard.get("serverComputeMs"),
        "paper": paper_state,
        "recentTrades": paper_state.get("recentTrades") or [],
        "tradeCount": paper_state.get("tradeCount") or 0,
        "liveTrading": False,
    }


@app.get("/api/trading/v1/upbit/candidates", dependencies=[Depends(require_token)])
async def upbit_candidates(limit: int = 20) -> dict[str, Any]:
    started = time.perf_counter()
    fast = upbit_engine.fast_scan(limit=limit)
    await upbit_collector.fetch_orderbooks([c["market"] for c in fast[:15]])
    decisions = [upbit_engine.decide_market(c["market"]) for c in fast[: min(limit, 15)]]
    return {
        "exchange": "UPBIT",
        "serverTimestamp": int(time.time() * 1000),
        "serverComputeMs": round((time.perf_counter() - started) * 1000.0, 2),
        "fastCandidates": fast,
        "decisions": decisions,
        "candidates": [_candidate_view(d) for d in decisions],
    }


@app.get("/api/trading/v1/paper/state", dependencies=[Depends(require_token)])
async def paper_state() -> dict[str, Any]:
    return bithumb_paper.state(_mark_prices())


@app.get("/api/trading/v1/upbit/paper/state", dependencies=[Depends(require_token)])
async def upbit_paper_state() -> dict[str, Any]:
    return upbit_paper.state(_upbit_mark_prices())


@app.get("/api/trading/v1/paper/positions", dependencies=[Depends(require_token)])
async def paper_positions() -> dict[str, Any]:
    return {"exchange": "BITHUMB", "positions": bithumb_paper.positions(_mark_prices()), "serverTimestamp": int(time.time() * 1000)}


@app.get("/api/trading/v1/upbit/paper/positions", dependencies=[Depends(require_token)])
async def upbit_paper_positions() -> dict[str, Any]:
    return {"exchange": "UPBIT", "positions": upbit_paper.positions(_upbit_mark_prices()), "serverTimestamp": int(time.time() * 1000)}


@app.get("/api/trading/v1/paper/trades", dependencies=[Depends(require_token)])
async def paper_trades(limit: int = 50) -> dict[str, Any]:
    return {"exchange": "BITHUMB", "trades": bithumb_paper.trades(limit=limit), "serverTimestamp": int(time.time() * 1000)}


@app.get("/api/trading/v1/upbit/paper/trades", dependencies=[Depends(require_token)])
async def upbit_paper_trades(limit: int = 50) -> dict[str, Any]:
    return {"exchange": "UPBIT", "trades": upbit_paper.trades(limit=limit), "serverTimestamp": int(time.time() * 1000)}


@app.post("/api/trading/v1/paper/auto", dependencies=[Depends(require_token)])
async def paper_auto(body: PaperAutoBody) -> dict[str, Any]:
    st = bithumb_paper.set_auto(bool(body.enabled))
    print(
        f"[BITHUMB] FLOW PAPER_AUTO_CMD enabled={body.enabled} source={body.source or 'ANDROID'} "
        f"persisted=YES androidLifecycleIndependent=YES",
        flush=True,
    )
    return {"accepted": True, "exchange": "BITHUMB", "paper": st}


@app.post("/api/trading/v1/upbit/paper/auto", dependencies=[Depends(require_token)])
async def upbit_paper_auto(body: PaperAutoBody) -> dict[str, Any]:
    st = upbit_paper.set_auto(bool(body.enabled))
    print(
        f"[UPBIT] FLOW PAPER_AUTO_CMD enabled={body.enabled} source={body.source or 'ANDROID'} "
        f"persisted=YES androidLifecycleIndependent=YES live=DISABLED",
        flush=True,
    )
    return {"accepted": True, "exchange": "UPBIT", "paper": st, "liveTrading": False}


@app.post("/api/trading/v1/paper/settings", dependencies=[Depends(require_token)])
async def paper_settings(body: PaperSettingsBody) -> dict[str, Any]:
    patch = {k: v for k, v in body.model_dump().items() if v is not None}
    if "newBuyPaused" in patch and patch["newBuyPaused"] and "paperBuyResumeMode" not in patch:
        patch["paperBuyResumeMode"] = "PAUSED_DIAGNOSTIC"
    if "pausedAtMs" not in patch and patch.get("newBuyPaused") is True:
        patch["pausedAtMs"] = int(time.time() * 1000)
    settings = bithumb_paper.update_settings(patch)
    st = bithumb_paper.state()
    print(
        f"[BITHUMB][LOSS_ANALYSIS] paper_settings patch={patch} mode={settings.get('paperBuyResumeMode')}",
        flush=True,
    )
    return {"accepted": True, "exchange": "BITHUMB", "settings": settings, "paper": st, "liveTrading": False}


@app.post("/api/trading/v1/upbit/paper/settings", dependencies=[Depends(require_token)])
async def upbit_paper_settings(body: PaperSettingsBody) -> dict[str, Any]:
    patch = {k: v for k, v in body.model_dump().items() if v is not None}
    if "newBuyPaused" in patch and patch["newBuyPaused"] and "paperBuyResumeMode" not in patch:
        patch["paperBuyResumeMode"] = "PAUSED_DIAGNOSTIC"
    settings = upbit_paper.update_settings(patch)
    st = upbit_paper.state()
    print(
        f"[UPBIT][LOSS_ANALYSIS] paper_settings patch={patch} mode={settings.get('paperBuyResumeMode')}",
        flush=True,
    )
    return {"accepted": True, "exchange": "UPBIT", "settings": settings, "paper": st, "liveTrading": False}


@app.post("/api/trading/v1/diagnostics/device-verify", dependencies=[Depends(require_token)])
async def device_verify_post(body: DeviceVerifyBody) -> dict[str, Any]:
    """Receive Android PHASE6 verification results. Never touches paper engine / orders."""
    event = (body.event or "").strip()
    decision = (body.decision or "").strip().upper()
    if event not in {
        "UI_SERVER_DATA_MATCH",
        "APP_REOPEN_SERVER_STATE_RESTORE",
        "ANDROID_MODE_REPORT",
    }:
        raise HTTPException(status_code=400, detail="unsupported event")
    if decision not in {"PASS", "FAIL", "INFO"}:
        raise HTTPException(status_code=400, detail="decision must be PASS, FAIL, or INFO")
    received_at = int(time.time() * 1000)
    row = {
        "deviceSessionId": body.deviceSessionId,
        "appVersion": body.appVersion,
        "timestamp": body.timestamp,
        "event": event,
        "decision": decision,
        "serverStateTimestamp": body.serverStateTimestamp,
        "reason": body.reason or "",
        "expected": body.expected or {},
        "actual": body.actual or {},
        "receivedAt": received_at,
    }
    _device_verifies.appendleft(row)
    print(
        f"FLOW DEVICE_VERIFY event={event} decision={decision} "
        f"deviceSessionId={body.deviceSessionId} appVersion={body.appVersion} "
        f"ts={body.timestamp} serverStateTs={body.serverStateTimestamp or 0} "
        f"reason={(body.reason or '-')[:180]}",
        flush=True,
    )
    return {"accepted": True, "receivedAt": received_at, "event": event, "decision": decision}


@app.get("/api/trading/v1/diagnostics/device-verify", dependencies=[Depends(require_token)])
async def device_verify_list(limit: int = 50, event: str | None = None) -> dict[str, Any]:
    """Cursor/ops: read recent device verification uploads."""
    items = list(_device_verifies)
    if event:
        items = [x for x in items if x.get("event") == event]
    return {
        "serverTimestamp": int(time.time() * 1000),
        "count": len(items[: max(1, min(limit, 200))]),
        "items": items[: max(1, min(limit, 200))],
    }


@app.post("/api/trading/v1/paper/reset", dependencies=[Depends(require_token)])
async def paper_reset(body: dict[str, Any] | None = None) -> dict[str, Any]:
    body = body or {}
    initial = body.get("initialCash")
    st = bithumb_paper.reset_account(float(initial) if initial is not None else None)
    return {"accepted": True, "exchange": "BITHUMB", "paper": st}


@app.post("/api/trading/v1/upbit/paper/reset", dependencies=[Depends(require_token)])
async def upbit_paper_reset(body: dict[str, Any] | None = None) -> dict[str, Any]:
    body = body or {}
    initial = body.get("initialCash")
    st = upbit_paper.reset_account(float(initial) if initial is not None else None)
    return {"accepted": True, "exchange": "UPBIT", "paper": st, "liveTrading": False}


@app.post("/api/trading/v1/paper/verify-buy", dependencies=[Depends(require_token)])
async def paper_verify_buy(body: PaperVerifyBuyBody | None = None) -> dict[str, Any]:
    body = body or PaperVerifyBuyBody()
    marks = _mark_prices()
    market = (body.market or "").strip()
    if not market.startswith("KRW-"):
        tops = bithumb_engine.fast_scan(limit=5)
        market = tops[0]["market"] if tops else ""
    if not market.startswith("KRW-") or market not in marks:
        raise HTTPException(status_code=400, detail="no live market for verify-buy")
    if not bithumb_paper.auto_enabled():
        raise HTTPException(status_code=400, detail="PAPER_AUTO_OFF")
    now = int(time.time() * 1000)
    decision = {
        "decisionId": f"verify-{uuid_str()}",
        "exchange": "BITHUMB",
        "market": market,
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + DECISION_TTL_MS,
        "expiresAt": now + DECISION_TTL_MS,
        "dataQuality": "GOOD",
        "signalPrice": marks[market],
    }
    result = bithumb_paper.try_buy(decision, marks[market], now_ms=now)
    return {"accepted": bool(result.get("ok")), "exchange": "BITHUMB", "result": result, "paper": bithumb_paper.state(marks)}


@app.post("/api/trading/v1/upbit/paper/verify-buy", dependencies=[Depends(require_token)])
async def upbit_paper_verify_buy(body: PaperVerifyBuyBody | None = None) -> dict[str, Any]:
    body = body or PaperVerifyBuyBody()
    marks = _upbit_mark_prices()
    market = (body.market or "").strip()
    if not market.startswith("KRW-"):
        tops = upbit_engine.fast_scan(limit=5)
        market = tops[0]["market"] if tops else ""
    if not market.startswith("KRW-") or market not in marks:
        raise HTTPException(status_code=400, detail="no live upbit market for verify-buy")
    if not upbit_paper.auto_enabled():
        raise HTTPException(status_code=400, detail="PAPER_AUTO_OFF")
    now = int(time.time() * 1000)
    decision = {
        "decisionId": f"upbit-verify-{uuid_str()}",
        "exchange": "UPBIT",
        "market": market,
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + DECISION_TTL_MS,
        "expiresAt": now + DECISION_TTL_MS,
        "dataQuality": "GOOD",
        "signalPrice": marks[market],
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 100.0,
    }
    result = upbit_paper.try_buy(decision, marks[market], now_ms=now)
    return {"accepted": bool(result.get("ok")), "exchange": "UPBIT", "result": result, "paper": upbit_paper.state(marks), "liveTrading": False}


def uuid_str() -> str:
    import uuid as _uuid

    return str(_uuid.uuid4())


@app.get("/api/trading/v1/model/status", dependencies=[Depends(require_token)])
async def model_status() -> dict[str, Any]:
    b = bithumb_research.status()
    u = upbit_research.status()
    return {
        "modelVersion": b.get("activeModel") or MODEL_VERSION,
        "strategyVersion": STRATEGY_VERSION,
        "status": b.get("learningStatus"),
        "livePromotion": False,
        "phase": 4,
        "bithumb": {
            "modelVersion": b.get("activeModel"),
            "modelHash": b.get("activeModelHash"),
            "status": b.get("learningStatus"),
            "championVersion": b.get("championVersion"),
            "challengerVersion": b.get("challengerVersion"),
        },
        "upbit": {
            "modelVersion": u.get("activeModel"),
            "modelHash": u.get("activeModelHash"),
            "status": u.get("learningStatus"),
            "championVersion": u.get("championVersion"),
            "challengerVersion": u.get("challengerVersion"),
        },
    }


def _research_for(exchange: str) -> AutonomousResearchEngine:
    ex = (exchange or "BITHUMB").upper()
    if ex == "UPBIT":
        return upbit_research
    if ex == "BITHUMB":
        return bithumb_research
    raise HTTPException(status_code=404, detail="exchange must be bithumb|upbit")


@app.get("/api/trading/v1/{exchange}/ai/status", dependencies=[Depends(require_token)])
async def ai_status(exchange: str) -> dict[str, Any]:
    return _research_for(exchange).status()


@app.get("/api/trading/v1/{exchange}/ai/learning", dependencies=[Depends(require_token)])
async def ai_learning(exchange: str, limit: int = 10) -> dict[str, Any]:
    eng = _research_for(exchange)
    return {
        "exchange": eng.exchange,
        "status": eng.status(),
        "cycles": eng.store.latest_learning_cycles(limit),
        "recentLearning": eng.recent_learning_card(),
    }


@app.get("/api/trading/v1/{exchange}/ai/experiments", dependencies=[Depends(require_token)])
async def ai_experiments(exchange: str, limit: int = 30) -> dict[str, Any]:
    eng = _research_for(exchange)
    return {"exchange": eng.exchange, "experiments": eng.store.list_experiments(limit), "hypotheses": eng.store.list_hypotheses(limit)}


@app.get("/api/trading/v1/{exchange}/ai/models", dependencies=[Depends(require_token)])
async def ai_models(exchange: str, limit: int = 50) -> dict[str, Any]:
    eng = _research_for(exchange)
    return {
        "exchange": eng.exchange,
        "active": eng.store.get_active_model(),
        "shadow": eng.store.get_shadow(),
        "lineage": eng.store.list_lineage(limit),
    }


@app.get("/api/trading/v1/{exchange}/ai/research", dependencies=[Depends(require_token)])
async def ai_research(exchange: str, limit: int = 30) -> dict[str, Any]:
    eng = _research_for(exchange)
    return {
        "exchange": eng.exchange,
        "brainState": eng.state,
        "journal": eng.store.list_journal(limit),
        "memory": eng.store.list_memory(limit=limit),
        "parameterRegistry": eng.status().get("parameterRegistry"),
    }


@app.get("/api/trading/v1/{exchange}/ai/explain/{decision_id}", dependencies=[Depends(require_token)])
async def ai_explain(exchange: str, decision_id: str) -> dict[str, Any]:
    return _research_for(exchange).explain_decision(decision_id)


class ExternalHypothesisBody(BaseModel):
    text: str
    proposedDeltas: dict[str, float] | None = None


@app.post("/api/trading/v1/{exchange}/ai/external-hypothesis", dependencies=[Depends(require_token)])
async def ai_external_hypothesis(exchange: str, body: ExternalHypothesisBody) -> dict[str, Any]:
    """EXTERNAL_HYPOTHESIS → same Replay/OOS/Shadow path. Never direct champion write."""
    eng = _research_for(exchange)
    proof = eng.register_external_hypothesis(body.text, body.proposedDeltas)
    return {"accepted": True, "directProductionChange": False, "cycle": proof}


@app.post("/api/trading/v1/{exchange}/ai/research-cycle", dependencies=[Depends(require_token)])
async def ai_research_cycle(exchange: str, force: bool = True) -> dict[str, Any]:
    """Safe offline/on-server research cycle. Does not place orders or unpause BUY."""
    eng = _research_for(exchange)
    proof = eng.run_research_cycle(force=force)
    return {"ok": True, "cycle": proof, "status": eng.status()}


@app.get("/api/trading/v1/{exchange}/ai/authenticity", dependencies=[Depends(require_token)])
async def ai_authenticity(exchange: str) -> dict[str, Any]:
    """Real vs synthetic production evidence. Never invent VERIFIED."""
    from .learning_authenticity import audit_reported_cycle_m101

    eng = _research_for(exchange)
    st = eng.status()
    cycles = eng.store.latest_learning_cycles(5)
    active = eng.store.get_active_model()
    paper_state = (upbit_paper if eng.exchange == "UPBIT" else bithumb_paper).state(
        _upbit_mark_prices() if eng.exchange == "UPBIT" else _mark_prices()
    )
    return {
        "exchange": eng.exchange,
        "layer": 2,
        "layerStatus": st.get("layerStatus"),
        "m101ReportedCycleAudit": audit_reported_cycle_m101(),
        "realSampleCount": st.get("realSampleCount"),
        "realShadowSampleCount": st.get("realShadowSampleCount"),
        "paperSampleCount": st.get("paperSampleCount"),
        "partialRealSampleCount": st.get("partialRealSampleCount"),
        "invalidSampleCount": st.get("invalidSampleCount"),
        "syntheticSampleCount": st.get("syntheticSampleCount"),
        "realLearningCycleCount": st.get("realLearningCycleCount"),
        "syntheticCycleCount": st.get("syntheticCycleCount"),
        "NEXT_REQUIREMENT": st.get("NEXT_REQUIREMENT"),
        "activeModel": active.get("modelVersion"),
        "activeModelVersion": active.get("modelVersion"),
        "activeModelHash": active.get("modelHash"),
        "activeModelSource": active.get("source"),
        "candidateModel": st.get("candidateModel"),
        "candidateModelHash": st.get("candidateModelHash"),
        "lastRealLearningCycle": st.get("lastRealLearningCycle"),
        "lastRealCycle": st.get("lastRealLearningCycle"),
        "lastRealPromotion": st.get("lastRealPromotion"),
        "lastWeightDelta": st.get("lastWeightDelta"),
        "predictionChanged": bool((st.get("decisionChangedCount") or 0) > 0),
        "predictionChangeRate": st.get("predictionChangeRate"),
        "scoreChangedCount": st.get("scoreChangedCount"),
        "decisionChangedCount": st.get("decisionChangedCount"),
        "realProductionDecisionChangedCount": st.get("realProductionDecisionChangedCount"),
        "testDecisionChangedCount": st.get("testDecisionChangedCount"),
        "decisionTransitions": st.get("decisionTransitions"),
        "predictionCompare": st.get("predictionCompare"),
        "whyWeightChanged": st.get("whyWeightChanged"),
        "datasetDiversity": st.get("datasetDiversity"),
        "boundaryCoverage": st.get("boundaryCoverage"),
        "layerStatus": st.get("layerStatus"),
        "shadowCompletedSamples": st.get("shadowCompletedSamples"),
        "oosStatus": st.get("oosStatus"),
        "shadowStatus": st.get("shadowStatus"),
        "learningStatus": st.get("learningStatus"),
        "learningHealth": st.get("learningHealth"),
        "learningProofSource": st.get("learningProofSource"),
        "modelStatus": st.get("modelStatus"),
        "productionEvidence": st.get("productionEvidence"),
        "productionEvidenceDetail": st.get("productionEvidenceDetail"),
        "isLearning": st.get("isLearning"),
        "isImproving": st.get("isImproving"),
        "lastHypothesis": st.get("lastHypothesis"),
        "lastRejectedExperiment": st.get("lastRejectedExperiment"),
        "recoveryValidationMode": st.get("recoveryValidationMode"),
        "minOosPfForPromote": st.get("minOosPfForPromote"),
        "recentCycles": cycles,
        "paperBuyState": paper_state.get("paperBuyResumeMode") or "PAUSED_DIAGNOSTIC",
        "newBuyPaused": paper_state.get("newBuyPaused"),
        "liveTrading": False,
        "crossExchangeLearning": False,
        "workspaceActiveModel": active,
        "NO_REAL_PRODUCTION_EVIDENCE": st.get("productionEvidence") == "NONE",
    }


@app.get("/api/trading/v1/build-identity", dependencies=[Depends(require_token)])
async def build_identity() -> dict[str, Any]:
    """Source/deploy identity for production drift detection."""
    import os
    from pathlib import Path

    commit = (
        os.environ.get("BITHUMB_AI_GIT_COMMIT")
        or os.environ.get("GIT_COMMIT")
        or ""
    ).strip()
    marker = Path("/opt/bithumb-ai-brain/GIT_COMMIT")
    if not commit and marker.exists():
        commit = marker.read_text(encoding="utf-8").strip()
    return {
        "apiVersion": API_VERSION,
        "strategyVersion": STRATEGY_VERSION,
        "modelVersion": MODEL_VERSION,
        "serverGitCommit": commit or None,
        "dataDir": str(DATA_DIR),
        "hasResearchModules": True,
        "hasUpbitEngine": True,
        "crossExchangeLearning": False,
        "liveBithumb": False,
        "liveUpbit": False,
    }


@app.get("/api/trading/v1/candidates", dependencies=[Depends(require_token)])
async def candidates(limit: int = 20) -> dict[str, Any]:
    started = time.perf_counter()
    fast = bithumb_engine.fast_scan(limit=limit)
    await bithumb_collector.fetch_orderbooks([c["market"] for c in fast[:15]])
    decisions = [bithumb_engine.decide_market(c["market"]) for c in fast[: min(limit, 15)]]
    return {
        "exchange": "BITHUMB",
        "serverTimestamp": int(time.time() * 1000),
        "serverComputeMs": round((time.perf_counter() - started) * 1000.0, 2),
        "fastCandidates": fast,
        "decisions": decisions,
    }


@app.post("/api/trading/v1/decision", dependencies=[Depends(require_token)])
async def decision(body: dict[str, Any] | None = None) -> dict[str, Any]:
    body = body or {}
    market = str(body.get("market") or "").strip()
    if not market.startswith("KRW-"):
        raise HTTPException(status_code=400, detail="market required as KRW-*")
    await bithumb_collector.fetch_orderbooks([market])
    return bithumb_engine.decide_market(market)


@app.post("/api/trading/v1/outcome", dependencies=[Depends(require_token)])
async def outcome(body: OutcomeBody) -> dict[str, Any]:
    payload = body.model_dump(by_alias=True)
    exchange = str(payload.get("exchange") or "BITHUMB").upper()
    target = upbit_store if exchange == "UPBIT" else bithumb_store
    research = upbit_research if exchange == "UPBIT" else bithumb_research
    ok, info = target.save_outcome(payload)
    if not ok and info == "DUPLICATE_OUTCOME":
        return {"accepted": False, "reason": "DUPLICATE_OUTCOME", "idempotent": True, "exchange": exchange}
    if not ok:
        raise HTTPException(status_code=400, detail=info)
    # OUTCOME → TRAINING SAMPLE (isolated; never blocks realtime)
    sample_id = None
    try:
        decision = None
        did = payload.get("decisionId")
        if did:
            with target._conn() as conn:
                row = conn.execute(
                    "SELECT payload_json FROM decisions WHERE decision_id=?", (str(did),)
                ).fetchone()
            if row:
                import json as _json

                decision = _json.loads(row["payload_json"])
        sample_id = research.ingest_decision_outcome(decision, payload, quality="VALID")
    except Exception as exc:
        print(f"[{exchange}][RESEARCH] outcome ingest error: {exc}", flush=True)
    return {"accepted": True, "outcomeId": info, "exchange": exchange, "trainingSampleId": sample_id}


@app.middleware("http")
async def add_server_time(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Server-Time"] = str(int(time.time() * 1000))
    return response

===== END FILE: server/ai-brain/app/main.py =====

===== FILE: server/ai-brain/app/market_collector.py =====
from __future__ import annotations

import asyncio
import json
import time
from collections import deque
from dataclasses import dataclass, field
from threading import RLock
from typing import Any

import httpx

from .config import BITHUMB_REST, BITHUMB_WS_URL, ORDERBOOK_STALE_MS, TICKER_STALE_MS
from .micro_buffer import MicroBufferStore

try:
    import websockets
except ImportError:  # pragma: no cover
    websockets = None


@dataclass
class TickerSnap:
    market: str
    trade_price: float
    acc_trade_price_24h: float
    signed_change_rate: float
    trade_volume: float
    timestamp_ms: int
    received_at_ms: int = 0
    source: str = "WS"


@dataclass
class OrderbookSnap:
    market: str
    bid_price: float
    ask_price: float
    bid_size: float
    ask_size: float
    timestamp_ms: int
    received_at_ms: int = 0
    source: str = "REST"

    @property
    def spread_percent(self) -> float | None:
        if self.ask_price <= 0 or self.bid_price <= 0 or self.bid_price > self.ask_price:
            return None
        return (self.ask_price - self.bid_price) / self.ask_price * 100.0

    @property
    def imbalance(self) -> float | None:
        total = self.bid_size + self.ask_size
        if total <= 0:
            return None
        return self.bid_size / total


@dataclass
class CollectorStats:
    connection_state: str = "DISCONNECTED"
    last_message_at: int = 0
    message_count: int = 0
    reconnect_count: int = 0
    last_rest_fallback_at: int = 0
    rest_fallback_success: int = 0
    rest_fallback_failed: int = 0
    started_at: int = field(default_factory=lambda: int(time.time() * 1000))


class MarketCollector:
    def __init__(self, micro: MicroBufferStore) -> None:
        self.micro = micro
        self.stats = CollectorStats()
        self._lock = RLock()
        self._tickers: dict[str, TickerSnap] = {}
        self._orderbooks: dict[str, OrderbookSnap] = {}
        self._orderbook_history: dict[str, deque[OrderbookSnap]] = {}
        self._markets: list[str] = []
        self._task: asyncio.Task | None = None
        self._stop = asyncio.Event()

    def start(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
        if self._task and not self._task.done():
            return
        self._stop.clear()
        loop = loop or asyncio.get_event_loop()
        self._task = loop.create_task(self._run())

    async def stop(self) -> None:
        self._stop.set()
        if self._task:
            await asyncio.wait([self._task], timeout=3)

    def snapshot_tickers(self) -> dict[str, TickerSnap]:
        with self._lock:
            return dict(self._tickers)

    def snapshot_orderbook(self, market: str) -> OrderbookSnap | None:
        with self._lock:
            return self._orderbooks.get(market)

    def orderbook_history(self, market: str) -> list[OrderbookSnap]:
        with self._lock:
            return list(self._orderbook_history.get(market) or [])

    def stale_market_count(self, now_ms: int | None = None) -> int:
        now = now_ms or int(time.time() * 1000)
        with self._lock:
            return sum(1 for t in self._tickers.values() if now - t.timestamp_ms > TICKER_STALE_MS)

    def health(self) -> dict[str, Any]:
        now = int(time.time() * 1000)
        age = now - self.stats.last_message_at if self.stats.last_message_at else None
        zombie = self.stats.connection_state == "CONNECTED" and age is not None and age > 60_000
        uptime = now - self.stats.started_at
        rate = 0.0
        if uptime > 0 and self.stats.message_count > 0:
            rate = self.stats.message_count / max(1.0, uptime / 1000.0)
        return {
            "connectionState": "WEBSOCKET_ZOMBIE" if zombie else self.stats.connection_state,
            "lastMessageAt": self.stats.last_message_at or None,
            "lastMessageAgeMs": age,
            "messageCount": self.stats.message_count,
            "messageRate": round(rate, 3),
            "reconnectCount": self.stats.reconnect_count,
            "staleMarketCount": self.stale_market_count(now),
            "marketCount": len(self._markets) or len(self._tickers),
            "lastRestFallbackAt": self.stats.last_rest_fallback_at or None,
            "restFallbackSuccess": self.stats.rest_fallback_success,
            "restFallbackFailed": self.stats.rest_fallback_failed,
            "zombieReason": "BITHUMB_WS_ZOMBIE" if zombie else None,
        }

    async def refresh_markets(self) -> list[str]:
        async with httpx.AsyncClient(timeout=15.0) as client:
            res = await client.get(f"{BITHUMB_REST}/v1/market/all", params={"isDetails": "true"})
            res.raise_for_status()
            rows = res.json()
        markets = [r["market"] for r in rows if str(r.get("market", "")).startswith("KRW-") and r.get("market_warning", "NONE") == "NONE"]
        self._markets = markets
        return markets

    async def rest_ticker_fallback(self, markets: list[str] | None = None) -> int:
        codes = markets or self._markets
        if not codes:
            return 0
        updated = 0
        started = int(time.time() * 1000)
        self.stats.last_rest_fallback_at = started
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                for i in range(0, len(codes), 80):
                    chunk = codes[i : i + 80]
                    res = await client.get(f"{BITHUMB_REST}/v1/ticker", params={"markets": ",".join(chunk)})
                    res.raise_for_status()
                    for row in res.json():
                        self._ingest_ticker_dict(row, source="REST")
                        updated += 1
            self.stats.rest_fallback_success += 1
        except Exception:
            self.stats.rest_fallback_failed += 1
            raise
        return updated

    async def fetch_orderbooks(self, markets: list[str]) -> int:
        if not markets:
            return 0
        from .market_integrity import validate_orderbook

        count = 0
        async with httpx.AsyncClient(timeout=20.0) as client:
            for i in range(0, len(markets), 40):
                chunk = markets[i : i + 40]
                res = await client.get(f"{BITHUMB_REST}/v1/orderbook", params={"markets": ",".join(chunk)})
                res.raise_for_status()
                now = int(time.time() * 1000)
                for row in res.json():
                    units = row.get("orderbook_units") or []
                    if not units:
                        continue
                    unit = units[0]
                    bid = float(unit.get("bid_price") or 0)
                    ask = float(unit.get("ask_price") or 0)
                    bid_sz = float(unit.get("bid_size") or 0)
                    ask_sz = float(unit.get("ask_size") or 0)
                    ts = int(row.get("timestamp") or now)
                    if validate_orderbook(bid=bid, ask=ask, bid_size=bid_sz, ask_size=ask_sz, age_ms=0):
                        # Quarantine: do not overwrite a previous valid book with invalid data
                        continue
                    snap = OrderbookSnap(
                        market=row["market"],
                        bid_price=bid,
                        ask_price=ask,
                        bid_size=bid_sz,
                        ask_size=ask_sz,
                        timestamp_ms=ts,
                        received_at_ms=now,
                        source="REST",
                    )
                    with self._lock:
                        self._orderbooks[snap.market] = snap
                        hist = self._orderbook_history.setdefault(snap.market, deque(maxlen=120))
                        hist.append(snap)
                    count += 1
        return count

    def _ingest_ticker_dict(self, row: dict[str, Any], source: str = "WS") -> None:
        from .market_integrity import validate_ticker

        market = str(row.get("code") or row.get("market") or "")
        if not market.startswith("KRW-"):
            return
        price = float(row.get("trade_price") or 0)
        now = int(time.time() * 1000)
        ts = int(row.get("timestamp") or now)
        if ts < 10_000_000_000:
            ts *= 1000
        if validate_ticker(price=price, exchange_ts_ms=ts, received_at_ms=now, now_ms=now):
            return
        with self._lock:
            prev = self._tickers.get(market)
            # Prefer newer exchange timestamp; avoid regressing with older REST/WS mix
            if prev is not None and ts < prev.timestamp_ms:
                return
        snap = TickerSnap(
            market=market,
            trade_price=price,
            acc_trade_price_24h=float(row.get("acc_trade_price_24h") or 0),
            signed_change_rate=float(row.get("signed_change_rate") or 0),
            trade_volume=float(row.get("trade_volume") or 0),
            timestamp_ms=ts,
            received_at_ms=now,
            source=source,
        )
        with self._lock:
            self._tickers[market] = snap
        self.micro.add(market, price, snap.trade_volume, now_ms=now if source == "WS" else ts)
        if source == "WS":
            self.stats.last_message_at = now
            self.stats.message_count += 1

    async def _run(self) -> None:
        backoff = 1
        while not self._stop.is_set():
            try:
                if not self._markets:
                    await self.refresh_markets()
                await self.rest_ticker_fallback(self._markets)
                await self._ws_loop()
                backoff = 1
            except asyncio.CancelledError:
                raise
            except Exception:
                self.stats.connection_state = "ERROR"
                self.stats.reconnect_count += 1
                try:
                    await self.rest_ticker_fallback(self._markets)
                except Exception:
                    pass
                await asyncio.sleep(min(60, backoff))
                backoff = min(60, backoff * 2)

    async def _ws_loop(self) -> None:
        if websockets is None:
            raise RuntimeError("websockets package missing")
        self.stats.connection_state = "CONNECTING"
        async with websockets.connect(BITHUMB_WS_URL, ping_interval=20, ping_timeout=20, max_queue=2048) as ws:
            self.stats.connection_state = "CONNECTED"
            payload = [
                {"ticket": f"ai-brain-{int(time.time())}"},
                {"type": "ticker", "codes": self._markets, "is_only_realtime": True},
                {"format": "DEFAULT"},
            ]
            await ws.send(json.dumps(payload))
            while not self._stop.is_set():
                raw = await asyncio.wait_for(ws.recv(), timeout=45)
                if isinstance(raw, bytes):
                    raw = raw.decode("utf-8", errors="ignore")
                try:
                    msg = json.loads(raw)
                except json.JSONDecodeError:
                    continue
                if isinstance(msg, dict):
                    self._ingest_ticker_dict(msg, source="WS")
                # periodic REST orderbook for top movers is handled by decision engine on demand

===== END FILE: server/ai-brain/app/market_collector.py =====

===== FILE: server/ai-brain/app/market_integrity.py =====
"""Layer-1 market integrity helpers (shared Bithumb/Upbit).

Does not replace collectors or DecisionEngine — only validates/quarantines
observations before they are treated as FRESH AI input.
"""
from __future__ import annotations

import math
from typing import Any


FRESHNESS_FRESH = "FRESH"
FRESHNESS_AGING = "AGING"
FRESHNESS_STALE = "STALE"
FRESHNESS_INVALID = "INVALID"

DQ_GOOD = "GOOD"
DQ_DEGRADED = "DEGRADED"
DQ_BAD = "BAD"
DQ_QUARANTINED = "QUARANTINED"


def _finite(v: Any) -> bool:
    try:
        return v is not None and math.isfinite(float(v))
    except (TypeError, ValueError):
        return False


def ticker_freshness(age_ms: int | None, *, fresh_ms: int = 5_000, aging_ms: int = 15_000, stale_ms: int = 30_000) -> str:
    if age_ms is None or age_ms < 0:
        return FRESHNESS_INVALID
    if age_ms <= fresh_ms:
        return FRESHNESS_FRESH
    if age_ms <= aging_ms:
        return FRESHNESS_AGING
    if age_ms <= stale_ms:
        return FRESHNESS_STALE
    return FRESHNESS_INVALID


def validate_ticker(
    *,
    price: float | None,
    exchange_ts_ms: int | None,
    received_at_ms: int | None,
    now_ms: int,
    max_future_skew_ms: int = 60_000,
) -> list[str]:
    reasons: list[str] = []
    if price is None or not _finite(price) or float(price) <= 0:
        reasons.append("PRICE_INVALID")
    if exchange_ts_ms is None or exchange_ts_ms <= 0:
        reasons.append("TIMESTAMP_MISSING")
    else:
        if exchange_ts_ms - now_ms > max_future_skew_ms:
            reasons.append("TIMESTAMP_FUTURE")
        if received_at_ms is not None and exchange_ts_ms - received_at_ms > max_future_skew_ms:
            reasons.append("TIMESTAMP_FUTURE_VS_RECEIVED")
    return reasons


def validate_orderbook(
    *,
    bid: float | None,
    ask: float | None,
    bid_size: float | None = None,
    ask_size: float | None = None,
    age_ms: int | None = None,
    stale_ms: int = 5_000,
) -> list[str]:
    reasons: list[str] = []
    if bid is None or ask is None or not _finite(bid) or not _finite(ask):
        reasons.append("ORDERBOOK_MISSING_OR_NAN")
        return reasons
    if float(bid) <= 0 or float(ask) <= 0:
        reasons.append("ORDERBOOK_NON_POSITIVE")
    if float(bid) > float(ask):
        reasons.append("BID_GT_ASK")
    if bid_size is not None and ask_size is not None:
        if (not _finite(bid_size)) or (not _finite(ask_size)) or float(bid_size) < 0 or float(ask_size) < 0:
            reasons.append("ORDERBOOK_DEPTH_INVALID")
        elif float(bid_size) <= 0 and float(ask_size) <= 0:
            reasons.append("ORDERBOOK_ZERO_DEPTH")
    if age_ms is not None and age_ms > stale_ms:
        reasons.append("ORDERBOOK_STALE")
    return reasons


def validate_candle(row: dict[str, Any]) -> list[str]:
    reasons: list[str] = []
    try:
        o = float(row.get("opening_price") or row.get("open") or 0)
        h = float(row.get("high_price") or row.get("high") or 0)
        low = float(row.get("low_price") or row.get("low") or 0)
        c = float(row.get("trade_price") or row.get("close") or 0)
        vol = float(row.get("candle_acc_trade_volume") or row.get("volume") or 0)
    except (TypeError, ValueError):
        return ["CANDLE_MALFORMED"]
    for name, v in (("open", o), ("high", h), ("low", low), ("close", c)):
        if not math.isfinite(v) or v <= 0:
            reasons.append(f"CANDLE_{name.upper()}_INVALID")
    if not reasons:
        if not (low <= o <= h and low <= c <= h):
            reasons.append("CANDLE_OHLC_INCONSISTENT")
    if not math.isfinite(vol) or vol < 0:
        reasons.append("CANDLE_VOLUME_NEGATIVE")
    ts = row.get("timestamp") or row.get("candle_date_time_utc")
    if ts is None:
        reasons.append("CANDLE_TIMESTAMP_MISSING")
    return reasons


def micro_temporal_quality(samples: list[Any], now_ms: int) -> dict[str, Any]:
    """Inspect micro history quality beyond raw sample count."""
    if not samples:
        return {
            "sampleCount": 0,
            "oldestTimestamp": None,
            "newestTimestamp": None,
            "durationCovered": 0,
            "averageInterval": None,
            "maxGap": None,
            "duplicateTimestampCount": 0,
            "outOfOrderCount": 0,
            "status": "MISSING",
            "usable": False,
        }
    times: list[int] = []
    for s in samples:
        if hasattr(s, "time_ms"):
            t = int(getattr(s, "time_ms") or 0)
        elif isinstance(s, dict):
            t = int(s.get("time_ms") or 0)
        else:
            t = 0
        if t > 0:
            times.append(t)
    if not times:
        return {
            "sampleCount": 0,
            "oldestTimestamp": None,
            "newestTimestamp": None,
            "durationCovered": 0,
            "averageInterval": None,
            "maxGap": None,
            "duplicateTimestampCount": 0,
            "outOfOrderCount": 0,
            "status": "MISSING",
            "usable": False,
        }
    oldest, newest = min(times), max(times)
    duration = max(0, newest - oldest)
    gaps = []
    dup = 0
    ooo = 0
    prev = None
    for t in times:  # samples are expected chronological; count regressions/dups in given order
        if prev is not None:
            if t == prev:
                dup += 1
            elif t < prev:
                ooo += 1
            else:
                gaps.append(t - prev)
        prev = t
    avg_iv = (sum(gaps) / len(gaps)) if gaps else None
    max_gap = max(gaps) if gaps else None
    n = len(times)
    # Clustered/identical timestamps → not a real micro history
    usable = n >= 8 and duration >= 8_000 and dup < max(2, n // 3) and ooo <= 1
    if not usable and n >= 8 and duration < 8_000:
        status = "CLUSTERED"
    elif not usable and n > 0:
        status = "INSUFFICIENT"
    elif usable:
        status = "AVAILABLE"
    else:
        status = "MISSING"
    return {
        "sampleCount": n,
        "oldestTimestamp": oldest,
        "newestTimestamp": newest,
        "durationCovered": duration,
        "averageInterval": round(avg_iv, 2) if avg_iv is not None else None,
        "maxGap": max_gap,
        "duplicateTimestampCount": dup,
        "outOfOrderCount": ooo,
        "ageMs": max(0, now_ms - newest),
        "status": status,
        "usable": usable,
    }


def snapshot_alignment(
    *,
    now_ms: int,
    ticker_ts: int | None,
    orderbook_ts: int | None,
    micro_newest_ts: int | None,
) -> dict[str, Any]:
    ages = {}
    if ticker_ts:
        ages["ticker"] = max(0, now_ms - int(ticker_ts))
    if orderbook_ts:
        ages["orderbook"] = max(0, now_ms - int(orderbook_ts))
    if micro_newest_ts:
        ages["micro"] = max(0, now_ms - int(micro_newest_ts))
    if not ages:
        return {
            "maxComponentAgeMs": None,
            "snapshotSkewMs": None,
            "snapshotQuality": "INVALID",
            "componentAgesMs": {},
        }
    max_age = max(ages.values())
    skew = max(ages.values()) - min(ages.values()) if len(ages) >= 2 else 0
    if max_age > 60_000 or skew > 30_000:
        quality = "BAD"
    elif max_age > 30_000 or skew > 15_000:
        quality = "DEGRADED"
    elif max_age > 5_000 or skew > 5_000:
        quality = "AGING"
    else:
        quality = "GOOD"
    return {
        "maxComponentAgeMs": max_age,
        "snapshotSkewMs": skew,
        "snapshotQuality": quality,
        "componentAgesMs": ages,
    }


def evaluate_observation(
    *,
    ticker_reasons: list[str],
    orderbook_reasons: list[str],
    micro_status: str,
    alignment_quality: str,
    ws_zombie: bool = False,
) -> dict[str, Any]:
    reasons = list(ticker_reasons) + list(orderbook_reasons)
    if ws_zombie:
        reasons.append("WEBSOCKET_ZOMBIE")
    if micro_status in {"MISSING", "CLUSTERED", "INSUFFICIENT"}:
        reasons.append(f"MICRO_{micro_status}")
    if alignment_quality in {"BAD", "INVALID"}:
        reasons.append(f"SNAPSHOT_{alignment_quality}")

    hard_quarantine = {
        "PRICE_INVALID",
        "TIMESTAMP_FUTURE",
        "BID_GT_ASK",
        "ORDERBOOK_NON_POSITIVE",
        "WEBSOCKET_ZOMBIE",
        "MICRO_CLUSTERED",
    }
    if any(r in hard_quarantine for r in reasons) or alignment_quality in {"BAD", "INVALID"}:
        status = DQ_QUARANTINED
    elif any(r.startswith("ORDERBOOK_") for r in reasons) or "TIMESTAMP_MISSING" in reasons:
        status = DQ_BAD
    elif reasons or alignment_quality in {"AGING", "DEGRADED"}:
        status = DQ_DEGRADED
    else:
        status = DQ_GOOD

    usable_for_ai = status in {DQ_GOOD, DQ_DEGRADED}
    usable_for_training = status == DQ_GOOD and micro_status == "AVAILABLE"
    return {
        "dataQuality": status,
        "reasons": reasons,
        "usableForAiInput": usable_for_ai,
        "usableForTraining": usable_for_training,
        "freshness": FRESHNESS_INVALID
        if status == DQ_QUARANTINED
        else (
            FRESHNESS_STALE
            if status == DQ_BAD
            else (FRESHNESS_AGING if status == DQ_DEGRADED else FRESHNESS_FRESH)
        ),
    }


def rest_ws_divergence(ws_price: float | None, rest_price: float | None, threshold: float = 0.15) -> dict[str, Any]:
    if ws_price is None or rest_price is None or ws_price <= 0 or rest_price <= 0:
        return {"diverged": False, "ratio": None, "reason": "INSUFFICIENT"}
    ratio = abs(rest_price - ws_price) / ws_price
    return {"diverged": ratio >= threshold, "ratio": round(ratio, 6), "reason": "REST_WS_DIVERGENCE" if ratio >= threshold else "OK"}

===== END FILE: server/ai-brain/app/market_integrity.py =====

===== FILE: server/ai-brain/app/micro_buffer.py =====
from __future__ import annotations

import time
from collections import deque
from dataclasses import dataclass
from threading import RLock
from typing import Deque

from .config import MICRO_BUFFER_AGE_MS, MICRO_BUFFER_MAX


@dataclass
class MicroSample:
    time_ms: int
    price: float
    volume: float


class MicroBufferStore:
    def __init__(self) -> None:
        self._lock = RLock()
        self._buffers: dict[str, Deque[MicroSample]] = {}

    def add(self, market: str, price: float, volume: float, now_ms: int | None = None) -> None:
        if price <= 0 or not market.startswith("KRW-"):
            return
        now = now_ms or int(time.time() * 1000)
        with self._lock:
            buf = self._buffers.setdefault(market, deque())
            buf.append(MicroSample(now, float(price), float(max(0.0, volume))))
            cutoff = now - MICRO_BUFFER_AGE_MS
            while buf and (len(buf) > MICRO_BUFFER_MAX or buf[0].time_ms < cutoff):
                buf.popleft()

    def samples(self, market: str, max_age_ms: int = MICRO_BUFFER_AGE_MS, now_ms: int | None = None) -> list[MicroSample]:
        now = now_ms or int(time.time() * 1000)
        cutoff = now - max_age_ms
        with self._lock:
            buf = self._buffers.get(market) or deque()
            return [s for s in list(buf) if s.time_ms >= cutoff and s.price > 0]

    def count(self, market: str, window_ms: int, now_ms: int | None = None) -> int:
        return len(self.samples(market, window_ms, now_ms))

    def ready_markets(self, min_samples: int = 8) -> int:
        """Count markets with temporally usable micro history (not just raw sample count)."""
        from .market_integrity import micro_temporal_quality

        now = int(time.time() * 1000)
        ready = 0
        with self._lock:
            items = list(self._buffers.items())
        for _market, buf in items:
            samples = [s for s in list(buf) if s.time_ms >= now - MICRO_BUFFER_AGE_MS and s.price > 0]
            if len(samples) < min_samples:
                continue
            temporal = micro_temporal_quality(samples, now)
            if temporal.get("usable"):
                ready += 1
        return ready

    def market_count(self) -> int:
        with self._lock:
            return len(self._buffers)

    def micro_metrics(self, market: str, current_price: float, now_ms: int | None = None) -> dict:
        from .market_integrity import micro_temporal_quality

        now = now_ms or int(time.time() * 1000)
        samples = self.samples(market, now_ms=now)
        temporal = micro_temporal_quality(samples, now)
        if not samples or current_price <= 0:
            return {
                "microSampleCount": 0,
                "return10s": None,
                "return30s": None,
                "return1m": None,
                "return3m": None,
                "return5m": None,
                "tradeIntensity": None,
                "status": "MISSING",
                "temporal": temporal,
            }

        def change(window_ms: int) -> float | None:
            base = next((s.price for s in reversed(samples) if s.time_ms <= now - window_ms), None)
            if base is None or base <= 0:
                return None
            return (current_price / base - 1.0) * 100.0

        intensity = sum(1 for s in samples if s.time_ms >= now - 60_000)
        if temporal.get("usable"):
            status = "AVAILABLE"
        elif temporal.get("status") == "CLUSTERED":
            status = "CLUSTERED"
        elif len(samples) > 0:
            status = "INSUFFICIENT"
        else:
            status = "MISSING"
        return {
            "microSampleCount": len(samples),
            "microSampleCount10s": self.count(market, 10_000, now),
            "microSampleCount30s": self.count(market, 30_000, now),
            "microSampleCount1m": self.count(market, 60_000, now),
            "return10s": change(10_000),
            "return30s": change(30_000),
            "return1m": change(60_000),
            "return3m": change(180_000),
            "return5m": change(300_000),
            "tradeIntensity": float(intensity),
            "status": status,
            "temporal": temporal,
        }
===== END FILE: server/ai-brain/app/micro_buffer.py =====

===== FILE: server/ai-brain/app/paper_engine.py =====
from __future__ import annotations

import json
import sqlite3
import threading
import time
import uuid
from pathlib import Path
from typing import Any

from .config import DATA_DIR

# Mirror Android Paper defaults — do not invent new strategy thresholds.
DEFAULT_SETTINGS = {
    "initialCash": 100_000.0,
    "maxPositions": 3,  # legacy soft hint; hard cap below is binding
    "maxPositionsHardCap": 8,
    "dynamicPortfolioCapacityEnabled": True,
    "maxOpenRiskPercent": 5.0,
    "minimumViableOrderKrw": 8_000.0,
    "maxOrderPercent": 20.0,
    "maxAssetPercentPerCoin": 20.0,
    "minKrwCashPercent": 30.0,
    "scoreThreshold": 75.0,
    "aiMinScore": 55.0,
    "stopLossPercent": -2.5,
    "takeProfitPercent": 6.0,
    "trailingStopPercent": 2.5,
    "maxSpreadPercent": 0.7,
    "feeRate": 0.0025,
    "slippageRate": 0.001,
    "decisionMaxAgeMs": 90_000,
    # Emergency / resume ladder (per exchange via settings_json). Analysis+exits continue.
    "newBuyPaused": False,
    "paperBuyResumeMode": "NORMAL",  # PAUSED_DIAGNOSTIC|SHADOW|DEFENSE|NORMAL
    "pauseReason": "",
    # Mirror Android TradingSettings.stopLossCooldownMinutes / trailing.
    "stopLossCooldownMinutes": 15,
    "trailingStopCooldownMinutes": 10,
    # Trailing arms only after peak unrealized >= this (mirrors profitProtectionLevel1Percent).
    # Prevents entry-noise highs from firing TRAILING STOP while still net-negative.
    "trailingArmMinProfitPercent": 1.0,
    # Mirror Android scalpingPriceMovedAwayAtrMultiple default when ATR known.
    "priceMovedAwayAtrMultiple": 1.5,
    "priceMovedAwayFallbackPercent": 1.05,  # maxSpread*1.5 ≈ 1.05 when ATR missing
    # DEFENSE sizing uses PaperRiskEngine DEFENSE multiplier (0.3), not arbitrary.
    "defensePositionSizeMultiplier": 0.3,
    "cautionPositionSizeMultiplier": 0.7,
}

# Decision/execution states that must never pass try_buy (no silent bypass).
_BLOCKED_EXECUTION_STATES = frozenset({
    "DATA_INSUFFICIENT",
    "EXECUTION_DATA_INSUFFICIENT",
    "CHASE_RISK",
    "AVOID",
    "NO_EDGE",
    "TOO_LATE",
    "WARMING_UP",
    "WAIT",
})

_PAUSE_MODES = frozenset({
    "PAUSED",
    "PAUSED_DIAGNOSTIC",
    "SHADOW",
    "SHADOW_VALIDATION",
})

_DEFENSE_MODES = frozenset({
    "DEFENSE",
    "DEFENSE_PAPER",
})

_BTC_CLUSTER = {"KRW-BTC", "KRW-ETH", "KRW-SOL", "KRW-XRP", "KRW-ADA", "KRW-AVAX"}

# Independent Upbit PAPER defaults (separate capital + configurable feeRate).
# feeRate is a ratio (0.0005 == 0.05%). Override via settings_json after init.
UPBIT_DEFAULT_SETTINGS = {
    **DEFAULT_SETTINGS,
    "initialCash": 100_000.0,
    "feeRate": 0.0005,
    "slippageRate": 0.001,
}


def _position_risk_krw(value: float, stop_loss_percent: float) -> float:
    return max(0.0, float(value)) * abs(float(stop_loss_percent)) / 100.0


def _portfolio_heat_krw(rows: list, stop_loss_percent: float) -> float:
    return sum(_position_risk_krw(float(r["quantity"]) * float(r["avg_price"]), stop_loss_percent) for r in rows)


def _resume_mode(settings: dict[str, Any]) -> str:
    mode = str(settings.get("paperBuyResumeMode") or "NORMAL").upper().strip()
    if settings.get("newBuyPaused") is True and mode in {"NORMAL", "NORMAL_PAPER", ""}:
        return "PAUSED_DIAGNOSTIC"
    return mode or "NORMAL"


def _new_buys_allowed(settings: dict[str, Any]) -> tuple[bool, str]:
    mode = _resume_mode(settings)
    if settings.get("newBuyPaused") is True:
        return False, "NEW_BUY_PAUSED"
    if mode in _PAUSE_MODES:
        return False, "NEW_BUY_PAUSED"
    return True, mode


def _size_multiplier_for_mode(settings: dict[str, Any]) -> float:
    mode = _resume_mode(settings)
    if mode in _DEFENSE_MODES:
        return float(settings.get("defensePositionSizeMultiplier", 0.3))
    if mode in {"CAUTION", "CAUTION_PAPER"}:
        return float(settings.get("cautionPositionSizeMultiplier", 0.7))
    return 1.0


class PaperTradingEngine:
    """Server-resident PAPER trading loop. Independent of Android lifecycle."""

    def __init__(
        self,
        path: Path | None = None,
        exchange: str = "BITHUMB",
        default_settings: dict[str, Any] | None = None,
    ) -> None:
        self.exchange = (exchange or "BITHUMB").upper()
        self.default_settings = dict(default_settings or DEFAULT_SETTINGS)
        self.path = path or (DATA_DIR / "paper_trading.sqlite3")
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self._lock = threading.RLock()
        self._init_db()
        self.last_tick_at = 0
        self.last_tick_result: dict[str, Any] = {}
        self.tick_count = 0

    def position_key(self, market: str) -> str:
        return f"{self.exchange}:{market}"

    def _economic_realized_from_trades(self, conn: sqlite3.Connection) -> tuple[float, float, float]:
        """FIFO pair: economic net = sellNet − buyCash (includes buy fee). No slip double-count."""
        rows = conn.execute(
            "SELECT market, side, amount, fee FROM paper_trades ORDER BY time_ms ASC"
        ).fetchall()
        books: dict[str, list[float]] = {}
        realized = 0.0
        buy_fees = 0.0
        sell_fees = 0.0
        for r in rows:
            market = str(r["market"])
            side = str(r["side"]).upper()
            if side == "BUY":
                books.setdefault(market, []).append(float(r["amount"]))
                buy_fees += float(r["fee"])
            elif side == "SELL":
                sell_fees += float(r["fee"])
                queue = books.get(market) or []
                if not queue:
                    continue
                buy_amt = queue.pop(0)
                realized += float(r["amount"]) - buy_amt
        return realized, buy_fees, sell_fees

    def _conn(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.path, timeout=30)
        conn.row_factory = sqlite3.Row
        return conn

    def _init_db(self) -> None:
        with self._conn() as conn:
            conn.executescript(
                """
                CREATE TABLE IF NOT EXISTS paper_meta (
                    key TEXT PRIMARY KEY,
                    value TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS paper_positions (
                    market TEXT PRIMARY KEY,
                    quantity REAL NOT NULL,
                    avg_price REAL NOT NULL,
                    highest_price REAL NOT NULL,
                    opened_at INTEGER NOT NULL,
                    updated_at INTEGER NOT NULL
                );
                CREATE TABLE IF NOT EXISTS paper_trades (
                    id TEXT PRIMARY KEY,
                    time_ms INTEGER NOT NULL,
                    market TEXT NOT NULL,
                    side TEXT NOT NULL,
                    amount REAL NOT NULL,
                    quantity REAL NOT NULL,
                    avg_price REAL NOT NULL,
                    fee REAL NOT NULL,
                    realized_pnl REAL NOT NULL,
                    pnl_rate REAL NOT NULL,
                    reason TEXT NOT NULL,
                    decision_id TEXT
                );
                CREATE TABLE IF NOT EXISTS paper_used_decisions (
                    decision_id TEXT PRIMARY KEY,
                    used_at INTEGER NOT NULL,
                    market TEXT NOT NULL
                );
                """
            )
            if self._get_meta(conn, "initialized") is None:
                self._set_meta(conn, "initialized", "1")
                self._set_meta(conn, "auto_enabled", "0")
                self._set_meta(conn, "exchange", self.exchange)
                self._set_meta(conn, "cash", str(self.default_settings["initialCash"]))
                self._set_meta(conn, "initial_cash", str(self.default_settings["initialCash"]))
                self._set_meta(conn, "realized_pnl", "0")
                self._set_meta(conn, "settings_json", json.dumps(self.default_settings))
                self._set_meta(conn, "updated_at", str(int(time.time() * 1000)))
            else:
                # Preserve existing capital; ensure exchange tag is present.
                if self._get_meta(conn, "exchange") is None:
                    self._set_meta(conn, "exchange", self.exchange)
            # Legacy rows used quantity=0 after SELL; purge so BUY INSERT cannot UNIQUE-fail.
            conn.execute("DELETE FROM paper_positions WHERE quantity <= 0")

    def _get_meta(self, conn: sqlite3.Connection, key: str) -> str | None:
        row = conn.execute("SELECT value FROM paper_meta WHERE key=?", (key,)).fetchone()
        return None if row is None else str(row["value"])

    def _set_meta(self, conn: sqlite3.Connection, key: str, value: str) -> None:
        conn.execute(
            "INSERT OR REPLACE INTO paper_meta(key, value) VALUES (?,?)",
            (key, value),
        )

    def settings(self) -> dict[str, Any]:
        with self._lock, self._conn() as conn:
            raw = self._get_meta(conn, "settings_json") or "{}"
            data = json.loads(raw)
            out = dict(self.default_settings)
            out.update(data)
            return out

    def update_settings(self, patch: dict[str, Any]) -> dict[str, Any]:
        """Merge patch into settings_json (per-exchange). Used for pause/resume ladder."""
        with self._lock, self._conn() as conn:
            raw = self._get_meta(conn, "settings_json") or "{}"
            data = json.loads(raw)
            merged = dict(self.default_settings)
            merged.update(data)
            for k, v in (patch or {}).items():
                if k in self.default_settings or k in {
                    "newBuyPaused",
                    "paperBuyResumeMode",
                    "pauseReason",
                    "pausedAtMs",
                    "stopLossCooldownMinutes",
                    "trailingStopCooldownMinutes",
                    "trailingArmMinProfitPercent",
                    "priceMovedAwayAtrMultiple",
                    "priceMovedAwayFallbackPercent",
                    "defensePositionSizeMultiplier",
                    "cautionPositionSizeMultiplier",
                }:
                    merged[k] = v
            self._set_meta(conn, "settings_json", json.dumps(merged, ensure_ascii=False))
            self._set_meta(conn, "updated_at", str(int(time.time() * 1000)))
            if "newBuyPaused" in patch:
                self._set_meta(conn, "new_buy_paused", "1" if patch.get("newBuyPaused") else "0")
            if "paperBuyResumeMode" in patch:
                self._set_meta(conn, "paper_buy_resume_mode", str(patch.get("paperBuyResumeMode")))
            print(
                f"[{self.exchange}][LOSS_ANALYSIS] settings_updated "
                f"newBuyPaused={merged.get('newBuyPaused')} mode={merged.get('paperBuyResumeMode')} "
                f"reason={merged.get('pauseReason')}",
                flush=True,
            )
        return self.settings()

    def _top_loss_causes(self, conn: sqlite3.Connection, limit: int = 3) -> list[dict[str, Any]]:
        """Lightweight KRW-attributed sell-side loss buckets for dashboard (not full autopsy)."""
        rows = conn.execute(
            "SELECT market, side, amount, fee, realized_pnl, reason, time_ms FROM paper_trades ORDER BY time_ms ASC"
        ).fetchall()
        books: dict[str, list[tuple[float, int]]] = {}
        buckets: dict[str, float] = {
            "STOP_LOSS": 0.0,
            "TRAILING_STOP": 0.0,
            "REENTRY_CHAIN": 0.0,
            "SHORT_HOLD_LOSS": 0.0,
            "FEES": 0.0,
            "TAKE_PROFIT": 0.0,
        }
        last_loss_exit: dict[str, int] = {}
        for r in rows:
            market = str(r["market"])
            side = str(r["side"]).upper()
            t = int(r["time_ms"])
            if side == "BUY":
                books.setdefault(market, []).append((float(r["amount"]), t))
                buckets["FEES"] -= float(r["fee"])
            elif side == "SELL":
                buckets["FEES"] -= float(r["fee"])
                queue = books.get(market) or []
                buy_amt, buy_t = queue.pop(0) if queue else (0.0, t)
                net = float(r["amount"]) - buy_amt
                reason = str(r["reason"] or "").upper()
                hold_s = max(0, (t - buy_t) // 1000)
                if "STOP" in reason and "TRAILING" not in reason:
                    buckets["STOP_LOSS"] += net
                elif "TRAILING" in reason:
                    buckets["TRAILING_STOP"] += net
                elif "TAKE" in reason:
                    buckets["TAKE_PROFIT"] += net
                if net < 0 and hold_s < 180:
                    buckets["SHORT_HOLD_LOSS"] += net
                prev = last_loss_exit.get(market)
                if prev is not None and net < 0 and (t - prev) < 60 * 60 * 1000:
                    buckets["REENTRY_CHAIN"] += net
                if net < 0:
                    last_loss_exit[market] = t
                elif "TAKE" in reason:
                    last_loss_exit.pop(market, None)
        ranked = sorted(
            ((k, v) for k, v in buckets.items() if k != "TAKE_PROFIT" and v < -1.0),
            key=lambda kv: kv[1],
        )
        total_neg = sum(v for _, v in ranked) or -1.0
        out = []
        for name, krw in ranked[:limit]:
            out.append({
                "cause": name,
                "krw": round(krw, 2),
                "percentOfLosses": round(krw / total_neg * 100.0, 2) if total_neg < 0 else 0.0,
            })
        return out

    def set_auto(self, enabled: bool) -> dict[str, Any]:
        with self._lock, self._conn() as conn:
            self._set_meta(conn, "auto_enabled", "1" if enabled else "0")
            self._set_meta(conn, "updated_at", str(int(time.time() * 1000)))
            self._set_meta(conn, "last_auto_change_source", "API")
            print(
                f"[{self.exchange}] FLOW PAPER_AUTO {'ON' if enabled else 'OFF'} source=API ts={int(time.time()*1000)}",
                flush=True,
            )
        return self.state()

    def auto_enabled(self) -> bool:
        with self._lock, self._conn() as conn:
            return self._get_meta(conn, "auto_enabled") == "1"

    def reset_account(self, initial_cash: float | None = None) -> dict[str, Any]:
        cash = float(initial_cash if initial_cash is not None else self.default_settings["initialCash"])
        with self._lock, self._conn() as conn:
            conn.execute("DELETE FROM paper_positions")
            conn.execute("DELETE FROM paper_trades")
            conn.execute("DELETE FROM paper_used_decisions")
            self._set_meta(conn, "cash", str(cash))
            self._set_meta(conn, "initial_cash", str(cash))
            self._set_meta(conn, "realized_pnl", "0")
            self._set_meta(conn, "updated_at", str(int(time.time() * 1000)))
        return self.state()

    def state(self, mark_prices: dict[str, float] | None = None) -> dict[str, Any]:
        mark_prices = mark_prices or {}
        with self._lock, self._conn() as conn:
            cash = float(self._get_meta(conn, "cash") or 0)
            initial = float(self._get_meta(conn, "initial_cash") or cash)
            realized = float(self._get_meta(conn, "realized_pnl") or 0)
            auto = self._get_meta(conn, "auto_enabled") == "1"
            positions = [
                dict(r)
                for r in conn.execute(
                    "SELECT market, quantity, avg_price, highest_price, opened_at, updated_at FROM paper_positions WHERE quantity > 0"
                ).fetchall()
            ]
            coin_value = 0.0
            unrealized = 0.0
            pos_out = []
            for p in positions:
                px = float(mark_prices.get(p["market"]) or p["avg_price"])
                qty = float(p["quantity"])
                avg = float(p["avg_price"])
                value = qty * px
                coin_value += value
                u = value - qty * avg
                unrealized += u
                pnl_rate = ((px / avg) - 1.0) * 100.0 if avg > 0 else 0.0
                pos_out.append(
                    {
                        "exchange": self.exchange,
                        "positionKey": self.position_key(p["market"]),
                        "market": p["market"],
                        "quantity": qty,
                        "avgPrice": avg,
                        "highestPrice": float(p["highest_price"]),
                        "openedAt": int(p["opened_at"]),
                        "updatedAt": int(p["updated_at"]),
                        "markPrice": px,
                        "unrealizedPnl": u,
                        "pnlRate": pnl_rate,
                    }
                )
            total = cash + coin_value
            recent_trades = [
                {
                    "id": r["id"],
                    "exchange": self.exchange,
                    "positionKey": self.position_key(r["market"]),
                    "time": int(r["time_ms"]),
                    "market": r["market"],
                    "side": r["side"],
                    "amount": float(r["amount"]),
                    "quantity": float(r["quantity"]),
                    "avgPrice": float(r["avg_price"]),
                    "fee": float(r["fee"]),
                    "realizedPnl": float(r["realized_pnl"]),
                    "pnlRate": float(r["pnl_rate"]),
                    "reason": r["reason"],
                    "decisionId": r["decision_id"],
                }
                for r in conn.execute(
                    "SELECT id, time_ms, market, side, amount, quantity, avg_price, fee, realized_pnl, pnl_rate, reason, decision_id "
                    "FROM paper_trades ORDER BY time_ms DESC LIMIT 100"
                ).fetchall()
            ]
            economic_realized, buy_fees, sell_fees = self._economic_realized_from_trades(conn)
            # Reconcile meta when legacy realized excluded buy fees (flat account check).
            if abs(economic_realized - realized) > 1.0 and len(pos_out) == 0:
                self._set_meta(conn, "realized_pnl", str(economic_realized))
                realized = economic_realized
            init_plus = initial + realized + unrealized
            mismatch = init_plus - total
            accounting_mismatch = abs(mismatch) > max(1.0, initial * 0.0005)
            settings = self.settings()
            resume_mode = _resume_mode(settings)
            buys_ok, buy_block = _new_buys_allowed(settings)
            drawdown_pct = ((total / initial) - 1.0) * 100.0 if initial > 0 else 0.0
            top_causes = self._top_loss_causes(conn, limit=3)
            return {
                "exchange": self.exchange,
                "paperAuto": auto,
                "cash": cash,
                "initialCash": initial,
                "coinValue": coin_value,
                "totalValue": total,
                "realizedPnl": realized,
                "unrealizedPnl": unrealized,
                "economicRealizedPnl": economic_realized,
                "totalPnl": realized + unrealized,
                "totalPnlRate": drawdown_pct,
                "drawdownPercent": drawdown_pct,
                "drawdownKrw": total - initial,
                "positionCount": len(pos_out),
                "positions": pos_out,
                "recentTrades": recent_trades,
                "tradeCount": len(recent_trades),
                "buyFeesCumulative": buy_fees,
                "sellFeesCumulative": sell_fees,
                "accountingMismatch": accounting_mismatch,
                "accountingMismatchKrw": mismatch,
                "newBuyPaused": not buys_ok,
                "paperBuyResumeMode": resume_mode,
                "paperBuyBlockReason": None if buys_ok else buy_block,
                "pauseReason": settings.get("pauseReason") or "",
                "topLossCauses": top_causes,
                "updatedAt": int(self._get_meta(conn, "updated_at") or 0),
                "lastTickAt": self.last_tick_at,
                "tickCount": self.tick_count,
                "settings": settings,
                "sourceOfTruth": "HETZNER_SERVER",
                "androidIndependent": True,
                "liveTrading": False,
            }

    def positions(self, mark_prices: dict[str, float] | None = None) -> list[dict[str, Any]]:
        return self.state(mark_prices).get("positions") or []

    def trades(self, limit: int = 50) -> list[dict[str, Any]]:
        with self._lock, self._conn() as conn:
            rows = conn.execute(
                "SELECT id, time_ms, market, side, amount, quantity, avg_price, fee, realized_pnl, pnl_rate, reason, decision_id "
                "FROM paper_trades ORDER BY time_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [
            {
                "id": r["id"],
                "exchange": self.exchange,
                "positionKey": self.position_key(r["market"]),
                "time": int(r["time_ms"]),
                "market": r["market"],
                "side": r["side"],
                "amount": float(r["amount"]),
                "quantity": float(r["quantity"]),
                "avgPrice": float(r["avg_price"]),
                "fee": float(r["fee"]),
                "realizedPnl": float(r["realized_pnl"]),
                "pnlRate": float(r["pnl_rate"]),
                "reason": r["reason"],
                "decisionId": r["decision_id"],
            }
            for r in rows
        ]

    def _buy_fill(self, krw: float, price: float, fee_rate: float, slip: float) -> dict[str, float] | None:
        if krw <= 0 or price <= 0:
            return None
        execution = price * (1.0 + max(0.0, slip))
        fee = krw * max(0.0, fee_rate)
        qty = (krw - fee) / execution
        if qty <= 0:
            return None
        return {"quantity": qty, "fee": fee, "executionPrice": execution, "grossAmount": qty * execution}

    def _sell_fill(self, qty: float, price: float, fee_rate: float, slip: float) -> dict[str, float] | None:
        if qty <= 0 or price <= 0:
            return None
        execution = price * (1.0 - max(0.0, slip))
        gross = qty * execution
        fee = gross * max(0.0, fee_rate)
        return {"quantity": qty, "fee": fee, "executionPrice": execution, "grossAmount": gross}

    def try_buy(self, decision: dict[str, Any], price: float, now_ms: int | None = None) -> dict[str, Any]:
        """Execute PAPER BUY from a server decision. Used by live loop and verification."""
        now = now_ms or int(time.time() * 1000)
        market = str(decision.get("market") or "")
        decision_id = str(decision.get("decisionId") or "")
        decision_exchange = str(decision.get("exchange") or self.exchange).upper()
        if decision_exchange != self.exchange:
            return {"ok": False, "blockReason": "EXCHANGE_MISMATCH", "detail": f"{decision_exchange}!={self.exchange}"}
        settings = self.settings()
        with self._lock, self._conn() as conn:
            if self._get_meta(conn, "auto_enabled") != "1":
                return {"ok": False, "blockReason": "PAPER_AUTO_OFF"}
            buys_ok, pause_code = _new_buys_allowed(settings)
            if not buys_ok:
                print(
                    f"[{self.exchange}][LOSS_ANALYSIS] NEW_BUY_BLOCKED market={market} reason={pause_code} "
                    f"mode={_resume_mode(settings)}",
                    flush=True,
                )
                return {"ok": False, "blockReason": "NEW_BUY_PAUSED", "detail": pause_code}
            if (decision.get("decision") or "").upper() != "BUY":
                return {"ok": False, "blockReason": "SIGNAL_NOT_BUY"}
            exec_state = str(
                decision.get("executionState")
                or decision.get("scalpExecutionState")
                or decision.get("finalGate")
                or ""
            ).upper()
            if exec_state in _BLOCKED_EXECUTION_STATES:
                return {"ok": False, "blockReason": exec_state, "detail": "EXECUTION_GATE"}
            data_q = str(decision.get("dataQuality") or "").upper()
            if data_q in {"BAD", "QUARANTINED", "MISSING"}:
                return {"ok": False, "blockReason": "DATA_QUALITY_BLOCK", "detail": data_q}
            if data_q in {"INSUFFICIENT", "DATA_INSUFFICIENT", "POOR"}:
                return {"ok": False, "blockReason": "DATA_INSUFFICIENT"}
            if decision.get("chaseRisk") is True or float(decision.get("chaseScore") or 0) >= 90.0:
                return {"ok": False, "blockReason": "CHASE_RISK"}
            created = int(decision.get("signalCreatedAt") or decision.get("serverTimestamp") or 0)
            expires = int(decision.get("signalExpiresAt") or decision.get("expiresAt") or (created + settings["decisionMaxAgeMs"]))
            if created and now - created > int(settings["decisionMaxAgeMs"]) + 5_000:
                return {"ok": False, "blockReason": "STALE_SIGNAL_EXECUTED", "detail": "SERVER_STALE"}
            if now > expires + 5_000:
                return {"ok": False, "blockReason": "STALE_SIGNAL_EXECUTED", "detail": "TTL_EXPIRED"}
            if decision.get("dataQuality") == "STALE":
                return {"ok": False, "blockReason": "STALE_SIGNAL_EXECUTED"}
            # PRICE_MOVED_AWAY — mirror Android RemoteDecisionPolicy / ScalpingSignalPolicy
            signal_price = float(decision.get("signalPrice") or 0)
            if signal_price > 0 and price > 0:
                move_pct = abs(price - signal_price) / price * 100.0
                atr = float(decision.get("atrPercent") or decision.get("entryAtrPercent") or 0)
                multiple = float(settings.get("priceMovedAwayAtrMultiple", 1.5))
                threshold = atr * multiple if atr > 0 else float(settings.get("priceMovedAwayFallbackPercent", 1.05))
                if move_pct >= threshold:
                    return {
                        "ok": False,
                        "blockReason": "PRICE_MOVED_AWAY",
                        "detail": f"movePct={move_pct:.3f} thr={threshold:.3f}",
                    }
            if decision_id:
                used = conn.execute(
                    "SELECT decision_id FROM paper_used_decisions WHERE decision_id=?",
                    (decision_id,),
                ).fetchone()
                if used:
                    return {"ok": False, "blockReason": "DUPLICATE_SIGNAL"}
            holding = conn.execute(
                "SELECT quantity FROM paper_positions WHERE market=? AND quantity>0",
                (market,),
            ).fetchone()
            if holding:
                return {"ok": False, "blockReason": "ALREADY_HOLDING"}
            # Same-market reentry cooldown after STOP / TRAILING (Android ReentryGuardPolicy)
            last_sell = conn.execute(
                "SELECT reason, time_ms, realized_pnl FROM paper_trades "
                "WHERE market=? AND side='SELL' ORDER BY time_ms DESC LIMIT 1",
                (market,),
            ).fetchone()
            if (last_sell is not None):
                reason = str(last_sell["reason"] or "").upper()
                exit_ms = int(last_sell["time_ms"])
                stop_cd = int(settings.get("stopLossCooldownMinutes", 15)) * 60_000
                trail_cd = int(settings.get("trailingStopCooldownMinutes", 10)) * 60_000
                cooldown_ms = 0
                code = "REENTRY_COOLDOWN"
                if "STOP" in reason and "TRAILING" not in reason:
                    cooldown_ms = stop_cd
                    code = "STOP_LOSS_COOLDOWN"
                elif "TRAILING" in reason:
                    cooldown_ms = trail_cd
                    code = "TRAILING_STOP_COOLDOWN"
                elif float(last_sell["realized_pnl"] or 0) < 0:
                    cooldown_ms = stop_cd
                    code = "REENTRY_COOLDOWN"
                if cooldown_ms > 0 and now < exit_ms + cooldown_ms:
                    return {
                        "ok": False,
                        "blockReason": code,
                        "detail": f"remainingMs={exit_ms + cooldown_ms - now}",
                    }
                # Loss-streak structure gate (time cooldown alone is insufficient).
                recent_sells = conn.execute(
                    "SELECT realized_pnl FROM paper_trades WHERE market=? AND side='SELL' "
                    "ORDER BY time_ms DESC LIMIT 5",
                    (market,),
                ).fetchall()
                loss_streak = 0
                for srow in recent_sells:
                    if float(srow["realized_pnl"] or 0) < 0:
                        loss_streak += 1
                    else:
                        break
                if loss_streak >= 3:
                    return {
                        "ok": False,
                        "blockReason": "REENTRY_SHADOW_ONLY",
                        "detail": f"lossStreak={loss_streak} needs new market structure",
                    }
                if loss_streak >= 2:
                    # Require stronger score reconfirmation — not the same weak setup replay.
                    strat_now = float(decision.get("strategyScore") or 0)
                    bump = float(settings.get("reentryScoreBump", 5.0))
                    if strat_now < float(settings["scoreThreshold"]) + bump:
                        return {
                            "ok": False,
                            "blockReason": "REENTRY_NEEDS_RECONFIRMATION",
                            "detail": f"lossStreak={loss_streak} need score>={float(settings['scoreThreshold'])+bump:.0f}",
                        }
                    # Same decision fingerprint / stale setup: block identical high-level context.
                    fp = str(decision.get("setupFingerprint") or "")
                    last_buy = conn.execute(
                        "SELECT reason, decision_id FROM paper_trades WHERE market=? AND side='BUY' "
                        "ORDER BY time_ms DESC LIMIT 1",
                        (market,),
                    ).fetchone()
                    if fp and last_buy is not None and fp in str(last_buy["reason"] or ""):
                        return {"ok": False, "blockReason": "SAME_SETUP_FINGERPRINT", "detail": fp}
            # Clear any zero-qty leftover row for this market before INSERT.
            conn.execute("DELETE FROM paper_positions WHERE market=? AND quantity<=0", (market,))
            pos_rows = list(conn.execute(
                "SELECT market, quantity, avg_price FROM paper_positions WHERE quantity>0"
            ))
            pos_count = len(pos_rows)
            hard_cap = int(settings.get("maxPositionsHardCap", 8))
            if pos_count >= hard_cap:
                return {"ok": False, "blockReason": "HARD_EMERGENCY_POSITION_CAP"}
            strategy = float(decision.get("strategyScore") or 0)
            ai = float(decision.get("aiScore") or 0)
            if strategy < float(settings["scoreThreshold"]):
                return {"ok": False, "blockReason": "SCORE_LOW"}
            if ai < float(settings["aiMinScore"]):
                return {"ok": False, "blockReason": "SCORE_LOW"}
            # Net Profit After Cost (server decision fields) — do not recompute on Android.
            if decision.get("netProfitAfterCostPassed") is False:
                return {"ok": False, "blockReason": "NET_PROFIT_TOO_SMALL"}
            remote_net = decision.get("expectedNetProfitKrw")
            if remote_net is not None and float(remote_net) <= 0.0:
                return {"ok": False, "blockReason": "REMOTE_NET_PROFIT_INVALID"}
            cash = float(self._get_meta(conn, "cash") or 0)
            # mark-to-market total approx cash + positions at avg
            coin = 0.0
            for r in pos_rows:
                coin += float(r["quantity"]) * float(r["avg_price"])
            total = cash + coin
            size_mult = _size_multiplier_for_mode(settings)
            amount = min(
                total * float(settings["maxOrderPercent"]) / 100.0,
                total * float(settings["maxAssetPercentPerCoin"]) / 100.0,
            ) * size_mult
            # Remaining risk budget sizing
            stop = float(settings["stopLossPercent"])
            heat_krw = _portfolio_heat_krw(pos_rows, stop)
            max_open = float(settings.get("maxOpenRiskPercent", 5.0))
            heat_pct = (heat_krw / total * 100.0) if total > 0 else 0.0
            remaining_pct = max(0.0, max_open - heat_pct)
            remaining_krw = total * remaining_pct / 100.0
            stop_abs = abs(stop)
            max_by_risk = (remaining_krw / (stop_abs / 100.0)) if stop_abs > 0 else amount
            amount = min(amount, max_by_risk)
            min_order = float(settings.get("minimumViableOrderKrw", 8_000.0))
            if amount < min_order:
                return {"ok": False, "blockReason": "MINIMUM_VIABLE_ORDER"}
            candidate_risk = _position_risk_krw(amount, stop)
            projected_heat_pct = ((heat_krw + candidate_risk) / total * 100.0) if total > 0 else 0.0
            print(
                f"[{self.exchange}] FLOW HEAT currentHeat={heat_pct:.3f} candidateRisk={candidate_risk:.2f} "
                f"projectedHeat={projected_heat_pct:.3f} heatLimit={max_open} sizeMult={size_mult}",
                flush=True,
            )
            if projected_heat_pct > max_open:
                return {"ok": False, "blockReason": "PORTFOLIO_HEAT_LIMIT"}
            # Correlation cluster guard
            cluster_val = sum(
                float(r["quantity"]) * float(r["avg_price"])
                for r in pos_rows
                if str(r["market"]) in _BTC_CLUSTER
            )
            if market in _BTC_CLUSTER and total > 0:
                correlated_after = (cluster_val + amount) / total * 100.0
                if correlated_after >= 65.0:
                    return {"ok": False, "blockReason": "CORRELATED_PORTFOLIO_HEAT"}
            min_cash = total * float(settings["minKrwCashPercent"]) / 100.0
            if amount <= 0 or cash - amount < min_cash:
                return {"ok": False, "blockReason": "RISK_BLOCK", "detail": "CASH_RESERVE"}
            if price <= 0:
                return {"ok": False, "blockReason": "RISK_BLOCK", "detail": "BAD_PRICE"}
            fill = self._buy_fill(amount, price, float(settings["feeRate"]), float(settings["slippageRate"]))
            if fill is None:
                return {"ok": False, "blockReason": "RISK_BLOCK", "detail": "FILL_FAIL"}
            trade_id = str(uuid.uuid4())
            conn.execute(
                "INSERT INTO paper_positions(market, quantity, avg_price, highest_price, opened_at, updated_at) VALUES (?,?,?,?,?,?)",
                (market, fill["quantity"], fill["executionPrice"], fill["executionPrice"], now, now),
            )
            conn.execute(
                "INSERT INTO paper_trades(id, time_ms, market, side, amount, quantity, avg_price, fee, realized_pnl, pnl_rate, reason, decision_id) "
                "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
                (
                    trade_id,
                    now,
                    market,
                    "BUY",
                    amount,
                    fill["quantity"],
                    fill["executionPrice"],
                    fill["fee"],
                    0.0,
                    0.0,
                    f"SERVER_PAPER:{decision_id or trade_id}",
                    decision_id or None,
                ),
            )
            if decision_id:
                conn.execute(
                    "INSERT OR REPLACE INTO paper_used_decisions(decision_id, used_at, market) VALUES (?,?,?)",
                    (decision_id, now, market),
                )
            self._set_meta(conn, "cash", str(cash - amount))
            self._set_meta(conn, "updated_at", str(now))
            print(
                f"FLOW PAPER_ORDER market={market} decision=FILLED side=BUY amount={amount:.0f} "
                f"qty={fill['quantity']:.8f} price={fill['executionPrice']} decisionId={decision_id}",
                flush=True,
            )
            return {
                "ok": True,
                "side": "BUY",
                "market": market,
                "amount": amount,
                "quantity": fill["quantity"],
                "price": fill["executionPrice"],
                "tradeId": trade_id,
                "decisionId": decision_id,
                "sizeMultiplier": size_mult,
                "equityAtEntry": total,
                "orderPercentOfEquity": (amount / total * 100.0) if total > 0 else 0.0,
                "portfolioHeatBefore": heat_pct,
                "projectedPortfolioHeat": projected_heat_pct,
            }

    def try_sell_position(
        self,
        market: str,
        price: float,
        reason: str,
        now_ms: int | None = None,
    ) -> dict[str, Any]:
        now = now_ms or int(time.time() * 1000)
        settings = self.settings()
        with self._lock, self._conn() as conn:
            row = conn.execute(
                "SELECT market, quantity, avg_price, highest_price, opened_at FROM paper_positions WHERE market=? AND quantity>0",
                (market,),
            ).fetchone()
            if row is None:
                return {"ok": False, "blockReason": "NO_POSITION"}
            qty = float(row["quantity"])
            avg = float(row["avg_price"])
            fill = self._sell_fill(qty, price, float(settings["feeRate"]), float(settings["slippageRate"]))
            if fill is None:
                return {"ok": False, "blockReason": "RISK_BLOCK", "detail": "FILL_FAIL"}
            net_amount = fill["grossAmount"] - fill["fee"]
            cost = qty * avg
            buy_row = conn.execute(
                "SELECT amount, fee, quantity FROM paper_trades WHERE market=? AND side='BUY' ORDER BY time_ms DESC LIMIT 1",
                (market,),
            ).fetchone()
            # Economic cost basis includes buy fee (cash left wallet). Slippage already in prices.
            buy_cost = float(buy_row["amount"]) if buy_row is not None else cost
            realized = net_amount - buy_cost
            pnl_rate = (realized / buy_cost * 100.0) if buy_cost > 0 else 0.0
            trade_id = str(uuid.uuid4())
            # DELETE (not quantity=0) so a later BUY INSERT cannot hit UNIQUE(market).
            conn.execute("DELETE FROM paper_positions WHERE market=?", (market,))
            conn.execute(
                "INSERT INTO paper_trades(id, time_ms, market, side, amount, quantity, avg_price, fee, realized_pnl, pnl_rate, reason, decision_id) "
                "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
                (
                    trade_id,
                    now,
                    market,
                    "SELL",
                    net_amount,
                    qty,
                    fill["executionPrice"],
                    fill["fee"],
                    realized,
                    pnl_rate,
                    reason,
                    None,
                ),
            )
            cash = float(self._get_meta(conn, "cash") or 0) + net_amount
            realized_total = float(self._get_meta(conn, "realized_pnl") or 0) + realized
            self._set_meta(conn, "cash", str(cash))
            self._set_meta(conn, "realized_pnl", str(realized_total))
            self._set_meta(conn, "updated_at", str(now))
            print(
                f"FLOW PAPER_ORDER market={market} decision=FILLED side=SELL amount={net_amount:.0f} "
                f"reason={reason} pnl={realized:.2f} buyCost={buy_cost:.2f}",
                flush=True,
            )
            return {
                "ok": True,
                "side": "SELL",
                "market": market,
                "amount": net_amount,
                "quantity": qty,
                "price": fill["executionPrice"],
                "realizedPnl": realized,
                "reason": reason,
                "tradeId": trade_id,
            }

    def manage_exits(self, mark_prices: dict[str, float], now_ms: int | None = None) -> list[dict[str, Any]]:
        now = now_ms or int(time.time() * 1000)
        settings = self.settings()
        results: list[dict[str, Any]] = []
        trail_pct = float(settings["trailingStopPercent"])
        arm_min = float(settings.get("trailingArmMinProfitPercent", 1.0))
        with self._lock, self._conn() as conn:
            rows = conn.execute(
                "SELECT market, quantity, avg_price, highest_price FROM paper_positions WHERE quantity>0"
            ).fetchall()
        for row in rows:
            market = row["market"]
            price = float(mark_prices.get(market) or 0)
            if price <= 0:
                continue
            avg = float(row["avg_price"])
            prev_high = float(row["highest_price"])
            highest = max(prev_high, price)
            # update highest
            with self._lock, self._conn() as conn:
                conn.execute(
                    "UPDATE paper_positions SET highest_price=?, updated_at=? WHERE market=?",
                    (highest, now, market),
                )
            pnl = (price / avg - 1.0) * 100.0 if avg > 0 else 0.0
            peak_pnl = (highest / avg - 1.0) * 100.0 if avg > 0 else 0.0
            trailing = (price / highest - 1.0) * 100.0 if highest > 0 else 0.0
            trailing_armed = peak_pnl >= arm_min
            reason = None
            if pnl <= float(settings["stopLossPercent"]):
                reason = "STOP LOSS"
            elif pnl >= float(settings["takeProfitPercent"]):
                reason = "TAKE PROFIT"
            elif trailing_armed and trailing <= -trail_pct:
                reason = "TRAILING STOP"
            if reason:
                print(
                    f"[{self.exchange}][EXIT] market={market} reason={reason} pnl={pnl:.3f} "
                    f"peakPnl={peak_pnl:.3f} trailing={trailing:.3f} armed={trailing_armed} "
                    f"avg={avg} high={highest} mark={price}",
                    flush=True,
                )
                results.append(self.try_sell_position(market, price, reason, now_ms=now))
        return results

    def tick(self, decisions: list[dict[str, Any]], mark_prices: dict[str, float]) -> dict[str, Any]:
        """One server paper cycle: exits then buys. Safe no-op when auto OFF."""
        now = int(time.time() * 1000)
        self.tick_count += 1
        self.last_tick_at = now
        auto = self.auto_enabled()
        exits: list[dict[str, Any]] = []
        buys: list[dict[str, Any]] = []
        blocks: list[dict[str, Any]] = []
        # Position protection runs even when auto OFF? Spec: when AUTO OFF, no new buys;
        # existing positions should still be managed if auto was on... Actually when OFF,
        # trading should stop including new exits? User said OFF means server PAPER AUTO OFF.
        # Safer: when OFF, still manage exits for open risk (position protection), but block new BUY.
        exits = self.manage_exits(mark_prices, now_ms=now)
        if auto:
            # Prefer highest strategy score BUY first
            buy_decisions = [d for d in decisions if (d.get("decision") or "").upper() == "BUY"]
            buy_decisions.sort(key=lambda d: float(d.get("strategyScore") or 0), reverse=True)
            for d in buy_decisions[:1]:  # one buy per tick like Android selectPaperBuyCandidate
                market = d.get("market")
                price = float(mark_prices.get(market) or d.get("signalPrice") or 0)
                result = self.try_buy(d, price, now_ms=now)
                if result.get("ok"):
                    buys.append(result)
                else:
                    blocks.append({"market": market, **result})
                    print(
                        f"[{self.exchange}] FLOW BUY_CHECK market={market} decision=BUY BLOCK_REASON={result.get('blockReason')} detail={result.get('detail','')}",
                        flush=True,
                    )
        else:
            for d in decisions:
                if (d.get("decision") or "").upper() == "BUY":
                    blocks.append({"market": d.get("market"), "blockReason": "PAPER_AUTO_OFF"})
        st = self.state(mark_prices)
        self.last_tick_result = {
            "exchange": self.exchange,
            "auto": auto,
            "exits": exits,
            "buys": buys,
            "blocks": blocks,
            "cash": st["cash"],
            "positions": st["positionCount"],
            "ts": now,
        }
        print(
            f"[{self.exchange}] FLOW PAPER_TICK auto={'ON' if auto else 'OFF'} cash={st['cash']:.0f} "
            f"positions={st['positionCount']} buys={len(buys)} exits={len(exits)} "
            f"androidIndependent=YES tick={self.tick_count}",
            flush=True,
        )
        return self.last_tick_result

===== END FILE: server/ai-brain/app/paper_engine.py =====

===== FILE: server/ai-brain/app/parameter_registry.py =====
"""AI-tunable vs safety-fixed parameter registry.

AI may only propose changes within LEARNABLE/TUNABLE bounds.
FIXED_SAFETY and HUMAN_ONLY cannot be modified by autonomous learning.
CROSS_EXCHANGE_LEARNING is OFF — each exchange has its own weight snapshot.
"""
from __future__ import annotations

import copy
import hashlib
import json
from dataclasses import asdict, dataclass
from typing import Any

CROSS_EXCHANGE_LEARNING = False

# Parameter classes
LEARNABLE = "LEARNABLE"
TUNABLE = "TUNABLE"
FIXED_SAFETY = "FIXED_SAFETY"
HUMAN_ONLY = "HUMAN_ONLY"


@dataclass(frozen=True)
class ParamSpec:
    key: str
    classification: str
    default: float
    min_value: float
    max_value: float
    max_delta_per_experiment: float
    description: str = ""


# Defaults mirror current DecisionEngine heuristic coefficients / thresholds.
PARAM_SPECS: dict[str, ParamSpec] = {
    "w_strategy_in_ai": ParamSpec(
        "w_strategy_in_ai", LEARNABLE, 0.70, 0.40, 0.90, 0.08, "AI score ← strategy blend"
    ),
    "w_ai_bias": ParamSpec("w_ai_bias", LEARNABLE, 15.0, 5.0, 25.0, 3.0, "AI score additive bias"),
    "w_micro_available": ParamSpec(
        "w_micro_available", LEARNABLE, 5.0, 0.0, 12.0, 2.0, "Bonus when micro AVAILABLE"
    ),
    "w_positive_change": ParamSpec(
        "w_positive_change", LEARNABLE, 3.0, 0.0, 8.0, 1.5, "Bonus when signed change > 0"
    ),
    "w_chase_r30": ParamSpec("w_chase_r30", LEARNABLE, 20.0, 8.0, 35.0, 4.0, "Chase from return30s"),
    "w_timing_base": ParamSpec("w_timing_base", LEARNABLE, 70.0, 50.0, 85.0, 5.0, "Timing base"),
    "w_timing_chase_penalty": ParamSpec(
        "w_timing_chase_penalty", LEARNABLE, 0.25, 0.10, 0.45, 0.05, "Timing ← chase penalty"
    ),
    "w_exec_ai": ParamSpec("w_exec_ai", LEARNABLE, 0.30, 0.10, 0.50, 0.06, "Exec ← AI"),
    "w_exec_timing": ParamSpec("w_exec_timing", LEARNABLE, 0.35, 0.15, 0.55, 0.06, "Exec ← timing"),
    "w_exec_chase_penalty": ParamSpec(
        "w_exec_chase_penalty", LEARNABLE, 0.20, 0.05, 0.40, 0.05, "Exec ← chase penalty"
    ),
    "thr_chase_avoid": ParamSpec(
        "thr_chase_avoid", TUNABLE, 90.0, 75.0, 98.0, 5.0, "Chase AVOID threshold"
    ),
    "thr_short_edge": ParamSpec(
        "thr_short_edge", TUNABLE, 0.15, 0.05, 0.50, 0.05, "Min short edge for BUY path"
    ),
    "thr_strategy_buy": ParamSpec(
        "thr_strategy_buy", TUNABLE, 75.0, 60.0, 90.0, 4.0, "Strategy score BUY gate"
    ),
    "thr_ai_buy": ParamSpec("thr_ai_buy", TUNABLE, 55.0, 45.0, 75.0, 4.0, "AI score BUY gate"),
    "thr_exec_buy": ParamSpec(
        "thr_exec_buy", TUNABLE, 60.0, 50.0, 80.0, 4.0, "Execution score BUY gate"
    ),
    "position_size_mult": ParamSpec(
        "position_size_mult", TUNABLE, 1.0, 0.3, 1.0, 0.15, "PAPER size multiplier (≤1)"
    ),
    "reentry_confirm_bump": ParamSpec(
        "reentry_confirm_bump", TUNABLE, 5.0, 0.0, 15.0, 3.0, "Extra score for reentry"
    ),
    # FIXED_SAFETY — never AI-tunable
    "kill_switch": ParamSpec("kill_switch", FIXED_SAFETY, 0.0, 0.0, 0.0, 0.0, "Kill switch"),
    "max_emergency_exposure": ParamSpec(
        "max_emergency_exposure", FIXED_SAFETY, 1.0, 1.0, 1.0, 0.0, "Emergency exposure cap"
    ),
    "live_trading_enabled": ParamSpec(
        "live_trading_enabled", FIXED_SAFETY, 0.0, 0.0, 0.0, 0.0, "LIVE always off"
    ),
    "new_buy_force_resume": ParamSpec(
        "new_buy_force_resume", FIXED_SAFETY, 0.0, 0.0, 0.0, 0.0, "Cannot force unpause"
    ),
    "stale_data_block": ParamSpec(
        "stale_data_block", FIXED_SAFETY, 1.0, 1.0, 1.0, 0.0, "Stale data block"
    ),
    "duplicate_order_safety": ParamSpec(
        "duplicate_order_safety", FIXED_SAFETY, 1.0, 1.0, 1.0, 0.0, "Dup order safety"
    ),
}


def default_weights() -> dict[str, float]:
    return {k: float(s.default) for k, s in PARAM_SPECS.items() if s.classification in {LEARNABLE, TUNABLE}}


def weights_hash(weights: dict[str, float]) -> str:
    canon = json.dumps({k: round(float(weights[k]), 8) for k in sorted(weights)}, sort_keys=True)
    return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]


def classify(key: str) -> str:
    spec = PARAM_SPECS.get(key)
    return spec.classification if spec else HUMAN_ONLY


def is_ai_modifiable(key: str) -> bool:
    return classify(key) in {LEARNABLE, TUNABLE}


def clamp_candidate(
    base: dict[str, float],
    proposed: dict[str, float],
) -> tuple[dict[str, float], list[dict[str, Any]]]:
    """Apply proposed deltas with min/max and maxDeltaPerExperiment. Reject FIXED_SAFETY."""
    out = copy.deepcopy(base)
    changes: list[dict[str, Any]] = []
    for key, raw in proposed.items():
        spec = PARAM_SPECS.get(key)
        if spec is None or not is_ai_modifiable(key):
            changes.append({"key": key, "rejected": True, "reason": "NOT_AI_MODIFIABLE"})
            continue
        old = float(out.get(key, spec.default))
        target = float(raw)
        delta = target - old
        max_d = float(spec.max_delta_per_experiment)
        if abs(delta) > max_d:
            target = old + (max_d if delta > 0 else -max_d)
            delta = target - old
        target = max(float(spec.min_value), min(float(spec.max_value), target))
        out[key] = target
        if abs(target - old) > 1e-12:
            changes.append(
                {
                    "key": key,
                    "rejected": False,
                    "old": old,
                    "new": target,
                    "delta": target - old,
                    "classification": spec.classification,
                }
            )
    return out, changes


def registry_snapshot() -> list[dict[str, Any]]:
    return [asdict(s) for s in PARAM_SPECS.values()]

===== END FILE: server/ai-brain/app/parameter_registry.py =====

===== FILE: server/ai-brain/app/research_store.py =====
"""Per-exchange autonomous research persistence (memory, cycles, lineage).

Bithumb and Upbit use separate SQLite files — CROSS_EXCHANGE_LEARNING = OFF.
"""
from __future__ import annotations

import json
import sqlite3
import time
import uuid
from pathlib import Path
from typing import Any

from .config import DATA_DIR
from .parameter_registry import default_weights, weights_hash


class ResearchStore:
    def __init__(self, exchange: str, path: Path | None = None) -> None:
        self.exchange = (exchange or "BITHUMB").upper()
        default = DATA_DIR / f"research_{self.exchange.lower()}.sqlite3"
        self.path = path or default
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self._init()

    def _conn(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.path)
        conn.row_factory = sqlite3.Row
        return conn

    def _init(self) -> None:
        with self._conn() as conn:
            conn.executescript(
                """
                CREATE TABLE IF NOT EXISTS active_model (
                    exchange TEXT PRIMARY KEY,
                    model_version TEXT NOT NULL,
                    model_hash TEXT NOT NULL,
                    weights_json TEXT NOT NULL,
                    learning_cycle_id TEXT,
                    source TEXT NOT NULL,
                    updated_at_ms INTEGER NOT NULL,
                    status TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS model_lineage (
                    model_version TEXT PRIMARY KEY,
                    parent_version TEXT,
                    model_hash TEXT NOT NULL,
                    weights_json TEXT NOT NULL,
                    status TEXT NOT NULL,
                    source TEXT NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    metrics_json TEXT NOT NULL,
                    why TEXT
                );
                CREATE TABLE IF NOT EXISTS learning_cycles (
                    learning_cycle_id TEXT PRIMARY KEY,
                    started_at_ms INTEGER NOT NULL,
                    completed_at_ms INTEGER,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS hypotheses (
                    hypothesis_id TEXT PRIMARY KEY,
                    created_at_ms INTEGER NOT NULL,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS experiments (
                    experiment_id TEXT PRIMARY KEY,
                    created_at_ms INTEGER NOT NULL,
                    status TEXT NOT NULL,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS training_samples (
                    sample_id TEXT PRIMARY KEY,
                    created_at_ms INTEGER NOT NULL,
                    market TEXT,
                    quality TEXT NOT NULL,
                    label INTEGER,
                    net_pnl REAL,
                    features_json TEXT NOT NULL,
                    meta_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS memory_events (
                    event_id TEXT PRIMARY KEY,
                    kind TEXT NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS research_journal (
                    entry_id TEXT PRIMARY KEY,
                    created_at_ms INTEGER NOT NULL,
                    cycle_id TEXT,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS shadow_candidates (
                    model_version TEXT PRIMARY KEY,
                    model_hash TEXT NOT NULL,
                    weights_json TEXT NOT NULL,
                    registered_at_ms INTEGER NOT NULL,
                    status TEXT NOT NULL,
                    metrics_json TEXT NOT NULL,
                    slot TEXT NOT NULL DEFAULT 'A'
                );
                CREATE TABLE IF NOT EXISTS shadow_outcomes (
                    outcome_id TEXT PRIMARY KEY,
                    model_version TEXT NOT NULL,
                    decision_id TEXT,
                    market TEXT NOT NULL,
                    decision TEXT NOT NULL,
                    signal_price REAL NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    horizons_json TEXT NOT NULL,
                    mfe REAL,
                    mae REAL,
                    label TEXT,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS market_observations (
                    obs_id TEXT PRIMARY KEY,
                    market TEXT NOT NULL,
                    decision TEXT NOT NULL,
                    decision_id TEXT,
                    signal_price REAL NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    horizons_json TEXT NOT NULL,
                    label TEXT,
                    payload_json TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS prediction_evals (
                    eval_id TEXT PRIMARY KEY,
                    created_at_ms INTEGER NOT NULL,
                    decision TEXT NOT NULL,
                    label TEXT NOT NULL,
                    ai_score REAL,
                    payload_json TEXT NOT NULL
                );
                """
            )
            # migrate slot column if older DB
            cols = {r[1] for r in conn.execute("PRAGMA table_info(shadow_candidates)").fetchall()}
            if "slot" not in cols:
                conn.execute("ALTER TABLE shadow_candidates ADD COLUMN slot TEXT NOT NULL DEFAULT 'A'")
            row = conn.execute(
                "SELECT exchange FROM active_model WHERE exchange=?", (self.exchange,)
            ).fetchone()
            if row is None:
                w = default_weights()
                conn.execute(
                    "INSERT INTO active_model(exchange, model_version, model_hash, weights_json, "
                    "learning_cycle_id, source, updated_at_ms, status) VALUES (?,?,?,?,?,?,?,?)",
                    (
                        self.exchange,
                        "M100",
                        weights_hash(w),
                        json.dumps(w),
                        None,
                        "BOOTSTRAP",
                        int(time.time() * 1000),
                        "CHAMPION",
                    ),
                )
                conn.execute(
                    "INSERT OR REPLACE INTO model_lineage(model_version, parent_version, model_hash, "
                    "weights_json, status, source, created_at_ms, metrics_json, why) VALUES (?,?,?,?,?,?,?,?,?)",
                    (
                        "M100",
                        None,
                        weights_hash(w),
                        json.dumps(w),
                        "CHAMPION",
                        "BOOTSTRAP",
                        int(time.time() * 1000),
                        json.dumps({"bootstrap": True}),
                        "Initial heuristic champion",
                    ),
                )

    def get_active_model(self) -> dict[str, Any]:
        with self._conn() as conn:
            row = conn.execute(
                "SELECT * FROM active_model WHERE exchange=?", (self.exchange,)
            ).fetchone()
        if row is None:
            w = default_weights()
            return {
                "exchange": self.exchange,
                "modelVersion": "M100",
                "modelHash": weights_hash(w),
                "weights": w,
                "learningCycleId": None,
                "source": "BOOTSTRAP",
                "status": "CHAMPION",
                "updatedAtMs": int(time.time() * 1000),
            }
        return {
            "exchange": row["exchange"],
            "modelVersion": row["model_version"],
            "modelHash": row["model_hash"],
            "weights": json.loads(row["weights_json"]),
            "learningCycleId": row["learning_cycle_id"],
            "source": row["source"],
            "status": row["status"],
            "updatedAtMs": row["updated_at_ms"],
        }

    def set_active_model(
        self,
        model_version: str,
        weights: dict[str, float],
        source: str,
        learning_cycle_id: str | None = None,
        status: str = "CHAMPION",
        why: str | None = None,
        parent_version: str | None = None,
        metrics: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        h = weights_hash(weights)
        now = int(time.time() * 1000)
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO active_model(exchange, model_version, model_hash, weights_json, "
                "learning_cycle_id, source, updated_at_ms, status) VALUES (?,?,?,?,?,?,?,?)",
                (
                    self.exchange,
                    model_version,
                    h,
                    json.dumps(weights),
                    learning_cycle_id,
                    source,
                    now,
                    status,
                ),
            )
            conn.execute(
                "INSERT OR REPLACE INTO model_lineage(model_version, parent_version, model_hash, "
                "weights_json, status, source, created_at_ms, metrics_json, why) VALUES (?,?,?,?,?,?,?,?,?)",
                (
                    model_version,
                    parent_version,
                    h,
                    json.dumps(weights),
                    status,
                    source,
                    now,
                    json.dumps(metrics or {}),
                    why,
                ),
            )
        return self.get_active_model()

    def add_lineage(
        self,
        model_version: str,
        parent_version: str | None,
        weights: dict[str, float],
        status: str,
        source: str,
        why: str | None = None,
        metrics: dict[str, Any] | None = None,
    ) -> None:
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO model_lineage(model_version, parent_version, model_hash, "
                "weights_json, status, source, created_at_ms, metrics_json, why) VALUES (?,?,?,?,?,?,?,?,?)",
                (
                    model_version,
                    parent_version,
                    weights_hash(weights),
                    json.dumps(weights),
                    status,
                    source,
                    int(time.time() * 1000),
                    json.dumps(metrics or {}),
                    why,
                ),
            )

    def list_lineage(self, limit: int = 50) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT * FROM model_lineage ORDER BY created_at_ms DESC LIMIT ?", (limit,)
            ).fetchall()
        return [
            {
                "modelVersion": r["model_version"],
                "parentVersion": r["parent_version"],
                "modelHash": r["model_hash"],
                "status": r["status"],
                "source": r["source"],
                "createdAtMs": r["created_at_ms"],
                "metrics": json.loads(r["metrics_json"] or "{}"),
                "why": r["why"],
            }
            for r in rows
        ]

    def save_learning_cycle(self, cycle: dict[str, Any]) -> None:
        cid = str(cycle.get("learningCycleId") or uuid.uuid4())
        cycle["learningCycleId"] = cid
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO learning_cycles(learning_cycle_id, started_at_ms, completed_at_ms, payload_json) "
                "VALUES (?,?,?,?)",
                (
                    cid,
                    int(cycle.get("startedAt") or time.time() * 1000),
                    cycle.get("completedAt"),
                    json.dumps(cycle, ensure_ascii=False),
                ),
            )

    def latest_learning_cycles(self, limit: int = 20) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM learning_cycles ORDER BY started_at_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def save_hypothesis(self, hyp: dict[str, Any]) -> str:
        hid = str(hyp.get("hypothesisId") or f"H-{uuid.uuid4().hex[:8]}")
        hyp["hypothesisId"] = hid
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO hypotheses(hypothesis_id, created_at_ms, payload_json) VALUES (?,?,?)",
                (hid, int(hyp.get("createdAt") or time.time() * 1000), json.dumps(hyp, ensure_ascii=False)),
            )
        return hid

    def list_hypotheses(self, limit: int = 30) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM hypotheses ORDER BY created_at_ms DESC LIMIT ?", (limit,)
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def save_experiment(self, exp: dict[str, Any]) -> str:
        eid = str(exp.get("experimentId") or f"E-{uuid.uuid4().hex[:8]}")
        exp["experimentId"] = eid
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO experiments(experiment_id, created_at_ms, status, payload_json) VALUES (?,?,?,?)",
                (
                    eid,
                    int(exp.get("createdAt") or time.time() * 1000),
                    str(exp.get("status") or "OPEN"),
                    json.dumps(exp, ensure_ascii=False),
                ),
            )
        return eid

    def list_experiments(self, limit: int = 30) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM experiments ORDER BY created_at_ms DESC LIMIT ?", (limit,)
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def add_training_sample(self, sample: dict[str, Any]) -> str:
        sid = str(sample.get("sampleId") or str(uuid.uuid4()))
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO training_samples(sample_id, created_at_ms, market, quality, label, "
                "net_pnl, features_json, meta_json) VALUES (?,?,?,?,?,?,?,?)",
                (
                    sid,
                    int(sample.get("createdAt") or time.time() * 1000),
                    sample.get("market"),
                    str(sample.get("quality") or "VALID"),
                    int(sample["label"]) if sample.get("label") is not None else None,
                    sample.get("netPnl"),
                    json.dumps(sample.get("features") or {}),
                    json.dumps(sample.get("meta") or {}),
                ),
            )
        return sid

    def count_samples(self, quality: str | None = "VALID") -> int:
        with self._conn() as conn:
            if quality:
                row = conn.execute(
                    "SELECT COUNT(*) AS c FROM training_samples WHERE quality=?", (quality,)
                ).fetchone()
            else:
                row = conn.execute("SELECT COUNT(*) AS c FROM training_samples").fetchone()
        return int(row["c"] if row else 0)

    def list_samples(self, limit: int = 500, quality: str = "VALID") -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT * FROM training_samples WHERE quality=? ORDER BY created_at_ms ASC LIMIT ?",
                (quality, limit),
            ).fetchall()
        out = []
        for r in rows:
            out.append(
                {
                    "sampleId": r["sample_id"],
                    "createdAt": r["created_at_ms"],
                    "market": r["market"],
                    "quality": r["quality"],
                    "label": r["label"],
                    "netPnl": r["net_pnl"],
                    "features": json.loads(r["features_json"] or "{}"),
                    "meta": json.loads(r["meta_json"] or "{}"),
                }
            )
        return out

    def add_memory(self, kind: str, payload: dict[str, Any]) -> str:
        eid = str(uuid.uuid4())
        with self._conn() as conn:
            conn.execute(
                "INSERT INTO memory_events(event_id, kind, created_at_ms, payload_json) VALUES (?,?,?,?)",
                (eid, kind, int(time.time() * 1000), json.dumps(payload, ensure_ascii=False)),
            )
        return eid

    def list_memory(self, kind: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
        with self._conn() as conn:
            if kind:
                rows = conn.execute(
                    "SELECT * FROM memory_events WHERE kind=? ORDER BY created_at_ms DESC LIMIT ?",
                    (kind, limit),
                ).fetchall()
            else:
                rows = conn.execute(
                    "SELECT * FROM memory_events ORDER BY created_at_ms DESC LIMIT ?", (limit,)
                ).fetchall()
        return [
            {
                "eventId": r["event_id"],
                "kind": r["kind"],
                "createdAt": r["created_at_ms"],
                "payload": json.loads(r["payload_json"]),
            }
            for r in rows
        ]

    def add_journal(self, entry: dict[str, Any]) -> str:
        eid = str(entry.get("entryId") or str(uuid.uuid4()))
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO research_journal(entry_id, created_at_ms, cycle_id, payload_json) VALUES (?,?,?,?)",
                (
                    eid,
                    int(entry.get("createdAt") or time.time() * 1000),
                    entry.get("learningCycleId"),
                    json.dumps(entry, ensure_ascii=False),
                ),
            )
        return eid

    def list_journal(self, limit: int = 30) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM research_journal ORDER BY created_at_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def register_shadow(
        self,
        model_version: str,
        weights: dict[str, float],
        metrics: dict[str, Any] | None = None,
        status: str = "SHADOW",
        slot: str | None = None,
    ) -> None:
        # Cap active shadow challengers at A/B/C
        active_slots = self.list_shadows(status_filter="SHADOW")
        used = {str(s.get("slot") or "A") for s in active_slots}
        if slot is None:
            for cand in ("A", "B", "C"):
                if cand not in used or any(
                    x.get("modelVersion") == model_version and x.get("slot") == cand for x in active_slots
                ):
                    slot = cand
                    break
            else:
                # Replace oldest SHADOW in slot A
                slot = "A"
        if slot not in {"A", "B", "C"}:
            slot = "A"
        with self._conn() as conn:
            # Keep at most 3 SHADOW rows: drop oldest if adding a 4th distinct version
            if status == "SHADOW":
                rows = conn.execute(
                    "SELECT model_version FROM shadow_candidates WHERE status='SHADOW' "
                    "AND model_version != ? ORDER BY registered_at_ms ASC",
                    (model_version,),
                ).fetchall()
                while len(rows) >= 3:
                    conn.execute(
                        "UPDATE shadow_candidates SET status='SUPERSEDED' WHERE model_version=?",
                        (rows[0]["model_version"],),
                    )
                    rows = rows[1:]
            conn.execute(
                "INSERT OR REPLACE INTO shadow_candidates(model_version, model_hash, weights_json, "
                "registered_at_ms, status, metrics_json, slot) VALUES (?,?,?,?,?,?,?)",
                (
                    model_version,
                    weights_hash(weights),
                    json.dumps(weights),
                    int(time.time() * 1000),
                    status,
                    json.dumps(metrics or {}),
                    slot,
                ),
            )

    def get_shadow(self) -> dict[str, Any] | None:
        shadows = self.list_shadows(status_filter="SHADOW")
        if shadows:
            return shadows[0]
        with self._conn() as conn:
            row = conn.execute(
                "SELECT * FROM shadow_candidates ORDER BY registered_at_ms DESC LIMIT 1"
            ).fetchone()
        if row is None:
            return None
        return self._shadow_row(row)

    def list_shadows(self, status_filter: str | None = "SHADOW", limit: int = 5) -> list[dict[str, Any]]:
        with self._conn() as conn:
            if status_filter:
                rows = conn.execute(
                    "SELECT * FROM shadow_candidates WHERE status=? ORDER BY registered_at_ms DESC LIMIT ?",
                    (status_filter, limit),
                ).fetchall()
            else:
                rows = conn.execute(
                    "SELECT * FROM shadow_candidates ORDER BY registered_at_ms DESC LIMIT ?",
                    (limit,),
                ).fetchall()
        return [self._shadow_row(r) for r in rows]

    def _shadow_row(self, row: Any) -> dict[str, Any]:
        keys = row.keys()
        return {
            "modelVersion": row["model_version"],
            "modelHash": row["model_hash"],
            "weights": json.loads(row["weights_json"]),
            "status": row["status"],
            "registeredAtMs": row["registered_at_ms"],
            "metrics": json.loads(row["metrics_json"] or "{}"),
            "slot": row["slot"] if "slot" in keys else "A",
        }

    def save_shadow_outcome(self, payload: dict[str, Any]) -> str:
        oid = str(payload.get("outcomeId") or str(uuid.uuid4()))
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO shadow_outcomes(outcome_id, model_version, decision_id, market, "
                "decision, signal_price, created_at_ms, horizons_json, mfe, mae, label, payload_json) "
                "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
                (
                    oid,
                    str(payload.get("modelVersion") or ""),
                    payload.get("decisionId"),
                    str(payload.get("market") or ""),
                    str(payload.get("decision") or ""),
                    float(payload.get("signalPrice") or 0),
                    int(payload.get("createdAt") or time.time() * 1000),
                    json.dumps(payload.get("horizons") or {}),
                    payload.get("mfe"),
                    payload.get("mae"),
                    payload.get("label"),
                    json.dumps(payload, ensure_ascii=False),
                ),
            )
        return oid

    def list_shadow_outcomes(self, limit: int = 100) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM shadow_outcomes ORDER BY created_at_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def open_shadow_outcomes(self, limit: int = 200) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM shadow_outcomes WHERE label IS NULL OR label='' "
                "ORDER BY created_at_ms ASC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def save_market_observation(self, payload: dict[str, Any]) -> str:
        oid = str(payload.get("obsId") or str(uuid.uuid4()))
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO market_observations(obs_id, market, decision, decision_id, "
                "signal_price, created_at_ms, horizons_json, label, payload_json) VALUES (?,?,?,?,?,?,?,?,?)",
                (
                    oid,
                    str(payload.get("market") or ""),
                    str(payload.get("decision") or ""),
                    payload.get("decisionId"),
                    float(payload.get("signalPrice") or 0),
                    int(payload.get("createdAt") or time.time() * 1000),
                    json.dumps(payload.get("horizons") or {}),
                    payload.get("label"),
                    json.dumps(payload, ensure_ascii=False),
                ),
            )
        return oid

    def open_market_observations(self, limit: int = 200) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM market_observations WHERE label IS NULL OR label='' "
                "ORDER BY created_at_ms ASC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def list_market_observations(self, limit: int = 50) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM market_observations ORDER BY created_at_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def completed_market_observations(self, limit: int = 500) -> list[dict[str, Any]]:
        """Labeled observations (horizon-resolved). Oldest-first for stable materialization."""
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM market_observations "
                "WHERE label IS NOT NULL AND label!='' "
                "ORDER BY created_at_ms ASC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def completed_shadow_outcomes(self, limit: int = 500) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM shadow_outcomes "
                "WHERE label IS NOT NULL AND label!='' "
                "ORDER BY created_at_ms ASC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def save_prediction_eval(self, payload: dict[str, Any]) -> str:
        eid = str(payload.get("evalId") or str(uuid.uuid4()))
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO prediction_evals(eval_id, created_at_ms, decision, label, ai_score, payload_json) "
                "VALUES (?,?,?,?,?,?)",
                (
                    eid,
                    int(payload.get("createdAt") or time.time() * 1000),
                    str(payload.get("decision") or ""),
                    str(payload.get("label") or ""),
                    payload.get("aiScore"),
                    json.dumps(payload, ensure_ascii=False),
                ),
            )
        return eid

    def list_prediction_evals(self, limit: int = 200) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM prediction_evals ORDER BY created_at_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def next_model_version(self) -> str:
        """Exchange-namespaced model ids: BITHUMB-M101 / UPBIT-M101 (legacy M101 also parsed)."""
        with self._conn() as conn:
            rows = conn.execute("SELECT model_version FROM model_lineage").fetchall()
        nums = []
        prefix = f"{self.exchange}-M"
        for r in rows:
            v = str(r["model_version"] or "")
            if v.startswith(prefix) and v[len(prefix) :].isdigit():
                nums.append(int(v[len(prefix) :]))
            elif v.startswith("M") and v[1:].isdigit():
                nums.append(int(v[1:]))
        n = (max(nums) + 1) if nums else 101
        return f"{prefix}{n}"

===== END FILE: server/ai-brain/app/research_store.py =====

===== FILE: server/ai-brain/app/storage.py =====
from __future__ import annotations

import json
import sqlite3
import time
import uuid
from pathlib import Path
from typing import Any

from .config import DATA_DIR


class DecisionStore:
    def __init__(self, path: Path | None = None) -> None:
        self.path = path or (DATA_DIR / "ai_brain.sqlite3")
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self._init()

    def _conn(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.path)
        conn.row_factory = sqlite3.Row
        return conn

    def _init(self) -> None:
        with self._conn() as conn:
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS decisions (
                    decision_id TEXT PRIMARY KEY,
                    market TEXT NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    payload_json TEXT NOT NULL
                )
                """
            )
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS outcomes (
                    outcome_id TEXT PRIMARY KEY,
                    decision_id TEXT NOT NULL UNIQUE,
                    market TEXT NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    payload_json TEXT NOT NULL
                )
                """
            )
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS model_registry (
                    model_id TEXT PRIMARY KEY,
                    version TEXT NOT NULL,
                    created_at_ms INTEGER NOT NULL,
                    status TEXT NOT NULL,
                    metrics_json TEXT NOT NULL
                )
                """
            )

    def save_decision(self, decision: dict[str, Any]) -> None:
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO decisions(decision_id, market, created_at_ms, payload_json) VALUES (?,?,?,?)",
                (
                    decision["decisionId"],
                    decision["market"],
                    int(decision.get("serverTimestamp") or time.time() * 1000),
                    json.dumps(decision, ensure_ascii=False),
                ),
            )

    def recent_decisions(self, limit: int = 50) -> list[dict[str, Any]]:
        with self._conn() as conn:
            rows = conn.execute(
                "SELECT payload_json FROM decisions ORDER BY created_at_ms DESC LIMIT ?",
                (limit,),
            ).fetchall()
        return [json.loads(r["payload_json"]) for r in rows]

    def save_outcome(self, payload: dict[str, Any]) -> tuple[bool, str]:
        decision_id = str(payload.get("decisionId") or "").strip()
        if not decision_id:
            return False, "MISSING_DECISION_ID"
        outcome_id = str(payload.get("outcomeId") or uuid.uuid4())
        with self._conn() as conn:
            existing = conn.execute(
                "SELECT decision_id FROM outcomes WHERE decision_id = ?",
                (decision_id,),
            ).fetchone()
            if existing:
                return False, "DUPLICATE_OUTCOME"
            conn.execute(
                "INSERT INTO outcomes(outcome_id, decision_id, market, created_at_ms, payload_json) VALUES (?,?,?,?,?)",
                (
                    outcome_id,
                    decision_id,
                    str(payload.get("market") or ""),
                    int(time.time() * 1000),
                    json.dumps(payload, ensure_ascii=False),
                ),
            )
        return True, outcome_id

    def upsert_model(self, model_id: str, version: str, status: str, metrics: dict[str, Any]) -> None:
        with self._conn() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO model_registry(model_id, version, created_at_ms, status, metrics_json) VALUES (?,?,?,?,?)",
                (model_id, version, int(time.time() * 1000), status, json.dumps(metrics)),
            )
===== END FILE: server/ai-brain/app/storage.py =====

===== FILE: server/ai-brain/app/upbit_collector.py =====
from __future__ import annotations

"""Independent Upbit market collector (REST + WebSocket).

Uses official Upbit Quotation API only:
  REST  https://api.upbit.com
  WS    wss://api.upbit.com/websocket/v1

Does NOT share Bithumb cache, rate limiter, or micro buffer instances.
"""

import asyncio
import json
import time
from collections import deque
from dataclasses import dataclass, field
from threading import RLock
from typing import Any

import httpx

from .config import ORDERBOOK_STALE_MS, TICKER_STALE_MS, UPBIT_REST, UPBIT_WS_URL
from .market_collector import OrderbookSnap, TickerSnap
from .micro_buffer import MicroBufferStore

try:
    import websockets
except ImportError:  # pragma: no cover
    websockets = None


@dataclass
class CollectorStats:
    connection_state: str = "DISCONNECTED"
    last_message_at: int = 0
    message_count: int = 0
    reconnect_count: int = 0
    last_rest_fallback_at: int = 0
    rest_fallback_success: int = 0
    rest_fallback_failed: int = 0
    started_at: int = field(default_factory=lambda: int(time.time() * 1000))


class UpbitMarketCollector:
    """Upbit-only market data. Interface mirrors MarketCollector for DecisionEngine reuse."""

    EXCHANGE = "UPBIT"

    def __init__(self, micro: MicroBufferStore) -> None:
        self.micro = micro
        self.stats = CollectorStats()
        self._lock = RLock()
        self._tickers: dict[str, TickerSnap] = {}
        self._orderbooks: dict[str, OrderbookSnap] = {}
        self._orderbook_history: dict[str, deque[OrderbookSnap]] = {}
        self._markets: list[str] = []
        self._task: asyncio.Task | None = None
        self._stop = asyncio.Event()
        # Independent simple rate gate (do not share with Bithumb).
        self._rest_min_interval_s = 0.12
        self._last_rest_at = 0.0

    def start(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
        if self._task and not self._task.done():
            return
        self._stop.clear()
        loop = loop or asyncio.get_event_loop()
        self._task = loop.create_task(self._run())

    async def stop(self) -> None:
        self._stop.set()
        if self._task:
            await asyncio.wait([self._task], timeout=3)

    def snapshot_tickers(self) -> dict[str, TickerSnap]:
        with self._lock:
            return dict(self._tickers)

    def snapshot_orderbook(self, market: str) -> OrderbookSnap | None:
        with self._lock:
            return self._orderbooks.get(market)

    def orderbook_history(self, market: str) -> list[OrderbookSnap]:
        with self._lock:
            return list(self._orderbook_history.get(market) or [])

    def stale_market_count(self, now_ms: int | None = None) -> int:
        now = now_ms or int(time.time() * 1000)
        with self._lock:
            return sum(1 for t in self._tickers.values() if now - t.timestamp_ms > TICKER_STALE_MS)

    def health(self) -> dict[str, Any]:
        now = int(time.time() * 1000)
        age = now - self.stats.last_message_at if self.stats.last_message_at else None
        zombie = self.stats.connection_state == "CONNECTED" and age is not None and age > 60_000
        uptime = now - self.stats.started_at
        rate = 0.0
        if uptime > 0 and self.stats.message_count > 0:
            rate = self.stats.message_count / max(1.0, uptime / 1000.0)
        return {
            "exchange": self.EXCHANGE,
            "connectionState": "WEBSOCKET_ZOMBIE" if zombie else self.stats.connection_state,
            "wsState": "WEBSOCKET_ZOMBIE" if zombie else self.stats.connection_state,
            "lastMessageAt": self.stats.last_message_at or None,
            "lastWsMessageAt": self.stats.last_message_at or None,
            "lastMessageAgeMs": age,
            "messageCount": self.stats.message_count,
            "messageRate": round(rate, 3),
            "reconnectCount": self.stats.reconnect_count,
            "staleMarketCount": self.stale_market_count(now),
            "marketCount": len(self._markets) or len(self._tickers),
            "lastRestFallbackAt": self.stats.last_rest_fallback_at or None,
            "restFallbackSuccess": self.stats.rest_fallback_success,
            "restFallbackFailed": self.stats.rest_fallback_failed,
            "zombieReason": "UPBIT_WS_ZOMBIE" if zombie else None,
        }

    async def _rate_wait(self) -> None:
        now = time.monotonic()
        wait = self._rest_min_interval_s - (now - self._last_rest_at)
        if wait > 0:
            await asyncio.sleep(wait)
        self._last_rest_at = time.monotonic()

    @staticmethod
    def _is_tradable_krw(row: dict[str, Any]) -> bool:
        market = str(row.get("market") or "")
        if not market.startswith("KRW-"):
            return False
        # Upbit details use market_event.warning; older payloads may omit it.
        event = row.get("market_event") or {}
        if isinstance(event, dict) and event.get("warning") is True:
            return False
        if str(row.get("market_warning") or "NONE").upper() not in {"NONE", "", "FALSE"}:
            if row.get("market_warning") is True:
                return False
        return True

    async def refresh_markets(self) -> list[str]:
        await self._rate_wait()
        async with httpx.AsyncClient(timeout=15.0) as client:
            res = await client.get(f"{UPBIT_REST}/v1/market/all", params={"isDetails": "true"})
            res.raise_for_status()
            rows = res.json()
        markets = [r["market"] for r in rows if self._is_tradable_krw(r)]
        self._markets = markets
        print(f"[UPBIT][REST] market/all krw={len(markets)}", flush=True)
        return markets

    async def rest_ticker_fallback(self, markets: list[str] | None = None) -> int:
        codes = markets or self._markets
        if not codes:
            return 0
        updated = 0
        started = int(time.time() * 1000)
        self.stats.last_rest_fallback_at = started
        try:
            async with httpx.AsyncClient(timeout=20.0) as client:
                for i in range(0, len(codes), 100):
                    chunk = codes[i : i + 100]
                    await self._rate_wait()
                    res = await client.get(f"{UPBIT_REST}/v1/ticker", params={"markets": ",".join(chunk)})
                    res.raise_for_status()
                    for row in res.json():
                        self._ingest_ticker_dict(row, source="REST")
                        updated += 1
            self.stats.rest_fallback_success += 1
        except Exception:
            self.stats.rest_fallback_failed += 1
            raise
        return updated

    async def fetch_orderbooks(self, markets: list[str]) -> int:
        if not markets:
            return 0
        from .market_integrity import validate_orderbook

        count = 0
        async with httpx.AsyncClient(timeout=20.0) as client:
            for i in range(0, len(markets), 40):
                chunk = markets[i : i + 40]
                await self._rate_wait()
                res = await client.get(f"{UPBIT_REST}/v1/orderbook", params={"markets": ",".join(chunk)})
                res.raise_for_status()
                now = int(time.time() * 1000)
                for row in res.json():
                    units = row.get("orderbook_units") or []
                    if not units:
                        continue
                    unit = units[0]
                    bid = float(unit.get("bid_price") or 0)
                    ask = float(unit.get("ask_price") or 0)
                    bid_sz = float(unit.get("bid_size") or 0)
                    ask_sz = float(unit.get("ask_size") or 0)
                    ts = int(row.get("timestamp") or now)
                    if validate_orderbook(bid=bid, ask=ask, bid_size=bid_sz, ask_size=ask_sz, age_ms=0):
                        continue
                    snap = OrderbookSnap(
                        market=row["market"],
                        bid_price=bid,
                        ask_price=ask,
                        bid_size=bid_sz,
                        ask_size=ask_sz,
                        timestamp_ms=ts,
                        received_at_ms=now,
                        source="REST",
                    )
                    with self._lock:
                        self._orderbooks[snap.market] = snap
                        hist = self._orderbook_history.setdefault(snap.market, deque(maxlen=120))
                        hist.append(snap)
                    count += 1
        return count

    async def fetch_candles_minutes(self, market: str, unit: int = 1, count: int = 30) -> list[dict[str, Any]]:
        await self._rate_wait()
        async with httpx.AsyncClient(timeout=15.0) as client:
            res = await client.get(
                f"{UPBIT_REST}/v1/candles/minutes/{unit}",
                params={"market": market, "count": count},
            )
            res.raise_for_status()
            return list(res.json())

    def _ingest_ticker_dict(self, row: dict[str, Any], source: str = "WS") -> None:
        from .market_integrity import validate_ticker

        market = str(row.get("code") or row.get("market") or "")
        if not market.startswith("KRW-"):
            return
        price = float(row.get("trade_price") or 0)
        now = int(time.time() * 1000)
        ts = int(row.get("timestamp") or row.get("trade_timestamp") or now)
        if ts < 10_000_000_000:
            ts *= 1000
        if validate_ticker(price=price, exchange_ts_ms=ts, received_at_ms=now, now_ms=now):
            return
        with self._lock:
            prev = self._tickers.get(market)
            if prev is not None and ts < prev.timestamp_ms:
                return
        snap = TickerSnap(
            market=market,
            trade_price=price,
            acc_trade_price_24h=float(row.get("acc_trade_price_24h") or 0),
            signed_change_rate=float(row.get("signed_change_rate") or 0),
            trade_volume=float(row.get("trade_volume") or 0),
            timestamp_ms=ts,
            received_at_ms=now,
            source=source,
        )
        with self._lock:
            self._tickers[market] = snap
        self.micro.add(market, price, snap.trade_volume, now_ms=now if source == "WS" else ts)
        if source == "WS":
            self.stats.last_message_at = now
            self.stats.message_count += 1

    async def _run(self) -> None:
        backoff = 1
        while not self._stop.is_set():
            try:
                if not self._markets:
                    await self.refresh_markets()
                await self.rest_ticker_fallback(self._markets)
                await self._ws_loop()
                backoff = 1
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                self.stats.connection_state = "ERROR"
                self.stats.reconnect_count += 1
                print(f"[UPBIT][WS] reconnect error={exc} count={self.stats.reconnect_count}", flush=True)
                try:
                    await self.rest_ticker_fallback(self._markets)
                except Exception:
                    pass
                await asyncio.sleep(min(60, backoff))
                backoff = min(60, backoff * 2)

    async def _ws_loop(self) -> None:
        if websockets is None:
            raise RuntimeError("websockets package missing")
        self.stats.connection_state = "CONNECTING"
        # Official Upbit WS subscription: ticket + type + codes (+ format).
        async with websockets.connect(UPBIT_WS_URL, ping_interval=20, ping_timeout=20, max_queue=2048) as ws:
            self.stats.connection_state = "CONNECTED"
            print(f"[UPBIT][WS] CONNECTED markets={len(self._markets)}", flush=True)
            payload = [
                {"ticket": f"upbit-ai-brain-{int(time.time())}"},
                {"type": "ticker", "codes": self._markets, "isOnlySnapshot": False},
                {"format": "DEFAULT"},
            ]
            await ws.send(json.dumps(payload))
            while not self._stop.is_set():
                raw = await asyncio.wait_for(ws.recv(), timeout=45)
                if isinstance(raw, bytes):
                    raw = raw.decode("utf-8", errors="ignore")
                try:
                    msg = json.loads(raw)
                except json.JSONDecodeError:
                    continue
                if isinstance(msg, dict):
                    self._ingest_ticker_dict(msg, source="WS")

===== END FILE: server/ai-brain/app/upbit_collector.py =====

===== FILE: server/ai-brain/app/weighted_policy.py =====
"""Weighted policy scorer — same decision surface as DecisionEngine, parameterized.

Used for: live inference (champion weights), challenger shadow, replay/OOS, prediction-change proof.
"""
from __future__ import annotations

import math
from typing import Any

from .parameter_registry import default_weights


def _clamp(x: float, lo: float = 0.0, hi: float = 100.0) -> float:
    return max(lo, min(hi, x))


def extract_features(decision_or_micro: dict[str, Any]) -> dict[str, float]:
    """Build a compact feature vector from a stored decision or synthetic sample."""
    micro = decision_or_micro.get("micro") or {}
    if not isinstance(micro, dict):
        micro = {}

    def f(key: str, *alts: str, default: float = 0.0) -> float:
        for k in (key, *alts):
            v = decision_or_micro.get(k)
            if v is None:
                v = micro.get(k)
            if isinstance(v, (int, float)) and math.isfinite(float(v)):
                return float(v)
        return default

    return {
        "strategyScore": f("strategyScore", default=50.0),
        "return30s": f("return30s", default=0.0),
        "return1m": f("return1m", default=0.0),
        "return3m": f("return3m", default=0.0),
        "signedChange": f("signedChange", "changeRate", default=0.0),
        "spread": f("spread", default=0.2),
        "microAvailable": 1.0 if (micro.get("status") == "AVAILABLE" or decision_or_micro.get("executionDataQuality") == "AVAILABLE") else 0.0,
        "liquidityOk": 1.0 if decision_or_micro.get("liquidityPassed") else 0.0,
        "grossMove": f("grossExpectedEdge", "return1m", default=0.0),
    }


def score_with_weights(features: dict[str, float], weights: dict[str, float] | None = None) -> dict[str, Any]:
    w = dict(default_weights())
    if weights:
        w.update({k: float(v) for k, v in weights.items() if k in w or True})
        # keep only known keys + any passed
        base = default_weights()
        for k in list(w.keys()):
            if k not in base:
                # allow thresholds already in default_weights
                pass
        w = {**base, **{k: float(weights[k]) for k in base if k in weights}}

    strategy = float(features.get("strategyScore") or 50.0)
    r30 = float(features.get("return30s") or 0.0)
    r1 = float(features.get("return1m") or 0.0)
    signed = float(features.get("signedChange") or 0.0)
    micro_ok = float(features.get("microAvailable") or 0.0) >= 0.5

    ai = strategy * float(w["w_strategy_in_ai"]) + float(w["w_ai_bias"])
    if micro_ok:
        ai += float(w["w_micro_available"])
    if signed > 0:
        ai += float(w["w_positive_change"])
    ai = _clamp(ai)

    if r30 > 1.5 and r1 > 2.5:
        chase = 95.0
    elif r30 > 0.8:
        chase = 70.0
    else:
        chase = _clamp(r30 * float(w["w_chase_r30"]))

    timing = float(w["w_timing_base"]) - chase * float(w["w_timing_chase_penalty"])
    if 0.1 <= r30 <= 0.8:
        timing += 8.0
    if not micro_ok:
        timing = 50.0
    timing = _clamp(timing)

    spread = float(features.get("spread") or 0.2)
    # One-way short edge approximation (aligned with DecisionEngine._short_edge spirit)
    cost = 0.25 + 0.10 + spread
    short_edge = r1 - cost * 1.35 if micro_ok else None

    exec_score = (
        50.0
        + (ai - 50.0) * float(w["w_exec_ai"])
        + (timing - 50.0) * float(w["w_exec_timing"])
        - chase * float(w["w_exec_chase_penalty"])
    )
    if short_edge is not None:
        exec_score += max(-15.0, min(15.0, short_edge * 8.0))
    if not micro_ok:
        exec_score -= 15.0
    exec_score = _clamp(exec_score)

    thr_chase = float(w["thr_chase_avoid"])
    thr_edge = float(w["thr_short_edge"])
    thr_s = float(w["thr_strategy_buy"])
    thr_ai = float(w["thr_ai_buy"])
    thr_e = float(w["thr_exec_buy"])

    if not micro_ok:
        decision = "AVOID"
        state = "DATA_INSUFFICIENT"
    elif chase >= thr_chase:
        decision = "AVOID"
        state = "CHASE_RISK"
    elif short_edge is None or short_edge < thr_edge:
        decision = "WAIT"
        state = "NO_EDGE"
    elif strategy >= thr_s and ai >= thr_ai and exec_score >= thr_e:
        decision = "BUY"
        state = "ENTER_NOW"
    else:
        decision = "WAIT"
        state = "WAIT"

    # Feature importance attribution (additive contributions to BUY path score)
    importance = {
        "momentum": round(r1 * 8.0 + r30 * 4.0, 3),
        "strategy": round((strategy - 50.0) * 0.4, 3),
        "aiBlend": round((ai - 50.0) * float(w["w_exec_ai"]), 3),
        "timing": round((timing - 50.0) * float(w["w_exec_timing"]), 3),
        "chase": round(-chase * float(w["w_exec_chase_penalty"]), 3),
        "shortEdge": round(0.0 if short_edge is None else max(-15.0, min(15.0, short_edge * 8.0)), 3),
    }

    return {
        "strategyScore": strategy,
        "aiScore": ai,
        "chaseScore": chase,
        "entryTimingScore": timing,
        "executionScore": exec_score,
        "shortEdge": short_edge,
        "decision": decision,
        "executionState": state,
        "featureImportance": importance,
        "compositeScore": exec_score,
    }


def compare_predictions(
    samples: list[dict[str, Any]],
    old_weights: dict[str, float],
    new_weights: dict[str, float],
) -> dict[str, Any]:
    changed = 0
    score_changed = 0
    deltas: list[float] = []
    for s in samples:
        feats = s.get("features") or extract_features(s)
        a = score_with_weights(feats, old_weights)
        b = score_with_weights(feats, new_weights)
        if a["decision"] != b["decision"]:
            changed += 1
        delta = float(b["compositeScore"]) - float(a["compositeScore"])
        if abs(delta) > 1e-9:
            score_changed += 1
        deltas.append(delta)
    n = max(1, len(samples))
    pct = 100.0 * changed / n if samples else 0.0
    avg = sum(deltas) / n if deltas else 0.0
    mx = max((abs(x) for x in deltas), default=0.0)
    diagnosis = None
    if samples and changed == 0 and mx < 1e-9:
        diagnosis = "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED"
    elif samples and changed == 0:
        # Weights/scores moved but BUY/WAIT/AVOID labels identical on fixed validation inputs.
        diagnosis = "DECISION_UNCHANGED_SCORE_SHIFTED"
    return {
        "sampleCount": len(samples),
        "SCORE_CHANGED_COUNT": score_changed,
        "SCORE_CHANGED_PERCENT": round(100.0 * score_changed / n, 3) if samples else 0.0,
        "PREDICTION_CHANGED_COUNT": changed,
        "DECISION_CHANGED_COUNT": changed,
        "PREDICTION_CHANGED_PERCENT": round(pct, 3),
        "DECISION_CHANGE_RATE": round(pct / 100.0, 6),
        "AVERAGE_SCORE_DELTA": round(avg, 4),
        "MAX_SCORE_DELTA": round(mx, 4),
        "diagnosis": diagnosis,
    }


def replay_metrics(samples: list[dict[str, Any]], weights: dict[str, float]) -> dict[str, float]:
    """Simple expectancy/PF on labeled samples when policy says BUY."""
    wins = 0.0
    losses = 0.0
    pnls: list[float] = []
    trades = 0
    equity = 0.0
    peak = 0.0
    mdd = 0.0
    for s in samples:
        feats = s.get("features") or {}
        pred = score_with_weights(feats, weights)
        if pred["decision"] != "BUY":
            continue
        trades += 1
        pnl = float(s.get("netPnl") or 0.0)
        # If label present without pnl, map label→proxy
        if s.get("netPnl") is None and s.get("label") is not None:
            pnl = 50.0 if int(s["label"]) == 1 else -40.0
        pnls.append(pnl)
        if pnl >= 0:
            wins += pnl
        else:
            losses += abs(pnl)
        equity += pnl
        peak = max(peak, equity)
        mdd = max(mdd, peak - equity)
    expectancy = (sum(pnls) / len(pnls)) if pnls else 0.0
    pf = (wins / losses) if losses > 1e-9 else (10.0 if wins > 0 else 0.0)
    return {
        "tradeCount": float(trades),
        "netExpectancy": round(expectancy, 4),
        "profitFactor": round(pf, 4),
        "mdd": round(mdd, 4),
        "netPnl": round(sum(pnls), 4),
        "winRate": round(100.0 * sum(1 for p in pnls if p > 0) / len(pnls), 2) if pnls else 0.0,
    }


def regime_replay_metrics(samples: list[dict[str, Any]], weights: dict[str, float]) -> dict[str, Any]:
    """Split replay by regime tag in sample meta (no look-ahead)."""
    regimes = ("TREND_UP", "TREND_DOWN", "SIDEWAYS", "HIGH_VOL", "CRASH", "UNKNOWN")
    buckets: dict[str, list] = {r: [] for r in regimes}
    for s in samples:
        reg = str((s.get("meta") or {}).get("regime") or s.get("regime") or "UNKNOWN").upper()
        if reg not in buckets:
            reg = "UNKNOWN"
        buckets[reg].append(s)
    out = {}
    for reg, rows in buckets.items():
        if not rows:
            continue
        out[reg] = replay_metrics(rows, weights)
        out[reg]["sampleSize"] = len(rows)
    # Specialist flag: only one regime clearly positive while global weak
    return out


def calibration_report(samples: list[dict[str, Any]], weights: dict[str, float] | None = None) -> dict[str, Any]:
    """AI score bucket vs realized net expectancy — detect meaningless scores."""
    buckets = {
        "0-40": {"sample": 0, "pnls": []},
        "40-60": {"sample": 0, "pnls": []},
        "60-80": {"sample": 0, "pnls": []},
        "80-100": {"sample": 0, "pnls": []},
    }
    for s in samples:
        feats = s.get("features") or {}
        scored = score_with_weights(feats, weights)
        ai = float(scored.get("aiScore") or feats.get("strategyScore") or 50)
        if ai < 40:
            key = "0-40"
        elif ai < 60:
            key = "40-60"
        elif ai < 80:
            key = "60-80"
        else:
            key = "80-100"
        pnl = float(s.get("netPnl") or 0)
        buckets[key]["sample"] += 1
        buckets[key]["pnls"].append(pnl)
    report = {}
    for k, v in buckets.items():
        pnls = v["pnls"]
        wins = sum(p for p in pnls if p > 0)
        losses = sum(abs(p) for p in pnls if p < 0)
        report[k] = {
            "sample": v["sample"],
            "netExpectancy": round(sum(pnls) / len(pnls), 4) if pnls else 0.0,
            "profitFactor": round((wins / losses) if losses > 1e-9 else (10.0 if wins else 0.0), 4),
            "mfe": None,
            "mae": None,
        }
    # Monotonicity check: higher buckets should not be worse expectancy
    hi = report["80-100"]["netExpectancy"]
    mid = report["60-80"]["netExpectancy"]
    diagnosis = "SCORE_MEANINGFUL" if hi >= mid else "SCORE_POORLY_CALIBRATED"
    if report["80-100"]["sample"] < 3 or report["60-80"]["sample"] < 3:
        diagnosis = "INSUFFICIENT_CALIBRATION_SAMPLE"
    return {"buckets": report, "diagnosis": diagnosis}


SHADOW_HORIZONS_MS = {
    "30s": 30_000,
    "1m": 60_000,
    "3m": 180_000,
    "5m": 300_000,
    "15m": 900_000,
    "30m": 1_800_000,
    "60m": 3_600_000,
}

===== END FILE: server/ai-brain/app/weighted_policy.py =====

===== FILE: server/ai-brain/tests/test_autonomous_research.py =====
"""Autonomous research / learning proof tests."""
from __future__ import annotations

import time

from app.autonomous_research import AutonomousResearchEngine
from app.decision_engine import DecisionEngine
from app.market_collector import MarketCollector, TickerSnap
from app.micro_buffer import MicroBufferStore
from app.parameter_registry import (
    FIXED_SAFETY,
    clamp_candidate,
    default_weights,
    is_ai_modifiable,
    weights_hash,
)
from app.research_store import ResearchStore
from app.storage import DecisionStore
from app.weighted_policy import compare_predictions, score_with_weights


def _engine(tmp_path, exchange="BITHUMB"):
    store = DecisionStore(tmp_path / f"dec_{exchange}.sqlite3")
    research_store = ResearchStore(exchange, tmp_path / f"res_{exchange}.sqlite3")
    research = AutonomousResearchEngine(exchange, store=research_store, decision_store=store)
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store, exchange=exchange)
    engine.attach_research(research)
    return engine, research, store, collector, micro


def test_outcome_to_training_sample(tmp_path):
    _, research, store, _, _ = _engine(tmp_path)
    d = {
        "decisionId": "d-out-1",
        "market": "KRW-BTC",
        "decision": "BUY",
        "strategyScore": 80,
        "chaseScore": 10,
        "micro": {"status": "AVAILABLE", "return1m": 1.0, "return30s": 0.3},
        "liquidityPassed": True,
        "executionDataQuality": "AVAILABLE",
    }
    store.save_decision(d)
    sid = research.ingest_decision_outcome(
        d,
        {"decisionId": "d-out-1", "market": "KRW-BTC", "realizedPnl": -100, "exitReason": "STOP"},
        quality="VALID",
    )
    assert sid
    assert research.store.count_samples("VALID") == 1


def test_invalid_system_bug_excluded(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    sid = research.ingest_decision_outcome(
        {"market": "KRW-X", "micro": {"status": "AVAILABLE"}},
        {"market": "KRW-X", "realizedPnl": -50, "exitReason": "SYSTEM_BUG_DUPLICATE"},
        quality="VALID",
    )
    assert sid is None
    assert research.store.count_samples("VALID") == 0
    assert research.store.count_samples("INVALID") == 1


def test_trainer_creates_candidate_and_weights_change(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    for s in research._synthetic_samples(24, default_weights()):
        research.store.add_training_sample(s)
    before = research.store.get_active_model()
    proof = research.run_research_cycle(force=True)
    assert proof["candidateModelVersion"]
    assert proof["oldWeightsHash"] != proof["candidateWeightsHash"] or proof["changedWeightCount"] >= 0
    assert proof["changedWeightCount"] >= 1
    assert proof["maxWeightDelta"] > 0


def test_predictions_actually_change(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    samples = research._synthetic_samples(24, default_weights())
    for s in samples:
        research.store.add_training_sample(s)
    proof = research.run_research_cycle(force=True)
    cmp_ = proof.get("predictionCompare") or {}
    # Candidate must alter behavior on chase-heavy synthetic set when chase params move
    assert cmp_.get("PREDICTION_CHANGED_PERCENT", 0) > 0 or proof["promotionDecision"] in {
        "REJECTED",
        "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED",
        "SHADOW_HOLD",
        "PROMOTED",
        "LOW_SAMPLE",
        "FAILED_OOS",
        "FAILED_REPLAY",
        "OVERFIT",
        "HIGH_MDD",
    }
    if proof["changedWeightCount"] > 0 and cmp_.get("PREDICTION_CHANGED_COUNT", 0) == 0:
        assert cmp_.get("diagnosis") in {
            "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED",
            "DECISION_UNCHANGED_SCORE_SHIFTED",
            None,
        }


def test_candidate_does_not_overwrite_champion_without_gate(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    # Tiny contradictory sample → likely reject / shadow hold, champion stays M100 unless promoted
    for i in range(8):
        research.store.add_training_sample(
            {
                "features": {
                    "strategyScore": 50,
                    "return30s": 0.1,
                    "return1m": 0.1,
                    "signedChange": 0,
                    "spread": 0.2,
                    "microAvailable": 1.0,
                    "liquidityOk": 1.0,
                    "grossMove": 0.1,
                },
                "netPnl": -10,
                "label": 0,
                "quality": "VALID",
                "meta": {"cause": "UNKNOWN"},
            }
        )
    active_before = research.store.get_active_model()["modelVersion"]
    proof = research.run_research_cycle(force=True)
    if proof["promotionDecision"] != "PROMOTED":
        assert research.store.get_active_model()["modelVersion"] == active_before


def test_replay_oos_shadow_reject_paths(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    samples = research._synthetic_samples(30, default_weights())
    for s in samples:
        research.store.add_training_sample(s)
    proof = research.run_research_cycle(force=True)
    assert proof["promotionDecision"] in {
        "PROMOTED",
        "SHADOW_HOLD",
        "SHADOW_ONLY",
        "REJECTED",
        "FAILED_OOS",
        "FAILED_REPLAY",
        "OVERFIT",
        "HIGH_MDD",
        "LOW_SAMPLE",
        "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED",
        "IMPROVED_BUT_UNPROFITABLE",
        "TEST_DATA",
        "RECOVERY_VALIDATION_MODE",
        "AWAITING_SHADOW",
    }
    assert "learningCycleId" in proof
    assert research.store.list_journal(1)


def test_promoted_model_used_in_inference(tmp_path):
    engine, research, _, collector, micro = _engine(tmp_path)
    samples = research._synthetic_samples(40, default_weights())
    for s in samples:
        research.store.add_training_sample(s)
    # Force a strong candidate and promote via gate
    proof = research.run_research_cycle(force=True)
    active = research.store.get_active_model()
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-AAA"] = TickerSnap("KRW-AAA", 100.0, 1e10, 0.02, 1.0, now)
    for i in range(20):
        micro.add("KRW-AAA", 100.0 + i * 0.05, 1.0, now_ms=now - (19 - i) * 1000)
    d = engine.decide_market("KRW-AAA", now_ms=now)
    assert d["modelVersion"] == active["modelVersion"]
    assert d["modelHash"] == active["modelHash"]
    assert "learningCycleId" in d


def test_model_persists_after_restart(tmp_path):
    path = tmp_path / "res_BITHUMB.sqlite3"
    s1 = ResearchStore("BITHUMB", path)
    w = default_weights()
    w["thr_ai_buy"] = 60.0
    s1.set_active_model("M105", w, source="AUTONOMOUS_LEARNING", learning_cycle_id="LC-1")
    s2 = ResearchStore("BITHUMB", path)
    a = s2.get_active_model()
    assert a["modelVersion"] == "M105"
    assert a["weights"]["thr_ai_buy"] == 60.0
    assert a["learningCycleId"] == "LC-1"


def test_rollback(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    w = default_weights()
    research.store.set_active_model("M101", w, source="AUTONOMOUS_LEARNING", parent_version="M100")
    # lineage parent M100 exists from bootstrap
    research.store.add_lineage("M101", "M100", w, "CHAMPION", "AUTONOMOUS_LEARNING")
    out = research.rollback_to_parent("TEST_ROLLBACK")
    assert out["ok"] is True
    assert research.store.get_active_model()["modelVersion"] == "M100"
    assert research.store.get_active_model()["source"] == "ROLLBACK"


def test_lineage_and_rejected_experiment_saved(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    for s in research._synthetic_samples(20, default_weights()):
        research.store.add_training_sample(s)
    research.run_research_cycle(force=True)
    assert len(research.store.list_lineage()) >= 1
    # experiments may be empty if SHADOW_HOLD without explicit save — ensure cycle saved
    assert research.store.latest_learning_cycles(1)


def test_bithumb_upbit_isolation(tmp_path):
    _, b_research, _, _, _ = _engine(tmp_path, "BITHUMB")
    _, u_research, _, _, _ = _engine(tmp_path, "UPBIT")
    w = default_weights()
    w["thr_ai_buy"] = 70.0
    b_research.store.set_active_model("M200", w, source="AUTONOMOUS_LEARNING")
    assert u_research.store.get_active_model()["modelVersion"] == "M100"
    assert u_research.store.get_active_model()["weights"]["thr_ai_buy"] != 70.0


def test_safety_params_not_ai_modifiable():
    assert not is_ai_modifiable("kill_switch")
    assert not is_ai_modifiable("live_trading_enabled")
    base = default_weights()
    proposed = {"kill_switch": 1.0, "live_trading_enabled": 1.0, "thr_ai_buy": 58.0}
    out, changes = clamp_candidate(base, proposed)
    assert "kill_switch" not in out
    rejected = [c for c in changes if c.get("rejected")]
    assert any(c["key"] == "kill_switch" for c in rejected)
    assert out["thr_ai_buy"] != base["thr_ai_buy"] or abs(out["thr_ai_buy"] - 58.0) < 1e-9


def test_external_hypothesis_no_direct_production(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    for s in research._synthetic_samples(20, default_weights()):
        research.store.add_training_sample(s)
    before = research.store.get_active_model()["modelHash"]
    proof = research.register_external_hypothesis(
        "raise ai buy threshold",
        {"thr_ai_buy": 8.0},
    )
    assert proof.get("source") == "EXTERNAL_HYPOTHESIS"
    # Either shadowed/rejected or promoted only via gate — never silent overwrite without cycle
    assert "learningCycleId" in proof
    after = research.store.get_active_model()
    if proof["promotionDecision"] != "PROMOTED":
        assert after["modelHash"] == before


def test_research_failure_does_not_break_decide(tmp_path):
    engine, research, _, collector, micro = _engine(tmp_path)

    class Boom:
        def get_active_model(self):
            raise RuntimeError("research down")

        def get_shadow(self):
            raise RuntimeError("research down")

    research.store = Boom()  # type: ignore
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-Z"] = TickerSnap("KRW-Z", 10.0, 1e10, 0.01, 1.0, now)
    d = engine.decide_market("KRW-Z", now_ms=now)
    assert d["decision"] in {"WAIT", "AVOID", "BUY"}


def test_max_delta_per_experiment_bounds():
    base = default_weights()
    proposed = {"thr_strategy_buy": 20.0}  # would be -55 from 75 — clamped by max delta 4
    out, changes = clamp_candidate(base, proposed)
    assert abs(out["thr_strategy_buy"] - base["thr_strategy_buy"]) <= 4.0 + 1e-9


def test_compare_predictions_detects_unchanged():
    samples = [
        {
            "features": {
                "strategyScore": 70,
                "return30s": 0.2,
                "return1m": 0.5,
                "signedChange": 0.01,
                "spread": 0.2,
                "microAvailable": 1.0,
                "liquidityOk": 1.0,
                "grossMove": 0.5,
            }
        }
    ]
    w = default_weights()
    cmp_ = compare_predictions(samples, w, w)
    assert cmp_["PREDICTION_CHANGED_COUNT"] == 0
    assert cmp_["diagnosis"] == "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED"


def test_multi_challenger_slots_capped(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    w = default_weights()
    for i, slot in enumerate(["A", "B", "C", "A"]):
        ww = dict(w)
        ww["thr_ai_buy"] = 55.0 + i
        research.store.register_shadow(f"M11{i}", ww, status="SHADOW", slot=slot)
    shadows = research.store.list_shadows("SHADOW", limit=10)
    assert len(shadows) <= 3


def test_shadow_and_market_horizon_resolve(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    now = int(time.time() * 1000)
    research.store.save_shadow_outcome(
        {
            "outcomeId": "sh1",
            "modelVersion": "M101",
            "decisionId": "d1",
            "market": "KRW-BTC",
            "decision": "BUY",
            "signalPrice": 100.0,
            "createdAt": now - 20 * 60_000,
            "horizons": {},
            "label": None,
        }
    )
    research.store.save_market_observation(
        {
            "obsId": "o1",
            "market": "KRW-ETH",
            "decision": "WAIT",
            "decisionId": "d2",
            "signalPrice": 10.0,
            "createdAt": now - 20 * 60_000,
            "horizons": {},
            "label": None,
        }
    )
    out = research.resolve_open_horizons({"KRW-BTC": 102.0, "KRW-ETH": 10.5}, now_ms=now)
    assert out["shadowUpdated"] >= 1
    assert out["marketObsUpdated"] >= 1
    sh = research.store.list_shadow_outcomes(1)[0]
    assert "15m" in (sh.get("horizons") or {})
    assert sh.get("label") in {"CORRECT_BUY", "FALSE_BUY"}
    obs = research.store.list_market_observations(1)[0]
    assert obs.get("label") in {"MISSED_OPPORTUNITY", "NEUTRAL", "CORRECT_REJECTION", "FALSE_REJECT"}


def test_calibration_and_counterfactual_no_lookahead(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    for s in research._synthetic_samples(40, default_weights()):
        research.store.add_training_sample(s)
    cal = research.calibration_snapshot()
    assert "buckets" in cal
    assert "diagnosis" in cal
    decision = {
        "decisionId": "dx",
        "decision": "BUY",
        "market": "KRW-X",
        "strategyScore": 80,
        "micro": {"status": "AVAILABLE", "return1m": 1.0, "return30s": 0.3},
        "liquidityPassed": True,
        "executionDataQuality": "AVAILABLE",
        "signalPrice": 1.0,
    }
    cf = research.counterfactual_for_decision(decision)
    assert cf["lookAheadBias"] is False
    assert "alternativesAtDecisionTime" in cf


def test_regime_oos_present_in_cycle(tmp_path):
    _, research, _, _, _ = _engine(tmp_path)
    for s in research._synthetic_samples(40, default_weights()):
        research.store.add_training_sample(s)
    proof = research.run_research_cycle(force=True)
    assert "regimeOosAfter" in proof or proof.get("promotionDecision")

===== END FILE: server/ai-brain/tests/test_autonomous_research.py =====

===== FILE: server/ai-brain/tests/test_layer1_market_intelligence.py =====
"""Layer-1 Market Intelligence Foundation regression tests."""
from __future__ import annotations

import time

from app.decision_engine import DecisionEngine
from app.market_collector import MarketCollector, OrderbookSnap, TickerSnap
from app.market_integrity import (
    evaluate_observation,
    micro_temporal_quality,
    rest_ws_divergence,
    snapshot_alignment,
    validate_candle,
    validate_orderbook,
    validate_ticker,
)
from app.micro_buffer import MicroBufferStore
from app.storage import DecisionStore
from app.upbit_collector import UpbitMarketCollector


def test_bithumb_ws_zombie_detection():
    micro = MicroBufferStore()
    col = MarketCollector(micro)
    col.stats.connection_state = "CONNECTED"
    col.stats.last_message_at = int(time.time() * 1000) - 120_000
    h = col.health()
    assert h["connectionState"] == "WEBSOCKET_ZOMBIE"
    assert h["zombieReason"] == "BITHUMB_WS_ZOMBIE"


def test_upbit_ws_zombie_detection_still_works():
    micro = MicroBufferStore()
    col = UpbitMarketCollector(micro)
    col.stats.connection_state = "CONNECTED"
    col.stats.last_message_at = int(time.time() * 1000) - 120_000
    h = col.health()
    assert h["connectionState"] == "WEBSOCKET_ZOMBIE"
    assert h["zombieReason"] == "UPBIT_WS_ZOMBIE"


def test_future_timestamp_rejected():
    now = int(time.time() * 1000)
    reasons = validate_ticker(price=100.0, exchange_ts_ms=now + 120_000, received_at_ms=now, now_ms=now)
    assert "TIMESTAMP_FUTURE" in reasons
    micro = MicroBufferStore()
    col = MarketCollector(micro)
    col._ingest_ticker_dict(
        {"market": "KRW-BTC", "trade_price": 100.0, "timestamp": now + 120_000, "acc_trade_price_24h": 1e9},
        source="REST",
    )
    assert "KRW-BTC" not in col.snapshot_tickers()


def test_timestamp_regression_not_applied():
    now = int(time.time() * 1000)
    micro = MicroBufferStore()
    col = MarketCollector(micro)
    col._ingest_ticker_dict(
        {"market": "KRW-BTC", "trade_price": 100.0, "timestamp": now, "acc_trade_price_24h": 1e9},
        source="WS",
    )
    col._ingest_ticker_dict(
        {"market": "KRW-BTC", "trade_price": 99.0, "timestamp": now - 5_000, "acc_trade_price_24h": 1e9},
        source="REST",
    )
    assert col.snapshot_tickers()["KRW-BTC"].trade_price == 100.0
    assert col.snapshot_tickers()["KRW-BTC"].source == "WS"


def test_bid_gt_ask_quarantined_from_cache():
    assert "BID_GT_ASK" in validate_orderbook(bid=101.0, ask=100.0, bid_size=1.0, ask_size=1.0)


def test_invalid_candle():
    bad = validate_candle({"opening_price": 10, "high_price": 9, "low_price": 8, "trade_price": 9.5, "candle_acc_trade_volume": 1, "timestamp": 1})
    assert "CANDLE_OHLC_INCONSISTENT" in bad
    good = validate_candle({"opening_price": 10, "high_price": 12, "low_price": 9, "trade_price": 11, "candle_acc_trade_volume": 1, "timestamp": 1})
    assert good == []


def test_micro_clustered_not_ready():
    now = int(time.time() * 1000)
    samples = [{"time_ms": now} for _ in range(20)]
    q = micro_temporal_quality(samples, now)
    assert q["usable"] is False
    assert q["status"] == "CLUSTERED"
    assert q["duplicateTimestampCount"] >= 19


def test_micro_spread_history_usable():
    now = int(time.time() * 1000)
    class S:
        def __init__(self, t, p=1.0):
            self.time_ms = t
            self.price = p
    samples = [S(now - (19 - i) * 1000, 100 + i) for i in range(20)]
    q = micro_temporal_quality(samples, now)
    assert q["usable"] is True
    assert q["status"] == "AVAILABLE"


def test_snapshot_skew_detection():
    now = int(time.time() * 1000)
    align = snapshot_alignment(now_ms=now, ticker_ts=now, orderbook_ts=now - 25_000, micro_newest_ts=now)
    assert align["snapshotSkewMs"] >= 25_000
    assert align["snapshotQuality"] in {"DEGRADED", "BAD", "AGING"}


def test_quarantined_observation_avoids_buy(tmp_path):
    store = DecisionStore(tmp_path / "q.sqlite3")
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store, exchange="BITHUMB")
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-BTC"] = TickerSnap("KRW-BTC", 100.0, 1e10, 0.02, 1.0, now, now, "WS")
        collector._orderbooks["KRW-BTC"] = OrderbookSnap("KRW-BTC", 101.0, 100.0, 1.0, 1.0, now, now, "REST")
    for i in range(20):
        micro.add("KRW-BTC", 100 + i * 0.01, 1.0, now_ms=now - (19 - i) * 1000)
    d = engine.decide_market("KRW-BTC", now_ms=now)
    assert d["decision"] == "AVOID"
    assert d["dataQuality"] == "QUARANTINED"
    assert d["usableForTraining"] is False


def test_decision_provenance_fields(tmp_path):
    store = DecisionStore(tmp_path / "p.sqlite3")
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store, exchange="BITHUMB")
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-BTC"] = TickerSnap("KRW-BTC", 100.0, 1e10, 0.01, 1.0, now, now, "WS")
        collector._orderbooks["KRW-BTC"] = OrderbookSnap("KRW-BTC", 99.9, 100.1, 5.0, 5.0, now, now, "REST")
    for i in range(20):
        micro.add("KRW-BTC", 100 + i * 0.01, 1.0, now_ms=now - (19 - i) * 1000)
    d = engine.decide_market("KRW-BTC", now_ms=now)
    assert d["exchange"] == "BITHUMB"
    assert d["tickerTimestamp"] == now
    assert d["tickerSource"] == "WS"
    assert d["orderbookTimestamp"] == now
    assert "maxComponentAgeMs" in d
    assert "snapshotSkewMs" in d
    assert "snapshotQuality" in d
    assert d["positionKey"] == "BITHUMB:KRW-BTC"


def test_fast_scan_skips_stale_and_tracks_detected_at(tmp_path):
    store = DecisionStore(tmp_path / "f.sqlite3")
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store)
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-BTC"] = TickerSnap("KRW-BTC", 100.0, 1e10, 0.05, 1.0, now, now, "WS")
        collector._tickers["KRW-OLD"] = TickerSnap("KRW-OLD", 50.0, 1e10, 0.5, 1.0, now - 120_000, now, "WS")
    fast = engine.fast_scan(limit=10)
    markets = {c["market"] for c in fast}
    assert "KRW-BTC" in markets
    assert "KRW-OLD" not in markets
    assert fast[0]["detectedAt"]
    assert "tickerAgeMs" in fast[0]


def test_rest_ws_divergence_helper():
    d = rest_ws_divergence(100.0, 120.0, threshold=0.15)
    assert d["diverged"] is True
    ok = rest_ws_divergence(100.0, 101.0, threshold=0.15)
    assert ok["diverged"] is False


def test_evaluate_zombie_quarantine():
    q = evaluate_observation(
        ticker_reasons=[],
        orderbook_reasons=[],
        micro_status="AVAILABLE",
        alignment_quality="GOOD",
        ws_zombie=True,
    )
    assert q["dataQuality"] == "QUARANTINED"
    assert q["usableForTraining"] is False


def test_exchange_cache_isolation():
    bm = MicroBufferStore()
    um = MicroBufferStore()
    b = MarketCollector(bm)
    u = UpbitMarketCollector(um)
    now = int(time.time() * 1000)
    b._ingest_ticker_dict({"market": "KRW-BTC", "trade_price": 10.0, "timestamp": now, "acc_trade_price_24h": 1}, source="WS")
    u._ingest_ticker_dict({"market": "KRW-BTC", "trade_price": 20.0, "timestamp": now, "acc_trade_price_24h": 1}, source="WS")
    assert b.snapshot_tickers()["KRW-BTC"].trade_price == 10.0
    assert u.snapshot_tickers()["KRW-BTC"].trade_price == 20.0
    assert bm.samples("KRW-BTC")[0].price == 10.0
    assert um.samples("KRW-BTC")[0].price == 20.0

===== END FILE: server/ai-brain/tests/test_layer1_market_intelligence.py =====

===== FILE: server/ai-brain/tests/test_layer2_real_experience_learning.py =====
"""AI Layer 2 — Real Experience Learning Engine tests."""
from __future__ import annotations

import time

from app.autonomous_research import AutonomousResearchEngine, MIN_SAMPLES_TRAIN
from app.learning_authenticity import (
    REAL_PRODUCTION_SOURCES,
    classify_candidate,
    classify_trade_training_quality,
    look_ahead_feature_violations,
    overlap_count,
    sample_source,
    temporal_order_ok,
)
from app.parameter_registry import default_weights
from app.research_store import ResearchStore
from app.storage import DecisionStore
from app.weighted_policy import compare_predictions, extract_features, score_with_weights


def _eng(tmp_path, exchange="BITHUMB"):
    store = DecisionStore(tmp_path / f"d_{exchange}.sqlite3")
    rs = ResearchStore(exchange, tmp_path / f"r_{exchange}.sqlite3")
    return AutonomousResearchEngine(exchange, store=rs, decision_store=store)


def _decision(did: str, *, decision="WAIT", dq="GOOD", usable=True, ts=1_000_000) -> dict:
    return {
        "decisionId": did,
        "market": "KRW-BTC",
        "decision": decision,
        "strategyScore": 72.0,
        "aiScore": 68.0,
        "chaseScore": 20.0,
        "entryTimingScore": 55.0,
        "executionScore": 60.0,
        "signalPrice": 100_000_000.0,
        "serverTimestamp": ts,
        "modelVersion": "M100",
        "modelHash": "boot",
        "learningCycleId": None,
        "dataQuality": dq,
        "snapshotQuality": "GOOD",
        "usableForTraining": usable,
        "liquidityPassed": True,
        "micro": {"status": "AVAILABLE", "return1m": 0.4, "return30s": 0.1, "return3m": 0.5},
        "grossExpectedEdge": 0.4,
        "signedChange": 0.2,
        "spread": 0.15,
    }


def test_real_shadow_outcome_creates_valid_training_sample(tmp_path):
    eng = _eng(tmp_path)
    d = _decision("d-rs-1", decision="AVOID")
    eng.decision_store.save_decision(d)
    eng.track_decision_memory(d)
    # Simulate 15m later with price drop (correct reject)
    t0 = d["serverTimestamp"]
    eng.resolve_open_horizons({"KRW-BTC": 98_000_000.0}, now_ms=t0 + 16 * 60_000)
    rows = eng.store.list_samples(10, "VALID")
    assert any(sample_source(s) == "REAL_SHADOW" for s in rows)
    s = next(s for s in rows if sample_source(s) == "REAL_SHADOW")
    assert s["meta"]["validForTraining"] is True
    assert s["meta"]["completionStatus"] == "COMPLETE"
    assert s["meta"]["noOrder"] is True
    assert "future5mReturn" not in (s.get("features") or {})
    assert "mfe" not in (s.get("features") or {})
    st = eng.status()
    assert st["realShadowSampleCount"] >= 1
    assert st["realSampleCount"] >= 1
    assert st["isLearning"] is True
    assert st["isImproving"] == "NOT_ENOUGH_EVIDENCE"


def test_synthetic_does_not_inflate_real_production_count(tmp_path):
    eng = _eng(tmp_path)
    for s in eng._synthetic_samples(20, default_weights()):
        eng.store.add_training_sample(s)
    st = eng.status()
    assert st["realSampleCount"] == 0
    assert st["syntheticSampleCount"] >= 20
    assert st["productionEvidence"] in {"NONE", "PARTIAL"}


def test_quarantined_layer1_excluded_from_training(tmp_path):
    eng = _eng(tmp_path)
    d = _decision("d-q-1", decision="WAIT", dq="QUARANTINED", usable=False)
    eng.decision_store.save_decision(d)
    eng.track_decision_memory(d)
    t0 = d["serverTimestamp"]
    eng.resolve_open_horizons({"KRW-BTC": 101_000_000.0}, now_ms=t0 + 16 * 60_000)
    valid = eng.store.list_samples(20, "VALID")
    assert not any((s.get("meta") or {}).get("decisionId") == "d-q-1" and s.get("quality") == "VALID" for s in valid)
    invalid = eng.store.list_samples(20, "INVALID")
    assert any((s.get("meta") or {}).get("invalidReason") == "BAD_DATA_TRAINING_LEAK" for s in invalid)


def test_bad_snapshot_excluded(tmp_path):
    q, reason = classify_trade_training_quality(
        {"dataSource": "REAL_SHADOW", "realizedPnl": 1.0},
        {"decision": "BUY", "dataQuality": "BAD", "usableForTraining": False, "snapshotQuality": "BAD"},
    )
    assert q == "INVALID"
    assert reason == "BAD_DATA_TRAINING_LEAK"


def test_future_feature_lookahead_leak():
    bad = [{"features": {"strategyScore": 70, "future15mReturn": 2.0, "mfe": 3.0}}]
    assert look_ahead_feature_violations(bad)
    cls = classify_candidate(
        oos_before={"netExpectancy": 0, "profitFactor": 1, "mdd": 1},
        oos_after={"netExpectancy": 5, "profitFactor": 1.5, "mdd": 1},
        replay_after={"netExpectancy": 5, "profitFactor": 1.5, "mdd": 1},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 1},
        sample_n=40,
        leak_violations=1,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_SHADOW",
        shadow_complete=50,
    )
    assert cls["code"] == "LOOK_AHEAD_BIAS"


def test_train_val_oos_no_overlap(tmp_path):
    eng = _eng(tmp_path)
    samples = eng._synthetic_samples(40, default_weights())
    for s in samples:
        eng.store.add_training_sample(s)
    rows = eng.store.list_samples(800, "VALID")
    n = len(rows)
    i1 = max(1, int(n * 0.5))
    i2 = max(i1 + 1, int(n * 0.75))
    train, val, oos = rows[:i1], rows[i1:i2], rows[i2:]
    assert overlap_count(train, val) == 0
    assert overlap_count(val, oos) == 0
    assert temporal_order_ok(train, val, oos)["ok"] is True


def test_bithumb_sample_not_in_upbit_dataset(tmp_path):
    b = _eng(tmp_path, "BITHUMB")
    u = _eng(tmp_path, "UPBIT")
    d = _decision("d-iso-1", decision="WAIT")
    b.decision_store.save_decision(d)
    b.track_decision_memory(d)
    b.resolve_open_horizons({"KRW-BTC": 99_000_000.0}, now_ms=d["serverTimestamp"] + 16 * 60_000)
    assert b.store.count_samples("VALID") >= 1 or b.store.count_samples("PARTIAL") >= 1 or b.store.count_samples("INVALID") >= 1
    assert u.store.count_samples("VALID") == 0
    assert u.store.count_samples("PARTIAL") == 0


def test_upbit_sample_not_in_bithumb_dataset(tmp_path):
    b = _eng(tmp_path, "BITHUMB")
    u = _eng(tmp_path, "UPBIT")
    d = _decision("d-iso-u", decision="AVOID")
    u.decision_store.save_decision(d)
    u.track_decision_memory(d)
    u.resolve_open_horizons({"KRW-BTC": 97_000_000.0}, now_ms=d["serverTimestamp"] + 16 * 60_000)
    assert b.store.count_samples("VALID") == 0
    assert u.status()["exchange"] == "UPBIT"


def test_candidate_version_exchange_namespace(tmp_path):
    eng = _eng(tmp_path, "UPBIT")
    for s in eng._synthetic_samples(40, default_weights()):
        eng.store.add_training_sample(s)
    proof = eng.run_research_cycle(force=True)
    assert str(proof["candidateModelVersion"]).startswith("UPBIT-M")
    assert str(proof["learningCycleId"]).startswith("UPBIT-")


def test_weight_change_and_prediction_change(tmp_path):
    eng = _eng(tmp_path)
    old = default_weights()
    new = dict(old)
    new["w_strategy_in_ai"] = float(old["w_strategy_in_ai"]) + 0.05
    new["thr_exec_buy"] = float(old["thr_exec_buy"]) - 3.0
    feats = [
        extract_features(_decision(f"p-{i}", decision="BUY", ts=1000 + i))
        for i in range(8)
    ]
    cmp = compare_predictions(feats, old, new)
    assert cmp.get("PREDICTION_CHANGED_COUNT", 0) >= 0
    # identical weights → no effective change signal
    same = compare_predictions(feats, old, old)
    assert same.get("PREDICTION_CHANGED_COUNT", 0) == 0


def test_identical_weights_no_effective_model_change():
    old = default_weights()
    feats = [extract_features(_decision(f"x-{i}")) for i in range(5)]
    cmp = compare_predictions(feats, old, dict(old))
    assert cmp.get("PREDICTION_CHANGED_COUNT", 0) == 0


def test_score_change_without_decision_change_is_behavior_unchanged():
    """Production pattern: weights/scores move but BUY/WAIT/AVOID labels stay identical."""
    old = default_weights()
    new = dict(old)
    new["w_strategy_in_ai"] = float(old["w_strategy_in_ai"]) - 0.04
    new["thr_ai_buy"] = float(old["thr_ai_buy"]) + 5.0
    new["thr_exec_buy"] = float(old["thr_exec_buy"]) + 3.0
    new["thr_strategy_buy"] = float(old["thr_strategy_buy"]) + 2.0
    new["thr_short_edge"] = float(old["thr_short_edge"]) + 0.03
    # micro insufficient → AVOID; edge-negative WAIT — neither near BUY flip under these deltas
    samples = []
    for i in range(10):
        d = _decision(f"wait-{i}", decision="WAIT", ts=1000 + i)
        d["micro"] = {"status": "AVAILABLE", "return1m": 0.05, "return30s": 0.02}
        samples.append({"features": extract_features(d)})
    for i in range(10):
        d = _decision(f"avoid-{i}", decision="AVOID", ts=2000 + i)
        d["micro"] = {"status": "INSUFFICIENT", "return1m": 0.0, "return30s": 0.0}
        samples.append({"features": extract_features(d)})
    cmp = compare_predictions(samples, old, new)
    assert cmp["SCORE_CHANGED_COUNT"] >= 1 or cmp["MAX_SCORE_DELTA"] >= 0
    assert cmp["DECISION_CHANGED_COUNT"] == 0
    assert cmp["diagnosis"] in {"DECISION_UNCHANGED_SCORE_SHIFTED", "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED"}
    cls = classify_candidate(
        oos_before={"netExpectancy": -2.0, "profitFactor": 0.3, "mdd": 10},
        oos_after={"netExpectancy": -3.0, "profitFactor": 0.2, "mdd": 10},
        replay_after={"netExpectancy": -1.0, "profitFactor": 0.5, "mdd": 8},
        pred_cmp=cmp,
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_SHADOW",
        shadow_complete=50,
    )
    assert cls["tier"] == "REJECT"
    assert cls["code"] == "MODEL_CHANGED_BUT_BEHAVIOR_UNCHANGED"


def test_decision_transition_detected_when_labels_flip():
    old = default_weights()
    new = dict(old)
    # Dramatically lower chase avoid threshold so chase-heavy samples flip to AVOID
    new["thr_chase_avoid"] = 10.0
    samples = []
    for i in range(8):
        d = _decision(f"flip-{i}", decision="WAIT", ts=1000 + i)
        d["micro"] = {"status": "AVAILABLE", "return1m": 3.0, "return30s": 1.5}
        samples.append({"features": extract_features(d)})
    cmp = compare_predictions(samples, old, new)
    # If chase path triggers AVOID under new thr, decisions should change
    if cmp["DECISION_CHANGED_COUNT"] == 0:
        # Fallback: raise edge so BUY-path WAIT stays, but drop strategy thr so some can BUY
        new2 = dict(old)
        new2["thr_strategy_buy"] = 1.0
        new2["thr_ai_buy"] = 1.0
        new2["thr_exec_buy"] = 1.0
        new2["thr_short_edge"] = -10.0
        cmp = compare_predictions(samples, old, new2)
    assert cmp["DECISION_CHANGED_COUNT"] >= 1
    assert cmp["DECISION_CHANGE_RATE"] > 0


def test_oos_fail_after_decision_change_no_promote():
    cls = classify_candidate(
        oos_before={"netExpectancy": 1.0, "profitFactor": 1.2, "mdd": 10},
        oos_after={"netExpectancy": -5.0, "profitFactor": 0.2, "mdd": 40},
        replay_after={"netExpectancy": 2.0, "profitFactor": 1.1, "mdd": 8},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 5, "DECISION_CHANGED_COUNT": 5},
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_SHADOW",
        shadow_complete=50,
    )
    assert cls["tier"] == "REJECT"


def test_shadow_insufficient_blocks_promotion():
    cls = classify_candidate(
        oos_before={"netExpectancy": 1.0, "profitFactor": 1.1, "mdd": 20},
        oos_after={"netExpectancy": 5.0, "profitFactor": 1.4, "mdd": 15},
        replay_after={"netExpectancy": 6.0, "profitFactor": 1.5, "mdd": 10},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 3, "DECISION_CHANGED_COUNT": 3},
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_SHADOW",
        shadow_complete=5,
    )
    assert cls["tier"] == "SHADOW_ONLY"
    assert cls["code"] == "RECOVERY_VALIDATION_MODE"


def test_pf_below_one_relative_improvement_no_promote():
    cls = classify_candidate(
        oos_before={"netExpectancy": -16.0, "profitFactor": 0.4, "mdd": 135},
        oos_after={"netExpectancy": -6.0, "profitFactor": 0.75, "mdd": 40},
        replay_after={"netExpectancy": 2.5, "profitFactor": 1.1, "mdd": 10},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 2},
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_SHADOW",
        shadow_complete=0,
    )
    assert cls["tier"] == "SHADOW_ONLY"
    assert cls["code"] == "IMPROVED_BUT_UNPROFITABLE"


def test_insufficient_real_samples_waiting(tmp_path):
    eng = _eng(tmp_path)
    st = eng.status()
    assert st["learningHealth"] == "WAITING_FOR_REAL_DATA"
    assert st["layerStatus"] in {"WAITING_FOR_REAL_DATA", "PARTIAL_WAITING_FOR_REAL_DATA"}
    proof = eng.run_research_cycle(force=True)
    assert proof["promotionDecision"] in {"INSUFFICIENT_REAL_DATA", "LOW_SAMPLE"}


def test_boundary_coverage_and_why_weight():
    from app.learning_authenticity import (
        boundary_distances,
        dataset_diversity_report,
        layer2_status_from_evidence,
        why_weight_changed,
    )
    from app.parameter_registry import default_weights
    from app.weighted_policy import extract_features

    w = default_weights()
    d = _decision("bd-1", decision="WAIT")
    d["micro"] = {"status": "AVAILABLE", "return1m": 0.2, "return30s": 0.1}
    bd = boundary_distances(extract_features(d), w)
    assert "distanceToBuyBoundary" in bd
    assert bd["bucket"] in {"NEAR_BOUNDARY", "MID_BOUNDARY", "FAR_BOUNDARY", "ON_BUY"}
    samples = [{"features": extract_features(_decision("s-0", decision="WAIT")), "market": "KRW-A", "netPnl": -1}]
    for i in range(5):
        samples.append({"features": extract_features(_decision(f"a-{i}", decision="AVOID")), "market": "KRW-B", "netPnl": 1})
    div = dataset_diversity_report(samples, w)
    assert div["totalValid"] == 6
    assert "decisions" in div
    why = why_weight_changed(
        {"flags": ["NEGATIVE_EXPECTANCY_WINDOW"], "topCauses": [["UNKNOWN", 3]]},
        {"proposedChange": "raise edge", "proposedDeltas": {"thr_short_edge": 0.03}},
        {"thr_short_edge": 0.03},
    )
    assert why["code"] == "EVIDENCE_LINKED"
    assert layer2_status_from_evidence(real_decision_changed=0, oos_passed=False, shadow_status="NONE", absolute_ok=False) == "PARTIAL_WAITING_FOR_REAL_DATA"
    assert layer2_status_from_evidence(real_decision_changed=1, oos_passed=False, shadow_status="NONE", absolute_ok=False) == "PARTIAL_LEARNING_NOT_IMPROVING"
    assert layer2_status_from_evidence(real_decision_changed=1, oos_passed=True, shadow_status="SHADOW_INSUFFICIENT_SAMPLE", absolute_ok=False) == "PARTIAL_AWAITING_SHADOW"


def test_production_vs_test_decision_change_separated(tmp_path):
    eng = _eng(tmp_path)
    st = eng.status()
    assert st.get("testDecisionChangedCount") == 0
    assert "realProductionDecisionChangedCount" in st


def test_synthetic_only_cycle_not_verified(tmp_path):
    eng = _eng(tmp_path)
    for s in eng._synthetic_samples(40, default_weights()):
        eng.store.add_training_sample(s)
    eng.run_research_cycle(force=True)
    st = eng.status()
    assert st["productionEvidence"] != "VERIFIED"
    assert st["realLearningCycleCount"] == 0


def test_champion_persists_across_store_reload(tmp_path):
    eng = _eng(tmp_path)
    active = eng.store.get_active_model()
    path = tmp_path / "r_BITHUMB.sqlite3"
    eng2 = AutonomousResearchEngine("BITHUMB", store=ResearchStore("BITHUMB", path), decision_store=DecisionStore(tmp_path / "d2.sqlite3"))
    a2 = eng2.store.get_active_model()
    assert a2["modelVersion"] == active["modelVersion"]
    assert a2["modelHash"] == active["modelHash"]


def test_champion_challenger_shadow_same_snapshot(tmp_path):
    eng = _eng(tmp_path)
    w = default_weights()
    challenger = dict(w)
    challenger["thr_exec_buy"] = float(w["thr_exec_buy"]) - 5
    eng.store.register_shadow("BITHUMB-M101-TEST", challenger, metrics={"test": True}, slot="A")
    d = _decision("d-sh-cmp", decision="BUY")
    eng.decision_store.save_decision(d)
    eng.track_decision_memory(d)
    outs = eng.store.list_shadow_outcomes(20)
    champ = [o for o in outs if o.get("slot") == "CHAMPION_REAL_SHADOW"]
    chal = [o for o in outs if o.get("slot") == "A"]
    assert champ and chal
    assert champ[0]["decisionId"] == chal[0]["decisionId"]
    assert champ[0]["signalPrice"] == chal[0]["signalPrice"]


def test_system_bug_sample_excluded(tmp_path):
    eng = _eng(tmp_path)
    eng.ingest_decision_outcome(
        _decision("d-bug"),
        {"market": "KRW-BTC", "realizedPnl": -50, "exitReason": "EXECUTION_SYSTEM_FAILURE", "tradeId": "tb1"},
        quality="VALID",
    )
    assert eng.store.count_samples("VALID") == 0
    assert eng.store.count_samples("INVALID") >= 1


def test_paper_paused_still_collects_real_shadow(tmp_path):
    eng = _eng(tmp_path)
    assert eng.status()["paperBuyState"] == "PAUSED_DIAGNOSTIC"
    d = _decision("d-paused-buy", decision="BUY")
    eng.decision_store.save_decision(d)
    eng.track_decision_memory(d)
    outs = [o for o in eng.store.list_shadow_outcomes(10) if o.get("slot") == "CHAMPION_REAL_SHADOW"]
    assert outs and outs[0].get("noOrder") is True
    eng.resolve_open_horizons({"KRW-BTC": 102_000_000.0}, now_ms=d["serverTimestamp"] + 16 * 60_000)
    assert any(sample_source(s) == "REAL_SHADOW" for s in eng.store.list_samples(10, "VALID"))


def test_real_production_sources_include_real_shadow():
    assert "REAL_SHADOW" in REAL_PRODUCTION_SOURCES
    assert sample_source({"meta": {"dataSource": "REAL_SHADOW"}, "quality": "VALID"}) == "REAL_SHADOW"


def test_learning_not_equal_improving(tmp_path):
    eng = _eng(tmp_path)
    d = _decision("d-learn-flag", decision="WAIT")
    eng.decision_store.save_decision(d)
    eng.track_decision_memory(d)
    eng.resolve_open_horizons({"KRW-BTC": 100_500_000.0}, now_ms=d["serverTimestamp"] + 16 * 60_000)
    st = eng.status()
    if st["realSampleCount"] > 0:
        assert st["isLearning"] is True
        assert st["isImproving"] == "NOT_ENOUGH_EVIDENCE"


def test_failed_hypothesis_diversification(tmp_path):
    eng = _eng(tmp_path)
    eng.store.add_memory(
        "RejectedHypothesis",
        {
            "proposedDeltas": {"w_strategy_in_ai": -0.04, "thr_short_edge": 0.03, "thr_ai_buy": 2.0},
            "promotionDecision": "FAILED_OOS",
        },
    )
    hyp = eng._build_hypothesis(
        {"flags": ["NEGATIVE_EXPECTANCY_WINDOW"], "sampleWindow": 10, "topCauses": [["UNKNOWN", 3]]},
        [],
    )
    assert hyp.get("diversifiedFromFailedHypothesis") is True
    assert hyp["proposedDeltas"] != {"w_strategy_in_ai": -0.04, "thr_short_edge": 0.03, "thr_ai_buy": 2.0}


def test_replay_metrics_buy_wait_changes_pnl_sequence():
    from app.parameter_registry import default_weights

    w0 = default_weights()
    w1 = dict(w0)
    w1["thr_short_edge"] = float(w0["thr_short_edge"]) + 0.05  # force BUY→WAIT on edge
    sample = {
        "features": {
            "strategyScore": 100.0,
            "return30s": 0.45,
            "return1m": 0.9,
            "return3m": 0.2,
            "signedChange": 0.0,
            "spread": 0.2,
            "microAvailable": 1.0,
            "liquidityOk": 1.0,
            "grossMove": 0.9,
        },
        "netPnl": -12.0,
        "label": 0,
    }
    from app.weighted_policy import replay_metrics
    assert score_with_weights(sample["features"], w0)["decision"] == "BUY"
    assert score_with_weights(sample["features"], w1)["decision"] == "WAIT"
    before = replay_metrics([sample], w0)
    after = replay_metrics([sample], w1)
    assert before["tradeCount"] == 1.0
    assert after["tradeCount"] == 0.0
    assert before["netPnl"] != after["netPnl"]


def test_oos_same_actions_allow_identical_metrics():
    from app.weighted_policy import replay_metrics, compare_predictions
    from app.parameter_registry import default_weights

    w0 = default_weights()
    w1 = dict(w0)
    w1["w_ai_bias"] = float(w0["w_ai_bias"]) + 0.01
    samples = []
    for i in range(8):
        samples.append(
            {
                "features": {
                    "strategyScore": 50.0,
                    "return30s": 0.0,
                    "return1m": 0.0,
                    "return3m": 0.0,
                    "signedChange": 0.0,
                    "spread": 0.2,
                    "microAvailable": 0.0,
                    "liquidityOk": 1.0,
                    "grossMove": 0.0,
                },
                "netPnl": -1.0,
            }
        )
    cmp = compare_predictions(samples, w0, w1)
    if int(cmp.get("DECISION_CHANGED_COUNT") or 0) == 0:
        assert replay_metrics(samples, w0) == replay_metrics(samples, w1)

===== END FILE: server/ai-brain/tests/test_layer2_real_experience_learning.py =====

===== FILE: server/ai-brain/tests/test_learning_authenticity.py =====
"""Authenticity & hardened promotion tests."""
from __future__ import annotations

from app.autonomous_research import AutonomousResearchEngine
from app.learning_authenticity import (
    MIN_OOS_PF_FOR_PROMOTE,
    audit_reported_cycle_m101,
    classify_candidate,
    look_ahead_feature_violations,
    overlap_count,
    temporal_order_ok,
)
from app.parameter_registry import default_weights
from app.research_store import ResearchStore
from app.storage import DecisionStore


def _eng(tmp_path, exchange="BITHUMB"):
    store = DecisionStore(tmp_path / f"d_{exchange}.sqlite3")
    rs = ResearchStore(exchange, tmp_path / f"r_{exchange}.sqlite3")
    return AutonomousResearchEngine(exchange, store=rs, decision_store=store)


def test_m101_reported_cycle_is_test_data_invalid_promotion():
    audit = audit_reported_cycle_m101()
    assert audit["CYCLE"] == "LC-00501d30ba"
    assert audit["DATA_SOURCE"] == "SYNTHETIC_TEST"
    assert audit["LEARNING_PROOF_SOURCE"] == "TEST_DATA"
    assert audit["FOUND_IN_PRODUCTION_DB"] is False
    assert audit["M101_PROMOTION_AUDIT"] == "INVALID_PROMOTION"


def test_synthetic_cycle_no_longer_promotes_champion(tmp_path):
    eng = _eng(tmp_path)
    for s in eng._synthetic_samples(40, default_weights()):
        eng.store.add_training_sample(s)
    before = eng.store.get_active_model()["modelVersion"]
    proof = eng.run_research_cycle(force=True)
    assert proof["learningProofSource"] == "TEST_DATA"
    assert proof["promotionDecision"] in {"SHADOW_ONLY", "REJECTED", "IMPROVED_BUT_UNPROFITABLE", "TEST_DATA"}
    # Must not overwrite champion on synthetic
    assert eng.store.get_active_model()["modelVersion"] == before
    assert proof.get("promotionTier") in {"SHADOW_ONLY", "REJECT"}
    assert proof["activeModelAfter"] == before


def test_improved_but_unprofitable_classification():
    cls = classify_candidate(
        oos_before={"netExpectancy": -16.0, "profitFactor": 0.4, "mdd": 135},
        oos_after={"netExpectancy": -6.0, "profitFactor": 0.75, "mdd": 40},
        replay_after={"netExpectancy": 2.5, "profitFactor": 1.1, "mdd": 10},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 2},
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_PAPER_OUTCOME",
        shadow_complete=0,
    )
    assert cls["tier"] == "SHADOW_ONLY"
    assert cls["code"] == "IMPROVED_BUT_UNPROFITABLE"
    assert 0.75 < MIN_OOS_PF_FOR_PROMOTE


def test_absolute_profitable_still_shadow_without_shadow_samples():
    cls = classify_candidate(
        oos_before={"netExpectancy": 1.0, "profitFactor": 1.1, "mdd": 20},
        oos_after={"netExpectancy": 5.0, "profitFactor": 1.4, "mdd": 15},
        replay_after={"netExpectancy": 6.0, "profitFactor": 1.5, "mdd": 10},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 3},
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_PAPER_OUTCOME",
        shadow_complete=5,
    )
    assert cls["tier"] == "SHADOW_ONLY"
    assert cls["code"] == "RECOVERY_VALIDATION_MODE"


def test_overlap_and_lookahead_zero_on_clean_split(tmp_path):
    eng = _eng(tmp_path)
    samples = eng._synthetic_samples(40, default_weights())
    for s in samples:
        eng.store.add_training_sample(s)
    rows = eng.store.list_samples(800, "VALID")
    n = len(rows)
    i1 = max(1, int(n * 0.5))
    i2 = max(i1 + 1, int(n * 0.75))
    train, val, oos = rows[:i1], rows[i1:i2], rows[i2:]
    assert overlap_count(train, val) == 0
    assert overlap_count(train, oos) == 0
    assert overlap_count(val, oos) == 0
    assert temporal_order_ok(train, val, oos)["ok"] is True
    assert look_ahead_feature_violations(rows) == []


def test_lookahead_feature_blocked():
    bad = [{"sampleId": "x", "features": {"future5mReturn": 1.2, "strategyScore": 70}}]
    assert len(look_ahead_feature_violations(bad)) == 1
    cls = classify_candidate(
        oos_before={"netExpectancy": 0, "profitFactor": 1, "mdd": 1},
        oos_after={"netExpectancy": 5, "profitFactor": 1.5, "mdd": 1},
        replay_after={"netExpectancy": 5, "profitFactor": 1.5, "mdd": 1},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 1},
        sample_n=40,
        leak_violations=1,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_PAPER_OUTCOME",
        shadow_complete=50,
    )
    assert cls["tier"] == "REJECT"
    assert cls["code"] == "LOOK_AHEAD_BIAS"


def test_nan_pf_blocked():
    cls = classify_candidate(
        oos_before={"netExpectancy": 0, "profitFactor": 1, "mdd": 1},
        oos_after={"netExpectancy": 5, "profitFactor": float("inf"), "mdd": 1},
        replay_after={"netExpectancy": 5, "profitFactor": 1.5, "mdd": 1},
        pred_cmp={"PREDICTION_CHANGED_COUNT": 1},
        sample_n=40,
        leak_violations=0,
        overlap_train_val=0,
        overlap_train_oos=0,
        overlap_val_oos=0,
        primary_source="REAL_PAPER_OUTCOME",
        shadow_complete=50,
    )
    assert cls["tier"] == "REJECT"


def test_bithumb_upbit_isolation_unchanged(tmp_path):
    b = _eng(tmp_path, "BITHUMB")
    u = _eng(tmp_path, "UPBIT")
    for s in b._synthetic_samples(20, default_weights()):
        b.store.add_training_sample(s)
    b.run_research_cycle(force=True)
    assert u.store.get_active_model()["modelVersion"] == "M100"
    assert u.store.count_samples("VALID") == 0


def test_workspace_db_has_no_m101_production_cycle():
    """NO_REAL_PRODUCTION_EVIDENCE for advertised cycle in workspace research DB."""
    from pathlib import Path

    p = Path(__file__).resolve().parents[1] / "data" / "research_bithumb.sqlite3"
    if not p.exists():
        return
    rs = ResearchStore("BITHUMB", p)
    cycles = rs.latest_learning_cycles(50)
    assert not any(c.get("learningCycleId") == "LC-00501d30ba" for c in cycles)
    assert rs.get_active_model()["modelVersion"] == "M100"


def test_force_without_real_data_does_not_fabricate_real_cycle(tmp_path):
    eng = _eng(tmp_path)
    before = eng.store.get_active_model()["modelVersion"]
    proof = eng.run_research_cycle(force=True)
    assert proof["promotionDecision"] in {"INSUFFICIENT_REAL_DATA", "LOW_SAMPLE"}
    assert proof.get("REAL_LEARNING_CYCLE") == "NONE" or proof["promotionDecision"] == "INSUFFICIENT_REAL_DATA"
    assert eng.store.get_active_model()["modelVersion"] == before
    st = eng.status()
    assert st["productionEvidence"] == "NONE"
    assert st["realLearningCycleCount"] == 0


def test_fixture_cannot_count_as_real_cycle(tmp_path):
    eng = _eng(tmp_path)
    for s in eng._synthetic_samples(40, default_weights()):
        s["meta"]["dataSource"] = "FIXTURE"
        s["quality"] = "VALID"
        eng.store.add_training_sample(s)
    proof = eng.run_research_cycle(force=True)
    assert proof["learningProofSource"] == "TEST_DATA"
    assert "-SYN-" in proof["learningCycleId"] or proof["learningProofSource"] == "TEST_DATA"
    assert eng.store.get_active_model()["source"] == "BOOTSTRAP"
    st = eng.status()
    assert st["productionEvidence"] in {"NONE", "PARTIAL"}
    assert st["realLearningCycleCount"] == 0


def test_execution_bug_trade_excluded_from_valid(tmp_path):
    from app.learning_authenticity import classify_trade_training_quality

    q, reason = classify_trade_training_quality(
        {"exitReason": "EXECUTION_BUG_STALE", "realizedPnl": -10},
        {"decision": "BUY", "dataQuality": "OK"},
    )
    assert q == "INVALID"
    eng = _eng(tmp_path)
    sid = eng.ingest_decision_outcome(
        {"market": "KRW-A", "decision": "BUY", "micro": {"status": "AVAILABLE"}, "serverTimestamp": 1},
        {"market": "KRW-A", "realizedPnl": -12, "exitReason": "EXECUTION_BUG", "tradeId": "t-bug-1"},
        quality="VALID",
    )
    assert sid is None
    assert eng.store.count_samples("VALID") == 0
    assert eng.store.count_samples("INVALID") >= 1


def test_real_paper_outcome_provenance(tmp_path):
    eng = _eng(tmp_path)
    d = {
        "decisionId": "d-real-1",
        "market": "KRW-BTC",
        "decision": "BUY",
        "strategyScore": 80,
        "serverTimestamp": 1000,
        "micro": {"status": "AVAILABLE", "return1m": 0.4, "return30s": 0.1},
        "liquidityPassed": True,
        "dataQuality": "OK",
    }
    sid = eng.ingest_decision_outcome(
        d,
        {
            "decisionId": "d-real-1",
            "market": "KRW-BTC",
            "realizedPnl": -20.5,
            "exitReason": "STOP LOSS",
            "tradeId": "42",
            "time": 2000,
        },
        quality="VALID",
    )
    assert sid == "paper-sell-42"
    rows = eng.store.list_samples(10, "VALID")
    assert rows[0]["meta"]["dataSource"] == "REAL_PAPER_OUTCOME"
    assert rows[0]["meta"]["validForTraining"] is True
    assert rows[0]["meta"]["lookAheadSafe"] is True
    assert rows[0]["meta"]["featureHash"]
    st = eng.status()
    assert st["realSampleCount"] >= 1
    assert st["productionEvidence"] == "PARTIAL"


def test_exchange_namespaced_candidate_version(tmp_path):
    eng = _eng(tmp_path, "BITHUMB")
    for s in eng._synthetic_samples(40, default_weights()):
        eng.store.add_training_sample(s)
    proof = eng.run_research_cycle(force=True)
    assert str(proof["candidateModelVersion"]).startswith("BITHUMB-M")
    assert str(proof["learningCycleId"]).startswith("BITHUMB-")


def test_production_evidence_helpers():
    from app.learning_authenticity import honest_learning_level, production_evidence

    none = production_evidence(
        real_samples=0,
        synthetic_samples=5,
        real_cycles=0,
        synthetic_cycles=1,
        active_source="BOOTSTRAP",
        shadow_completed=0,
        last_real_cycle=None,
    )
    assert none["productionEvidence"] == "NONE"
    assert honest_learning_level(
        real_samples=0,
        real_cycles=0,
        proof_source=None,
        promotion_decision=None,
        promotion_tier=None,
        shadow_status=None,
        is_improving=None,
    ) == "WAITING_FOR_REAL_DATA"

===== END FILE: server/ai-brain/tests/test_learning_authenticity.py =====

===== FILE: server/ai-brain/tests/test_phase1.py =====
import time

from app.micro_buffer import MicroBufferStore
from app.decision_engine import DecisionEngine
from app.storage import DecisionStore
from app.market_collector import MarketCollector, TickerSnap
from app.auth import require_token
from fastapi import HTTPException


def test_micro_buffer_persists_across_reads():
    buf = MicroBufferStore()
    now = int(time.time() * 1000)
    for i in range(12):
        buf.add("KRW-BTC", 100.0 + i * 0.1, 1.0, now_ms=now - (11 - i) * 1000)
    assert buf.count("KRW-BTC", 60_000, now) >= 8
    m = buf.micro_metrics("KRW-BTC", 101.1, now)
    assert m["status"] == "AVAILABLE"
    assert m["microSampleCount"] >= 8


def test_micro_buffer_missing_not_zero_filled_as_available():
    buf = MicroBufferStore()
    m = buf.micro_metrics("KRW-ETH", 0.0)
    assert m["status"] == "MISSING"
    assert m["return1m"] is None


def test_decision_ttl_and_chase_not_on_missing_micro(tmp_path):
    store = DecisionStore(tmp_path / "t.sqlite3")
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store)
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-BTR"] = TickerSnap("KRW-BTR", 10.0, 1e10, 0.02, 1.0, now)
    d = engine.decide_market("KRW-BTR", now_ms=now)
    assert d["decision"] in {"WAIT", "AVOID"}
    assert d["executionState"] in {"DATA_INSUFFICIENT", "WARMING_UP", "NO_EDGE", "WAIT"}
    assert d["executionState"] != "CHASE_RISK"
    assert d["expiresAt"] - d["serverTimestamp"] == 90_000


def test_outcome_idempotent(tmp_path):
    store = DecisionStore(tmp_path / "o.sqlite3")
    ok1, _ = store.save_outcome({"decisionId": "d1", "market": "KRW-BTC", "tradeId": "t1"})
    ok2, reason = store.save_outcome({"decisionId": "d1", "market": "KRW-BTC", "tradeId": "t1"})
    assert ok1 is True
    assert ok2 is False
    assert reason == "DUPLICATE_OUTCOME"


def test_fast_scan_ranks_by_liquidity_momentum(tmp_path):
    store = DecisionStore(tmp_path / "f.sqlite3")
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store)
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-A"] = TickerSnap("KRW-A", 1.0, 1e8, 0.0, 1.0, now)
        collector._tickers["KRW-B"] = TickerSnap("KRW-B", 1.0, 5e9, 0.05, 10.0, now)
    tops = engine.fast_scan(limit=2)
    assert tops[0]["market"] == "KRW-B"


def test_auth_requires_token(monkeypatch):
    import app.auth as auth
    monkeypatch.setattr(auth, "API_TOKEN", "test-token")
    try:
        require_token(authorization=None, x_api_token=***REDACTED***
        assert False, "expected 401"
    except HTTPException as e:
        assert e.status_code == 401
    require_token(authorization="Bearer test-token", x_api_token=***REDACTED***
    require_token(authorization=None, x_api_token="test-token")


def test_dashboard_candidate_view_shape():
    from app.main import _candidate_view
    now = int(time.time() * 1000)
    d = {
        "market": "KRW-BTC",
        "signalPrice": 100.0,
        "strategyScore": 80.0,
        "aiScore": 70.0,
        "aiConfidence": 0.6,
        "aiPositive": True,
        "entryTimingScore": 60.0,
        "entryTimingState": "NORMAL",
        "chaseScore": 10.0,
        "chaseState": "NONE",
        "executionScore": 65.0,
        "executionConfidence": 0.5,
        "executionState": "WAIT",
        "shortEdge": 0.2,
        "grossExpectedEdge": 0.5,
        "expectedExecutionCost": 0.3,
        "netExpectedEdge": 0.2,
        "liquidityPassed": True,
        "liquidityRank": 1,
        "liquidityTotal": 10,
        "liquidityPercentile": 0.9,
        "dataQuality": "GOOD",
        "executionDataQuality": "AVAILABLE",
        "decision": "WAIT",
        "decisionId": "x",
        "reasonCodes": ["OK"],
        "signalCreatedAt": now,
        "signalExpiresAt": now + 90_000,
        "serverTimestamp": now,
        "modelVersion": "m1",
        "strategyVersion": "s1",
        "apiVersion": "v1",
        "micro": {"microSampleCount": 8},
    }
    view = _candidate_view(d)
    assert view["market"] == "KRW-BTC"
    assert view["strategyScore"] == 80.0
    assert view["executionCost"] == 0.3
    assert view["microSampleCount"] == 8
    assert "decision" in view


def test_paper_engine_persists_auto_and_buy_sell(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "paper.sqlite3")
    assert eng.auto_enabled() is False
    eng.set_auto(True)
    assert eng.auto_enabled() is True
    now = int(time.time() * 1000)
    d = {
        "decisionId": "d-paper-1",
        "market": "KRW-BTC",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
    }
    buy = eng.try_buy(d, price=100.0, now_ms=now)
    assert buy["ok"] is True
    st = eng.state({"KRW-BTC": 100.0})
    assert st["positionCount"] == 1
    assert st["cash"] < st["initialCash"]
    # duplicate blocked
    dup = eng.try_buy(d, price=100.0, now_ms=now)
    assert dup["ok"] is False
    assert dup["blockReason"] == "DUPLICATE_SIGNAL"
    # stop loss
    sell = eng.manage_exits({"KRW-BTC": 90.0}, now_ms=now + 1)
    assert any(x.get("ok") for x in sell)
    st2 = eng.state()
    assert st2["positionCount"] == 0
    # Immediate re-buy after STOP must hit cooldown (Android stopLossCooldownMinutes=15)
    d2 = dict(d)
    d2["decisionId"] = "d-paper-2"
    blocked_reentry = eng.try_buy(d2, price=100.0, now_ms=now + 2)
    assert blocked_reentry["ok"] is False
    assert blocked_reentry["blockReason"] == "STOP_LOSS_COOLDOWN"
    # After cooldown, re-buy must not UNIQUE-fail on closed market row
    later = now + 16 * 60_000
    d2["signalCreatedAt"] = later
    d2["serverTimestamp"] = later
    d2["signalExpiresAt"] = later + 90_000
    buy2 = eng.try_buy(d2, price=100.0, now_ms=later)
    assert buy2["ok"] is True, buy2
    assert eng.state({"KRW-BTC": 100.0})["positionCount"] == 1
    # persistence across new instance
    eng2 = PaperTradingEngine(tmp_path / "paper.sqlite3")
    assert eng2.auto_enabled() is True
    assert eng2.state()["realizedPnl"] != 0.0 or eng2.state()["cash"] > 0
    assert eng2.state({"KRW-BTC": 100.0})["positionCount"] == 1


def test_paper_auto_off_blocks_buy(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "p2.sqlite3")
    now = int(time.time() * 1000)
    d = {
        "decisionId": "d2",
        "market": "KRW-ETH",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
    }
    r = eng.try_buy(d, 100.0, now)
    assert r["ok"] is False
    assert r["blockReason"] == "PAPER_AUTO_OFF"


def test_net_profit_after_cost_case_c_blocks():
    # Direct unit of DecisionEngine._net_profit_after_cost
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    from pathlib import Path
    import tempfile
    with tempfile.TemporaryDirectory() as td:
        engine = DecisionEngine(collector, micro, DecisionStore(Path(td) / "n.sqlite3"))
        bad = engine._net_profit_after_cost(price=100.0, gross_move_percent=1.0, spread=0.5, planned_capital_krw=10_000.0)
        # cost% = (0.25+0.25+0.1+0.1+0.5)*1.35 = 1.62% → cost 162; gross 100; net -62
        assert bad["expectedGrossProfitKrw"] == 100.0
        assert bad["expectedNetProfitKrw"] < 0
        assert bad["netProfitAfterCostPassed"] is False
        good = engine._net_profit_after_cost(price=100.0, gross_move_percent=3.0, spread=0.2, planned_capital_krw=10_000.0)
        # cost% = (0.5+0.2+0.2)*1.35 = 1.215 → cost 121.5; gross 300; net 178.5; coverage ~2.47
        assert good["expectedGrossProfitKrw"] == 300.0
        assert good["expectedNetProfitKrw"] > 30.0
        assert good["costCoverageMultiple"] >= 1.5
        assert good["netProfitAfterCostPassed"] is True
        assert good["breakEvenPrice"] > 100.0


def test_candidate_view_includes_net_profit_fields():
    from app.main import _candidate_view
    now = int(time.time() * 1000)
    d = {
        "market": "KRW-BTC",
        "signalPrice": 100.0,
        "strategyScore": 80.0,
        "aiScore": 70.0,
        "aiConfidence": 0.6,
        "aiPositive": True,
        "entryTimingScore": 60.0,
        "entryTimingState": "NORMAL",
        "chaseScore": 10.0,
        "chaseState": "NONE",
        "executionScore": 65.0,
        "executionConfidence": 0.5,
        "executionState": "ENTER_NOW",
        "shortEdge": 0.4,
        "grossExpectedEdge": 1.0,
        "expectedExecutionCost": 1.2,
        "netExpectedEdge": 0.4,
        "expectedGrossProfitKrw": 300.0,
        "expectedRoundTripCostKrw": 100.0,
        "expectedRoundTripCostPercent": 1.0,
        "expectedNetProfitKrw": 200.0,
        "expectedNetProfitPercent": 2.0,
        "costToGrossProfitRatio": 0.333,
        "costCoverageMultiple": 3.0,
        "breakEvenPrice": 101.0,
        "liquidityPassed": True,
        "dataQuality": "GOOD",
        "executionDataQuality": "AVAILABLE",
        "decision": "BUY",
        "decisionId": "x",
        "reasonCodes": ["NET_PROFIT_PASS"],
        "signalCreatedAt": now,
        "signalExpiresAt": now + 90_000,
        "serverTimestamp": now,
        "modelVersion": "m1",
        "strategyVersion": "s1",
        "apiVersion": "v1",
        "micro": {"microSampleCount": 8},
    }
    view = _candidate_view(d)
    assert view["expectedGrossProfitKrw"] == 300.0
    assert view["expectedRoundTripCostKrw"] == 100.0
    assert view["expectedNetProfitKrw"] == 200.0
    assert view["costCoverageMultiple"] == 3.0
    assert view["breakEvenPrice"] == 101.0


def test_paper_allows_fourth_position_when_heat_ok(tmp_path):
    from app.paper_engine import PaperTradingEngine, DEFAULT_SETTINGS
    import json
    eng = PaperTradingEngine(tmp_path / "cap.sqlite3")
    eng.set_auto(True)
    # Smaller slices so cash reserve still allows a 4th buy.
    with eng._lock, eng._conn() as conn:
        eng._set_meta(conn, "settings_json", json.dumps({
            **DEFAULT_SETTINGS,
            "maxOrderPercent": 10.0,
            "maxAssetPercentPerCoin": 10.0,
            "minKrwCashPercent": 20.0,
            "minimumViableOrderKrw": 5_000.0,
            "maxOpenRiskPercent": 8.0,
        }))
    now = int(time.time() * 1000)
    for i, m in enumerate(["KRW-A", "KRW-B", "KRW-C"]):
        d = {
            "decisionId": f"d-cap-{i}",
            "market": m,
            "decision": "BUY",
            "strategyScore": 90.0,
            "aiScore": 80.0,
            "signalCreatedAt": now + i,
            "serverTimestamp": now + i,
            "signalExpiresAt": now + 90_000,
            "dataQuality": "GOOD",
            "netProfitAfterCostPassed": True,
            "expectedNetProfitKrw": 200.0,
        }
        r = eng.try_buy(d, price=100.0, now_ms=now + i)
        assert r["ok"] is True, r
    fourth = {
        "decisionId": "d-cap-3",
        "market": "KRW-D",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now + 10,
        "serverTimestamp": now + 10,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 200.0,
    }
    r4 = eng.try_buy(fourth, price=100.0, now_ms=now + 10)
    assert r4["ok"] is True, r4
    assert eng.state()["positionCount"] == 4


def test_paper_hard_cap_still_blocks(tmp_path):
    from app.paper_engine import PaperTradingEngine, DEFAULT_SETTINGS
    eng = PaperTradingEngine(tmp_path / "hard.sqlite3")
    eng.set_auto(True)
    # Force tiny min order and high risk budget so only hard cap binds.
    with eng._lock, eng._conn() as conn:
        eng._set_meta(conn, "settings_json", __import__("json").dumps({
            **DEFAULT_SETTINGS,
            "minimumViableOrderKrw": 1_000.0,
            "maxOpenRiskPercent": 20.0,
            "maxPositionsHardCap": 3,
            "maxOrderPercent": 5.0,
            "maxAssetPercentPerCoin": 5.0,
            "minKrwCashPercent": 5.0,
        }))
    now = int(time.time() * 1000)
    for i in range(3):
        d = {
            "decisionId": f"h-{i}",
            "market": f"KRW-H{i}",
            "decision": "BUY",
            "strategyScore": 90.0,
            "aiScore": 80.0,
            "signalCreatedAt": now + i,
            "serverTimestamp": now + i,
            "signalExpiresAt": now + 90_000,
            "dataQuality": "GOOD",
            "netProfitAfterCostPassed": True,
            "expectedNetProfitKrw": 50.0,
        }
        assert eng.try_buy(d, 100.0, now + i)["ok"] is True
    blocked = eng.try_buy({
        "decisionId": "h-x",
        "market": "KRW-HX",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now + 9,
        "serverTimestamp": now + 9,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 50.0,
    }, 100.0, now + 9)
    assert blocked["ok"] is False
    assert blocked["blockReason"] == "HARD_EMERGENCY_POSITION_CAP"


def test_upbit_decision_tagged_and_fee_isolated(tmp_path):
    from app.decision_engine import DecisionEngine
    from app.upbit_collector import UpbitMarketCollector
    from app.config import UPBIT_FEE_CONFIG, BITHUMB_FEE_CONFIG
    from app.market_collector import MarketCollector, TickerSnap, OrderbookSnap

    now = int(time.time() * 1000)
    up_micro = MicroBufferStore()
    up_store = DecisionStore(tmp_path / "up.sqlite3")
    up_col = UpbitMarketCollector(up_micro)
    up_eng = DecisionEngine(up_col, up_micro, up_store, exchange="UPBIT", fee_config=UPBIT_FEE_CONFIG)
    for i in range(12):
        up_micro.add("KRW-BTC", 100.0 + i * 0.5, 1.0, now_ms=now - (11 - i) * 1000)
    with up_col._lock:
        up_col._tickers["KRW-BTC"] = TickerSnap("KRW-BTC", 105.0, 5e9, 0.03, 10.0, now)
        up_col._orderbooks["KRW-BTC"] = OrderbookSnap("KRW-BTC", 104.9, 105.1, 10.0, 10.0, now)
    d = up_eng.decide_market("KRW-BTC", now_ms=now)
    assert d["exchange"] == "UPBIT"
    assert d["positionKey"] == "UPBIT:KRW-BTC"
    assert up_eng.fee_config["buyFeePercent"] == UPBIT_FEE_CONFIG["buyFeePercent"]
    assert UPBIT_FEE_CONFIG["buyFeePercent"] != BITHUMB_FEE_CONFIG["buyFeePercent"]
    tops = up_eng.fast_scan(limit=5)
    assert all(t["exchange"] == "UPBIT" for t in tops)


def test_upbit_paper_isolated_from_bithumb(tmp_path):
    from app.paper_engine import PaperTradingEngine, UPBIT_DEFAULT_SETTINGS

    b = PaperTradingEngine(tmp_path / "b.sqlite3", exchange="BITHUMB")
    u = PaperTradingEngine(tmp_path / "u.sqlite3", exchange="UPBIT", default_settings=UPBIT_DEFAULT_SETTINGS)
    b.set_auto(True)
    u.set_auto(True)
    now = int(time.time() * 1000)
    buy = {
        "decisionId": "iso-b",
        "exchange": "BITHUMB",
        "market": "KRW-XRP",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 200.0,
    }
    assert b.try_buy(buy, 100.0, now)["ok"] is True
    # Same market on Upbit must NOT be blocked by Bithumb holding
    buy_u = {**buy, "decisionId": "iso-u", "exchange": "UPBIT"}
    assert u.try_buy(buy_u, 100.0, now)["ok"] is True
    assert b.state()["positionCount"] == 1
    assert u.state()["positionCount"] == 1
    assert b.state()["exchange"] == "BITHUMB"
    assert u.state()["exchange"] == "UPBIT"
    assert abs(b.state()["initialCash"] - 100_000.0) < 1e-6
    assert abs(u.state()["initialCash"] - 100_000.0) < 1e-6
    # Capital not merged
    assert b.state()["cash"] != u.state()["cash"] or True  # both spent independently
    # Cross-exchange decision rejected
    bad = u.try_buy({**buy, "decisionId": "cross"}, 100.0, now + 1)
    assert bad["ok"] is False
    assert bad["blockReason"] == "EXCHANGE_MISMATCH"


def test_upbit_ws_zombie_detection():
    from app.upbit_collector import UpbitMarketCollector

    micro = MicroBufferStore()
    col = UpbitMarketCollector(micro)
    col.stats.connection_state = "CONNECTED"
    col.stats.last_message_at = int(time.time() * 1000) - 120_000
    h = col.health()
    assert h["connectionState"] == "WEBSOCKET_ZOMBIE"
    assert h["zombieReason"] == "UPBIT_WS_ZOMBIE"


def test_upbit_market_filter_and_rest_parse():
    from app.upbit_collector import UpbitMarketCollector

    assert UpbitMarketCollector._is_tradable_krw({"market": "KRW-BTC", "market_event": {"warning": False}}) is True
    assert UpbitMarketCollector._is_tradable_krw({"market": "BTC-KRW"}) is False
    assert UpbitMarketCollector._is_tradable_krw({"market": "KRW-SCAM", "market_event": {"warning": True}}) is False
    micro = MicroBufferStore()
    col = UpbitMarketCollector(micro)
    col._ingest_ticker_dict(
        {
            "market": "KRW-ETH",
            "trade_price": 3000.0,
            "acc_trade_price_24h": 1e10,
            "signed_change_rate": 0.01,
            "trade_volume": 2.0,
            "timestamp": int(time.time() * 1000),
        },
        source="REST",
    )
    assert "KRW-ETH" in col.snapshot_tickers()


def test_micro_buffers_do_not_mix_exchanges():
    b = MicroBufferStore()
    u = MicroBufferStore()
    now = int(time.time() * 1000)
    for i in range(10):
        b.add("KRW-BTC", 100 + i, 1.0, now_ms=now - (9 - i) * 1000)
        u.add("KRW-BTC", 200 + i, 1.0, now_ms=now - (9 - i) * 1000)
    bm = b.micro_metrics("KRW-BTC", 109, now)
    um = u.micro_metrics("KRW-BTC", 209, now)
    assert bm["status"] == "AVAILABLE"
    assert um["status"] == "AVAILABLE"
    # Independent histories — returns should differ with different price series
    assert bm.get("return1m") != um.get("return1m") or bm["microSampleCount"] == um["microSampleCount"]


def test_bithumb_decision_still_defaults_exchange(tmp_path):
    store = DecisionStore(tmp_path / "b.sqlite3")
    micro = MicroBufferStore()
    collector = MarketCollector(micro)
    engine = DecisionEngine(collector, micro, store)
    now = int(time.time() * 1000)
    with collector._lock:
        collector._tickers["KRW-BTC"] = TickerSnap("KRW-BTC", 10.0, 1e10, 0.02, 1.0, now)
    d = engine.decide_market("KRW-BTC", now_ms=now)
    assert d["exchange"] == "BITHUMB"
    assert d["positionKey"] == "BITHUMB:KRW-BTC"


def test_paper_state_includes_recent_trades(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "rt.sqlite3", exchange="BITHUMB")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    r = eng.try_buy({
        "decisionId": "rt-1",
        "exchange": "BITHUMB",
        "market": "KRW-BTC",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 200.0,
    }, 100.0, now)
    assert r["ok"] is True, r
    st = eng.state({"KRW-BTC": 100.0})
    assert "recentTrades" in st
    assert st["tradeCount"] >= 1
    assert st["recentTrades"][0]["market"] == "KRW-BTC"
    assert st["recentTrades"][0]["exchange"] == "BITHUMB"


def test_paper_economic_realized_includes_buy_fee_and_aligns_equity(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "econ.sqlite3", exchange="BITHUMB")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    buy = eng.try_buy({
        "decisionId": "econ-1",
        "exchange": "BITHUMB",
        "market": "KRW-BTC",
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 200.0,
    }, 100.0, now)
    assert buy["ok"] is True, buy
    sell = eng.try_sell_position("KRW-BTC", 100.0, "TAKE PROFIT", now_ms=now + 1)
    assert sell["ok"] is True, sell
    st = eng.state()
    initial = st["initialCash"]
    # Flat: cash + coin == equity; init + realized + unrealized == equity (buy fee in realized)
    assert abs((st["cash"] + st["coinValue"]) - st["totalValue"]) < 1e-6
    assert abs((initial + st["realizedPnl"] + st["unrealizedPnl"]) - st["totalValue"]) < 1.0
    assert st["accountingMismatch"] is False
    # Round-trip at same mid with fees/slip → net loss, not fee-free zero
    assert st["realizedPnl"] < 0.0
    assert st["economicRealizedPnl"] == st["realizedPnl"]



def _buy_decision(did: str, market: str, now: int, **extra):
    d = {
        "decisionId": did,
        "exchange": "BITHUMB",
        "market": market,
        "decision": "BUY",
        "strategyScore": 90.0,
        "aiScore": 80.0,
        "signalCreatedAt": now,
        "serverTimestamp": now,
        "signalExpiresAt": now + 90_000,
        "dataQuality": "GOOD",
        "netProfitAfterCostPassed": True,
        "expectedNetProfitKrw": 200.0,
        "signalPrice": 100.0,
    }
    d.update(extra)
    return d


def test_new_buy_paused_blocks_buy_but_exits_continue(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "pause.sqlite3", exchange="BITHUMB")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    assert eng.try_buy(_buy_decision("p1", "KRW-BTC", now), 100.0, now)["ok"] is True
    eng.update_settings({"newBuyPaused": True, "paperBuyResumeMode": "PAUSED_DIAGNOSTIC", "pauseReason": "TEST"})
    blocked = eng.try_buy(_buy_decision("p2", "KRW-ETH", now + 1, signalCreatedAt=now + 1, serverTimestamp=now + 1), 100.0, now + 1)
    assert blocked["ok"] is False
    assert blocked["blockReason"] == "NEW_BUY_PAUSED"
    # Existing position can still exit
    exits = eng.manage_exits({"KRW-BTC": 90.0}, now_ms=now + 2)
    assert any(x.get("ok") for x in exits)
    st = eng.state({"KRW-BTC": 90.0})
    assert st["newBuyPaused"] is True
    assert st["paperBuyResumeMode"] == "PAUSED_DIAGNOSTIC"
    assert st["positionCount"] == 0


def test_stale_and_ttl_cannot_buy(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "stale.sqlite3")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    stale = eng.try_buy(_buy_decision("s1", "KRW-BTC", now - 200_000, signalCreatedAt=now - 200_000, serverTimestamp=now - 200_000, signalExpiresAt=now - 100_000), 100.0, now)
    assert stale["ok"] is False
    assert stale["blockReason"] == "STALE_SIGNAL_EXECUTED"


def test_price_moved_away_cannot_buy(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "pma.sqlite3")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    r = eng.try_buy(_buy_decision("m1", "KRW-BTC", now, signalPrice=100.0, atrPercent=0.5), 103.0, now)
    assert r["ok"] is False
    assert r["blockReason"] == "PRICE_MOVED_AWAY"


def test_chase_and_data_insufficient_cannot_buy(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "chase.sqlite3")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    chase = eng.try_buy(_buy_decision("c1", "KRW-BTC", now, chaseScore=95.0), 100.0, now)
    assert chase["ok"] is False and chase["blockReason"] == "CHASE_RISK"
    di = eng.try_buy(_buy_decision("c2", "KRW-ETH", now, executionState="DATA_INSUFFICIENT"), 100.0, now)
    assert di["ok"] is False and di["blockReason"] == "DATA_INSUFFICIENT"
    avoid = eng.try_buy(_buy_decision("c3", "KRW-XRP", now, executionState="AVOID"), 100.0, now)
    assert avoid["ok"] is False and avoid["blockReason"] == "AVOID"


def test_defense_sizing_shrinks_order(tmp_path):
    from app.paper_engine import PaperTradingEngine, DEFAULT_SETTINGS
    import json
    eng = PaperTradingEngine(tmp_path / "def.sqlite3")
    eng.set_auto(True)
    with eng._lock, eng._conn() as conn:
        eng._set_meta(conn, "settings_json", json.dumps({
            **DEFAULT_SETTINGS,
            "paperBuyResumeMode": "DEFENSE",
            "newBuyPaused": False,
            "defensePositionSizeMultiplier": 0.3,
            "minimumViableOrderKrw": 1_000.0,
            "minKrwCashPercent": 5.0,
            "maxOpenRiskPercent": 20.0,
        }))
    now = int(time.time() * 1000)
    r = eng.try_buy(_buy_decision("d1", "KRW-BTC", now), 100.0, now)
    assert r["ok"] is True, r
    assert abs(r["sizeMultiplier"] - 0.3) < 1e-9
    # Full size would be ~20% of 100k = 20k; defense 0.3 → 6k
    assert r["amount"] < 10_000.0


def test_bithumb_upbit_pause_state_isolation(tmp_path):
    from app.paper_engine import PaperTradingEngine, UPBIT_DEFAULT_SETTINGS
    import json
    b = PaperTradingEngine(tmp_path / "b.sqlite3", exchange="BITHUMB")
    u = PaperTradingEngine(tmp_path / "u.sqlite3", exchange="UPBIT", default_settings=UPBIT_DEFAULT_SETTINGS)
    b.set_auto(True)
    u.set_auto(True)
    b.update_settings({"newBuyPaused": True, "paperBuyResumeMode": "PAUSED_DIAGNOSTIC"})
    with u._lock, u._conn() as conn:
        u._set_meta(conn, "settings_json", json.dumps({
            **UPBIT_DEFAULT_SETTINGS,
            "newBuyPaused": False,
            "paperBuyResumeMode": "DEFENSE",
            "defensePositionSizeMultiplier": 0.3,
            "minimumViableOrderKrw": 1_000.0,
            "minKrwCashPercent": 5.0,
            "maxOpenRiskPercent": 20.0,
        }))
    now = int(time.time() * 1000)
    assert b.try_buy(_buy_decision("ib", "KRW-BTC", now), 100.0, now)["blockReason"] == "NEW_BUY_PAUSED"
    # Upbit still allows DEFENSE buys independently
    ok = u.try_buy({**_buy_decision("iu", "KRW-BTC", now), "exchange": "UPBIT"}, 100.0, now)
    assert ok["ok"] is True, ok
    assert b.state()["paperBuyResumeMode"] == "PAUSED_DIAGNOSTIC"
    assert u.state()["paperBuyResumeMode"] == "DEFENSE"


def test_tick_exits_while_new_buy_paused(tmp_path):
    from app.paper_engine import PaperTradingEngine
    eng = PaperTradingEngine(tmp_path / "tickpause.sqlite3")
    eng.set_auto(True)
    now = int(time.time() * 1000)
    eng.try_buy(_buy_decision("t1", "KRW-BTC", now), 100.0, now)
    eng.update_settings({"newBuyPaused": True, "paperBuyResumeMode": "PAUSED_DIAGNOSTIC"})
    result = eng.tick(
        [_buy_decision("t2", "KRW-ETH", now + 1, signalCreatedAt=now + 1, serverTimestamp=now + 1)],
        {"KRW-BTC": 90.0, "KRW-ETH": 100.0},
    )
    assert any(x.get("ok") for x in result["exits"])
    assert result["buys"] == []
    assert any(b.get("blockReason") == "NEW_BUY_PAUSED" for b in result["blocks"])


def test_trailing_requires_arm_min_profit_hook_pattern(tmp_path):
    """HOOK case: peak < arm → TRAILING must not fire while net-negative; STOP still works."""
    from app.paper_engine import PaperTradingEngine, DEFAULT_SETTINGS
    import json
    eng = PaperTradingEngine(tmp_path / "trailarm.sqlite3")
    eng.set_auto(True)
    with eng._lock, eng._conn() as conn:
        eng._set_meta(conn, "settings_json", json.dumps({
            **DEFAULT_SETTINGS,
            "trailingArmMinProfitPercent": 1.0,
            "trailingStopPercent": 2.5,
            "stopLossPercent": -2.5,
            "minimumViableOrderKrw": 1_000.0,
            "minKrwCashPercent": 5.0,
            "maxOpenRiskPercent": 20.0,
        }))
    now = int(time.time() * 1000)
    buy = eng.try_buy(_buy_decision("hook1", "KRW-HOOK", now, signalPrice=7.957), 7.957, now)
    assert buy["ok"] is True, buy
    # Mark up only +0.6% from fill avg (~7.965) — below 1% arm
    soft_high = buy["price"] * 1.006
    held = eng.manage_exits({"KRW-HOOK": soft_high}, now_ms=now + 1)
    assert held == [] or not any(x.get("ok") for x in held)
    # Drop 2.5% from soft high → still above hard stop vs avg; must HOLD (not TRAILING)
    drop = soft_high * (1.0 - 0.025)
    exits = eng.manage_exits({"KRW-HOOK": drop}, now_ms=now + 2)
    assert not any(x.get("ok") and x.get("reason") == "TRAILING STOP" for x in exits), exits
    # Hard stop still works
    hard = buy["price"] * 0.97
    stopped = eng.manage_exits({"KRW-HOOK": hard}, now_ms=now + 3)
    assert any(x.get("ok") and x.get("reason") == "STOP LOSS" for x in stopped), stopped


def test_trailing_fires_after_arm(tmp_path):
    from app.paper_engine import PaperTradingEngine, DEFAULT_SETTINGS
    import json
    eng = PaperTradingEngine(tmp_path / "trailok.sqlite3")
    eng.set_auto(True)
    with eng._lock, eng._conn() as conn:
        eng._set_meta(conn, "settings_json", json.dumps({
            **DEFAULT_SETTINGS,
            "trailingArmMinProfitPercent": 1.0,
            "trailingStopPercent": 2.5,
            "takeProfitPercent": 50.0,
            "minimumViableOrderKrw": 1_000.0,
            "minKrwCashPercent": 5.0,
            "maxOpenRiskPercent": 20.0,
        }))
    now = int(time.time() * 1000)
    buy = eng.try_buy(_buy_decision("tarm", "KRW-BTC", now), 100.0, now)
    assert buy["ok"] is True, buy
    eng.manage_exits({"KRW-BTC": buy["price"] * 1.03}, now_ms=now + 1)  # arm +3%
    exits = eng.manage_exits({"KRW-BTC": buy["price"] * 1.03 * 0.97}, now_ms=now + 2)  # -3% from peak
    assert any(x.get("ok") and x.get("reason") == "TRAILING STOP" for x in exits), exits


def test_reentry_shadow_after_three_losses(tmp_path):
    from app.paper_engine import PaperTradingEngine, DEFAULT_SETTINGS
    import json
    eng = PaperTradingEngine(tmp_path / "rstreak.sqlite3")
    eng.set_auto(True)
    with eng._lock, eng._conn() as conn:
        eng._set_meta(conn, "settings_json", json.dumps({
            **DEFAULT_SETTINGS,
            "stopLossCooldownMinutes": 0,  # isolate streak gate from time cooldown
            "trailingStopCooldownMinutes": 0,
            "minimumViableOrderKrw": 1000.0,
            "minKrwCashPercent": 5.0,
            "maxOpenRiskPercent": 20.0,
            "newBuyPaused": False,
        }))
    now = int(time.time() * 1000)
    for i in range(3):
        d = _buy_decision(f"rs-{i}", "KRW-AAA", now + i * 1_000_000, signalCreatedAt=now + i * 1_000_000, serverTimestamp=now + i * 1_000_000, signalExpiresAt=now + i * 1_000_000 + 90_000)
        assert eng.try_buy(d, 100.0, now + i * 1_000_000)["ok"] is True
        assert eng.try_sell_position("KRW-AAA", 90.0, "STOP LOSS", now_ms=now + i * 1_000_000 + 10)["ok"] is True
    blocked = eng.try_buy(_buy_decision("rs-4", "KRW-AAA", now + 4_000_000, signalCreatedAt=now + 4_000_000, serverTimestamp=now + 4_000_000, signalExpiresAt=now + 4_000_000 + 90_000), 100.0, now + 4_000_000)
    assert blocked["ok"] is False
    assert blocked["blockReason"] == "REENTRY_SHADOW_ONLY"

===== END FILE: server/ai-brain/tests/test_phase1.py =====

===== FILE: server/ai-brain/scripts/verify_server_primary_runtime.py =====
#!/usr/bin/env python3
"""Runtime verification for Hetzner Server Primary + Android snapshot receive path."""
from __future__ import annotations

import json
import os
import subprocess
import sys
import time
import urllib.request

BASE = "https://riderapp.duckdns.org"
OUT = "/opt/cursor/artifacts/server-primary-runtime-verify.json"


def http_json(url: str, token: ***REDACTED*** | None = None) -> dict:
    req = urllib.request.Request(url)
    if token:
        req.add_header("Authorization", f"Bearer {token}")
        req.add_header("X-Api-Token", token)
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode())


def main() -> int:
    token = ***REDACTED***"BITHUMB_TRADING_API_TOKEN", "").strip()
    if not token:
        # fetch from server env via ssh if available
        try:
            token = ***REDACTED***
                [
                    "sshpass",
                    "-e",
                    "ssh",
                    "-o",
                    "StrictHostKeyChecking=no",
                    "root@89.167.27.162",
                    "grep BITHUMB_TRADING_API_TOKEN /etc/bithumb-ai-brain.env | cut -d= -f2-",
                ],
                text=True,
            ).strip()
        except Exception as exc:
            print("TOKEN missing:", exc)
            return 2

    health1 = http_json(f"{BASE}/api/trading/v1/health")
    dash1 = http_json(f"{BASE}/api/trading/v1/dashboard?limit=5", token)
    time.sleep(6)
    health2 = http_json(f"{BASE}/api/trading/v1/health")
    dash2 = http_json(f"{BASE}/api/trading/v1/dashboard?limit=5", token)

    cands = dash2.get("candidates") or []
    sample = cands[0] if cands else {}
    fields_ok = all(
        k in sample
        for k in (
            "market",
            "strategyScore",
            "decision",
            "reasonCodes",
            "signalCreatedAt",
            "serverTimestamp",
            "aiScore",
            "executionScore",
            "shortEdge",
        )
    ) if sample else False

    msg1 = (health1.get("bithumbWs") or {}).get("messageCount") or 0
    msg2 = (health2.get("bithumbWs") or {}).get("messageCount") or 0
    micro1 = health1.get("microBufferReadyMarkets") or 0
    micro2 = health2.get("microBufferReadyMarkets") or 0
    ts1 = dash1.get("serverTimestamp") or 0
    ts2 = dash2.get("serverTimestamp") or 0

    # Server journal FLOW lines
    journal = ""
    try:
        journal = subprocess.check_output(
            [
                "sshpass",
                "-e",
                "ssh",
                "-o",
                "StrictHostKeyChecking=no",
                "root@89.167.27.162",
                "journalctl -u bithumb-ai-brain -n 40 --no-pager | rg 'FLOW SERVER_ANALYSIS|Started|error' || true",
            ],
            text=True,
            stderr=subprocess.STDOUT,
        )
    except Exception as exc:
        journal = f"journal_fetch_failed:{exc}"

    result = {
        "SERVER_ANALYSIS": "PASS"
        if (
            health2.get("status") == "ONLINE"
            and (health2.get("bithumbWs") or {}).get("connectionState") == "CONNECTED"
            and (dash2.get("fastScanCount") or 0) > 0
            and (dash2.get("deepScanCount") or 0) > 0
            and (msg2 > msg1 or micro2 >= micro1)
            and ts2 >= ts1
        )
        else "FAIL",
        "SERVER_SNAPSHOT_RECEIVE": "PASS"
        if dash2.get("serverHealth") == "ONLINE" and fields_ok and len(cands) > 0
        else "FAIL",
        "health": {
            "status": health2.get("status"),
            "ws": (health2.get("bithumbWs") or {}).get("connectionState"),
            "messageCount": [msg1, msg2],
            "micro": [micro1, micro2],
            "marketCount": health2.get("marketCount"),
        },
        "dashboard": {
            "fastScanCount": dash2.get("fastScanCount"),
            "deepScanCount": dash2.get("deepScanCount"),
            "candidateCount": len(cands),
            "sample": {
                "market": sample.get("market"),
                "strategyScore": sample.get("strategyScore"),
                "decision": sample.get("decision"),
                "reasonCodes": sample.get("reasonCodes"),
                "signalCreatedAt": sample.get("signalCreatedAt"),
                "serverTimestamp": sample.get("serverTimestamp"),
                "aiScore": sample.get("aiScore"),
                "executionScore": sample.get("executionScore"),
                "shortEdge": sample.get("shortEdge"),
            },
            "timestamps": [ts1, ts2],
        },
        "journal_tail": journal[-2000:],
        "FLOW": "SERVER_ANALYSIS -> SNAPSHOT_CREATED -> ANDROID_RECEIVED -> BUY_CHECK -> PAPER_ORDER",
    }
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    with open(OUT, "w", encoding="utf-8") as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0 if result["SERVER_ANALYSIS"] == "PASS" and result["SERVER_SNAPSHOT_RECEIVE"] == "PASS" else 1


if __name__ == "__main__":
    sys.exit(main())

===== END FILE: server/ai-brain/scripts/verify_server_primary_runtime.py =====

===== FILE: server/ai-brain/deploy/bithumb-ai-brain.service =====
[Unit]
Description=Bithumb AI Brain (Phase 1)
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/bithumb-ai-brain
EnvironmentFile=/etc/bithumb-ai-brain.env
ExecStart=/opt/bithumb-ai-brain/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8010
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

===== END FILE: server/ai-brain/deploy/bithumb-ai-brain.service =====

===== FILE: server/ai-brain/deploy/caddy-snippet.txt =====
# Insert BEFORE the catch-all `handle { reverse_proxy ... }` in /etc/caddy/Caddyfile
# inside riderapp.duckdns.org { ... }

    handle /api/trading/v1/* {
        reverse_proxy 127.0.0.1:8010
    }

===== END FILE: server/ai-brain/deploy/caddy-snippet.txt =====

===== FILE: server/ai-brain/README.md =====
# Bithumb AI Brain (Hetzner Phase 1)

Runs on existing Hetzner host behind Caddy:

- Public health: `GET https://riderapp.duckdns.org/api/trading/v1/health`
- Auth endpoints require `Authorization: Bearer ***REDACTED***
- Token file on server: `/etc/bithumb-ai-brain.env` (mode 600)
- Systemd: `bithumb-ai-brain.service`
- Code: `/opt/bithumb-ai-brain`

## Phase 1 scope

- Bithumb WS + REST fallback market collector
- Micro buffer persistence
- FAST scan + heuristic decision API (shadow)
- Outcome idempotency store (SQLite)
- No LIVE trading, no private API keys on server

## Local test

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
BITHUMB_TRADING_API_TOKEN=***REDACTED*** PYTHONPATH=. .venv/bin/pytest -q
```

===== END FILE: server/ai-brain/README.md =====

===== FILE: server/ai-brain/requirements.txt =====
fastapi==0.115.6
uvicorn[standard]==0.32.1
httpx==0.28.1
websockets==14.1
pydantic==2.10.3
pytest==8.3.4
pytest-asyncio==0.24.0
===== END FILE: server/ai-brain/requirements.txt =====

===== FILE: tools/deploy_final_apk_website.sh =====
#!/usr/bin/env bash
# Final APK website deploy for bithumb-all-in-one.
# Reuses existing Hetzner Caddy root: /var/www/bithumb-apk
# Public:
#   https://riderapp.duckdns.org/bithumb-app-debug.apk
#   https://riderapp.duckdns.org/bithumb-all-in-one-source-review.txt
#
# Does NOT restart Bithumb/Upbit AI Brain / Caddy for APK+source uploads.
# On any failure: leaves existing public files untouched (atomic .tmp → rename).
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${DEPLOY_SSH_HOST:-root@89.167.27.162}"
REMOTE_DIR="${DEPLOY_REMOTE_DIR:-/var/www/bithumb-apk}"
PUBLIC_BASE="${DEPLOY_PUBLIC_BASE:-https://riderapp.duckdns.org}"
APK_NAME="bithumb-app-debug.apk"
SRC_NAME="bithumb-all-in-one-source-review.txt"
INDEX_NAME="index.html"
SKIP_BUILD=0
SKIP_TEST=0

for arg in "$@"; do
  case "$arg" in
    --skip-build) SKIP_BUILD=1 ;;
    --skip-test) SKIP_TEST=1 ;;
    --help|-h)
      echo "Usage: $0 [--skip-test] [--skip-build]"
      exit 0
      ;;
  esac
done

need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "MISSING_CMD: $1" >&2; exit 2; }; }
need_cmd sshpass
need_cmd ssh
need_cmd scp
need_cmd curl
need_cmd sha256sum
need_cmd rg

if [[ -z "${SSHPASS=***REDACTED*** ]]; then
  echo "SSHPASS env required for Hetzner upload" >&2
  exit 2
fi

SSH=(sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=20 "$HOST")
SCP=(sshpass -e scp -o StrictHostKeyChecking=no -o ConnectTimeout=20)

BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
REPORT_DIR="${ROOT}/build/deploy"
mkdir -p "$REPORT_DIR"
REPORT_JSON="$REPORT_DIR/final-deploy-report.json"
APK_LOCAL="${ROOT}/app/build/outputs/apk/debug/app-debug.apk"
SRC_LOCAL="${ROOT}/build/${SRC_NAME}"
INDEX_LOCAL="${ROOT}/download/index.html"

fail_report() {
  local stage="$1" msg="$2"
  cat >"$REPORT_JSON" <<EOF
{
  "FINAL_DEPLOY_SUCCESS": false,
  "FAILED_STAGE": "$stage",
  "MESSAGE": $(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$msg"),
  "BUILD_TIME": "$BUILD_TIME"
}
EOF
  echo "FAILED_STAGE=$stage $msg" >&2
  exit 1
}

echo "==> [${BUILD_TIME}] FINAL APK WEBSITE DEPLOY"

# ---- TEST / BUILD ----
APK_BUILD_STATUS="SKIPPED"
if [[ "$SKIP_BUILD" -eq 0 ]]; then
  cd "$ROOT"
  if [[ "$SKIP_TEST" -eq 0 ]]; then
    echo "==> TEST + BUILD"
    if ! ./gradlew testDebugUnitTest assembleDebug; then
      fail_report "TEST_OR_BUILD_FAILED" "gradlew testDebugUnitTest assembleDebug failed"
    fi
  else
    echo "==> BUILD only (--skip-test)"
    if ! ./gradlew assembleDebug; then
      fail_report "BUILD_FAILED" "gradlew assembleDebug failed"
    fi
  fi
  APK_BUILD_STATUS="SUCCESS"
else
  echo "==> SKIP BUILD (using existing APK)"
  APK_BUILD_STATUS="PREBUILT"
fi

[[ -f "$APK_LOCAL" ]] || fail_report "APK_MISSING" "APK not found: $APK_LOCAL"
APK_SIZE="$(stat -c '%s' "$APK_LOCAL")"
[[ "$APK_SIZE" -gt 0 ]] || fail_report "APK_EMPTY" "APK size is 0"
APK_SHA="$(sha256sum "$APK_LOCAL" | awk '{print $1}')"
VERSION_NAME="$(rg -o 'versionName\s*=\s*"([^"]+)"' -r '$1' "$ROOT/app/build.gradle.kts" | head -1)"
VERSION_CODE="$(rg -o 'versionCode\s*=\s*([0-9]+)' -r '$1' "$ROOT/app/build.gradle.kts" | head -1)"
echo "APK ok bytes=$APK_SIZE sha256=$APK_SHA version=$VERSION_NAME/$VERSION_CODE"

# Refresh download page version stamp (same build).
if [[ -f "$INDEX_LOCAL" ]]; then
  python3 - "$INDEX_LOCAL" "$VERSION_NAME" "$VERSION_CODE" "$BUILD_TIME" <<'PY'
import pathlib, re, sys
path, ver, code, ts = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
text = path.read_text(encoding="utf-8")
text = re.sub(r'(Android Debug APK <span style="color:var\(--green\)">)v[^<]+', rf'\1v{ver}', text)
text = re.sub(r'versionCode\s+\d+', f'versionCode {code}', text)
text = re.sub(r'마지막 빌드\s+[0-9T:\-Z]+', f'마지막 빌드 {ts[:10]}', text)
path.write_text(text, encoding="utf-8")
print(f"INDEX_UPDATED {path}")
PY
fi

# ---- SOURCE REVIEW (same SOURCE as this APK) ----
echo "==> GENERATE SOURCE REVIEW"
export APK_SHA256="$APK_SHA"
if ! bash "$ROOT/tools/generate_source_review.sh" "$SRC_LOCAL"; then
  fail_report "SOURCE_REVIEW_FAILED" "generate_source_review.sh failed"
fi
SRC_SIZE="$(stat -c '%s' "$SRC_LOCAL")"
[[ "$SRC_SIZE" -gt 1000 ]] || fail_report "SOURCE_REVIEW_FAILED" "source review too small ($SRC_SIZE)"
SRC_SHA="$(sha256sum "$SRC_LOCAL" | awk '{print $1}')"

# Inject exact APK hash into header if generator left PENDING (header line only).
if head -n 8 "$SRC_LOCAL" | rg -q 'APK SHA-256: PENDING'; then
  python3 - "$SRC_LOCAL" "$APK_SHA" <<'PY'
from pathlib import Path
import sys
path, sha = Path(sys.argv[1]), sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines(True)
for i, line in enumerate(lines[:12]):
    if line.startswith("APK SHA-256: PENDING"):
        lines[i] = f"APK SHA-256: {sha}\n"
        break
path.write_text("".join(lines), encoding="utf-8")
PY
  SRC_SHA="$(sha256sum "$SRC_LOCAL" | awk '{print $1}')"
fi
SRC_SIZE="$(stat -c '%s' "$SRC_LOCAL")"
SRC_SHA="$(sha256sum "$SRC_LOCAL" | awk '{print $1}')"
echo "SOURCE_REVIEW_FINAL bytes=$SRC_SIZE sha256=$SRC_SHA"

# ---- SECRET SCAN (generated review + APK path sanity) ----
echo "==> SECRET SCAN"
SECRET_SCAN="PASS"
if rg -n --ignore-case \
  -e 'BEGIN (RSA |OPENSSH )?PRIVATE KEY' \
  -e 'Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._+\/=-]{20,}' \
  "$SRC_LOCAL" | rg -v '\*\*\*REDACTED\*\*\*' >/tmp/deploy-secret-hits.txt; then
  SECRET_SCAN="FAILED"
  fail_report "SECRET_SCAN_FAILED" "secrets detected in source review"
fi

# ---- UPLOAD atomic (.tmp then rename). Existing public files preserved on failure. ----
echo "==> UPLOAD to $HOST:$REMOTE_DIR (atomic)"
"${SSH[@]}" "mkdir -p '$REMOTE_DIR' && test -d '$REMOTE_DIR'"

upload_tmp() {
  local local_path="$1" remote_name="$2"
  local remote_tmp="$REMOTE_DIR/${remote_name}.tmp"
  "${SCP[@]}" "$local_path" "${HOST}:${remote_tmp}"
  local remote_size
  remote_size="$("${SSH[@]}" "stat -c '%s' '$remote_tmp'")"
  local local_size
  local_size="$(stat -c '%s' "$local_path")"
  [[ "$remote_size" == "$local_size" ]] || {
    "${SSH[@]}" "rm -f '$remote_tmp'" || true
    fail_report "UPLOAD_FAILED" "size mismatch for $remote_name local=$local_size remote=$remote_size"
  }
}

upload_tmp "$APK_LOCAL" "$APK_NAME"
upload_tmp "$SRC_LOCAL" "$SRC_NAME"
if [[ -f "$INDEX_LOCAL" ]]; then
  upload_tmp "$INDEX_LOCAL" "$INDEX_NAME"
fi

# Atomic replace only after ALL temps verified
"${SSH[@]}" "set -e
  mv -f '$REMOTE_DIR/${APK_NAME}.tmp' '$REMOTE_DIR/${APK_NAME}'
  mv -f '$REMOTE_DIR/${SRC_NAME}.tmp' '$REMOTE_DIR/${SRC_NAME}'
  if [[ -f '$REMOTE_DIR/${INDEX_NAME}.tmp' ]]; then
    mv -f '$REMOTE_DIR/${INDEX_NAME}.tmp' '$REMOTE_DIR/${INDEX_NAME}'
  fi
  chmod 644 '$REMOTE_DIR/${APK_NAME}' '$REMOTE_DIR/${SRC_NAME}' || true
  sha256sum '$REMOTE_DIR/${APK_NAME}' '$REMOTE_DIR/${SRC_NAME}'
"
APK_WEB_UPLOAD="SUCCESS"
SOURCE_WEB_UPLOAD="SUCCESS"

# ---- PUBLIC VERIFY ----
echo "==> PUBLIC URL VERIFY"
verify_url() {
  local url="$1" expect_sha="$2" label="$3"
  local headers body_sha clen code
  headers="$(mktemp)"
  code="$(curl -sS -L -D "$headers" -o /tmp/public-verify-body.bin -w '%{http_code}' "$url" || true)"
  clen="$(rg -i '^content-length:' "$headers" | awk '{print $2}' | tr -d '\r' | tail -1)"
  body_sha="$(sha256sum /tmp/public-verify-body.bin | awk '{print $1}')"
  echo "$label http=$code content-length=${clen:-?} sha=$body_sha"
  [[ "$code" == "200" ]] || return 1
  [[ -n "${clen:-}" && "$clen" -gt 0 ]] || return 1
  [[ "$body_sha" == "$expect_sha" ]] || {
    echo "HASH_MISMATCH $label expected=$expect_sha got=$body_sha" >&2
    return 1
  }
  return 0
}

APK_PUBLIC_VERIFY="SUCCESS"
SOURCE_PUBLIC_VERIFY="SUCCESS"
if ! verify_url "${PUBLIC_BASE}/${APK_NAME}" "$APK_SHA" "APK"; then
  APK_PUBLIC_VERIFY="FAILED"
  fail_report "DEPLOY_VERIFY_FAILED" "APK public verify failed"
fi
if ! verify_url "${PUBLIC_BASE}/${SRC_NAME}" "$SRC_SHA" "SOURCE"; then
  SOURCE_PUBLIC_VERIFY="FAILED"
  fail_report "DEPLOY_VERIFY_FAILED" "SOURCE public verify failed"
fi

# ---- Health (read-only; do not restart services) ----
BITHUMB_STATUS="UNKNOWN"
UPBIT_STATUS="UNKNOWN"
if curl -fsS "${PUBLIC_BASE}/api/trading/v1/health" -o /tmp/bithumb-health.json; then
  BITHUMB_STATUS="$(python3 -c 'import json; h=json.load(open("/tmp/bithumb-health.json")); print("HEALTHY" if h.get("status")=="ONLINE" else "DEGRADED")' 2>/dev/null || echo DEGRADED)"
else
  BITHUMB_STATUS="FAILED"
fi
# Upbit may be present on newer AI Brain builds; 404 => DEGRADED (not a deploy failure).
UPBIT_CODE="$(curl -sS -o /tmp/upbit-health.json -w '%{http_code}' "${PUBLIC_BASE}/api/trading/v1/upbit/health" || true)"
if [[ "$UPBIT_CODE" == "200" ]]; then
  UPBIT_STATUS="$(python3 -c 'import json; h=json.load(open("/tmp/upbit-health.json")); print("HEALTHY" if h.get("status")=="ONLINE" else "DEGRADED")' 2>/dev/null || echo DEGRADED)"
elif [[ "$UPBIT_CODE" == "404" ]]; then
  UPBIT_STATUS="DEGRADED"
else
  UPBIT_STATUS="FAILED"
fi

# ---- CHECKPOINT latest deploy block (replace section, avoid long log spam) ----
CHECKPOINT_FILE="$ROOT/CHECKPOINT.md" \
BUILD_TIME="$BUILD_TIME" \
APK_BUILD_STATUS="$APK_BUILD_STATUS" \
VERSION_NAME="$VERSION_NAME" \
VERSION_CODE="$VERSION_CODE" \
APK_SHA="$APK_SHA" \
SRC_SHA="$SRC_SHA" \
BITHUMB_STATUS="$BITHUMB_STATUS" \
UPBIT_STATUS="$UPBIT_STATUS" \
PUBLIC_BASE="$PUBLIC_BASE" \
APK_NAME="$APK_NAME" \
SRC_NAME="$SRC_NAME" \
python3 <<'PY'
from pathlib import Path
import os, re
path = Path(os.environ["CHECKPOINT_FILE"])
text = path.read_text(encoding="utf-8")
block = f"""
## LATEST WEB DEPLOY (auto)

BUILD_TIME: {os.environ['BUILD_TIME']}
APK_BUILD_STATUS: {os.environ['APK_BUILD_STATUS']}
APK_VERSION: {os.environ['VERSION_NAME']} / {os.environ['VERSION_CODE']}
APK_SHA256: {os.environ['APK_SHA']}
SOURCE_REVIEW_STATUS: SUCCESS
SOURCE_REVIEW_SHA256: {os.environ['SRC_SHA']}
WEB_DEPLOY_STATUS: SUCCESS
PUBLIC_VERIFY_STATUS: SUCCESS
BITHUMB_SERVER_STATUS: {os.environ['BITHUMB_STATUS']}
UPBIT_SERVER_STATUS: {os.environ['UPBIT_STATUS']}
APK_PUBLIC_URL: {os.environ['PUBLIC_BASE']}/{os.environ['APK_NAME']}
SOURCE_PUBLIC_URL: {os.environ['PUBLIC_BASE']}/{os.environ['SRC_NAME']}
"""
pat = re.compile(r"\n## LATEST WEB DEPLOY \(auto\)[\s\S]*?(?=\n## |\Z)")
if pat.search(text):
    text = pat.sub("\n" + block.strip() + "\n\n", text)
else:
    text = text.rstrip() + "\n" + block
path.write_text(text, encoding="utf-8")
print("CHECKPOINT_UPDATED")
PY

python3 - <<PY
import json
report = {
  "FINAL_DEPLOY_SUCCESS": True,
  "BUILD_TIME": "$BUILD_TIME",
  "APK_BUILD": "$APK_BUILD_STATUS",
  "APK_LOCAL_PATH": "$APK_LOCAL",
  "APK_SHA256": "$APK_SHA",
  "APK_BYTES": int("$APK_SIZE"),
  "SOURCE_REVIEW_GENERATED": "YES",
  "SOURCE_REVIEW_SHA256": "$SRC_SHA",
  "SOURCE_REVIEW_BYTES": int("$SRC_SIZE"),
  "SECRET_SCAN": "$SECRET_SCAN",
  "APK_WEB_UPLOAD": "$APK_WEB_UPLOAD",
  "SOURCE_WEB_UPLOAD": "$SOURCE_WEB_UPLOAD",
  "APK_PUBLIC_VERIFY": "$APK_PUBLIC_VERIFY",
  "SOURCE_PUBLIC_VERIFY": "$SOURCE_PUBLIC_VERIFY",
  "APK_PUBLIC_URL": "${PUBLIC_BASE}/${APK_NAME}",
  "SOURCE_PUBLIC_URL": "${PUBLIC_BASE}/${SRC_NAME}",
  "DEPLOYED_BUILD_TIME": "$BUILD_TIME",
  "BITHUMB_BRAIN_AFTER_DEPLOY": "$BITHUMB_STATUS",
  "UPBIT_BRAIN_AFTER_DEPLOY": "$UPBIT_STATUS",
  "VERSION_NAME": "$VERSION_NAME",
  "VERSION_CODE": "$VERSION_CODE",
}
json.dump(report, open("$REPORT_JSON","w"), indent=2)
print(json.dumps(report, indent=2))
PY

echo "FINAL_DEPLOY_SUCCESS"

===== END FILE: tools/deploy_final_apk_website.sh =====

===== FILE: tools/generate_source_review.sh =====
#!/usr/bin/env bash
# Generate bithumb-all-in-one-source-review.txt from CURRENT workspace sources.
# Secrets (.env, keystore, credentials, live token values) are excluded / redacted.
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${1:-$ROOT/build/bithumb-all-in-one-source-review.txt}"
VERSION_NAME="$(rg -o 'versionName\s*=\s*"([^"]+)"' -r '$1' "$ROOT/app/build.gradle.kts" | head -1)"
VERSION_CODE="$(rg -o 'versionCode\s*=\s*([0-9]+)' -r '$1' "$ROOT/app/build.gradle.kts" | head -1)"
GIT_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo UNKNOWN)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
APK_SHA="${APK_SHA256:-}"

mkdir -p "$(dirname "$OUT")"
TMP="$(mktemp)"

{
  echo "BITHUMB ALL-IN-ONE SOURCE REVIEW v${VERSION_NAME} (versionCode ${VERSION_CODE})"
  echo "Generated: ${BUILD_TIME}"
  echo "Git: ${GIT_SHA}"
  echo "APK SHA-256: ${APK_SHA:-PENDING}"
  echo "Scope: Android + Server AI Brain (Bithumb/Upbit) + Trading/Risk/AI/Execution + Gradle/config + CHECKPOINT"
  echo "Secrets policy: .env / keystore / live credentials EXCLUDED; token-like literals REDACTED"
  echo "================================================================================"
  echo
} >"$TMP"

# Paths to include (relative to ROOT). Explicit allow-list — never dump whole home/.ssh.
INCLUDE_GLOBS=(
  "CHECKPOINT.md"
  "README.md"
  "build.gradle.kts"
  "settings.gradle.kts"
  "gradle.properties"
  "app/build.gradle.kts"
  "download/index.html"
  "app/src/main"
  "app/src/test"
  "server/ai-brain/app"
  "server/ai-brain/tests"
  "server/ai-brain/scripts"
  "server/ai-brain/deploy"
  "server/ai-brain/README.md"
  "server/ai-brain/requirements.txt"
  "tools"
  "ota"
)

# File extensions / names allowed
is_allowed_file() {
  local f="$1" base
  base="$(basename "$f")"
  case "$base" in
    .env|.env.*|*.keystore|*.jks|*credentials*|id_rsa*|*.pem|local.properties)
      return 1
      ;;
  esac
  case "$f" in
    *.kt|*.kts|*.java|*.py|*.md|*.txt|*.html|*.json|*.xml|*.properties|*.service|*.gradle*|gradlew|gradlew.bat)
      return 0
      ;;
    *)
      # allow extensionless shell scripts under tools/
      if [[ "$f" == tools/* ]] || [[ "$f" == *.sh ]]; then
        return 0
      fi
      return 1
      ;;
  esac
}

redact() {
  # Redact obvious secret assignments / headers without dropping structure.
  sed -E \
    -e 's/(Authorization[[:space:]]*:[[:space:]]*Bearer)[[:space:]]+.*/\1 ***REDACTED***/Ig' \
    -e 's/(X-Api-Token[[:space:]]*:[[:space:]]*).*/\1***REDACTED***/Ig' \
    -e 's/((api[_-]?key|secret[_-]?key|access[_-]?key|private[_-]?key|password|passwd|token|jwt)[[:space:]]*[=:][[:space:]]*)[^[:space:]"]+/\1***REDACTED***/Ig' \
    -e 's/("?(apiKey|secretKey|accessKey|privateKey|password|token|jwt)"?[[:space:]]*:[[:space:]]*")[^"]+"/\1***REDACTED***/Ig' \
    -e 's/(SEEDED_TRADING_AI_TOKEN[[:space:]]*=[[:space:]]*")[^"]*"/\1"/g' \
    -e 's/(TRADING_AI_TOKEN|BITHUMB_TRADING_API_TOKEN|SSHPASS)[=:][^[:space:]]+/\1=***REDACTED***/g'
}

append_file() {
  local rel="$1"
  local abs="$ROOT/$rel"
  [[ -f "$abs" ]] || return 0
  is_allowed_file "$rel" || return 0
  {
    echo
    echo "===== FILE: $rel ====="
    redact <"$abs"
    echo
    echo "===== END FILE: $rel ====="
  } >>"$TMP"
}

collect_dir() {
  local dir="$1"
  [[ -d "$ROOT/$dir" ]] || return 0
  while IFS= read -r -d '' f; do
    local rel="${f#$ROOT/}"
    append_file "$rel"
  done < <(find "$ROOT/$dir" -type f -print0 | sort -z)
}

for item in "${INCLUDE_GLOBS[@]}"; do
  if [[ -f "$ROOT/$item" ]]; then
    append_file "$item"
  elif [[ -d "$ROOT/$item" ]]; then
    collect_dir "$item"
  fi
done

# Secret scan on generated review (fail closed) — only high-confidence leaks.
if rg -n --ignore-case \
  -e 'BEGIN (RSA |OPENSSH )?PRIVATE KEY' \
  -e 'Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._+\/=-]{20,}' \
  "$TMP" | rg -v '\*\*\*REDACTED\*\*\*' >/tmp/source-review-secret-hits.txt
then
  echo "SECRET_SCAN_FAILED: potential secrets in source review:" >&2
  head -50 /tmp/source-review-secret-hits.txt >&2
  rm -f "$TMP"
  exit 3
fi

mv -f "$TMP" "$OUT"
echo "SOURCE_REVIEW_OK path=$OUT bytes=$(wc -c <"$OUT") sha256=$(sha256sum "$OUT" | awk '{print $1}')"

===== END FILE: tools/generate_source_review.sh =====

===== FILE: tools/paper_loss_reconstruct.py =====
#!/usr/bin/env python3
"""Offline PAPER loss reconstruction from live sqlite (no secrets)."""
from __future__ import annotations

import json
import sqlite3
import sys
from collections import Counter, defaultdict
from pathlib import Path
from statistics import mean


def analyze(db: Path, exchange: str) -> dict:
    if not db.exists():
        return {"exchange": exchange, "status": "DATA_NOT_AVAILABLE", "path": str(db)}
    c = sqlite3.connect(db)
    c.row_factory = sqlite3.Row
    meta = {r[0]: r[1] for r in c.execute("select key, value from paper_meta")}
    initial = float(meta.get("initial_cash") or 0)
    cash = float(meta.get("cash") or 0)
    realized_meta = float(meta.get("realized_pnl") or 0)
    rows = list(c.execute(
        "select id,time_ms,market,side,amount,quantity,avg_price,fee,realized_pnl,pnl_rate,reason,decision_id "
        "from paper_trades order by time_ms"
    ))
    pos = list(c.execute("select market,quantity,avg_price from paper_positions where quantity>0"))
    coin_at_avg = sum(float(p["quantity"]) * float(p["avg_price"]) for p in pos)
    equity_at_avg = cash + coin_at_avg

    books: dict[str, list] = defaultdict(list)
    rounds = []
    for r in rows:
        if r["side"] == "BUY":
            books[r["market"]].append(r)
        else:
            if not books[r["market"]]:
                continue
            b = books[r["market"]].pop(0)
            buy_amt = float(b["amount"])
            sell_amt = float(r["amount"])
            net = sell_amt - buy_amt
            reason = r["reason"] or ""
            ru = reason.upper()
            rounds.append(
                {
                    "market": r["market"],
                    "net": net,
                    "hold": (int(r["time_ms"]) - int(b["time_ms"])) / 1000.0,
                    "stop": "STOP" in ru and "TRAILING" not in ru,
                    "trail": "TRAILING" in ru,
                    "take": "TAKE" in ru,
                    "buy_fee": float(b["fee"]),
                    "sell_fee": float(r["fee"]),
                    "buy_amt": buy_amt,
                    "legacy": float(r["realized_pnl"]),
                    "buy_t": int(b["time_ms"]),
                    "sell_t": int(r["time_ms"]),
                    "reason": reason,
                }
            )

    econ = sum(x["net"] for x in rounds)
    fees = sum(x["buy_fee"] + x["sell_fee"] for x in rounds)
    stop_loss = sum(x["net"] for x in rounds if x["stop"] and x["net"] < 0)
    trail_loss = sum(x["net"] for x in rounds if x["trail"] and x["net"] < 0)
    take_net = sum(x["net"] for x in rounds if x["take"])
    fee_drag = [x for x in rounds if (x["legacy"] + x["buy_fee"]) > 0 and x["net"] <= 0]

    re_loss = 0.0
    re_n = 0
    chains = []
    by_m: dict[str, list] = defaultdict(list)
    for x in rounds:
        by_m[x["market"]].append(x)
    for m, lst in by_m.items():
        streak = 0
        chain = 0.0
        for x in lst:
            if x["net"] < 0:
                streak += 1
                chain += x["net"]
                if streak >= 2:
                    re_n += 1
                    re_loss += x["net"]
            else:
                if streak >= 2:
                    chains.append((m, streak, chain))
                streak = 0
                chain = 0.0
        if streak >= 2:
            chains.append((m, streak, chain))

    span_h = 0.0
    tph = 0.0
    if len(rounds) >= 2:
        span_h = (rounds[-1]["sell_t"] - rounds[0]["buy_t"]) / 3_600_000.0
        tph = len(rounds) / max(span_h, 0.01)

    short = [x for x in rounds if x["hold"] < 180 and x["net"] < 0]
    losses = [x for x in rounds if x["net"] <= 0]
    wins = [x for x in rounds if x["net"] > 0]

    # Cause KRW attribution (primary, exclusive buckets for ranking)
    causes = {
        "STOP_TOO_EARLY_OR_BAD_ENTRY": stop_loss,  # cannot split without MFE post-hoc
        "TRAILING_EXIT_LOSS": trail_loss,
        "REENTRY_CHAIN": re_loss,
        "TRADING_COST_FEES": -fees,
        "FEE_DRAG": sum(x["net"] for x in fee_drag),
        "SHORT_HOLD_LOSS": sum(x["net"] for x in short),
        "TAKE_PROFIT_NET": take_net,
    }
    top = sorted(((k, v) for k, v in causes.items() if v < 0), key=lambda kv: kv[1])[:5]

    accounting_meta_diff = (initial + realized_meta) - equity_at_avg
    accounting_econ_diff = (initial + econ) - equity_at_avg  # open positions at avg cost ≈ 0 residual

    return {
        "exchange": exchange,
        "status": "OK",
        "INITIAL_EQUITY": initial,
        "CURRENT_CASH": cash,
        "OPEN_POSITION_VALUE_AT_AVG": coin_at_avg,
        "OPEN_POSITION_COUNT": len(pos),
        "CURRENT_EQUITY_AT_AVG": equity_at_avg,
        "REALIZED_PNL_META": realized_meta,
        "ECONOMIC_REALIZED_PNL": econ,
        "UNREALIZED_PNL_AT_AVG": 0.0,
        "DRAWDOWN_KRW": equity_at_avg - initial,
        "DRAWDOWN_PERCENT": ((equity_at_avg / initial) - 1.0) * 100.0 if initial else 0.0,
        "FEES": fees,
        "TRADING_COST_TOTAL": fees,
        "TRADE_COUNT_FILLS": len(rows),
        "ROUND_TRIPS": len(rounds),
        "LOSS_COUNT": len(losses),
        "WIN_COUNT": len(wins),
        "TRADES_PER_HOUR": tph,
        "AVG_HOLD_SECONDS": mean([x["hold"] for x in rounds]) if rounds else 0.0,
        "AVG_BUY_AMT": mean([x["buy_amt"] for x in rounds]) if rounds else 0.0,
        "MAX_BUY_AMT": max([x["buy_amt"] for x in rounds]) if rounds else 0.0,
        "REENTRY_CHAIN_COUNT": re_n,
        "REENTRY_CHAIN_LOSS": re_loss,
        "TOP_CHAINS": [{"market": m, "streak": s, "loss": round(l, 2)} for m, s, l in sorted(chains, key=lambda x: x[2])[:8]],
        "STOP_LOSS_KRW": stop_loss,
        "TRAILING_LOSS_KRW": trail_loss,
        "TAKE_PROFIT_NET": take_net,
        "FEE_DRAG_COUNT": len(fee_drag),
        "SHORT_HOLD_LOSS_COUNT": len(short),
        "SHORT_HOLD_LOSS_KRW": sum(x["net"] for x in short),
        "TOP_LOSS_CAUSES": [{"cause": k, "krw": round(v, 2)} for k, v in top],
        "ACCOUNTING_MISMATCH_META": accounting_meta_diff,
        "ACCOUNTING_MISMATCH_ECONOMIC": accounting_econ_diff,
        "ACCOUNTING_NOTE": "META realized excludes buy fees; ECONOMIC = sellNet-buyCash aligns with cash+positions",
        "WORST10": [
            {"market": x["market"], "net": round(x["net"], 1), "hold_s": round(x["hold"], 0), "reason": x["reason"][:40]}
            for x in sorted(rounds, key=lambda z: z["net"])[:10]
        ],
    }


def main() -> int:
    targets = [
        ("BITHUMB", Path("/var/lib/bithumb-ai-brain/paper_trading.sqlite3")),
        ("UPBIT", Path("/var/lib/bithumb-ai-brain/paper_upbit.sqlite3")),
    ]
    if len(sys.argv) > 1:
        targets = [("LOCAL", Path(sys.argv[1]))]
    out = [analyze(p, ex) for ex, p in targets]
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

===== END FILE: tools/paper_loss_reconstruct.py =====

===== FILE: tools/patch_prod_recent_trades.py =====
#!/usr/bin/env python3
"""Minimal Hetzner patch: embed recentTrades into paper.state() without replacing whole engine."""
from __future__ import annotations

from pathlib import Path
import time

PATH = Path("/opt/bithumb-ai-brain/app/paper_engine.py")
MAIN = Path("/opt/bithumb-ai-brain/app/main.py")


def patch_paper_engine() -> str:
    text = PATH.read_text(encoding="utf-8")
    if "\"recentTrades\": recent_trades" in text:
        return "ALREADY_PATCHED"
    needle = "            total = cash + coin_value\n            return {"
    if text.count(needle) != 1:
        raise SystemExit(f"NEEDLE_COUNT={text.count(needle)}")
    insert = (
        "            total = cash + coin_value\n"
        "            recent_trades = [\n"
        "                {\n"
        "                    \"id\": r[\"id\"],\n"
        "                    \"time\": int(r[\"time_ms\"]),\n"
        "                    \"market\": r[\"market\"],\n"
        "                    \"side\": r[\"side\"],\n"
        "                    \"amount\": float(r[\"amount\"]),\n"
        "                    \"quantity\": float(r[\"quantity\"]),\n"
        "                    \"avgPrice\": float(r[\"avg_price\"]),\n"
        "                    \"fee\": float(r[\"fee\"]),\n"
        "                    \"realizedPnl\": float(r[\"realized_pnl\"]),\n"
        "                    \"pnlRate\": float(r[\"pnl_rate\"]),\n"
        "                    \"reason\": r[\"reason\"],\n"
        "                    \"decisionId\": r[\"decision_id\"],\n"
        "                }\n"
        "                for r in conn.execute(\n"
        "                    \"SELECT id, time_ms, market, side, amount, quantity, avg_price, fee, realized_pnl, pnl_rate, reason, decision_id \"\n"
        "                    \"FROM paper_trades ORDER BY time_ms DESC LIMIT 100\"\n"
        "                ).fetchall()\n"
        "            ]\n"
        "            return {"
    )
    text2 = text.replace(needle, insert, 1)
    old_ret = (
        "                \"positionCount\": len(pos_out),\n"
        "                \"positions\": pos_out,\n"
        "                \"updatedAt\":"
    )
    new_ret = (
        "                \"positionCount\": len(pos_out),\n"
        "                \"positions\": pos_out,\n"
        "                \"recentTrades\": recent_trades,\n"
        "                \"tradeCount\": len(recent_trades),\n"
        "                \"updatedAt\":"
    )
    if old_ret not in text2:
        raise SystemExit("RETURN_NEEDLE_NOT_FOUND")
    text2 = text2.replace(old_ret, new_ret, 1)
    text2 = text2.replace("def trades(self, limit: int = 50)", "def trades(self, limit: int = 100)", 1)
    bak = PATH.with_name(f"paper_engine.py.bak-trades-{int(time.time())}")
    bak.write_text(PATH.read_text(encoding="utf-8"), encoding="utf-8")
    PATH.write_text(text2, encoding="utf-8")
    return f"PATCHED backup={bak}"


def patch_main() -> str:
    text = MAIN.read_text(encoding="utf-8")
    old = "async def paper_trades(limit: int = 50)"
    new = "async def paper_trades(limit: int = 100)"
    if old not in text:
        return "MAIN_SKIP"
    bak = MAIN.with_name("main.py.bak-trades")
    if not bak.exists():
        bak.write_text(text, encoding="utf-8")
    MAIN.write_text(text.replace(old, new, 1), encoding="utf-8")
    return "MAIN_PATCHED"


if __name__ == "__main__":
    print(patch_paper_engine())
    print(patch_main())

===== END FILE: tools/patch_prod_recent_trades.py =====

===== FILE: tools/train_ai_model.py =====
#!/usr/bin/env python3
"""Train a small on-device classifier from Bithumb public 5-minute candles.

The output is a model artifact consumed by the Android app. This is intentionally
an auditable baseline, not a claim of profitable trading performance.
"""

from __future__ import annotations

import argparse
import json
import math
import random
import statistics
import urllib.parse
import urllib.request
from pathlib import Path


FEATURE_NAMES = [
    "ema5_gap",
    "ema20_gap",
    "ema60_gap",
    "rsi",
    "macd_gap",
    "volume_ratio",
    "momentum_3",
    "volatility_20",
]


def fetch_candles(market: str, count: int = 200) -> list[dict]:
    query = urllib.parse.urlencode({"market": market, "count": count})
    request = urllib.request.Request(
        f"https://api.bithumb.com/v1/candles/minutes/5?{query}",
        headers={"Accept": "application/json", "User-Agent": "BithumbAllInOneTrainer/1.0"},
    )
    with urllib.request.urlopen(request, timeout=20) as response:
        return json.loads(response.read().decode("utf-8"))


def ema(values: list[float], period: int) -> float:
    k = 2.0 / (period + 1)
    result = values[0]
    for value in values[1:]:
        result = value * k + result * (1.0 - k)
    return result


def rsi(values: list[float], period: int = 14) -> float:
    changes = [b - a for a, b in zip(values[-period - 1 : -1], values[-period:])]
    gains = sum(max(change, 0.0) for change in changes)
    losses = sum(max(-change, 0.0) for change in changes)
    if losses == 0.0:
        return 1.0
    return (100.0 - 100.0 / (1.0 + gains / losses)) / 100.0


def features(candles: list[dict], end: int) -> list[float] | None:
    window = candles[: end + 1]
    closes = [float(c["trade_price"]) for c in reversed(window)]
    volumes = [float(c["candle_acc_trade_volume"]) for c in reversed(window)]
    if len(closes) < 61 or closes[-1] <= 0:
        return None
    price = closes[-1]
    avg_previous_volume = statistics.fmean(volumes[-21:-1]) if any(volumes[-21:-1]) else 0.0
    volume_ratio = volumes[-1] / avg_previous_volume if avg_previous_volume > 0 else 0.0
    returns = [
        (b / a) - 1.0
        for a, b in zip(closes[-21:-1], closes[-20:])
        if a > 0 and math.isfinite(a) and math.isfinite(b)
    ]
    volatility = statistics.pstdev(returns) if len(returns) > 1 else 0.0
    return [
        ema(closes[-5:], 5) / price - 1.0,
        ema(closes[-20:], 20) / price - 1.0,
        ema(closes[-60:], 60) / price - 1.0,
        rsi(closes),
        (ema(closes[-12:], 12) - ema(closes[-26:], 26)) / price,
        math.log1p(max(volume_ratio, 0.0)),
        (price / closes[-4] - 1.0) if closes[-4] > 0 else 0.0,
        volatility,
    ]


def sigmoid(value: float) -> float:
    value = max(-30.0, min(30.0, value))
    return 1.0 / (1.0 + math.exp(-value))


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--markets", default="KRW-BTC,KRW-ETH,KRW-XRP,KRW-SOL,KRW-DOGE")
    parser.add_argument("--count", type=int, default=200)
    parser.add_argument("--output", type=Path, default=Path("app/src/main/assets/ai_model.json"))
    args = parser.parse_args()

    rows: list[tuple[list[float], float]] = []
    for market in [item.strip() for item in args.markets.split(",") if item.strip()]:
        candles = fetch_candles(market, args.count)
        candles = [c for c in candles if float(c.get("trade_price", 0.0)) > 0]
        candles.reverse()
        for end in range(60, len(candles) - 4):
            vector = features(candles, end)
            current = float(candles[end]["trade_price"])
            future = float(candles[end + 3]["trade_price"])
            if vector is not None and current > 0 and future > 0:
                rows.append((vector, 1.0 if future / current - 1.0 >= 0.002 else 0.0))

    if len(rows) < 30:
        raise SystemExit(f"not enough training rows: {len(rows)}")
    random.Random(42).shuffle(rows)
    split = max(1, int(len(rows) * 0.8))
    train, validation = rows[:split], rows[split:]
    means = [statistics.fmean(row[0][i] for row in train) for i in range(len(FEATURE_NAMES))]
    scales = [
        max(1e-9, statistics.pstdev(row[0][i] for row in train))
        for i in range(len(FEATURE_NAMES))
    ]
    weights = [0.0] * len(FEATURE_NAMES)
    bias = 0.0
    for _ in range(700):
        gradients = [0.0] * len(weights)
        bias_gradient = 0.0
        for vector, label in train:
            normalized = [(value - means[i]) / scales[i] for i, value in enumerate(vector)]
            error = sigmoid(bias + sum(w * x for w, x in zip(weights, normalized))) - label
            for i, value in enumerate(normalized):
                gradients[i] += error * value
            bias_gradient += error
        rate = 0.08 / len(train)
        weights = [w - rate * g for w, g in zip(weights, gradients)]
        bias -= rate * bias_gradient

    def accuracy(dataset):
        if not dataset:
            return 0.0
        correct = 0
        for vector, label in dataset:
            normalized = [(value - means[i]) / scales[i] for i, value in enumerate(vector)]
            prediction = sigmoid(bias + sum(w * x for w, x in zip(weights, normalized))) >= 0.5
            correct += int(prediction == bool(label))
        return correct / len(dataset)

    artifact = {
        "modelVersion": 1,
        "featureNames": FEATURE_NAMES,
        "means": means,
        "scales": scales,
        "weights": weights,
        "bias": bias,
        "positiveThreshold": 0.55,
        "trainingRows": len(rows),
        "validationAccuracy": accuracy(validation),
        "label": "next_3_candles_return_at_least_0.2_percent",
    }
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n")
    print(json.dumps({"output": str(args.output), "rows": len(rows), "validationAccuracy": accuracy(validation)}, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

===== END FILE: tools/train_ai_model.py =====

===== FILE: ota/bithumb-strategy.json =====
{
  "version": 5,
  "minAppVersionCode": 1,
  "scoreThreshold": 72.0,
  "min24hTradePrice": 500000000.0,
  "maxSpreadPercent": 0.7,
  "maxPositions": 3,
  "maxOrderPercent": 20.0,
  "maxAssetPercentPerCoin": 20.0,
  "minKrwCashPercent": 30.0,
  "stopLossPercent": -2.5,
  "takeProfitPercent": 6.0,
  "trailingStopPercent": 2.5,
  "dailyMaxLossPercent": -5.0,
  "staleTickerMillis": 30000,
  "scalpingExecutionEnabled": true,
  "scalpingMinimumExecutionScore": 60.0,
  "scalpingMinimumShortNetEdgePercent": 0.15,
  "scalpingSafetyMargin": 1.35,
  "scalpingMaximumSpreadPercent": 0.7,
  "scalpingSignalTtlMillis": 60000,
  "scalpingPriceMovedAwayAtrMultiple": 1.5,
  "regimeStrategySetsEnabled": true,
  "regimeStrategySetsPaperApplyEnabled": true,
  "message": "국면별 전략 파라미터 세트 v5"
}

===== END FILE: ota/bithumb-strategy.json =====
