// pages.jsx — 모든 페이지 컴포넌트 // Cycles · Portfolio · Orders · Problems · Schedule · News · Memory · Backtest · System const { useState: useP, useMemo: useM, useEffect: useE } = React; /* ════════════════════════════════════════════════════════ 페이지 헤더 (공통) ════════════════════════════════════════════════════════ */ function PageHead({ title, sub, actions, right }) { return (

{title}

{sub &&
{sub}
}
{(actions || right) &&
{actions}{right}
}
); } /* ════════════════════════════════════════════════════════ 사이클 페이지 ════════════════════════════════════════════════════════ */ function parseCycleSummary(lastMessage) { if (!lastMessage) return null; // last_message is the Codex final assistant message. By convention it's JSON. // It may be truncated with "…" suffix; try parsing the trimmed version. let txt = lastMessage.trim(); if (txt.endsWith('…')) txt = txt.slice(0, -1); try { return JSON.parse(txt); } catch (_) {} // Try extracting first {...} block const m = txt.match(/\{[\s\S]*\}/); if (m) { try { return JSON.parse(m[0]); } catch (_) {} } return null; } function decisionCounts(decisions) { const c = { BUY: 0, SELL: 0, HOLD: 0, UNCERTAIN: 0 }; for (const d of decisions || []) { const a = (d.action || '').toUpperCase(); if (c[a] != null) c[a]++; } return c; } function adaptCycleEvents(events, baseUtcIso) { const base = baseUtcIso ? new Date(baseUtcIso) : new Date(); const out = []; let i = 0; for (const e of events || []) { i++; const t = new Date(+base + i * 800); if (e.kind === 'message') { out.push({ id: e.id || 'm' + i, kind: 'message', t, summary: (e.text || '').slice(0, 240) }); } else if (e.kind === 'tool') { let result = e.output_preview; try { result = JSON.parse(e.output_preview); } catch (_) {} const argsPreview = e.arguments ? Object.entries(e.arguments).slice(0, 3).map(([k, v]) => `${k}=${typeof v === 'object' ? '…' : String(v).slice(0, 24)}`).join(', ') : ''; out.push({ id: e.id || 't' + i, kind: 'tool', t, tool: e.tool || e.server || 'tool', args: e.arguments || {}, result, summary: argsPreview ? `(${argsPreview})` : (e.status || ''), }); } else if (e.kind === 'cmd') { out.push({ id: e.id || 'c' + i, kind: 'cmd', t, cmd: e.command || '', output: e.output_preview || '', summary: (e.command || '').split('\n')[0].slice(0, 80), }); } else if (e.kind === 'web_search') { out.push({ id: e.id || 'w' + i, kind: 'tool', t, tool: 'web_search', args: { query: e.query }, result: null, summary: `“${(e.query || '').slice(0, 80)}”`, }); } else if (e.kind === 'file_change') { out.push({ id: e.id || 'f' + i, kind: 'cmd', t, cmd: 'file_change', output: JSON.stringify(e.changes || [], null, 2), summary: 'file_change: ' + (e.changes?.[0]?.path || ''), }); } // skip thread_started / turn_started / turn_completed for display } return out; } function CyclesPage() { const cyclesQ = useApi(() => API.cycles.list({ limit: 30 }), [], { refreshMs: 60000 }); const items = (cyclesQ.data && cyclesQ.data.items) || []; const [selectedId, setSelected] = useP(null); useE(() => { if (!selectedId && items.length > 0) setSelected(items[0].cycle_id); }, [items, selectedId]); const [filter, setFilter] = useP('all'); const filtered = items.filter(c => filter === 'all' ? true : filter === 'kr' ? c.market === 'KR' : c.market === 'US' || ['NASDAQ', 'NYSE', 'AMEX'].includes(c.market)); const selected = items.find(c => c.cycle_id === selectedId) || items[0]; if (cyclesQ.loading && items.length === 0) { return
로딩 중…
; } if (items.length === 0) { return
아직 사이클 로그가 없음
; } return (
} />
{selected && }
); } function CycleDetail({ item }) { const tl = useApi(() => API.cycles.timeline(item.cycle_id), [item.cycle_id]); const summary = useM(() => parseCycleSummary(item.last_message), [item]); const decisions = summary?.decisions || []; const counts = decisionCounts(decisions); const total = counts.BUY + counts.SELL + counts.HOLD + counts.UNCERTAIN; const events = tl.data?.events || []; const adaptedEvents = useM(() => adaptCycleEvents(events, item.started_at_utc), [events, item]); const toolCounts = useM(() => { const c = {}; for (const e of events) { if (e.kind === 'tool') { const name = (e.server ? e.server + ':' : '') + (e.tool || 'tool'); c[name] = (c[name] || 0) + 1; } else if (e.kind === 'cmd') { c['shell'] = (c['shell'] || 0) + 1; } else if (e.kind === 'web_search') { c['web_search'] = (c['web_search'] || 0) + 1; } } return c; }, [events]); return (

{item.market} 사이클

{summary?.paper && }
{item.started_at_utc ? fmtAbsTime(new Date(item.started_at_utc)) : '—'} · 토큰 in {Math.round((item.usage?.input_tokens || 0) / 1000)}K / out {Math.round((item.usage?.output_tokens || 0) / 1000)}K · {item.cycle_id}
결정 {total}건
매수 {counts.BUY} 매도 {counts.SELL} 관망 {counts.HOLD} {counts.UNCERTAIN > 0 && 검토 {counts.UNCERTAIN}}
{decisions.length > 0 ? (
{decisions.map((d, i) => (
{d.symbol} {d.rationale || d.reason || ''} {d.qty != null && ×{d.qty}}
))}
) : (
결정 JSON을 파싱할 수 없음 (last_message 형식이 다름)
)}
활동 타임라인 /cycle.events · {events.length}
{tl.loading ?
로딩 중…
: tl.error ?
{tl.error.message}
: adaptedEvents.length === 0 ?
표시할 이벤트 없음
: }
도구 호출 빈도 /cycle.tools
{Object.values(toolCounts).reduce((a, b) => a + b, 0)}회
{Object.keys(toolCounts).length === 0 ? (
없음
) : ( )}
사이클 메타
시작{item.started_at_utc ? fmtAbsTime(new Date(item.started_at_utc)) : '—'}
크기{fmtBytes(item.size_bytes)}
토큰 in{((item.usage?.input_tokens || 0) / 1000).toFixed(1)}K
토큰 out{((item.usage?.output_tokens || 0) / 1000).toFixed(1)}K
cached{((item.usage?.cached_input_tokens || 0) / 1000).toFixed(1)}K
reasoning{((item.usage?.reasoning_output_tokens || 0) / 1000).toFixed(1)}K
ID{item.cycle_id}
{summary && (
최종 결정 JSON /cycle.result
)}
); } /* ════════════════════════════════════════════════════════ 포트폴리오 페이지 ════════════════════════════════════════════════════════ */ function PieDonut({ data, size = 200 }) { const total = data.reduce((a, b) => a + b.pct, 0) || 100; const r = size / 2 - 8; const cx_ = size / 2, cy = size / 2; let acc = 0; return ( {data.map((d, i) => { const start = acc / total * 2 * Math.PI - Math.PI/2; acc += d.pct; const end = acc / total * 2 * Math.PI - Math.PI/2; const large = (end - start) > Math.PI ? 1 : 0; const x1 = cx_ + r * Math.cos(start), y1 = cy + r * Math.sin(start); const x2 = cx_ + r * Math.cos(end), y2 = cy + r * Math.sin(end); const path = `M ${cx_},${cy} L ${x1},${y1} A ${r},${r} 0 ${large} 1 ${x2},${y2} Z`; return ; })} ); } function PortfolioPage() { // Fallback only — actual rate comes from /api/fx/rates (Toss-backed, 60s // server cache). Picked deliberately stale-low so any UI showing the // fallback stands out vs the real ~1500 won range. const FX_FALLBACK = 1380; const [market, setMarket] = useP('all'); const [modal, setModal] = useP(null); const summary = useApi(() => API.portfolio.summary(), [], { refreshMs: 60000 }); const positions = useApi(() => API.portfolio.positions(), [], { refreshMs: 60000 }); const goals = useApi(() => API.goals.list(), [], { refreshMs: 60000 }); const fxQ = useApi(() => API.fx.rates(), [], { refreshMs: 300000 }); const _num = (s) => { const v = parseFloat(s); return isNaN(v) ? 0 : v; }; const fxRate = fxQ.data && fxQ.data.ok !== false ? Number(fxQ.data.krw_per_usd) : null; const fxFresh = !!fxRate; const FX = fxRate || FX_FALLBACK; const totals = useM(() => { const s = summary.data || {}; const kr = s.kr || {}, us = s.us || {}; const krEquity = kr.ok === false ? 0 : _num(kr.summary?.total_equity_krw); const krPnl = kr.ok === false ? 0 : _num(kr.summary?.unrealized_pnl_krw); const usEquityUSD = us.ok === false ? 0 : _num(us.summary?.total_equity_usd); const usPnlUSD = us.ok === false ? 0 : _num(us.summary?.unrealized_pnl_usd); return { krEquity, krPnl, usEquityUSD, usPnlUSD, usEquityKRW: usEquityUSD * FX, total: krEquity + usEquityUSD * FX, krOk: kr.ok !== false, usOk: us.ok !== false, }; }, [summary.data, FX]); const holdings = useM(() => { const p = positions.data || {}; const kr = (p.kr && p.kr.ok !== false) ? (p.kr.positions || []) : []; const us = (p.us && p.us.ok !== false) ? (p.us.positions || []) : []; const map = (arr, side) => arr.map(x => ({ sym: x.symbol, name: x.name || x.symbol, market: x.market || (side === 'kr' ? 'KR' : 'NASDAQ'), qty: _num(x.qty), avg: _num(x.avg_price), cur: _num(x.current_price), marketValue: _num(x.market_value), pnl: _num(x.pnl), pct: _num(x.pnl_pct), currency: side === 'kr' ? 'KRW' : 'USD', })); return [...map(kr, 'kr'), ...map(us, 'us')]; }, [positions.data]); const rows = useM(() => holdings.filter(h => market === 'all' ? true : market === 'kr' ? h.market === 'KR' : h.market !== 'KR' ), [holdings, market]); const goalItems = (goals.data && goals.data.items) || []; const goalKr = goalItems.find(g => g.market === 'KR'); const goalUs = goalItems.find(g => g.market === 'US'); return (
{/* Goals — growth target per market (anchor → target, +pct%) */}
성장 목표 /goals · +pct% per market
{goalItems.length}/2 설정
{['KR', 'US'].map((m) => ( { if (!confirm(`${mkt} 시장의 anchor를 현재 자산으로 다시 잡습니다.`)) return; const r = await API.goals.configSet({ market: mkt, reset_anchor: true }); if (!r.ok) { alert('재설정 실패: ' + (r.error?.message || 'unknown')); return; } goals.reload(); }} /> ))}
{/* Composition (simple horizontal bars) */}
자산 구성 /portfolio.allocation · 평가액 비중
s.value > 0)} /> {!totals.krOk && (
KR 데이터 오류 — KIS 토큰 상태 확인
)} {!totals.usOk && (
US 데이터 오류 — KIS 토큰 상태 확인
)}
{/* Equity timeline placeholder */}
평가액 시계열 /portfolio.timeline · 히스토리 적립 중
📈
일별 스냅샷 저장 후 표시
{/* Holdings */}
보유 종목 /portfolio.holdings · {rows.length}
{positions.loading && holdings.length === 0 ? (
로딩 중…
) : rows.length === 0 ? (
보유 종목 없음
) : (
종목 수량 / 평균가 현재가 평가액 손익
{rows.map(h => { const evalNative = h.qty * h.cur; return ( ); })}
)}
{modal && setModal(null)} />}
); } function GoalBlock({ market, item, onReanchor }) { // Shape (goal_preview / goal_config_get): // pct, anchor_equity, target_equity, anchor_set_at_utc, // last_advanced_at_utc, currency, status // Computed (only when /api/goals could fetch live equity): // current_equity, distance_to_target, progress_pct, drawdown_pct, // would_advance const isSet = item && item.status === 'set'; const hasLive = isSet && item.current_equity != null; const currency = item ? item.currency : (market === 'KR' ? 'KRW' : 'USD'); const fmt = currency === 'KRW' ? (v) => fmtKRWFull(Math.round(v)) + '원' : (v) => '$' + Number(v).toFixed(2); const pct = item ? parseFloat(item.pct || '0') : 0; const anchor = item ? parseFloat(item.anchor_equity || '0') : 0; const target = item ? parseFloat(item.target_equity || '0') : 0; const cur = hasLive ? parseFloat(item.current_equity || '0') : anchor; const progress = hasLive && target > 0 ? Math.max(0, Math.min(100, (cur / target) * 100)) : 0; const willAdvance = hasLive && (item.would_advance || progress >= 100); // Single accent color for everything — no defensive-mode coloring anymore. const color = willAdvance ? 'var(--success)' : 'var(--accent)'; return (
{market === 'KR' ? '한국' : '미국'} {isSet && ( +{pct}% )} {isSet ? `→ ${fmt(target)}` : '아직 목표가 설정되지 않음'}
{isSet && onReanchor && ( )}
{isSet ? (
달성률 {hasLive ? progress.toFixed(1) : '—'} %
{fmt(cur)} / {fmt(target)}
{hasLive && parseFloat(item.distance_to_target || '0') > 0 && (
남은 금액 +{fmt(parseFloat(item.distance_to_target))}
)} {willAdvance && (
✓ 다음 사이클에서 목표 갱신 예정
)}
anchor {fmt(anchor)} {item.last_advanced_at_utc && 직전 갱신 {fmtRelativeTime(item.last_advanced_at_utc)}}
) : (
아직 anchor가 없습니다. 다음 시장 마감 Codex 사이클이 자동으로 잡거나, 지금 직접 잡을 수 있습니다.
{onReanchor && ( )}
기본 +10% · KR 5만원 단위 / US $5 단위로 반올림
)}
); } function CompositionBars({ slices }) { if (!slices || slices.length === 0) { return
표시할 자산 없음
; } const total = slices.reduce((s, x) => s + x.value, 0) || 1; return (
{slices.map(s => (
))}
{slices.map(s => (
{s.label} {fmtKRW(s.value)}원 {s.sub && ({s.sub})}
))}
); } function HoldingModal({ holding, onClose }) { const [tab, setTab] = useP('notes'); const isKR = holding.market === 'KR'; const memScope = { scope: 'stock', symbol: holding.sym, market: isKR ? 'KR' : 'US' }; const memory = useApi(() => API.memory.read(memScope), [holding.sym, holding.market]); const news = useApi(() => API.news.stock({ symbol: holding.sym, market: isKR ? 'KR' : 'US', limit: 20 }), [holding.sym, holding.market], { skip: tab !== 'news' }); useE(() => { const h = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); const memBody = memory.data?.body || ''; const memUpd = memory.data?.updated_at_utc; const newsItems = news.data?.items || []; const evalNative = holding.qty * holding.cur; return (
e.stopPropagation()}>

{holding.name}

{holding.sym}
현재가 {isKR ? fmtKRWFull(holding.cur) + '원' : '$' + holding.cur.toFixed(4)}
평균가 {isKR ? fmtKRWFull(holding.avg) + '원' : '$' + holding.avg.toFixed(4)}
평가액 {isKR ? fmtKRW(evalNative) + '원' : '$' + evalNative.toFixed(2)}
손익률
{tab === 'notes' && ( memory.loading ?
로딩 중…
: memory.error ?
{memory.error.message}
: memBody ? (
{(memBody.length / 1024).toFixed(1)}KB · {memUpd ? fmtRelativeTime(memUpd) : '—'}
                    {memBody}
                  
) :
아직 종목 노트 없음 (Codex가 기록하면 표시)
)} {tab === 'news' && ( news.loading ?
로딩 중…
: news.error ?
{news.error.message}
: newsItems.length === 0 ?
관련 뉴스 없음
: (
{newsItems.map(n => (
{n.title}
{n.source} · {fmtRelativeTime(n.published_at_utc)}
))}
) )}
); } /* ════════════════════════════════════════════════════════ 주문 페이지 (3 탭) ════════════════════════════════════════════════════════ */ function StatusPill({ status }) { const k = (status || '').toString().toUpperCase(); const meta = { 'FILLED': { c: 'var(--success)', soft: 'var(--success-soft)', label: '체결' }, 'QUEUED': { c: 'var(--text-2)', soft: 'var(--bg-elev-2)', label: '대기' }, 'SUBMITTED': { c: 'var(--info)', soft: 'var(--info-soft)', label: '제출' }, 'CANCEL_REQUESTED': { c: 'var(--warn)', soft: 'var(--warn-soft)', label: '취소 요청' }, 'CANCELLED': { c: 'var(--text-4)', soft: 'var(--bg-elev-2)', label: '취소됨' }, 'FAILED': { c: 'var(--critical)', soft: 'var(--critical-soft)', label: '실패' }, // legacy mock labels '체결': { c: 'var(--success)', soft: 'var(--success-soft)', label: '체결' }, '대기': { c: 'var(--text-2)', soft: 'var(--bg-elev-2)', label: '대기' }, '처리 중': { c: 'var(--info)', soft: 'var(--info-soft)', label: '처리 중' }, '실패': { c: 'var(--critical)', soft: 'var(--critical-soft)', label: '실패' }, 'PAPER': { c: 'var(--warn)', soft: 'var(--warn-soft)', label: 'PAPER' }, '취소됨': { c: 'var(--text-4)', soft: 'var(--bg-elev-2)', label: '취소됨' }, }[k] || { c: 'var(--text-3)', soft: 'var(--bg-elev-2)', label: status || '—' }; return ( {meta.label} ); } function OrdersPage() { const [tab, setTab] = useP('queued'); const [marketTab, setMarketTab] = useP('KR'); // Intents = lite-queue rows (Codex → worker) const intentsQ = useApi(() => API.orders.intents({ limit: 200 }), [], { refreshMs: 30000 }); const intents = (intentsQ.data && intentsQ.data.items) || []; // Reservations from PyKis side (worker-fired orders) const resvQ = useApi(() => API.orders.reservations(marketTab), [marketTab], { refreshMs: tab === 'submitted' ? 30000 : 0, skip: tab !== 'submitted' }); const groupedByStatus = useM(() => { const g = { QUEUED: [], SUBMITTED: [], FILLED: [], CANCEL_REQUESTED: [], CANCELLED: [], FAILED: [] }; for (const it of intents) { const s = (it.status || '').toUpperCase(); if (g[s]) g[s].push(it); } return g; }, [intents]); const queued = groupedByStatus.QUEUED; const filledHistory = useM(() => intents.filter(i => ['FILLED', 'CANCELLED', 'FAILED'].includes((i.status || '').toUpperCase())), [intents]); return (
{tab === 'queued' && (
{[ { id: 'QUEUED', label: '대기 중', color: 'var(--text-2)' }, { id: 'SUBMITTED', label: '제출됨', color: 'var(--info)' }, { id: 'CANCEL_REQUESTED', label: '취소 요청', color: 'var(--warn)' }, { id: 'FAILED', label: '실패', color: 'var(--critical)' }, ].map(col => { const items = groupedByStatus[col.id] || []; return (
{col.label} {items.length}
{items.length === 0 &&
} {items.map(o => { const target = o.schedule_at_utc ? new Date(o.schedule_at_utc) : null; const overdue = target && +target < Date.now(); const isQueued = col.id === 'QUEUED'; return (
#{o.id}
{o.symbol}
×{o.qty}
{isQueued ? ( <>
{overdue ? '예정 시각 경과' : '실행까지'}
{target ? (overdue ? '대기 중' : (() => { const d = (+target - Date.now()) / 1000; return d < 3600 ? Math.floor(d / 60) + 'm' : Math.floor(d / 3600) + 'h ' + Math.floor((d % 3600) / 60) + 'm'; })()) : '—'}
) : (
{col.id === 'SUBMITTED' ? '제출 완료' : col.id === 'FAILED' ? '실행 실패' : '취소 요청됨'}
)}
{target ? fmtAbsTime(target) : '—'}
); })}
); })}
)} {tab === 'submitted' && (
KIS 미체결 /orders.reservations
{resvQ.loading ?
로딩 중…
: resvQ.error ?
{resvQ.error.message}
: (() => { const submitted = (resvQ.data?.submitted) || []; const queuedAlt = (resvQ.data?.queued) || []; if (submitted.length === 0 && queuedAlt.length === 0) { return
{marketTab} 시장에 미체결 주문 없음
; } return (
시각시장종목구분수량상태참조 ID
{[...queuedAlt, ...submitted].map((o, idx) => (
{o.schedule_at_utc ? fmtAbsTime(new Date(o.schedule_at_utc)).slice(5, 16) : '—'} {o.instrument?.symbol || '—'} {o.qty} {o.reservation_id || '—'}
))}
); })() }
)} {tab === 'history' && (
실행 이력 최근 {filledHistory.length}건
{filledHistory.length === 0 ? (
기록 없음
) : (
시각시장종목구분수량상태참조 ID
{filledHistory.map(o => (
{o.updated_at_utc ? fmtAbsTime(new Date(o.updated_at_utc)).slice(5, 16) : '—'} {o.symbol} {o.qty} #{o.id}
))}
)}
)}
); } /* ════════════════════════════════════════════════════════ 문제 로그 페이지 ════════════════════════════════════════════════════════ */ function parseProblemBody(body) { if (!body) return { message: '', context: null }; // body shape from PROJECT_PROBLEMS.md: // **Message**: // // **Context**: // // --- const messageMatch = body.match(/\*\*Message\*\*:\s*([\s\S]*?)(?:\n\s*\n\*\*Context\*\*|\n---|$)/); const message = messageMatch ? messageMatch[1].trim() : body.trim().slice(0, 200); const ctxMatch = body.match(/\*\*Context\*\*:\s*```json\n([\s\S]*?)\n```/); let context = null; if (ctxMatch) { try { context = JSON.parse(ctxMatch[1]); } catch (_) { context = ctxMatch[1]; } } else { const ctxPlain = body.match(/\*\*Context\*\*:\s*([\s\S]*?)(?:\n---|$)/); if (ctxPlain && ctxPlain[1].trim() !== '-') context = ctxPlain[1].trim(); } return { message, context }; } function ProblemsPage() { const [activeSet, setActiveSet] = useP(new Set(['CRITICAL', 'ERROR', 'WARN'])); const [q, setQ] = useP(''); const [openMap, setOpenMap] = useP({}); const problemsQ = useApi(() => API.problems.list({ limit: 200 }), [], { refreshMs: 60000 }); const all = (problemsQ.data && problemsQ.data.items) || []; // Normalize each problem into a stable shape const items = useM(() => all.map((p, idx) => { const parsed = parseProblemBody(p.body); return { id: (p.timestamp_utc || 'p') + ':' + idx, sev: (p.severity || '').toUpperCase(), msg: parsed.message, ctx: parsed.context, source: p.source || 'unknown', t: p.timestamp_utc ? new Date(p.timestamp_utc) : null, }; }), [all]); const counts = useM(() => { const c = { CRITICAL: 0, ERROR: 0, WARN: 0, INFO: 0 }; items.forEach(p => { if (c[p.sev] != null) c[p.sev]++; }); return c; }, [items]); const filtered = items.filter(p => activeSet.has(p.sev) && (q ? ((p.msg || '').toLowerCase().includes(q.toLowerCase()) || (p.source || '').toLowerCase().includes(q.toLowerCase())) : true) ); const toggle = (s) => { const next = new Set(activeSet); if (next.has(s)) next.delete(s); else next.add(s); setActiveSet(next); }; return (
{['CRITICAL', 'ERROR', 'WARN', 'INFO'].map(s => ( ))}
setQ(e.target.value)} />
{items.length === 0 ? (
아직 기록된 문제 없음
) : (
{filtered.map(p => (
{openMap[p.id] && p.ctx != null && (
{typeof p.ctx === 'object' ? :
{p.ctx}
}
)}
))} {filtered.length === 0 &&
조건에 맞는 문제 없음
}
)}
해결한 항목은 PROJECT_PROBLEMS.md를 직접 편집해서 지우세요.
); } /* ════════════════════════════════════════════════════════ 스케줄 페이지 ════════════════════════════════════════════════════════ */ function SchedulePage() { const today = useM(() => new Date(), []); const scheduleQ = useApi(() => API.schedule.read(), [], { refreshMs: 60000 }); const items = (scheduleQ.data && scheduleQ.data.items) || []; const jobs = useM(() => items.map(it => { const kind = scheduleKind(it.label); const next = cronNextRun(it.cron_expression, today); const lastT = it.last_run_utc ? new Date(it.last_run_utc) : null; return { ...it, kind, color: KIND_COLOR[kind] || KIND_COLOR.other, next, lastT }; }), [items, today]); const startOfWeek = new Date(today); startOfWeek.setDate(today.getDate() - today.getDay()); const days = Array.from({ length: 14 }, (_, i) => { const d = new Date(startOfWeek); d.setDate(d.getDate() + i); return d; }); // For week calendar — compute next occurrences within 14 days const jobsByDay = useM(() => { const m = {}; for (const j of jobs) { if (!j.cron_expression || j.cron_expression === '@reboot') continue; // walk forward day-by-day let cursor = new Date(today); for (let i = 0; i < 14; i++) { const next = cronNextRun(j.cron_expression, cursor); if (!next) break; const dayKey = next.toDateString(); if (!m[dayKey]) m[dayKey] = []; if (!m[dayKey].find(x => x.label === j.label)) m[dayKey].push(j); cursor = new Date(+next + 60000); if (+next > +days[days.length - 1] + 86400000) break; } } return m; }, [jobs, today, days]); // 24h dial jobs (today ± 1 day) const dialJobs = useM(() => { const out = []; for (const j of jobs) { if (j.lastT) out.push({ t: j.lastT, label: j.label + ' (완료)', color: j.color, key: j.label + '-last' }); if (j.next) out.push({ t: j.next, label: j.label, color: j.color, key: j.label + '-next' }); } return out; }, [jobs]); const nextJob = jobs.slice().sort((a, b) => (a.next ? +a.next : Infinity) - (b.next ? +b.next : Infinity)).find(j => j.next && j.next > today); return (
24시간 시계 /clock · 오늘
{fmtAbsTime(today).slice(0, 16)}
KR 사이클 US 사이클 KR 워커 US 워커 백테스트
다음 자동 실행
{nextJob && }
{nextJob ? (
{relUntil(nextJob.next, today)}
{nextJob.label}
{fmtAbsTime(nextJob.next)}
cron · {nextJob.cron_expression}
{nextJob.lastT && (
마지막 실행 {fmtRelativeTime(nextJob.lastT.toISOString())}
)}
) : (
예약된 잡 없음
)}
주간 캘린더 이번 주 + 다음 주
{['일', '월', '화', '수', '목', '금', '토'].map(d => (
{d}
))} {days.map(d => { const isToday = d.toDateString() === today.toDateString(); const isPast = d < today && !isToday; const list = jobsByDay[d.toDateString()] || []; return (
{d.getDate()}
{list.slice(0, 4).map((j, i) => )}
); })}
잡 카드 {jobs.length}개
{jobs.length === 0 ?
등록된 잡 없음
: (
{jobs.map(j => (
{j.label} {j.lastT ? '실행 기록 있음' : '미실행'}
크론{j.cron_expression}
다음 실행{j.next ? relUntil(j.next, today) + ' 후' : '—'}
마지막 실행{j.lastT ? fmtRelativeTime(j.lastT.toISOString()) : '—'}
로그 크기{fmtBytes(j.log_bytes)}
))}
)}
); } /* ════════════════════════════════════════════════════════ 뉴스 페이지 ════════════════════════════════════════════════════════ */ function SentimentBadge({ sentiment }) { const meta = { POSITIVE: { label: 'POS', c: 'var(--buy)' }, NEGATIVE: { label: 'NEG', c: 'var(--sell)' }, NEUTRAL: { label: 'NEU', c: 'var(--text-3)' }, NOT_DETECTED: { label: 'N/D', c: 'var(--text-4)' }, SHORT_NEWS: { label: 'SHORT', c: 'var(--info)' }, }[sentiment] || { label: sentiment, c: 'var(--text-3)' }; return ( {meta.label} ); } function sentimentColor(s) { const k = (s || '').toUpperCase(); if (k === 'POSITIVE') return 'var(--buy)'; if (k === 'NEGATIVE') return 'var(--sell)'; if (k === 'NEUTRAL') return 'var(--text-3)'; return 'var(--text-4)'; } function NewsCard({ n, onOpen, readBadge }) { return (
{n.related_stocks && n.related_stocks[0] && } {n.source || n.source_feed || '—'} · {fmtRelativeTime(n.published_at_utc)} {readBadge && Codex 읽음}
{n.title}
{n.summary &&
{n.summary.slice(0, 140)}
}
); } function NewsDetailModal({ newsId, onClose }) { const q = useApi(() => API.news.get(newsId), [newsId]); useE(() => { const h = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.stopPropagation()}>

{q.data?.title || newsId}

{q.data?.source || ''} · {q.data?.published_at_utc ? fmtAbsTime(new Date(q.data.published_at_utc)) : '—'}
{q.loading ?
로딩 중…
: q.error ?
{q.error.message}
: ( <> {q.data?.summary &&

{q.data.summary}

}
                  {q.data?.body || '— 본문 없음'}
                
{q.data?.related_stocks_resolved && q.data.related_stocks_resolved.length > 0 && (
관련 종목
{q.data.related_stocks_resolved.map(s => ( {s.symbol}{s.stock_name ? ` · ${s.stock_name}` : ''} ))}
)} )}
); } function NewsPage() { const [tab, setTab] = useP('market'); const [readKind, setReadKind] = useP('opened'); const [q, setQ] = useP(''); const [debouncedQ, setDebouncedQ] = useP(''); const [openId, setOpenId] = useP(null); useE(() => { const id = setTimeout(() => setDebouncedQ(q), 300); return () => clearTimeout(id); }, [q]); const marketQ = useApi(() => API.news.market({ limit: 60 }), [], { refreshMs: 60000, skip: tab !== 'market' }); const readsQ = useApi(() => API.news.reads({ limit: 60, reader: 'codex', kind: readKind }), [readKind], { refreshMs: 60000, skip: tab !== 'codex' }); const searchQ = useApi(() => debouncedQ.trim() ? API.news.search({ query: debouncedQ.trim(), limit: 60 }) : Promise.resolve({ ok: true, data: { ok: true, count: 0, items: [] } }), [debouncedQ], { skip: tab !== 'search' }); const marketItems = (marketQ.data && marketQ.data.items) || []; const rawReads = (readsQ.data && readsQ.data.items) || []; const searchItems = (searchQ.data && searchQ.data.items) || []; // The same headline is re-read every cycle (Codex lists it each run), so // collapse to one card per news_id keeping the most recent read (rows arrive // newest-first from the backend). const reads = useM(() => { const seen = new Set(); const out = []; for (const r of rawReads) { if (seen.has(r.news_id)) continue; seen.add(r.news_id); out.push(r); } return out; }, [rawReads]); // codex-read news_ids for the badge const readSet = useM(() => new Set(reads.map(r => r.news_id)), [reads]); return (
{tab === 'market' && ( marketQ.loading ?
로딩 중…
: marketQ.error ?
{marketQ.error.message}
: marketItems.length === 0 ?
수집된 시장 뉴스 없음
:
{marketItems.map(n => setOpenId(n.news_id)} />)}
)} {tab === 'codex' && (

{readKind === 'opened' ? 'Codex가 news_get으로 본문까지 연 뉴스' : 'Codex가 목록/검색에서 제목·요약만 본 뉴스 (본문 미열람)'}

{readsQ.loading ?
로딩 중…
: reads.length === 0 ?
{readKind === 'opened' ? 'Codex가 본문까지 연 뉴스가 없습니다' : 'Codex가 목록에서 확인한 뉴스가 없습니다'}
:
{reads.map(r => (
setOpenId(r.news_id)}>
{r.source || '—'} · {fmtRelativeTime(r.read_at_utc)} {readKind === 'opened' ? '열람' : '노출'}
{r.title}
))}
}
)} {tab === 'search' && (
setQ(e.target.value)} style={{ marginBottom: 12 }} /> {!debouncedQ.trim() ?
검색어를 입력하세요
: searchQ.loading ?
검색 중…
: searchQ.error ?
{searchQ.error.message}
: searchItems.length === 0 ?
결과 없음
:
{searchItems.map(n => setOpenId(n.news_id)} />)}
}
)} {openId && setOpenId(null)} />}
); } /* ════════════════════════════════════════════════════════ 메모리 페이지 ════════════════════════════════════════════════════════ */ function MemoryStickerCard({ label, scope, tint, tilt, payload, loading }) { const body = payload?.body || ''; const upd = payload?.updated_at_utc; const cap = 50000; // matches _BODY_MAX in stl/memory.py const usedPct = body ? Math.min(100, (body.length / cap) * 100) : 0; return (
{label} {scope}
        {loading ? '로딩 중…' : (body || '— 비어 있음')}
      
{(body.length / 1024).toFixed(1)} / {Math.round(cap / 1024)} KB {upd ? fmtRelativeTime(upd) : '—'}
); } function MemoryPage() { const [modal, setModal] = useP(null); const mainQ = useApi(() => API.memory.read({ scope: 'main' }), []); const krMarketQ = useApi(() => API.memory.read({ scope: 'kr_market' }), []); const usMarketQ = useApi(() => API.memory.read({ scope: 'us_market' }), []); const stocksQ = useApi(() => API.memory.stocks(), [], { refreshMs: 60000 }); const stocks = (stocksQ.data && stocksQ.data.items) || []; return (
종목별 메모 {stocks.length}개 · scope=stock
{stocksQ.loading && stocks.length === 0 ? (
로딩 중…
) : stocks.length === 0 ? (
아직 종목별 메모 없음 (Codex가 작성하면 자동으로 표시)
) : (
{stocks.map((m, i) => { const cap = 50000; const pct = m.body_len ? Math.min(100, (m.body_len / cap) * 100) : 0; return ( ); })}
)}
{modal && setModal(null)} />}
); } function StockMemoryModal({ stock, onClose }) { const q = useApi(() => API.memory.read({ scope: 'stock', symbol: stock.symbol, market: stock.market }), [stock]); useE(() => { const h = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); const body = q.data?.body || ''; return (
e.stopPropagation()}>

{stock.symbol}

memory:stock:{stock.market}:{stock.symbol}
          {q.loading ? '로딩 중…' : (body || '— 비어 있음')}
        
); } /* ════════════════════════════════════════════════════════ 백테스트 페이지 ════════════════════════════════════════════════════════ */ function BacktestDetail({ month, onClose }) { const q = useApi(() => API.backtests.get(month), [month]); useE(() => { const h = (e) => e.key === 'Escape' && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.stopPropagation()}>

백테스트 {month}

{q.loading ?
로딩 중…
: q.error ?
{q.error.message}
: q.data?.summary ? :
데이터 없음
}
); } function BacktestPage() { const listQ = useApi(() => API.backtests.list({ limit: 24 }), [], { refreshMs: 300000 }); const items = (listQ.data && listQ.data.items) || []; const [openMonth, setOpenMonth] = useP(null); return (
{listQ.loading && items.length === 0 ?
로딩 중…
: items.length === 0 ? (
📊
아직 백테스트 기록 없음
매월 1일 00:00 UTC에 자동 실행
) : (
{items.map(b => ( ))}
) } {openMonth && setOpenMonth(null)} />}
); } /* ════════════════════════════════════════════════════════ 시스템 페이지 ════════════════════════════════════════════════════════ */ function CodexUsageCard({ usage, loading }) { if (loading && !usage) return
Codex 사용량 로딩 중…
; if (!usage || usage.ok === false) return (
Codex 사용량 기록 없음
{usage?.error?.message || '~/.codex/sessions 에 아직 token_count 이벤트 없음'}
); const rows = [ { label: '5시간 윈도', win: usage.primary }, { label: '주간 윈도', win: usage.secondary }, ]; const toneFor = (p) => p == null ? 'var(--text-3)' : p >= 90 ? 'var(--critical)' : p >= 70 ? 'var(--warn)' : 'var(--accent)'; return (
{usage.plan_type || '—'} · {usage.rate_limit_reached_type ? `한도 도달: ${usage.rate_limit_reached_type}` : '한도 도달 없음'} {usage.updated_at_utc ? fmtRelativeTime(usage.updated_at_utc) : ''}
{rows.map(r => { const used = r.win && typeof r.win.used_percent === 'number' ? r.win.used_percent : null; const color = toneFor(used); const w = used == null ? 0 : Math.max(0, Math.min(100, used)); return (
{r.label} {used == null ? '—' : used.toFixed(1) + '%'}
{r.win?.window_minutes ? `${r.win.window_minutes}분 단위` : ''} {r.win?.resets_at_utc ? `리셋 ${fmtRelativeTime(r.win.resets_at_utc)}` : ''}
); })} {(() => { // total_token_usage / last_token_usage are dicts: { input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens, total_tokens } const lastTotal = usage.last_token_usage && typeof usage.last_token_usage === 'object' ? usage.last_token_usage.total_tokens : usage.last_token_usage; const allTotal = usage.total_token_usage && typeof usage.total_token_usage === 'object' ? usage.total_token_usage.total_tokens : usage.total_token_usage; const ctx = usage.model_context_window; if (lastTotal == null && allTotal == null && ctx == null) return null; return (
{lastTotal != null && last: {(lastTotal / 1000).toFixed(1)}K} {allTotal != null && total: {(allTotal / 1000).toFixed(1)}K} {ctx != null && ctx: {(ctx / 1000).toFixed(0)}K} {usage.source && {(usage.source).slice(0, 24)}{usage.source.length > 24 ? '…' : ''}}
); })()}
); } function SystemPage() { const stateQ = useApi(() => API.system.state(), [], { refreshMs: 10000 }); const usageQ = useApi(() => API.system.codexUsage(), [], { refreshMs: 30000 }); const s = stateQ.data || {}; const r = s.resources || {}; const cpu = r.cpu || {}; const mem = r.memory || {}; const swap = r.swap || {}; const disk = s.disk || {}; const host = s.host || {}; const db = s.db || {}; const logs = s.logs || {}; const toneFor = (pct) => pct == null ? 'var(--text-3)' : pct >= 80 ? 'var(--critical)' : pct >= 60 ? 'var(--uncertain)' : 'var(--success)'; const memUsedPct = mem.used_pct; const swapUsedPct = swap.used_pct; const swapEnabled = (swap.total_bytes || 0) > 0; const diskUsedPct = disk.data_used_pct; const cpuPct = cpu.percent; return (
{s.paper_trading === false ? ( <> LIVE 실거래 모드 — 모든 주문이 실제로 체결됩니다. ) : ( <> 모의 운영 모드 활성. 실제 체결이 발생하지 않습니다. )}
v{s.version || '—'} · uptime {fmtUptime(host.uptime_seconds)} · proc {fmtUptime(host.process_uptime_seconds)}
{swapEnabled ? 'CPU / 메모리 / 스왑 / 디스크' : 'CPU / 메모리 / 디스크'}
CPU {cpu.cores || '?'}코어 · L1 {cpu.load_1m != null ? cpu.load_1m.toFixed(2) : '—'}
RAM {mem.used_bytes != null ? (mem.used_bytes / 1e9).toFixed(1) : '?'}/{mem.total_bytes != null ? (mem.total_bytes / 1e9).toFixed(0) : '?'}GB
{swapEnabled && (
Swap {swap.used_bytes != null ? (swap.used_bytes / 1e9).toFixed(1) : '?'}/{swap.total_bytes != null ? (swap.total_bytes / 1e9).toFixed(0) : '?'}GB
)}
Disk {disk.data_free_bytes != null ? ((disk.data_total_bytes - disk.data_free_bytes) / 1e9).toFixed(1) : '?'}/{disk.data_total_bytes != null ? (disk.data_total_bytes / 1e9).toFixed(0) : '?'}GB
Codex 사용량
호스트
SYS
플랫폼{host.platform || '—'}
Python {host.python_version || '—'}
UPT
uptime{fmtUptime(host.uptime_seconds)}
backend uptime {fmtUptime(host.process_uptime_seconds)}
L5
load avg {cpu.load_1m != null ? cpu.load_1m.toFixed(2) : '—'} · {cpu.load_5m != null ? cpu.load_5m.toFixed(2) : '—'} · {cpu.load_15m != null ? cpu.load_15m.toFixed(2) : '—'}
1m / 5m / 15m
SQLite 테이블
DB
{db.path ? db.path.split('/').pop() : 'runs.db'} {fmtBytes(db.size_bytes)}
{db.exists === false ? '파일 없음' : (db.tables || []).length + '개 테이블'}
{(db.tables || []).map(t => (
·
{t.name} {t.row_count != null ? t.row_count.toLocaleString() : '—'}
))}
로그
LOG
{logs.dir ? logs.dir.split('/').slice(-2).join('/') : 'data/logs'}{fmtBytes(logs.total_bytes)}
{logs.file_count || 0}개 파일
); } Object.assign(window, { CyclesPage, PortfolioPage, OrdersPage, ProblemsPage, SchedulePage, NewsPage, MemoryPage, BacktestPage, SystemPage, SentimentBadge, StatusPill, GaugeRing, });