@@ -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+ }
10711115function 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