Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 15 additions & 232 deletions apps/web/app/(pages)/replay/page.tsx
Original file line number Diff line number Diff line change
@@ -1,234 +1,17 @@
import { api } from "@/lib/api";
import { formatPct, formatScore } from "@/lib/format";
import { Badge } from "@/components/shared/badge";
import { Card, CardHeader } from "@/components/shared/card";

function outcomeVariant(label: string): "ok" | "warn" | "danger" {
if (label === "강한 적중" || label === "양호") {
return "ok";
}
if (label === "혼조") {
return "warn";
}
return "danger";
}

function tierLabel(tier: string) {
return tier === "leader" ? "주도주" : "2등주";
}

export default async function ReplayPage() {
try {
const data = await api.weeklyReplay();

return (
<div className="space-y-6">
<Card>
<CardHeader
eyebrow="Weekly Replay"
title="최근 7거래일 추론 리플레이"
description="각 날짜 장전 시점에 볼 수 있었던 미국 뉴스와 시장 반응만으로 다시 추론하고, 실제 익일 한국장에서 어떻게 반영됐는지 비교합니다."
/>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-[20px] bg-[color:var(--bg-muted)] p-4">
<p className="section-eyebrow">Window</p>
<p className="keep-korean mt-2 text-[20px] font-semibold">{data.windowLabel}</p>
<p className="mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
현재 운영 중인 추론 규칙을 같은 방식으로 과거 구간에 재적용한 결과입니다.
</p>
</Card>
<Card className="rounded-[20px] bg-[color:var(--bg-muted)] p-4">
<p className="section-eyebrow">Hit Days</p>
<p className="mt-2 text-[20px] font-semibold">
{data.aggregate.positiveHitDays} / {data.aggregate.daysAnalyzed}
</p>
<p className="mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
상위 테마 주도주의 평균 종가 수익률이 양호 이상이었던 날짜 수입니다.
</p>
</Card>
<Card className="rounded-[20px] bg-[color:var(--bg-muted)] p-4">
<p className="section-eyebrow">Avg Leader Return</p>
<p className="mt-2 text-[20px] font-semibold">
{formatPct(data.aggregate.avgLeaderCloseReturnPct)}
</p>
<p className="mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
전체 리플레이 기준 주도주 평균 종가 수익률입니다.
</p>
</Card>
</div>
</Card>

<div className="space-y-5">
{data.days.map((day) => (
<Card key={day.marketDateLabel}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="section-eyebrow">Replay Day</p>
<h2 className="keep-korean mt-2 text-[22px] font-bold leading-[1.25]">
{day.marketDateLabel} 한국장
</h2>
<p className="keep-korean mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
추론 기준 시각 {day.asOfLabel} / evidence hash {day.evidencePackHash.slice(0, 12)}
</p>
</div>
<div className="grid gap-2 rounded-[18px] bg-[color:var(--bg-muted)] px-4 py-3 text-[13px] text-[color:var(--text-muted)]">
<p>KOSPI {formatPct(day.marketContext.kospiCloseReturnPct)}</p>
<p>KOSDAQ {formatPct(day.marketContext.kosdaqCloseReturnPct)}</p>
<p className="keep-korean">{day.marketContext.summary}</p>
</div>
</div>

<div className="mt-4 rounded-[18px] border border-[color:var(--border)] bg-[color:var(--bg-muted)] px-4 py-3">
<p className="keep-korean text-[14px] leading-6 text-[color:var(--text-muted)]">{day.summary}</p>
</div>

<div className="mt-5 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
<div className="space-y-4">
{day.predictedThemes.length ? (
day.predictedThemes.map((theme) => (
<div
key={`${day.marketDateLabel}-${theme.name}`}
className="rounded-[20px] border border-[color:var(--border)] bg-[color:var(--surface-white)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Badge>{theme.name}</Badge>
<Badge variant={outcomeVariant(theme.actualOutcome.outcomeLabel)}>
{theme.actualOutcome.outcomeLabel}
</Badge>
<Badge variant={theme.confidence >= 0.7 ? "ok" : "warn"}>
확신 {formatScore(theme.confidence)}
</Badge>
</div>
<p className="keep-korean text-[13px] leading-6 text-[color:var(--text-muted)]">
{theme.rationale}
</p>
</div>
<div className="grid gap-1 rounded-[18px] bg-[color:var(--bg-muted)] px-4 py-3 text-[13px] text-[color:var(--text-muted)]">
<p>주도주 평균 {formatPct(theme.actualOutcome.avgLeaderCloseReturnPct)}</p>
<p>
최고 수익 {theme.actualOutcome.bestStockLabel ?? "-"}
{theme.actualOutcome.bestCloseReturnPct !== null
? ` / ${formatPct(theme.actualOutcome.bestCloseReturnPct)}`
: ""}
</p>
</div>
</div>

<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="rounded-[18px] bg-[color:var(--bg-muted)] p-4">
<p className="text-[14px] font-semibold">예상 주도주 / 2등주</p>
<p className="keep-korean mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
주도주: {theme.leaders.map((item) => `${item.ticker} ${item.name}`).join(", ") || "-"}
</p>
<p className="keep-korean mt-1 text-[13px] leading-6 text-[color:var(--text-muted)]">
2등주: {theme.secondTier.map((item) => `${item.ticker} ${item.name}`).join(", ") || "-"}
</p>
<p className="keep-korean mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
무효화 조건: {theme.invalidationCondition}
</p>
</div>
<div className="rounded-[18px] bg-[color:var(--bg-muted)] p-4">
<p className="text-[14px] font-semibold">실제 종목 반응</p>
<div className="mt-3 space-y-2">
{theme.actualOutcome.stockResults.length ? (
theme.actualOutcome.stockResults.map((stock) => (
<div
key={`${day.marketDateLabel}-${theme.name}-${stock.ticker}`}
className="rounded-[16px] border border-[color:var(--border)] px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[13px] font-semibold">
{stock.ticker} {stock.name}
</p>
<Badge variant={stock.closeReturnPct >= 0 ? "ok" : "danger"}>
{tierLabel(stock.tier)}
</Badge>
</div>
<p className="mt-2 text-[12px] leading-6 text-[color:var(--text-muted)]">
시가 갭 {formatPct(stock.openGapPct)} / 종가 수익률 {formatPct(stock.closeReturnPct)} / 장중 변화{" "}
{formatPct(stock.intradayMovePct)}
</p>
</div>
))
) : (
<p className="text-[13px] leading-6 text-[color:var(--text-muted)]">
실제 종목 데이터를 불러오지 못했습니다.
</p>
)}
</div>
</div>
</div>
</div>
))
) : (
<div className="rounded-[20px] border border-dashed border-[color:var(--border)] px-4 py-6 text-[13px] text-[color:var(--text-muted)]">
해당 날짜에는 현재 규칙 기준으로 상위 한국 테마 예측이 생성되지 않았습니다.
</div>
)}
</div>

<div className="space-y-4">
<Card className="rounded-[20px] bg-[color:var(--bg-muted)] p-4">
<p className="text-[14px] font-semibold">근거 이벤트</p>
<div className="mt-3 space-y-3">
{day.evidenceItems.map((item) => (
<div
key={`${day.marketDateLabel}-${item.title}`}
className="rounded-[18px] border border-[color:var(--border)] bg-[color:var(--surface-white)] px-4 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[12px] uppercase tracking-[0.12em] text-[color:var(--text-muted)]">
{item.categoryLabel}
</p>
<Badge variant={item.marketConfirmationScore >= 0.35 ? "ok" : "warn"}>
시장 확인 {formatScore(item.marketConfirmationScore)}
</Badge>
</div>
<p className="keep-korean mt-2 text-[14px] font-semibold leading-6">{item.title}</p>
<p className="keep-korean mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{item.summary}
</p>
<p className="mt-2 text-[12px] text-[color:var(--text-muted)]">
{item.sourceName} / {item.publishedAtLabel}
</p>
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex text-[12px] text-[color:var(--primary)] underline underline-offset-4"
>
원문 보기
</a>
</div>
))}
</div>
</Card>
</div>
</div>
</Card>
))}
</div>
</div>
);
} catch (error) {
const message = error instanceof Error ? error.message : "알 수 없는 오류";

return (
<div className="space-y-6">
<Card>
<CardHeader
eyebrow="Weekly Replay"
title="리플레이 랩을 불러오지 못했습니다"
description="리플레이 데이터 요청 중 오류가 발생했습니다. API 서버와 데이터 적재 상태를 확인해 주세요."
/>
<div className="rounded-[20px] border border-dashed border-[color:var(--border)] bg-[color:var(--bg-muted)] px-5 py-5 text-[14px] text-[color:var(--text-muted)]">
<p className="font-semibold text-white">오류 메시지</p>
<p className="keep-korean mt-2 leading-6">{message}</p>
</div>
</Card>
</div>
);
}
import { WeeklyReplayClient } from "@/components/replay/weekly-replay-client";

export default function ReplayPage() {
return (
<div className="space-y-6">
<Card>
<CardHeader
eyebrow="Weekly Replay"
title="최근 7거래일 추론 리플레이"
description="각 날짜 장전 시점에 볼 수 있었던 미국 뉴스와 시장 반응만으로 다시 추론하고, 실제 다음 한국장에서 어떻게 반영됐는지 비교합니다."
/>
</Card>
<WeeklyReplayClient />
</div>
);
}
Loading