- 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
165 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|