oilverse-api / frontend /src /components /CausalGraph.jsx
孙家明
deploy: OilVerse for HuggingFace (Node.js 18 fix)
fab9847
Raw
History Blame Contribute Delete
11.2 kB
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchCausal } from '../api';
const GROUP_COLORS = {
Price: '#3b82f6', Supply: '#10b981', Demand: '#f59e0b',
Risk_Geo: '#ef4444', Technical: '#8b5cf6', Alternative: '#06b6d4', Target: '#ff6b6b',
};
const GROUP_ZH = {
Price: '价格', Supply: '供给', Demand: '需求',
Risk_Geo: '风险/地缘', Technical: '技术面', Alternative: '另类数据', Target: '目标',
};
const FEAT_ZH = {
Brent_spot: 'Brent', vix_lag1: 'VIX', rsi12m: 'RSI',
vix_lag2: 'VIX(L2)', mom1m_lag1: '动量', hist_vol_12m: '历史波动率',
usd_index: 'USD指数', iron_ore_spot: '铁矿石', rig_count_us_new: '钻井数',
ipi_us: '工业产出', natgas_spot_henry: '天然气', nonfarm_us: '非农就业',
supply_saudi: '沙特产量', pmi_us_mfg: 'PMI制造业', target_ret_1m: '油价收益率',
news_oil_sentiment: '新闻情绪', news_geo_tone: '地缘情绪', news_article_volume: '新闻量',
};
/* Simple force layout — runs once on data load */
function layoutNodes(nodes, edges, W, H) {
const cx = W / 2, cy = H / 2;
// Place target at center
const targetIdx = nodes.findIndex(n => n.id === 'target_ret_1m');
if (targetIdx >= 0) { nodes[targetIdx].x = cx; nodes[targetIdx].y = cy; }
// Others in a circle around center
const others = nodes.filter(n => n.id !== 'target_ret_1m');
const r = Math.min(W, H) * 0.38;
others.forEach((n, i) => {
const angle = (2 * Math.PI * i) / others.length - Math.PI / 2;
n.x = cx + r * Math.cos(angle);
n.y = cy + r * Math.sin(angle);
});
// Simple force iterations
for (let iter = 0; iter < 60; iter++) {
// Repulsion
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = 800 / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
if (nodes[i].id !== 'target_ret_1m') { nodes[i].x -= fx; nodes[i].y -= fy; }
if (nodes[j].id !== 'target_ret_1m') { nodes[j].x += fx; nodes[j].y += fy; }
}
}
// Attraction for edges
edges.forEach(e => {
const src = nodes.find(n => n.id === e.source);
const tgt = nodes.find(n => n.id === e.target);
if (!src || !tgt) return;
const dx = tgt.x - src.x;
const dy = tgt.y - src.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 120) * 0.01 * (e.strength || 0.5);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
if (src.id !== 'target_ret_1m') { src.x += fx; src.y += fy; }
if (tgt.id !== 'target_ret_1m') { tgt.x += fx; tgt.y += fy; }
});
// Bounds
nodes.forEach(n => {
n.x = Math.max(55, Math.min(W - 55, n.x));
n.y = Math.max(30, Math.min(H - 30, n.y));
});
}
return nodes;
}
export default function CausalGraph() {
const [data, setData] = useState(null);
const [hovered, setHovered] = useState(null);
const W = 680, H = 440;
useEffect(() => {
fetchCausal().then(d => {
if (!d || !d.network) return;
// Build nodes
const nodeMap = {};
// Target node
nodeMap['target_ret_1m'] = { id: 'target_ret_1m', group: 'Target', label: '油价收益率', size: 22 };
// Feature nodes from ranking
(d.ranking || []).forEach(r => {
nodeMap[r.feature] = {
id: r.feature, group: r.group,
label: FEAT_ZH[r.feature] || r.feature,
size: 10 + (r.causal_strength || 0) * 3,
strength: r.causal_strength,
pValue: r.granger_p,
isCausal: r.is_causal === 'True',
};
});
// Edges: feature → target
const edges = [];
(d.network.feature_to_target || []).forEach(e => {
if (!nodeMap[e.cause]) return;
edges.push({
source: e.cause, target: 'target_ret_1m',
pValue: e.p_value, significant: e.significant === 'True',
lag: e.best_lag, type: 'to_target',
strength: e.significant === 'True' ? 1 : 0.3,
});
});
// Edges: inter-feature (only significant ones, p < 0.05)
(d.network.inter_feature || []).forEach(e => {
if (!nodeMap[e.cause] || !nodeMap[e.effect]) return;
if (e.p_value > 0.05) return;
edges.push({
source: e.cause, target: e.effect,
pValue: e.p_value, significant: true,
lag: e.best_lag, type: 'inter',
strength: 0.5,
});
});
const nodes = Object.values(nodeMap);
layoutNodes(nodes, edges, W, H);
setData({ nodes, edges, groupStrength: d.group_strength });
}).catch(() => {});
}, []);
if (!data) return null;
const { nodes, edges, groupStrength } = data;
const getNode = id => nodes.find(n => n.id === id);
return (
<div className="card" style={{ padding: '1.2rem' }}>
<h3>🔗 因果因子网络 <span style={{ fontSize: '.75rem', fontWeight: 400, color: 'var(--muted)' }}>Granger Causality Network</span></h3>
<p style={{ fontSize: '.8rem', color: 'var(--muted)', margin: '.3rem 0 .8rem' }}>
节点大小 = 因果强度 · 实线 = 显著因果关系(p&lt;0.05) · 虚线 = 非显著 · 箭头方向 = 因果方向
</p>
<div style={{ display: 'flex', gap: '1.5rem' }}>
<svg width={W} height={H} style={{ background: 'rgba(0,0,0,.15)', borderRadius: '.6rem', flexShrink: 0 }}>
<defs>
<marker id="arrow-sig" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,3 L0,6 Z" fill="rgba(255,255,255,.6)" />
</marker>
<marker id="arrow-inter" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="7" markerHeight="5" orient="auto-start-reverse">
<path d="M0,0 L10,3 L0,6 Z" fill="rgba(139,92,246,.4)" />
</marker>
</defs>
{/* Edges */}
{edges.map((e, i) => {
const src = getNode(e.source);
const tgt = getNode(e.target);
if (!src || !tgt) return null;
const isHighlight = hovered && (hovered === e.source || hovered === e.target);
const isTarget = e.type === 'to_target';
const opacity = hovered ? (isHighlight ? 1 : 0.1) : (e.significant ? 0.7 : 0.25);
return (
<line key={i}
x1={src.x} y1={src.y} x2={tgt.x} y2={tgt.y}
stroke={isTarget ? (e.significant ? '#ff6b6b' : '#556688') : 'rgba(139,92,246,.3)'}
strokeWidth={e.significant ? 2 : 1}
strokeDasharray={e.significant ? '' : '4,3'}
opacity={opacity}
markerEnd={isTarget ? 'url(#arrow-sig)' : 'url(#arrow-inter)'}
/>
);
})}
{/* Nodes */}
{nodes.map(n => {
const isHighlight = !hovered || hovered === n.id || edges.some(e => (e.source === hovered && e.target === n.id) || (e.target === hovered && e.source === n.id));
const color = GROUP_COLORS[n.group] || '#888';
return (
<g key={n.id}
onMouseEnter={() => setHovered(n.id)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: 'pointer' }}
opacity={isHighlight ? 1 : 0.2}
>
{n.isCausal && (
<circle cx={n.x} cy={n.y} r={n.size + 4}
fill="none" stroke={color} strokeWidth={2} opacity={0.4} />
)}
<circle cx={n.x} cy={n.y} r={n.size}
fill={color} stroke="#0d1117" strokeWidth={1.5}
opacity={n.id === 'target_ret_1m' ? 1 : 0.85} />
<text x={n.x} y={n.y + n.size + 13}
textAnchor="middle" fill="var(--text)" fontSize={n.id === 'target_ret_1m' ? 11 : 9.5}
fontWeight={n.id === 'target_ret_1m' ? 700 : 400}>
{n.label}
</text>
</g>
);
})}
{/* Hover tooltip */}
{hovered && (() => {
const n = getNode(hovered);
if (!n || n.id === 'target_ret_1m') return null;
const tx = Math.min(n.x + 15, W - 140);
const ty = Math.max(n.y - 40, 15);
return (
<g>
<rect x={tx} y={ty} width={130} height={48} rx={6} fill="rgba(13,17,23,.92)" stroke="rgba(99,102,241,.4)" />
<text x={tx + 8} y={ty + 16} fill="#e2e8f0" fontSize={10} fontWeight={600}>{n.label}</text>
<text x={tx + 8} y={ty + 30} fill="#94a3b8" fontSize={9}>
p={n.pValue?.toFixed(4)} | 强度={n.strength?.toFixed(2)}
</text>
<text x={tx + 8} y={ty + 42} fill={n.isCausal ? '#10b981' : '#ef4444'} fontSize={9} fontWeight={600}>
{n.isCausal ? '✓ 显著因果' : '✗ 非显著'}
</text>
</g>
);
})()}
</svg>
{/* Legend & Group Strength */}
<div style={{ flex: 1, minWidth: 0 }}>
<h4 style={{ fontSize: '.85rem', marginBottom: '.6rem', color: 'var(--accent)' }}>因子组因果强度</h4>
{groupStrength && Object.entries(groupStrength)
.sort((a, b) => b[1].total_strength - a[1].total_strength)
.map(([group, gs]) => (
<div key={group} style={{ marginBottom: '.7rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '.8rem', marginBottom: '.2rem' }}>
<span style={{ color: GROUP_COLORS[group] }}>{GROUP_ZH[group] || group}</span>
<span style={{ color: 'var(--muted)' }}>
{gs.n_causal}/{gs.n_total} 显著 · 强度 {gs.total_strength.toFixed(1)}
</span>
</div>
<div style={{ height: 6, background: 'rgba(255,255,255,.06)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(gs.total_strength / 4 * 100, 100)}%`,
height: '100%', borderRadius: 3,
background: `linear-gradient(90deg, ${GROUP_COLORS[group]}55, ${GROUP_COLORS[group]})`,
}} />
</div>
</div>
))
}
<div style={{ marginTop: '1rem', fontSize: '.75rem', color: 'var(--muted)', lineHeight: 1.6 }}>
<div style={{ fontWeight: 600, marginBottom: '.3rem', color: 'var(--text)' }}>图例</div>
<div><span style={{ color: '#ff6b6b' }}>━━</span> 显著因果 → 目标</div>
<div><span style={{ color: '#556688' }}>╌╌</span> 非显著 → 目标</div>
<div><span style={{ color: '#8b5cf6' }}>━━</span> 因子间因果</div>
<div>◉ 外环 = 通过显著性检验</div>
</div>
</div>
</div>
</div>
);
}