Spaces:
Build error
Build error
| 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<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> | |
| ); | |
| } | |