Skip to content

Commit 8c9cbcc

Browse files
lanfuliclaude
andcommitted
analytics v2 Stage 5: momentum · freshness · concentration
All mention-based (real data immediately; no price backfill needed): - row.momentum {accel = Δvelocity, ageDays since first_seen, recencyDays} -> badges in the focus panel ("加速 +N · 入档 Nd · 最近提及 Nd前"). - dashboardData.themeConcentration: attention share per theme + 7D rotation delta -> "主题集中度" card with a stacked share bar + ▲▼ rotation legend (CPO/photonics currently 40% and rotating in). - dashboardData.clusters: correlated-cluster detection (pairwise daily- return Pearson among top-40, |r|≥0.6, min-overlap 60d, connected components, dominant-theme label, avgCorr) -> "相关性集群 · 同一押注" warning with clickable members. Framed as concentration risk, not causation. Populates after the price backfill (empty now). Verified live: 6-segment theme bar + legend with real shares, 3 momentum badges per ticker, no console errors. windowRow already carries momentum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a797c49 commit 8c9cbcc

2 files changed

Lines changed: 137 additions & 1 deletion

File tree

reports/aleabito-60d-dashboard.html

Lines changed: 47 additions & 1 deletion
Large diffs are not rendered by default.

scripts/build-aleabito-dashboard.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,21 @@ async function main() {
910910
const mbd = {}; (r.mentionSeries || []).forEach((p) => { mbd[p.date] = (mbd[p.date] || 0) + (p.mentioned_posts || 0); });
911911
r.marketStats = computeMarketStats(daily, spyDeepDaily, smhDeepDaily, mbd);
912912
});
913+
summary.forEach((r) => {
914+
const series = dailyByTicker.get(r.ticker) || [];
915+
const w14_7 = getWindowCount(series, latestDate, 7, 14);
916+
const prevVel = r.prev7 - w14_7;
917+
r.momentum = { accel: r.velocity - prevVel, ageDays: r.first_seen ? daysBetween(dateOnly(r.first_seen), latestDate) : null, recencyDays: r.daysSinceLast };
918+
});
919+
const themeAgg = new Map();
920+
summary.forEach((r) => {
921+
const t = r.primary_theme || "Other / unclassified";
922+
const cur = themeAgg.get(t) || { theme: t, mentions: 0, recent7: 0, prior7: 0 };
923+
cur.mentions += r.mentioned_posts; cur.recent7 += r.last7; cur.prior7 += r.prev7;
924+
themeAgg.set(t, cur);
925+
});
926+
const themeConcentration = [...themeAgg.values()].map((x) => Object.assign({}, x, { delta: x.recent7 - x.prior7 })).sort((a, b) => b.mentions - a.mentions);
927+
const clusters = computeClusters(summary, deepByTicker);
913928

914929
const themeStats = new Map();
915930
summary.forEach((row) => {
@@ -966,6 +981,8 @@ async function main() {
966981
topMovers,
967982
benchmarks: benchmarksData,
968983
trackRecord: trackRecordAgg,
984+
themeConcentration,
985+
clusters,
969986
rows: summary,
970987
};
971988

@@ -1068,6 +1085,33 @@ function computeTrackRecord(rows, deepCache, meaningfulSet) {
10681085
return { n: recs.length, coverage: meaningfulCount ? recs.length / meaningfulCount : 0, winRate: wins / recs.length, meanFwd: mean(fwd), medianFwd: median(fwd), meanExcess: mean(exc), medianExcess: median(exc), basketRet: mean(fwd), basketExcess: mean(exc), meaningfulCount };
10691086
}
10701087

1088+
function computeClusters(rows, deepByTicker) {
1089+
const cand = rows.filter((r) => deepByTicker.has(r.ticker)).slice(0, 40);
1090+
if (cand.length < 2) return [];
1091+
const retMap = {};
1092+
cand.forEach((r) => { const m = new Map(); for (const x of logRetSeries(deepByTicker.get(r.ticker))) m.set(x.d, x.r); retMap[r.ticker] = m; });
1093+
const adj = {}; cand.forEach((r) => { adj[r.ticker] = []; });
1094+
const pairCorr = {};
1095+
for (let i = 0; i < cand.length; i++) for (let j = i + 1; j < cand.length; j++) {
1096+
const a = cand[i].ticker, b = cand[j].ticker, ma = retMap[a], mb = retMap[b];
1097+
const xs = [], ys = []; ma.forEach((v, d) => { if (mb.has(d)) { xs.push(v); ys.push(mb.get(d)); } });
1098+
if (xs.length >= 60) { const r = pearsonB(xs, ys); if (Math.abs(r) >= 0.6) { adj[a].push(b); adj[b].push(a); pairCorr[a + "|" + b] = r; pairCorr[b + "|" + a] = r; } }
1099+
}
1100+
const themeOf = {}; cand.forEach((r) => { themeOf[r.ticker] = r.primary_theme || "Other"; });
1101+
const seen = new Set(), clusters = [];
1102+
cand.forEach((r) => {
1103+
if (seen.has(r.ticker)) return;
1104+
const stack = [r.ticker], comp = [];
1105+
while (stack.length) { const t = stack.pop(); if (seen.has(t)) continue; seen.add(t); comp.push(t); adj[t].forEach((nb) => { if (!seen.has(nb)) stack.push(nb); }); }
1106+
if (comp.length >= 2) {
1107+
let sum = 0, cnt = 0; for (let i = 0; i < comp.length; i++) for (let j = i + 1; j < comp.length; j++) { const k = pairCorr[comp[i] + "|" + comp[j]]; if (k != null) { sum += Math.abs(k); cnt++; } }
1108+
const tc = {}; comp.forEach((t) => { tc[themeOf[t]] = (tc[themeOf[t]] || 0) + 1; });
1109+
let label = "?", best = -1; for (const k in tc) if (tc[k] > best) { best = tc[k]; label = k; }
1110+
clusters.push({ members: comp.sort(), avgCorr: cnt ? sum / cnt : 0, label });
1111+
}
1112+
});
1113+
return clusters.sort((a, b) => b.members.length - a.members.length);
1114+
}
10711115
function buildHtmlV2(data) {
10721116
const json = JSON.stringify(data).replace(/</g, "\\u003c");
10731117
return `<!doctype html>
@@ -1838,6 +1882,19 @@ function buildHtmlV2(data) {
18381882
.fc-l { font-size: 10px; color: var(--subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
18391883
.fc-v { font-family: var(--mono); font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; }
18401884
.foreign-pill { display: inline-block; font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: rgba(246,200,95,0.16); color: var(--amber); }
1885+
.conc-card { padding: 16px 22px 18px; margin: 0 0 18px; }
1886+
.conc-bar { display: flex; height: 18px; border-radius: 7px; overflow: hidden; margin: 4px 0 10px; gap: 1px; }
1887+
.conc-seg { min-width: 2px; }
1888+
.conc-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 11px; }
1889+
.conc-lg { display: inline-flex; align-items: center; gap: 5px; }
1890+
.conc-lg i { width: 9px; height: 9px; border-radius: 2px; display: inline-block; }
1891+
.cluster { margin: 7px 0; font-size: 12px; }
1892+
.cluster-label { font-family: var(--mono); color: var(--muted); margin-right: 6px; }
1893+
.cluster-mem { font-family: var(--mono); font-size: 11px; font-weight: 700; padding: 2px 8px; margin: 2px 3px 2px 0; border-radius: 7px; background: var(--panel-3); border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
1894+
.cluster-mem:hover { background: var(--cyan); color: #06121b; }
1895+
.conc-note { font-size: 11px; margin-top: 8px; }
1896+
.mb-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
1897+
.mb { font-family: var(--mono); font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; background: rgba(13,18,33,0.6); border: 1px solid var(--line); }
18411898
.track-card { padding: 16px 22px 18px; margin: 0 0 18px; }
18421899
.track-empty { display: flex; flex-direction: column; gap: 4px; }
18431900
.track-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
@@ -2410,6 +2467,8 @@ function buildHtmlV2(data) {
24102467
<div id="briefBody"></div>
24112468
</section>
24122469
2470+
<section class="card reveal conc-card" id="concCard" data-testid="conc-card"><div id="concBody"></div></section>
2471+
24132472
<section class="card reveal focus-card" id="focusCard" data-testid="focus-card"><div id="focusBody"></div></section>
24142473
<section class="dashboard-grid">
24152474
<div class="main-stack">
@@ -3698,6 +3757,35 @@ function buildHtmlV2(data) {
36983757
cells += fundCell('下次财报 Earnings', earn);
36993758
return '<div class="section-label"><span>基本面与流动性 · Fundamentals</span><span class="muted">' + srcLabel + (f.foreignListed ? ' · 海外上市' : '') + '</span></div><div class="fund-grid">' + cells + '</div>';
37003759
}
3760+
function momentumBadges(row) {
3761+
var m = row.momentum;
3762+
if (!m) return '';
3763+
var parts = [];
3764+
if (m.accel != null && m.accel !== 0) parts.push('<span class="mb ' + deltaClass(m.accel) + '">加速 ' + (m.accel > 0 ? '+' : '') + m.accel + '</span>');
3765+
if (m.ageDays != null) parts.push('<span class="mb">入档 ' + m.ageDays + 'd</span>');
3766+
if (m.recencyDays != null) parts.push('<span class="mb ' + (m.recencyDays <= 2 ? 'delta-up' : (m.recencyDays > 14 ? 'delta-down' : '')) + '">最近提及 ' + m.recencyDays + 'd前</span>');
3767+
return parts.length ? '<div class="mb-row">' + parts.join('') + '</div>' : '';
3768+
}
3769+
var CONC_COLORS = ['var(--cyan)', 'var(--purple)', 'var(--green)', 'var(--amber)', 'var(--pink)', 'var(--subtle)'];
3770+
function renderConcentration() {
3771+
var el = $("concBody");
3772+
if (!el) return;
3773+
var tc = DASHBOARD_DATA.themeConcentration || [];
3774+
var clusters = DASHBOARD_DATA.clusters || [];
3775+
if (!tc.length) { el.innerHTML = ''; return; }
3776+
var top = tc.slice(0, 6);
3777+
var total = tc.reduce(function (s, t) { return s + t.mentions; }, 0) || 1;
3778+
var bar = top.map(function (t, i) { var w = t.mentions / total * 100; return '<div class="conc-seg" style="width:' + w.toFixed(1) + '%;background:' + CONC_COLORS[i % 6] + '" title="' + html(t.theme) + ' ' + w.toFixed(0) + '%"></div>'; }).join('');
3779+
var legend = top.map(function (t, i) { var d = t.delta > 0 ? '▲' : (t.delta < 0 ? '▼' : '·'); return '<span class="conc-lg"><i style="background:' + CONC_COLORS[i % 6] + '"></i>' + html(t.theme) + ' ' + (t.mentions / total * 100).toFixed(0) + '% <span class="' + (t.delta > 0 ? 'delta-up' : (t.delta < 0 ? 'delta-down' : 'muted')) + '">' + d + '</span></span>'; }).join('');
3780+
var clusterHtml = '';
3781+
if (clusters.length) {
3782+
clusterHtml = '<div class="section-label" style="margin-top:14px"><span>相关性集群 · 同一押注</span><span class="muted">日收益相关性 ≥ 0.6</span></div>' +
3783+
clusters.map(function (c) { return '<div class="cluster"><span class="cluster-label">' + html(c.label) + ' · ρ̄=' + c.avgCorr.toFixed(2) + '</span> ' + c.members.map(function (m) { return '<button type="button" class="cluster-mem" data-ticker="' + m + '">' + m + '</button>'; }).join('') + '</div>'; }).join('') +
3784+
'<div class="muted conc-note">提示集中度风险(这些其实是同一条供应链押注),非统计因果。</div>';
3785+
}
3786+
el.innerHTML = '<div class="section-label"><span>主题集中度 · Concentration</span><span class="muted">她的注意力分布 + 7D 轮动 ▲▼</span></div><div class="conc-bar">' + bar + '</div><div class="conc-legend">' + legend + '</div>' + clusterHtml;
3787+
el.querySelectorAll('.cluster-mem').forEach(function (b) { b.addEventListener('click', function () { setPinnedTicker(b.dataset.ticker); }); });
3788+
}
37013789
function renderTrackRecord() {
37023790
var el = $("trackBody");
37033791
if (!el) return;
@@ -3735,6 +3823,7 @@ function buildHtmlV2(data) {
37353823
'<div class="focus-head"><div class="focus-id"><span class="focus-ticker">' + row.ticker + '</span><span class="focus-name muted">' + html(px.symbol || row.ticker) + ' · ' + html(row.primary_theme) + (row.fundamentals && row.fundamentals.foreignListed ? ' <span class="foreign-pill">海外</span>' : '') + '</span></div>' +
37363824
pxHtml + '</div>' +
37373825
trackBadge(row) +
3826+
momentumBadges(row) +
37383827
combinedChart(row, true) +
37393828
statsPanel(model, row);
37403829
attachComboHandlers();
@@ -3900,6 +3989,7 @@ function buildHtmlV2(data) {
39003989
39013990
function renderAll() {
39023991
renderTrackRecord();
3992+
renderConcentration();
39033993
renderLegend();
39043994
renderCompositionChart();
39053995
renderBubbleChart();

0 commit comments

Comments
 (0)