951b733ac3
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
Variable was used before declaration (temporal dead zone error). Moved windowHours/hoursBack/cutoffTs above the filtered const that references cutoffTs.
846 lines
35 KiB
TypeScript
846 lines
35 KiB
TypeScript
import { useParams, useNavigate } from '@tanstack/react-router';
|
|
import { useState } from 'react';
|
|
import {
|
|
useCompany,
|
|
useCompanySources,
|
|
useCreateAlias,
|
|
useCreateSource,
|
|
useCompanyMacroImpacts,
|
|
useCompanyCompetitors,
|
|
useInferCompetitors,
|
|
useHistoricalPatterns,
|
|
useCompetitiveSignals,
|
|
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, MarketPrice } from '../api/hooks';
|
|
import {
|
|
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
|
CartesianGrid, Legend,
|
|
} from 'recharts';
|
|
|
|
const sourceCols: Column<Source>[] = [
|
|
{ key: 'source_type', header: 'Type' },
|
|
{ key: 'source_name', header: 'Name' },
|
|
{ key: 'credibility_score', header: 'Credibility', render: (r) => <span>{(r.credibility_score * 100).toFixed(0)}%</span> },
|
|
{ key: 'active', header: 'Status', render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} /> },
|
|
];
|
|
|
|
export function CompanyDetailPage() {
|
|
const { id } = useParams({ from: '/companies/$id' });
|
|
const navigate = useNavigate();
|
|
const { data: company, isLoading } = useCompany(id);
|
|
const { data: sources } = useCompanySources(id);
|
|
const { data: macroData } = useCompanyMacroImpacts(company?.ticker);
|
|
const { data: competitors } = useCompanyCompetitors(id);
|
|
const inferCompetitors = useInferCompetitors(id);
|
|
const { data: patterns } = useHistoricalPatterns(company?.ticker);
|
|
const { data: signals } = useCompetitiveSignals(company?.ticker);
|
|
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, 200);
|
|
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
|
|
|
if (isLoading || !company) return <LoadingSpinner />;
|
|
|
|
const tabs = ['trends', 'sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-xl font-semibold text-gray-100">{company.ticker}</h1>
|
|
<StatusBadge status={company.active ? 'active' : 'disabled'} />
|
|
</div>
|
|
|
|
<Card>
|
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
|
<div><dt className="text-gray-500">Name</dt><dd className="text-gray-200">{company.legal_name}</dd></div>
|
|
<div><dt className="text-gray-500">Exchange</dt><dd className="text-gray-200">{company.exchange ?? '—'}</dd></div>
|
|
<div><dt className="text-gray-500">Sector</dt><dd className="text-gray-200">{company.sector ?? '—'}</dd></div>
|
|
<div><dt className="text-gray-500">Industry</dt><dd className="text-gray-200">{company.industry ?? '—'}</dd></div>
|
|
<div><dt className="text-gray-500">Market Cap</dt><dd className="text-gray-200">{company.market_cap_bucket ?? '—'}</dd></div>
|
|
<div><dt className="text-gray-500">Sources</dt><dd className="text-gray-200">{company.active_source_count ?? 0}</dd></div>
|
|
</dl>
|
|
</Card>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-4 border-b border-surface-700 overflow-x-auto">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`whitespace-nowrap pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'trends' && (
|
|
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices ?? []} />
|
|
)}
|
|
|
|
{tab === 'sources' && (
|
|
<div className="space-y-4">
|
|
<DataTable<Source> data={sources ?? []} columns={sourceCols} keyField="id" />
|
|
<AddSourceForm companyId={id} />
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'aliases' && (
|
|
<div className="space-y-4">
|
|
<AliasesList aliases={company.aliases ?? []} />
|
|
<AddAliasForm companyId={id} />
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'macro' && (
|
|
<MacroExposurePanel macroData={macroData} onEventClick={(eventId) => navigate({ to: '/macro/events/$id', params: { id: eventId } })} />
|
|
)}
|
|
|
|
{tab === 'competitors' && (
|
|
<CompetitorsPanel competitors={competitors ?? []} onInfer={() => inferCompetitors.mutate()} isInferring={inferCompetitors.isPending} />
|
|
)}
|
|
|
|
{tab === 'patterns' && (
|
|
<HistoricalPatternsPanel patterns={patterns ?? []} />
|
|
)}
|
|
|
|
{tab === 'signals' && (
|
|
<CompetitiveSignalsPanel signals={signals ?? []} />
|
|
)}
|
|
|
|
{tab === 'decisions' && (
|
|
<DecisionsPanel decisions={decisions ?? []} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AliasesList({ aliases }: { aliases: Alias[] }) {
|
|
if (aliases.length === 0) return <p className="text-sm text-gray-500">No aliases configured</p>;
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
{aliases.map((a) => (
|
|
<span key={a.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
|
{a.alias} <span className="text-gray-500">({a.alias_type})</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddAliasForm({ companyId }: { companyId: string }) {
|
|
const [alias, setAlias] = useState('');
|
|
const mutation = useCreateAlias(companyId);
|
|
|
|
return (
|
|
<form
|
|
className="flex gap-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (alias.trim()) mutation.mutate({ alias: alias.trim() }, { onSuccess: () => setAlias('') });
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
placeholder="Add alias…"
|
|
value={alias}
|
|
onChange={(e) => setAlias(e.target.value)}
|
|
className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
|
aria-label="New alias"
|
|
/>
|
|
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
|
Add
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function AddSourceForm({ companyId }: { companyId: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [sourceType, setSourceType] = useState('market_api');
|
|
const [sourceName, setSourceName] = useState('');
|
|
const [credibility, setCredibility] = useState(0.5);
|
|
const mutation = useCreateSource(companyId);
|
|
|
|
if (!open) {
|
|
return (
|
|
<button onClick={() => setOpen(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
|
|
Add Source
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<form
|
|
className="space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
mutation.mutate(
|
|
{ source_type: sourceType, source_name: sourceName, credibility_score: credibility },
|
|
{ onSuccess: () => { setOpen(false); setSourceName(''); } },
|
|
);
|
|
}}
|
|
>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-type">Type</label>
|
|
<select
|
|
id="source-type"
|
|
value={sourceType}
|
|
onChange={(e) => setSourceType(e.target.value)}
|
|
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200"
|
|
>
|
|
{['market_api', 'news_api', 'filings_api', 'web_scrape', 'broker'].map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-name">Name</label>
|
|
<input
|
|
id="source-name"
|
|
type="text"
|
|
value={sourceName}
|
|
onChange={(e) => setSourceName(e.target.value)}
|
|
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500"
|
|
placeholder="Source name"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-gray-500" htmlFor="credibility">Credibility: {(credibility * 100).toFixed(0)}%</label>
|
|
<input
|
|
id="credibility"
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
value={credibility}
|
|
onChange={(e) => setCredibility(Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
|
Save
|
|
</button>
|
|
<button type="button" onClick={() => setOpen(false)} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function MacroExposurePanel({ macroData, onEventClick }: {
|
|
macroData: { exposure_profile: import('../api/hooks').ExposureProfile | null; impacts: MacroImpactRecord[] } | undefined;
|
|
onEventClick: (eventId: string) => void;
|
|
}) {
|
|
if (!macroData) return <p className="text-sm text-gray-500">Loading macro data…</p>;
|
|
|
|
const profile = macroData.exposure_profile;
|
|
const impacts = macroData.impacts ?? [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Exposure Profile */}
|
|
<Card>
|
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Exposure Profile</h2>
|
|
{!profile ? (
|
|
<p className="text-sm text-gray-500">No exposure profile configured</p>
|
|
) : (
|
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
|
|
<div>
|
|
<dt className="text-gray-500">Market Position</dt>
|
|
<dd><StatusBadge status={profile.market_position_tier} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Export Dependency</dt>
|
|
<dd className="text-gray-200">{(profile.export_dependency_pct * 100).toFixed(0)}%</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Source</dt>
|
|
<dd><StatusBadge status={profile.source} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Confidence</dt>
|
|
<dd><ConfidenceBar value={profile.confidence} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Revenue Mix</dt>
|
|
<dd className="flex flex-wrap gap-1 mt-1">
|
|
{Object.entries(profile.geographic_revenue_mix).map(([region, pct]) => (
|
|
<span key={region} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">
|
|
{region}: {(pct * 100).toFixed(0)}%
|
|
</span>
|
|
))}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Supply Chain Regions</dt>
|
|
<dd className="flex flex-wrap gap-1 mt-1">
|
|
{profile.supply_chain_regions.map((r) => (
|
|
<span key={r} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{r}</span>
|
|
))}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Key Commodities</dt>
|
|
<dd className="flex flex-wrap gap-1 mt-1">
|
|
{profile.key_input_commodities.length > 0
|
|
? profile.key_input_commodities.map((c) => (
|
|
<span key={c} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{c}</span>
|
|
))
|
|
: <span className="text-gray-500">—</span>}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Active Macro Impacts */}
|
|
<Card>
|
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Active Macro Impacts ({impacts.length})</h2>
|
|
{impacts.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No active macro impacts</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{impacts.map((impact) => (
|
|
<div
|
|
key={impact.id}
|
|
className="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-950 p-3 cursor-pointer hover:border-brand-500/50"
|
|
onClick={() => onEventClick(impact.event_id)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<StatusBadge status={impact.impact_direction} />
|
|
<ConfidenceBar value={impact.macro_impact_score} />
|
|
<div className="flex flex-wrap gap-1">
|
|
{impact.contributing_factors.map((f, i) => (
|
|
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{f}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-gray-500">{new Date(impact.computed_at).toLocaleDateString()}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompetitorsPanel({ competitors, onInfer, isInferring }: {
|
|
competitors: CompetitorRelationship[];
|
|
onInfer: () => void;
|
|
isInferring: boolean;
|
|
}) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-medium text-gray-400">Active Competitors ({competitors.length})</h2>
|
|
<button
|
|
onClick={onInfer}
|
|
disabled={isInferring}
|
|
className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
|
>
|
|
{isInferring ? 'Inferring…' : 'Infer Competitors'}
|
|
</button>
|
|
</div>
|
|
{competitors.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No competitor relationships defined</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{competitors.map((c) => (
|
|
<div key={c.id} className="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-950 p-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono font-semibold text-brand-300">{c.ticker ?? c.legal_name ?? 'Unknown'}</span>
|
|
<StatusBadge status={c.relationship_type} />
|
|
<ConfidenceBar value={c.strength} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${c.source === 'manual' ? 'bg-blue-900/40 border border-blue-700/50 text-blue-400' : 'bg-emerald-900/40 border border-emerald-700/50 text-emerald-400'}`}>
|
|
{c.source.toUpperCase()}
|
|
</span>
|
|
{c.bidirectional && (
|
|
<span className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">↔ bidirectional</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HistoricalPatternsPanel({ patterns }: { patterns: HistoricalPattern[] }) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<h2 className="text-sm font-medium text-gray-400">Historical Patterns ({patterns.length})</h2>
|
|
{patterns.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No historical patterns found</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{patterns.map((p, i) => (
|
|
<Card key={i}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-200">{p.catalyst_type}</span>
|
|
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{p.time_horizon}</span>
|
|
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${p.tier === 'major_corporate_decision' ? 'bg-orange-900/40 border border-orange-700/50 text-orange-400' : 'bg-surface-800 text-gray-400'}`}>
|
|
{p.tier === 'major_corporate_decision' ? 'MAJOR' : 'ROUTINE'}
|
|
</span>
|
|
{p.insufficient_data && (
|
|
<span className="rounded bg-yellow-900/40 border border-yellow-700/50 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">LOW DATA</span>
|
|
)}
|
|
</div>
|
|
<ConfidenceBar value={p.pattern_confidence} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
|
|
<div>
|
|
<span className="text-gray-500">Samples: </span>
|
|
<span className="text-gray-300">{p.sample_count}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Bullish: </span>
|
|
<span className="text-green-400">{(p.bullish_pct * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Bearish: </span>
|
|
<span className="text-red-400">{(p.bearish_pct * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Avg Strength: </span>
|
|
<span className="text-gray-300">{(p.avg_strength * 100).toFixed(0)}%</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompetitiveSignalsPanel({ signals }: { signals: CompetitiveSignal[] }) {
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h2 className="text-sm font-medium text-gray-400">Incoming Competitive Signals ({signals.length})</h2>
|
|
{signals.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No competitive signals received</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{signals.map((s) => (
|
|
<div key={s.id}>
|
|
<div
|
|
className="flex items-center justify-between rounded-lg border border-cyan-700/30 bg-cyan-900/10 p-3 cursor-pointer hover:border-cyan-500/50"
|
|
onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="rounded bg-cyan-900/40 border border-cyan-700/50 px-1.5 py-0.5 text-[10px] font-medium text-cyan-400">COMPETITIVE</span>
|
|
<span className="font-mono text-sm text-brand-300">{s.source_ticker}</span>
|
|
<span className="text-xs text-gray-400">→</span>
|
|
<StatusBadge status={s.catalyst_type} />
|
|
<StatusBadge status={s.signal_direction} />
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<ConfidenceBar value={s.signal_strength} />
|
|
<span className="text-xs text-gray-500">{new Date(s.computed_at).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
{expandedId === s.id && (
|
|
<Card className="mt-1 ml-4">
|
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:grid-cols-3">
|
|
<div>
|
|
<dt className="text-gray-500">Source Ticker</dt>
|
|
<dd className="font-mono text-gray-200">{s.source_ticker}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Target Ticker</dt>
|
|
<dd className="font-mono text-gray-200">{s.target_ticker}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Catalyst Type</dt>
|
|
<dd className="text-gray-200">{s.catalyst_type}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Pattern Confidence</dt>
|
|
<dd><ConfidenceBar value={s.pattern_confidence} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Signal Strength</dt>
|
|
<dd><ConfidenceBar value={s.signal_strength} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Relationship Strength</dt>
|
|
<dd><ConfidenceBar value={s.relationship_strength} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Source Document</dt>
|
|
<dd className="font-mono text-gray-400 text-[10px]">{s.source_document_id}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Computed At</dt>
|
|
<dd className="text-gray-200">{new Date(s.computed_at).toLocaleString()}</dd>
|
|
</div>
|
|
</dl>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DecisionsPanel({ decisions }: { decisions: CorporateDecision[] }) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<h2 className="text-sm font-medium text-gray-400">Corporate Decision Timeline ({decisions.length})</h2>
|
|
{decisions.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No major corporate decisions found</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{decisions.map((d, i) => (
|
|
<div key={i} className="flex items-start gap-4 rounded-lg border border-surface-700 bg-surface-950 p-3">
|
|
<div className="flex flex-col items-center">
|
|
<span className="text-xs font-medium text-gray-400">{new Date(d.date).toLocaleDateString()}</span>
|
|
<div className="mt-1 h-full w-px bg-surface-700" />
|
|
</div>
|
|
<div className="flex-1 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded bg-orange-900/40 border border-orange-700/50 px-1.5 py-0.5 text-[10px] font-medium text-orange-400">
|
|
{d.catalyst_type}
|
|
</span>
|
|
<StatusBadge status={d.trend_direction} />
|
|
<ConfidenceBar value={d.trend_strength} />
|
|
</div>
|
|
<p className="text-sm text-gray-300">{d.summary}</p>
|
|
<div className="flex gap-4 text-xs text-gray-500">
|
|
<span>Samples: {d.sample_count}</span>
|
|
<span>Confidence: {(d.pattern_confidence * 100).toFixed(0)}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trend History Chart
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DIRECTION_VALUE: Record<string, number> = {
|
|
bullish: 1,
|
|
bearish: -1,
|
|
mixed: 0,
|
|
neutral: 0,
|
|
};
|
|
|
|
const WINDOW_ORDER = ['intraday', '1d', '7d', '30d', '90d'];
|
|
|
|
interface ChartPoint {
|
|
time: string;
|
|
timestamp: number;
|
|
strength: number;
|
|
confidence: number;
|
|
contradiction: number;
|
|
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; 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">
|
|
<div className="mb-1 text-gray-400">{String(label ?? '')}</div>
|
|
{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.dataKey === 'price' ? `$${item.value.toFixed(2)}` : `${item.value}%`}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[] }) {
|
|
const [selectedWindow, setSelectedWindow] = useState('7d');
|
|
|
|
// Determine the time range for the selected window to filter data
|
|
const windowHours: Record<string, number> = {
|
|
intraday: 12,
|
|
'1d': 24,
|
|
'7d': 7 * 24,
|
|
'30d': 30 * 24,
|
|
'90d': 90 * 24,
|
|
};
|
|
const hoursBack = windowHours[selectedWindow] ?? 7 * 24;
|
|
const cutoffTs = Date.now() - hoursBack * 3600_000;
|
|
|
|
// Use history data for charts — filter to selected window and time range
|
|
const filtered = (trends ?? [])
|
|
.filter((t) => t.entity_id === ticker && t.window === selectedWindow && new Date(t.generated_at).getTime() >= cutoffTs)
|
|
.sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime());
|
|
|
|
// Build a price lookup — match by closest timestamp to each trend point
|
|
const sortedPrices = [...(marketPrices ?? [])]
|
|
.filter((p) => p.bar_timestamp != null && p.close != null)
|
|
.sort((a, b) => a.bar_timestamp - b.bar_timestamp);
|
|
|
|
// Filter prices to the selected window's time range
|
|
const windowPrices = sortedPrices.filter((p) => p.bar_timestamp >= cutoffTs);
|
|
|
|
function findClosestPrice(ts: number): number | undefined {
|
|
if (windowPrices.length === 0) return undefined;
|
|
let best = windowPrices[0];
|
|
let bestDiff = Math.abs(ts - best.bar_timestamp);
|
|
for (const p of windowPrices) {
|
|
const diff = Math.abs(ts - p.bar_timestamp);
|
|
if (diff < bestDiff) {
|
|
best = p;
|
|
bestDiff = diff;
|
|
}
|
|
}
|
|
// Only match if within 2 hours (for intraday) or 36 hours (for daily)
|
|
const maxGap = selectedWindow === 'intraday' ? 2 * 3600_000 : 36 * 3600_000;
|
|
return bestDiff <= maxGap ? best.close : undefined;
|
|
}
|
|
|
|
const chartData: ChartPoint[] = filtered.map((t) => {
|
|
const trendTs = new Date(t.generated_at).getTime();
|
|
const price = findClosestPrice(trendTs);
|
|
return {
|
|
time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
timestamp: trendTs,
|
|
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 ?? [])];
|
|
const availableWindows = [...new Set(allTrends.filter((t) => t.entity_id === ticker).map((t) => t.window))];
|
|
availableWindows.sort((a, b) => WINDOW_ORDER.indexOf(a) - WINDOW_ORDER.indexOf(b));
|
|
|
|
// Use latest trends for the summary card
|
|
const latestForWindow = (latestTrends ?? [])
|
|
.filter((t) => t.entity_id === ticker && t.window === selectedWindow)
|
|
.sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime());
|
|
const latest = latestForWindow[0] ?? (filtered.length > 0 ? filtered[filtered.length - 1] : null);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Window selector */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-gray-400">Window:</span>
|
|
{(availableWindows.length > 0 ? availableWindows : WINDOW_ORDER).map((w) => (
|
|
<button
|
|
key={w}
|
|
onClick={() => setSelectedWindow(w)}
|
|
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
|
selectedWindow === w
|
|
? 'bg-brand-600 text-white'
|
|
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
|
|
}`}
|
|
>
|
|
{w}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{chartData.length === 0 ? (
|
|
<Card>
|
|
<p className="text-sm text-gray-500">No trend history for {ticker} / {selectedWindow}</p>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
{/* Trend Strength & Confidence Chart */}
|
|
<Card>
|
|
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
|
Trend Strength & Confidence — {ticker} / {selectedWindow}
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
|
<XAxis
|
|
dataKey="time"
|
|
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
|
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"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
dot={{ r: 3, fill: '#3b82f6' }}
|
|
activeDot={{ r: 5 }}
|
|
/>
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="confidence"
|
|
name="Confidence"
|
|
stroke="#10b981"
|
|
strokeWidth={2}
|
|
dot={{ r: 3, fill: '#10b981' }}
|
|
activeDot={{ r: 5 }}
|
|
/>
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="contradiction"
|
|
name="Contradiction"
|
|
stroke="#f59e0b"
|
|
strokeWidth={1.5}
|
|
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>
|
|
|
|
{/* Direction Timeline */}
|
|
<Card>
|
|
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
|
Direction Timeline — {ticker} / {selectedWindow}
|
|
</h2>
|
|
<div className="flex items-end gap-1 overflow-x-auto pb-2" style={{ minHeight: 60 }}>
|
|
{chartData.map((pt, i) => {
|
|
const color =
|
|
pt.directionLabel === 'bullish' ? 'bg-green-500' :
|
|
pt.directionLabel === 'bearish' ? 'bg-red-500' :
|
|
pt.directionLabel === 'mixed' ? 'bg-yellow-500' :
|
|
'bg-gray-600';
|
|
const height = Math.max(8, pt.strength * 0.5);
|
|
return (
|
|
<div key={i} className="flex flex-col items-center gap-1" title={`${pt.time}: ${pt.directionLabel} (${pt.strength}%)`}>
|
|
<div
|
|
className={`w-3 rounded-sm ${color}`}
|
|
style={{ height: `${height}px` }}
|
|
/>
|
|
{i % Math.max(1, Math.floor(chartData.length / 8)) === 0 && (
|
|
<span className="text-[9px] text-gray-500 -rotate-45 origin-top-left whitespace-nowrap">{pt.time}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
|
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-green-500" /> Bullish</span>
|
|
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-red-500" /> Bearish</span>
|
|
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-yellow-500" /> Mixed</span>
|
|
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-gray-600" /> Neutral</span>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Latest trend summary */}
|
|
<Card>
|
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Latest Trend</h2>
|
|
{latest && (() => {
|
|
return (
|
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
|
<div>
|
|
<dt className="text-gray-500">Direction</dt>
|
|
<dd><StatusBadge status={latest.trend_direction} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Strength</dt>
|
|
<dd><ConfidenceBar value={latest.trend_strength} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Confidence</dt>
|
|
<dd><ConfidenceBar value={latest.confidence} /></dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Contradiction</dt>
|
|
<dd className={`font-mono ${latest.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-300'}`}>
|
|
{(latest.contradiction_score * 100).toFixed(0)}%
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Catalysts</dt>
|
|
<dd className="flex flex-wrap gap-1">
|
|
{(latest.dominant_catalysts ?? []).map((c, i) => (
|
|
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{c}</span>
|
|
))}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-gray-500">Generated</dt>
|
|
<dd className="text-gray-300">{new Date(latest.generated_at).toLocaleString()}</dd>
|
|
</div>
|
|
</dl>
|
|
);
|
|
})()}
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|