/* global React, STRATEGIES, TICKERS, ORDERS_SEED, AUDIT_SEED, WEBHOOKS_SEED, krw, krwSigned, pct, sigQty, fmtPx */ const { useState } = React; // 포트폴리오 매매 대상 추가/삭제 — 칩 + 입력 박스. function PortfolioEditor({ s }) { const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const portfolio = s.portfolio || []; const submit = async (next) => { setBusy(true); setErr(null); const error = await s.setPortfolio(next); setBusy(false); if (error) setErr(error); else setDraft(''); }; const remove = (sym) => { if (portfolio.length <= 1) { setErr('최소 1종목이 남아 있어야 합니다'); return; } if (!confirm(`${sym.replace('KRW-', '')} 을(를) 포트폴리오에서 제거할까요?`)) return; submit(portfolio.filter(p => p !== sym)); }; const add = () => { const raw = draft.trim().toUpperCase(); if (!raw) return; const normalized = raw.startsWith('KRW-') ? raw : `KRW-${raw}`; if (portfolio.includes(normalized)) { setErr('이미 포트폴리오에 있는 종목입니다'); return; } submit([...portfolio, normalized]); }; return (
포트폴리오 종목
{portfolio.map(sym => { const t = TICKERS.find(x => x.sym === sym); const open = t && (t.qty > 0 || t.slot); return ( {sym.replace('KRW-', '')} ); })} { setDraft(e.target.value); setErr(null); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } }} placeholder="예: BTC 또는 KRW-DOGE" disabled={busy} style={{ width: 140 }} /> {err && {err}} {!err && 변경 즉시 반영 · 다음 틱부터 적용}
); } // =============== STRATEGY PAGE =============== const PARAMS_BY_STRAT = { volatility_breakout: [{ k: 'K', v: '0.5', hint: 'breakout factor (0.3 ~ 0.7)' }, { k: 'lookback', v: '1', hint: 'days' }], sma200_regime: [{ k: 'MA period', v: '200' }, { k: 'sell on break', v: 'true' }], atr_channel_breakout: [{ k: 'ATR period', v: '14' }, { k: 'mult', v: '2.0' }], ema_adx_atr_trend: [{ k: 'EMA fast', v: '12' }, { k: 'EMA slow', v: '26' }, { k: 'ADX min', v: '22' }], ad_turtle: [{ k: 'donchian high', v: '20' }, { k: 'donchian low', v: '10' }], sma200_ema_adx_composite: [{ k: 'SMA filter', v: '200' }, { k: 'EMA fast', v: '12' }, { k: 'EMA slow', v: '26' }, { k: 'ADX min', v: '22' }], }; function StrategyPage({ s }) { const sel = STRATEGIES.find(x => x.id === s.activeStrat); const params = PARAMS_BY_STRAT[s.activeStrat] || []; return (

전략 · 선택 & 파라미터

6개 전략 중 1개 선택 · 변경은 다음 틱부터 즉시 반영 (재시작 불필요)
{STRATEGIES.map(st => (
s.setActiveStrat(st.id)}> {st.recommended && 권장}
{st.code} · {st.sub}
{st.name}
{st.desc}
손익 30일
= 0 ? 'up' : 'down'}`}>{pct(st.pnl30d, 1)}
샤프
{st.sharpe.toFixed(2)}
승률
{st.winRate}%
))}

파라미터 · {sel.code}

{sel.sub}
{params.map(p => (
{p.k}
{p.hint || ''}
))}
관찰 주기
분 (텔레그램 리포트)

Walk-Forward 검증

최근 실행 · 6시간 전
안정성
통과 σ(IS) / σ(OOS) = 0.84
전략 알파
통과 +1.8% vs equal_weight
증분 알파
한계 +0.4% vs btc_only
IS CAGR
+18.4%
OOS CAGR
+12.1%
최대 낙폭
−8.7%
권고
페이퍼 OK 실거래: 30일 페이퍼 검증 대기
); } // =============== POSITIONS PAGE (full table + per-symbol detail stub) =============== function PositionsPage({ s }) { return (

포지션 & 관심 종목

진입 · 현재가 · 손익 · 손절 · 종목별 전략별
{s.tickers.length === 0 && ( )} {s.tickers.map(t => { const open = (t.qty || 0) > 0; const stale = !!t.price_stale; const upnl = open && !stale ? (t.price - t.entry) * t.qty : 0; const upnlPct = open && !stale && t.entry > 0 ? ((t.price - t.entry) / t.entry) * 100 : 0; const stratCode = STRATEGIES.find(x => x.id === t.strat)?.code; return ( ); })}
종목 이름 슬롯 전략 WF 현재가 수량 진입가 손익 수익률 평가액
표시할 종목 없음
{t.sym} {t.name} {t.slot ? `#${t.slot}` : } {stratCode || } = 1 ? 'up' : 'muted'}> {t.wf != null ? Number(t.wf).toFixed(2) : '—'} {Math.round(Number(t.price || 0)).toLocaleString()}{stale ? ' ·' : ''} {open ? sigQty(t.qty) : } {open ? fmtPx(t.entry) : } = 0 ? 'up' : 'down') : 'muted'}> {open && !stale ? krwSigned(Math.round(upnl)) : '—'} = 0 ? 'up' : 'down') : 'muted'}> {open && !stale ? pct(upnlPct) : '—'} {open ? krw(Math.round(t.qty * t.price)) : }
); } // =============== ORDERS PAGE =============== function OrdersPage({ s }) { return (

주문 · 24시간 스트림

체결 {s.orders.length}건 · 오류 0 · UUID 멱등
{s.orders.length === 0 && ( )} {s.orders.map((o, i) => ( ))}
시간방향종목전략가격수량금액상태UUID
주문 내역 없음
{o.ts} {o.side === 'BUY' ? '매수' : '매도'} {o.sym} {o.strat || } {fmtPx(o.px)} {sigQty(o.qty)} {krw(Math.round(o.krw || 0))} {o.status} {(o.uuid || '').slice(0, 13) || '—'}
); } // =============== WEBHOOKS PAGE =============== function WebhooksPage({ s }) { const hooks = s.webhooks || []; const latest = hooks[0]; const statusClass = (status) => { const v = String(status || '').toLowerCase(); if (v === 'accepted' || v === 'executed') return 'up'; if (v === 'duplicate') return 'accent'; if (v === 'failed' || v === 'rejected') return 'down'; return 'warn'; }; return (

TradingView 웹훅

최근 수신 {hooks.length}건 · secret/allowlist/stale/idempotency 통과 이벤트만 표시 · 현재 단계는 주문 미연결
마지막 수신
{latest ? latest.ts : '—'}
{latest ? `${latest.market} · ${latest.action}` : '아직 수신 없음'}
최근 중복 감지
{hooks.filter(h => Number(h.seen_count || 1) > 1).length}
같은 event_id 재수신 건수
실행 연결
OFF
Phase 1: 수신/저장만
{hooks.length === 0 && ( )} {hooks.map((h, i) => ( ))}
수신 상태 액션 마켓 TF 가격 Seen Event ID
웹훅 수신 내역 없음
{h.ts || '—'} {h.status || '—'} {h.action || '—'} {h.market || '—'} {h.timeframe || '—'} {h.price != null ? fmtPx(h.price) : } 1 ? 'up' : 'muted'}>{h.seen_count || 1} {h.event_id || '—'}
); } // =============== RISK PAGE =============== function RiskPage({ s }) { const r = s.risk || {}; const rows = [ { k: 'max_position_ratio', v: (r.max_position_ratio ?? 0).toFixed(2), hint: '1슬롯 투입 = 총자본 × 비율' }, { k: 'max_concurrent_positions', v: String(r.max_concurrent_positions ?? '—'), hint: '동시 보유 상한' }, { k: 'daily_loss_limit_pct', v: (r.daily_loss_limit_pct ?? 0).toFixed(2), hint: '포트폴리오 합산 기준 (%)' }, { k: 'per_position_stop_pct', v: (r.stop_loss_pct ?? 0).toFixed(2), hint: '진입가 기준 종목별 손절 (%)' }, { k: 'min_order_krw', v: krw(r.min_order_krw ?? 0), hint: '업비트 floor' }, { k: 'paper_capital', v: krw(r.paper_initial_krw ?? 0), hint: '가상 자본 (paper 기준선)' }, { k: 'kill_switch', v: s.killSwitch ? 'ON' : 'OFF', hint: '신규 진입 차단 (청산은 정상)' }, ]; return (

리스크 설정

변경 시 다음 틱부터 즉시 반영 · 감사 로그 기록
{rows.map(r => (
{r.k}
{r.k === 'kill_switch' ? ( ) : ( )} {r.hint}
))}

오늘 리스크 사용량

실시간 집계
일일 손실 소진
60 ? 'down' : 'accent')}>{Math.round(s.lossUsedPct)}%
한도 {krw(Math.abs(Math.round(s.dailyLossLimit)))} ({pct(-Math.abs(s.dailyLossLimitPct), 1)})
운용 자본
{krw(Math.round(s.positionValue))}
전체 자산의 {s.totalEquity > 0 ? Math.round(s.positionValue / s.totalEquity * 100) : 0}%
슬롯 사용률
s.slotsMax ? 'down' : 'accent')}>{s.slotsUsed}/{s.slotsMax}
{s.slotsMax > 0 ? Math.round((s.slotsUsed / s.slotsMax) * 100) : 0}% {s.slotsUsed > s.slotsMax && ' · 상한 초과'}
); } // =============== AUDIT PAGE =============== function AuditPage({ s }) { const log = s.audit || []; return (

감사 로그

설정 변경 이력 · API 키 자동 마스킹
{log.length === 0 && ( )} {log.map((a, i) => ( ))}
시간종류메시지
기록 없음
{a.ts} {a.kind} {a.msg}
); } // =============== SETTINGS PAGE =============== function SettingsPage({ s }) { const settingsLinks = [ { title: 'API 키 / Telegram', desc: 'Upbit Access·Secret 저장, 연결 테스트, 보유 코인 조회, Telegram 테스트 메시지 전송', href: '/settings/api-keys', primary: true, tags: ['마스킹', 'Fernet 암호화', '보유 코인'], }, { title: '전략 설정', desc: '전략 이름과 파라미터를 기존 검증 규칙으로 편집', href: '/settings/strategy', tags: ['런타임 반영'], }, { title: '리스크 설정', desc: '일일 손실 한도, 슬롯, 포지션 한도를 보수적으로 조정', href: '/settings/risk', tags: ['킬스위치', '한도'], }, { title: '포트폴리오 설정', desc: '거래 대상 종목 목록을 DB 설정 기준으로 관리', href: '/settings/portfolio', tags: ['KRW 마켓', '종목 목록'], }, { title: '스케줄 설정', desc: 'paper/live 모드와 자동 실행 스케줄을 관리', href: '/settings/schedule', tags: ['paper/live', '스케줄'], }, { title: '감사 로그', desc: '설정 변경 이력과 마스킹된 운영 이벤트 확인', href: '/settings/audit', tags: ['AuditLog', '마스킹'], }, ]; return (

설정 · 연동

터미널에서 누락됐던 기존 설정 화면으로 바로 이동 · 민감값은 기존 Jinja 라우터의 마스킹/암호화 계약을 그대로 사용
{settingsLinks.map(link => ( {link.primary && 필수}
설정
{link.title}
{link.desc}
{link.tags.map(tag => {tag})}
))}
현재 상태
{s.mode === 'live' ? '실거래 모드' : '페이퍼 모드'} · {s.running ? '스케줄 실행중' : '스케줄 정지'} {s.killSwitch ? ' · 킬스위치 ON' : ''}
API 키 저장 화면은 빈 입력 시 기존 값을 유지하고, 저장값이 없으면 .env 값을 fallback으로 사용합니다.
); } Object.assign(window, { StrategyPage, PositionsPage, OrdersPage, RiskPage, AuditPage, SettingsPage });