feat: overlay stock price on trend charts with right Y axis
- New GET /api/market/prices/{ticker} endpoint serving OHLCV data from
market_snapshots, deduped by bar_timestamp
- New useMarketPrices hook in frontend
- Trend chart now shows price (purple line) on a right Y axis ($)
alongside trend metrics (%) on the left Y axis
- Custom tooltip formats price as dollars, metrics as percentages
- Price line uses connectNulls for days with missing bar data
This commit is contained in:
@@ -245,6 +245,26 @@ export function useTrendHistory(params?: { ticker?: string; window?: string; lim
|
||||
return useGet<TrendSummary[]>(['trend-history', params], 'query', path);
|
||||
}
|
||||
|
||||
export interface MarketPrice {
|
||||
ticker: string;
|
||||
close: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
bar_timestamp: number;
|
||||
captured_at: string;
|
||||
}
|
||||
|
||||
export function useMarketPrices(ticker: string | undefined, limit = 30) {
|
||||
return useGet<MarketPrice[]>(
|
||||
['market-prices', ticker, limit],
|
||||
'query',
|
||||
`/api/market/prices/${ticker}?limit=${limit}`,
|
||||
!!ticker,
|
||||
);
|
||||
}
|
||||
|
||||
export function useTrend(id: string | undefined) {
|
||||
return useGet<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
useCorporateDecisions,
|
||||
useTrends,
|
||||
useTrendHistory,
|
||||
useMarketPrices,
|
||||
} from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary } from '../api/hooks';
|
||||
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary, MarketPrice } from '../api/hooks';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
CartesianGrid, Legend,
|
||||
@@ -42,6 +43,7 @@ export function CompanyDetailPage() {
|
||||
const { data: decisions } = useCorporateDecisions(company?.ticker);
|
||||
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
|
||||
const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, limit: 500 });
|
||||
const { data: marketPrices } = useMarketPrices(company?.ticker);
|
||||
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
||||
|
||||
if (isLoading || !company) return <LoadingSpinner />;
|
||||
@@ -80,7 +82,7 @@ export function CompanyDetailPage() {
|
||||
</div>
|
||||
|
||||
{tab === 'trends' && (
|
||||
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} />
|
||||
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices ?? []} />
|
||||
)}
|
||||
|
||||
{tab === 'sources' && (
|
||||
@@ -563,11 +565,12 @@ interface ChartPoint {
|
||||
direction: number;
|
||||
directionLabel: string;
|
||||
window: string;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
function TrendTooltip({ active, payload, label }: Record<string, unknown>) {
|
||||
if (!active) return null;
|
||||
const items = payload as Array<{ name: string; value: number; color: string }> | undefined;
|
||||
const items = payload as Array<{ name: string; value: number; color: string; dataKey: string }> | undefined;
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-700 bg-surface-900 px-3 py-2 text-xs shadow-lg">
|
||||
@@ -575,14 +578,16 @@ function TrendTooltip({ active, payload, label }: Record<string, unknown>) {
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between gap-4" style={{ color: item.color }}>
|
||||
<span>{item.name}:</span>
|
||||
<span className="font-semibold">{item.value}%</span>
|
||||
<span className="font-semibold">
|
||||
{item.dataKey === 'price' ? `$${item.value.toFixed(2)}` : `${item.value}%`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string }) {
|
||||
function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[] }) {
|
||||
const [selectedWindow, setSelectedWindow] = useState('7d');
|
||||
|
||||
// Use history data for charts
|
||||
@@ -590,16 +595,32 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm
|
||||
.filter((t) => t.entity_id === ticker && t.window === selectedWindow)
|
||||
.sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime());
|
||||
|
||||
const chartData: ChartPoint[] = filtered.map((t) => ({
|
||||
time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
||||
timestamp: new Date(t.generated_at).getTime(),
|
||||
strength: +(t.trend_strength * 100).toFixed(1),
|
||||
confidence: +(t.confidence * 100).toFixed(1),
|
||||
contradiction: +(t.contradiction_score * 100).toFixed(1),
|
||||
direction: DIRECTION_VALUE[t.trend_direction] ?? 0,
|
||||
directionLabel: t.trend_direction,
|
||||
window: t.window,
|
||||
}));
|
||||
// Build a price lookup by date (closest price per day)
|
||||
const priceByDay = new Map<string, number>();
|
||||
for (const p of marketPrices ?? []) {
|
||||
if (p.bar_timestamp && p.close != null) {
|
||||
const d = new Date(p.bar_timestamp).toISOString().slice(0, 10);
|
||||
priceByDay.set(d, p.close);
|
||||
}
|
||||
}
|
||||
|
||||
const chartData: ChartPoint[] = filtered.map((t) => {
|
||||
const trendDate = new Date(t.generated_at).toISOString().slice(0, 10);
|
||||
const price = priceByDay.get(trendDate);
|
||||
return {
|
||||
time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
||||
timestamp: new Date(t.generated_at).getTime(),
|
||||
strength: +(t.trend_strength * 100).toFixed(1),
|
||||
confidence: +(t.confidence * 100).toFixed(1),
|
||||
contradiction: +(t.contradiction_score * 100).toFixed(1),
|
||||
direction: DIRECTION_VALUE[t.trend_direction] ?? 0,
|
||||
directionLabel: t.trend_direction,
|
||||
window: t.window,
|
||||
price,
|
||||
};
|
||||
});
|
||||
|
||||
const hasPrice = chartData.some((pt) => pt.price != null);
|
||||
|
||||
// Available windows from the data (check both history and latest)
|
||||
const allTrends = [...(trends ?? []), ...(latestTrends ?? [])];
|
||||
@@ -652,14 +673,26 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm
|
||||
tickLine={{ stroke: '#475569' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#475569' }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
{hasPrice && (
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: '#e879f9', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#475569' }}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
domain={['dataMin - 2', 'dataMax + 2']}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={TrendTooltip} />
|
||||
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="strength"
|
||||
name="Trend Strength"
|
||||
@@ -669,6 +702,7 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="confidence"
|
||||
name="Confidence"
|
||||
@@ -678,6 +712,7 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="contradiction"
|
||||
name="Contradiction"
|
||||
@@ -686,6 +721,19 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm
|
||||
strokeDasharray="5 5"
|
||||
dot={{ r: 2, fill: '#f59e0b' }}
|
||||
/>
|
||||
{hasPrice && (
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
name="Price"
|
||||
stroke="#e879f9"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#e879f9' }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user