diff --git a/app/asset/[symbol]/why-moved/[date]/page.tsx b/app/asset/[symbol]/why-moved/[date]/page.tsx
index 541473c..e263c4d 100644
--- a/app/asset/[symbol]/why-moved/[date]/page.tsx
+++ b/app/asset/[symbol]/why-moved/[date]/page.tsx
@@ -83,8 +83,9 @@ export default async function WhyMovedPage({ params }: Props) {
{symbol.toUpperCase()} — {date} 데이터 없음
- 이 날에 추적된 가격 시그널이 없습니다. signalmap이 매 5분 윈도우 1%
- 이상 변동만 감지합니다.
+ 이 날에 추적된 가격 시그널이 없습니다. signalmap 은 5분 윈도우 변동을
+ 자산별 적응형 임계값(조용한 장에서는 0.2% 안팎부터)으로 감지하고, Alpha 는
+ 그중 의미 있는 움직임(단일 0.5% 또는 하루 순변화 1% 이상)이 있는 날만 기사로 씁니다.
@@ -118,7 +119,9 @@ export default async function WhyMovedPage({ params }: Props) {
"@context": "https://schema.org",
"@type": "NewsArticle",
headline: article.title,
- datePublished: date + "T00:00:00+09:00",
+ // When the article was actually written — not the URL date at midnight,
+ // which was a constant unrelated to anything that happened.
+ datePublished: article.generatedAt,
dateModified: article.generatedAt,
author: { "@type": "Organization", name: "Alpha by Mossland" },
publisher: { "@type": "Organization", name: "Mossland" },
@@ -193,9 +196,11 @@ export default async function WhyMovedPage({ params }: Props) {
가격 시그널 ({article.pulses.length})
+ {/* Not compact: this is the one page whose point is causation, and
+ PulseCard hides the 사후 검증 block in compact mode. */}
{article.pulses.map((p) => (
-
+
))}
diff --git a/lib/why-moved.ts b/lib/why-moved.ts
index 9765ccf..b1f605d 100644
--- a/lib/why-moved.ts
+++ b/lib/why-moved.ts
@@ -21,9 +21,53 @@ import {
type Pulse,
} from "./mic";
-const PROMPT_VERSION = "why-moved-v1";
+// v2 (2026-08-19): 24-hour KST clock, UTC stamps inside summaries rewritten,
+// confidence + 사후 검증 shown, title fixed, 3–5 points. Bumped so v1 rows are
+// not replayed from cache.
+const PROMPT_VERSION = "why-moved-v2";
const KST_OFFSET_MS = 9 * 3600_000;
+/** HH:MM in KST, 24-hour. toLocaleTimeString("ko-KR") gave "오전 12:20" for
+ * 00:20 and the model wrote "12:20 KST" in the article. */
+function kstHm(iso: string): string {
+ const t = Date.parse(iso);
+ return Number.isFinite(t) ? new Date(t + KST_OFFSET_MS).toISOString().slice(11, 16) : "--:--";
+}
+/** Flatten free text for a prompt line: SignalMap writes raw UTC ISO stamps
+ * ("2026-08-18T16:11:59.999Z 기준 …") into pulse summaries; rewrite them as
+ * KST so the model is not handed two clocks. Cut on a sentence boundary. */
+function promptText(text: string | null | undefined, max: number): string {
+ const flat = (text ?? "")
+ .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z/g, (m) => `${kstHm(m)} KST`)
+ .replace(/\s+/g, " ")
+ .trim();
+ if (flat.length <= max) return flat;
+ const cut = flat.slice(0, max);
+ const at = Math.max(cut.lastIndexOf("다."), cut.lastIndexOf(". "), cut.lastIndexOf("."));
+ return at > max * 0.5 ? cut.slice(0, at + 1) : cut;
+}
+
+/**
+ * Is this day's pulse set worth an article?
+ *
+ * The cron used to build an article for every asset that had ANY pulse, and
+ * SignalMap's adaptive floor fires on ~0.2% moves on quiet days — so the site
+ * published indexed NewsArticles titled "왜 움직였나" about a 0.19% five-minute
+ * blip whose own text said the cause was unconfirmed. Require a real move.
+ */
+export const ARTICLE_MIN_SINGLE_MOVE_PCT = 0.5;
+export const ARTICLE_MIN_NET_MOVE_PCT = 1.0;
+export function articleWorthy(pulses: Pulse[]): boolean {
+ if (!pulses.length) return false;
+ const maxAbs = Math.max(...pulses.map((p) => Math.abs(p.magnitudePct)));
+ if (maxAbs >= ARTICLE_MIN_SINGLE_MOVE_PCT) return true;
+ const sorted = [...pulses].sort((a, b) => Date.parse(a.detectedAt) - Date.parse(b.detectedAt));
+ const first = sorted.find((p) => Number.isFinite(p.priceFrom))?.priceFrom;
+ const last = [...sorted].reverse().find((p) => Number.isFinite(p.priceTo))?.priceTo;
+ if (first && last) return Math.abs(((last - first) / first) * 100) >= ARTICLE_MIN_NET_MOVE_PCT;
+ return false;
+}
+
export type WhyMovedArticle = {
asset: string;
date: string;
@@ -225,15 +269,18 @@ function parseWhyMovedResponse(content: string): ParsedWhyMovedResponse {
throw new Error("response must be an object");
}
const value = candidate as Record;
+ // The title is no longer the model's to write (see generateWhyMoved) —
+ // it authored "GLD, 6% 국채금리 급등에 0.4% 하락" over a pulse whose own
+ // 사후 검증 called the cause unconfirmed. Points: 3–5, so a one-fact day
+ // does not force two invented slots.
if (
- typeof value.title !== "string" ||
- !value.title.trim() ||
typeof value.oneLine !== "string" ||
!value.oneLine.trim() ||
typeof value.why !== "string" ||
!value.why.trim() ||
!Array.isArray(value.points) ||
- value.points.length !== 5 ||
+ value.points.length < 3 ||
+ value.points.length > 5 ||
!value.points.every(
(point) => typeof point === "string" && point.trim().length > 0
)
@@ -241,7 +288,7 @@ function parseWhyMovedResponse(content: string): ParsedWhyMovedResponse {
throw new Error("response does not match the why-moved schema");
}
return {
- title: value.title,
+ title: typeof value.title === "string" ? value.title : "",
oneLine: value.oneLine,
why: value.why,
points: value.points as string[],
@@ -303,22 +350,25 @@ export async function generateWhyMoved(
const pulseLines = pulses
.map((p) => {
const dirSign = p.direction === "up" ? "+" : "-";
- const t = new Date(p.detectedAt);
- const timeStr = t.toLocaleTimeString("ko-KR", {
- hour: "2-digit",
- minute: "2-digit",
- timeZone: "Asia/Seoul",
- });
// State the unit. A bare "90,557,000 → 90,735,000" for a KRW-quoted
// pulse reads as dollars to the model, and roughly a third of active
// pulses are KRW-quoted (BTC-KRW, USDKRW).
const money = (n?: number) => (n == null ? "?" : fmtPulsePrice(n, p.priceUnit));
- return `- ${timeStr} KST: ${dirSign}${Math.abs(p.magnitudePct).toFixed(2)}% (${money(p.priceFrom)} → ${money(p.priceTo)}) — ${p.summary.slice(0, 200)}`;
+ const head = `- ${kstHm(p.detectedAt)} KST: ${dirSign}${Math.abs(p.magnitudePct).toFixed(2)}% (${money(p.priceFrom)} → ${money(p.priceTo)}) [신뢰도: ${p.confidence}] — ${promptText(p.summary, 320)}`;
+ // The verifier's verdict is the most honest sentence in the data and
+ // was never shown to the model — so the article asserted causes the
+ // 사후 검증 had already called unconfirmed.
+ const verified = p.verifiedSummary ? `\n 사후 검증: ${promptText(p.verifiedSummary, 320)}` : "";
+ return head + verified;
})
.join("\n");
const sourceLines = uniqueSources
- .map((s) => `- ${s.title || s.url} (${s.publisher || "source"})`)
+ .map((s) => {
+ const when = s.publishedAt ? ` · 발행 ${kstHm(s.publishedAt)} KST` : "";
+ const excerpt = s.excerpt ? ` · 발췌: ${promptText(s.excerpt, 160)}` : "";
+ return `- ${s.title || s.url} (${s.publisher || "source"})${when}${excerpt}`;
+ })
.join("\n");
const prompt = `${date} 한국 시장 시각으로 "${assetLabel}이 왜 움직였나" 정리.
@@ -334,21 +384,24 @@ ${sourceLines}
스키마:
{
- "title": "오늘 ${assetLabel}는 왜 움직였나? — ${date} (≤60자)",
"oneLine": "한 줄 결론 (≤80자, 사실 + 인사이트)",
"why": "왜 중요한가 (≤120자)",
"points": [
"확인된 사실 (≤50자)",
- "가능한 원인 (≤50자)",
- "다른 해석 (≤50자)",
- "연결된 자산/매크로 (≤50자)",
+ "가능한 원인 (≤50자) — 자료에 있을 때만",
+ "다른 해석 (≤50자) — 자료에 있을 때만",
+ "연결된 자산/매크로 (≤50자) — 자료에 있을 때만",
"아직 불확실한 것 (≤50자)"
]
}
+points 는 3~5개. 해당 근거가 자료에 없는 슬롯은 생략하고, 슬롯끼리 같은 말을 반복하지 않는다.
규칙:
- 한국어 출력
- 가격 권유 X · 정치 비방 X · 단정 X
+- 위 자료의 시각은 전부 KST 다. 시차·경과 시간을 계산하지 말고 표기된 시각만 쓴다.
+- 변동률·가격은 위 시그널 줄의 수치 그대로. 여러 건을 합산하지 않는다.
+- 사후 검증이 인과를 부정하거나 미확인으로 두면 한 줄 결론에서 그 원인을 단정하지 않는다. speculative 시그널은 원인 서술 대신 미확인으로 쓴다.
- pulse summary와 보도 내용 *구체적*으로 인용
- generic 문구 X
- "...로 보임", "~가능성" 어휘`;
@@ -375,7 +428,10 @@ ${sourceLines}
const article: WhyMovedArticle = {
asset,
date,
- title: parsed.title,
+ // Deterministic — the intended SEO form (scripts/audit-baseline.ts Q1),
+ // and it takes the whole class of model-authored causal titles off the
+ // table.
+ title: `오늘 ${assetLabel}는 왜 움직였나? — ${date}`,
oneLine: parsed.oneLine,
why: parsed.why,
points: parsed.points,
diff --git a/scripts/generate-why-moved.ts b/scripts/generate-why-moved.ts
index 7c21c94..372cd8b 100644
--- a/scripts/generate-why-moved.ts
+++ b/scripts/generate-why-moved.ts
@@ -142,6 +142,9 @@ async function main(parsed: ParsedArgs) {
kstDateForTimestamp,
kstDayBounds,
listAllWhyMoved,
+ articleWorthy,
+ ARTICLE_MIN_SINGLE_MOVE_PCT,
+ ARTICLE_MIN_NET_MOVE_PCT,
} = await import("../lib/why-moved");
const { formatPulseLoadDiagnostics, getAllPulses, getPulseLoadDiagnostics } =
await import("../lib/mic");
@@ -178,7 +181,7 @@ async function main(parsed: ParsedArgs) {
}
const combos = new Map<
string,
- { asset: string; date: string; pulseIds: string[] }
+ { asset: string; date: string; pulseIds: string[]; pulses: typeof pulses }
>();
for (const p of pulses) {
// getAllPulses() already rejects pulses without a parseable detectedAt.
@@ -187,9 +190,16 @@ async function main(parsed: ParsedArgs) {
const asset = p.asset.toLowerCase();
const key = `${asset}|${date}`;
const combo = combos.get(key);
- if (combo) combo.pulseIds.push(p.id);
- else combos.set(key, { asset, date, pulseIds: [p.id] });
+ if (combo) { combo.pulseIds.push(p.id); combo.pulses.push(p); }
+ else combos.set(key, { asset, date, pulseIds: [p.id], pulses: [p] });
}
+ // A day whose biggest five-minute move is 0.2% does not get a "왜 움직였나"
+ // article — see articleWorthy(). Counted, not silent.
+ let filteredQuiet = 0;
+ for (const [key, combo] of [...combos.entries()]) {
+ if (!articleWorthy(combo.pulses)) { combos.delete(key); filteredQuiet++; }
+ }
+ console.log(`Asset-days: ${combos.size} worth an article, ${filteredQuiet} too quiet (max move < ${ARTICLE_MIN_SINGLE_MOVE_PCT}% and net < ${ARTICLE_MIN_NET_MOVE_PCT}%)`);
const samePulseIds = (
stored: { id: string }[],