Files
investor-flow/app/src/components/MarketOutlookPanel.tsx
T
Investor Flow Build 55f07e6b42 fix: wire data adapters, fix OptionsAdapter v3, build Research pages
- Fix OptionsAdapter for yahoo-finance2 v3 API ({ date: expiry } instead of positional arg)
- Fix institutional.flow and insiderStream endpoints to query correct DB tables
- Fix anonymous user INSERT (pw_hash column, not password_hash)
- Create secDataFetcher module for 13F/Form 4 data population
- Add institutional and marketOutlook endpoints to frontend trpc client
- Build out Chart Lab, Institutional, Market Outlook pages with real components
- Add focus-visible rings, aria-labels, hex tokens, shape icons across all panels
- Integrate EmotionLogger, MobileTabNav, useKeyboardShortcuts
- Add localStorage persistence for emotion logs
- Normalize spacing, type scale, hover effects across all components
2026-07-05 08:50:12 -04:00

165 lines
7.5 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useActiveSymbol } from '@/stores/active-symbol-store';
import { api, type Snapshot } from '@/lib/trpc';
/**
* Market Outlook Panel — macro analysis and sector context for the active symbol.
* Shows sector/industry info, ticker kind classification, and peer context.
* Educational display only — no trade recommendations.
*/
interface SectorData {
symbol: string;
name: string | null;
sector: string | null;
industry: string | null;
exchange: string | null;
tickerKind: string;
peers: string[] | null;
}
export function MarketOutlookPanel() {
const symbol = useActiveSymbol((s) => s.activeSymbol);
const [snap, setSnap] = useState<Snapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api.market.snapshot(symbol)
.then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } })
.catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'request failed'); setLoading(false); } });
return () => { cancelled = true; };
}, [symbol]);
const sector: SectorData | null = snap?.sector ?? null;
const quote = snap?.quote ?? null;
const candles = snap?.candles ?? [];
// Simple trend estimation from recent candles
const trend = candles.length >= 20 ? (() => {
const recent20 = candles.slice(-20);
const firstHalf = recent20.slice(0, 10).reduce((s, c) => s + c.c, 0) / 10;
const secondHalf = recent20.slice(10).reduce((s, c) => s + c.c, 0) / 10;
const pctChange = ((secondHalf - firstHalf) / firstHalf) * 100;
if (pctChange > 3) return { label: 'Trending Up', color: 'text-[#34d399]', icon: '▲' };
if (pctChange < -3) return { label: 'Trending Down', color: 'text-[#ef4444]', icon: '▼' };
return { label: 'Range-Bound', color: 'text-[#fbbf24]', icon: '—' };
})() : null;
// Volatility estimate from recent candles
const volatility = candles.length >= 20 ? (() => {
const returns = candles.slice(-20).map((c, i, arr) => i > 0 ? (c.c - arr[i - 1].c) / arr[i - 1].c : 0).slice(1);
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length;
const dailyVol = Math.sqrt(variance);
const annualizedVol = dailyVol * Math.sqrt(252) * 100;
if (annualizedVol > 50) return { label: 'High', color: 'text-[#ef4444]', value: annualizedVol.toFixed(0) };
if (annualizedVol > 25) return { label: 'Moderate', color: 'text-[#fbbf24]', value: annualizedVol.toFixed(0) };
return { label: 'Low', color: 'text-[#34d399]', value: annualizedVol.toFixed(0) };
})() : null;
if (loading) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
<p className="text-sm text-[#5a5b6a] py-8">Loading market data…</p>
</section>
);
}
if (error) {
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
<p className="text-sm text-[#ef4444] py-8">{error}</p>
</section>
);
}
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<header className="flex items-baseline justify-between gap-4">
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
<span className="text-xs text-[#8a8b9a]">{symbol}</span>
</header>
{/* Sector & Industry */}
{sector && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Sector</p>
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.sector ?? '—'}</p>
</div>
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Industry</p>
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.industry ?? '—'}</p>
</div>
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Type</p>
<p className="text-sm font-semibold text-[#e6e7ec] capitalize">{sector.tickerKind}</p>
</div>
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Exchange</p>
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.exchange ?? '—'}</p>
</div>
</div>
)}
{/* Trend & Volatility */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{trend && (
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-4">
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">20-Day Trend</p>
<div className="flex items-center gap-2">
<span className={`text-lg ${trend.color}`} aria-hidden="true">{trend.icon}</span>
<span className={`text-sm font-semibold ${trend.color}`}>{trend.label}</span>
</div>
<p className="text-[11px] text-[#5a5b6a] mt-2">
Based on the average closing price of the first 10 vs last 10 of the most recent 20 trading days.
</p>
</div>
)}
{volatility && (
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-4">
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">Volatility (Annualized)</p>
<div className="flex items-baseline gap-2">
<span className={`text-2xl font-bold font-mono ${volatility.color}`}>{volatility.value}%</span>
<span className={`text-xs ${volatility.color}`}>{volatility.label}</span>
</div>
<p className="text-[11px] text-[#5a5b6a] mt-2">
Derived from daily returns over the last 20 trading days, annualized (×√252).
</p>
</div>
)}
</div>
{/* Peers */}
{sector?.peers && sector.peers.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">Peers</p>
<div className="flex flex-wrap gap-1.5">
{sector.peers.map((peer) => (
<span key={peer} className="px-2 py-1 rounded-lg bg-[#1a1b24] border border-[#2a2b3a] text-xs text-[#8a8b9a] font-mono">
{peer}
</span>
))}
</div>
</div>
)}
{/* Educational note */}
<div className="pt-3 border-t border-[#2a2b3a]">
<p className="text-[11px] text-[#5a5b6a]">
This panel provides sector and statistical context for the active symbol. Trend and volatility
estimates are based on recent price history and are for educational reference only.
</p>
</div>
</section>
);
}