feat: competitive intelligence & historical pattern matching layer

This commit is contained in:
Celes Renata
2026-04-14 19:42:48 +00:00
parent b478022ba3
commit f7a11d14ea
203 changed files with 20155 additions and 97 deletions
+262
View File
@@ -474,3 +474,265 @@ export function useCoverageGaps() {
export function useSymbolCoverage() {
return useGet<unknown[]>(['symbol-coverage'], 'query', '/api/admin/companies/coverage');
}
// ---------------------------------------------------------------------------
// Competitors (Symbol Registry)
// ---------------------------------------------------------------------------
export interface CompetitorRelationship {
id: string;
company_a_id: string;
company_b_id: string;
relationship_type: string;
strength: number;
bidirectional: boolean;
source: string;
active: boolean;
created_at: string;
updated_at: string;
// Enriched fields from API
ticker?: string;
legal_name?: string;
}
export function useCompanyCompetitors(companyId: string | undefined) {
return useGet<CompetitorRelationship[]>(
['company-competitors', companyId],
'registry',
`/companies/${companyId}/competitors`,
!!companyId,
);
}
export function useInferCompetitors(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<CompetitorRelationship[]>('registry', `/companies/${companyId}/competitors/infer`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['company-competitors', companyId] }),
});
}
// ---------------------------------------------------------------------------
// Historical Patterns (Query API)
// ---------------------------------------------------------------------------
export interface HistoricalPattern {
source_ticker: string;
target_ticker: string;
catalyst_type: string;
time_horizon: string;
sample_count: number;
bullish_pct: number;
bearish_pct: number;
avg_strength: number;
avg_time_to_resolution: number;
pattern_confidence: number;
data_start: string;
data_end: string;
tier: string;
insufficient_data: boolean;
}
export function useHistoricalPatterns(ticker: string | undefined, params?: { catalyst_type?: string; time_horizon?: string }) {
const qs = new URLSearchParams();
if (params?.catalyst_type) qs.set('catalyst_type', params.catalyst_type);
if (params?.time_horizon) qs.set('time_horizon', params.time_horizon);
const path = `/api/patterns/${ticker}${qs.toString() ? '?' + qs : ''}`;
return useGet<HistoricalPattern[]>(['historical-patterns', ticker, params], 'query', path, !!ticker);
}
// ---------------------------------------------------------------------------
// Competitive Signals (Query API)
// ---------------------------------------------------------------------------
export interface CompetitiveSignal {
id: string;
source_document_id: string;
source_ticker: string;
target_ticker: string;
catalyst_type: string;
pattern_confidence: number;
signal_direction: string;
signal_strength: number;
relationship_strength: number;
computed_at: string;
}
export function useCompetitiveSignals(ticker: string | undefined) {
return useGet<CompetitiveSignal[]>(
['competitive-signals', ticker],
'query',
`/api/patterns/${ticker}/competitive-signals`,
!!ticker,
);
}
// ---------------------------------------------------------------------------
// Corporate Decisions (Query API)
// ---------------------------------------------------------------------------
export interface CorporateDecision {
catalyst_type: string;
date: string;
summary: string;
trend_direction: string;
trend_strength: number;
sample_count: number;
pattern_confidence: number;
document_id?: string;
}
export function useCorporateDecisions(ticker: string | undefined) {
return useGet<CorporateDecision[]>(
['corporate-decisions', ticker],
'query',
`/api/patterns/${ticker}/decisions`,
!!ticker,
);
}
// ---------------------------------------------------------------------------
// Competitive Layer Toggle (Query API)
// ---------------------------------------------------------------------------
export interface CompetitiveStatus {
enabled: boolean;
toggled_at: string | null;
toggled_by: string | null;
}
export function useCompetitiveStatus() {
return useGet<CompetitiveStatus>(['competitive-status'], 'query', '/api/admin/competitive/status');
}
export function useToggleCompetitive() {
const qc = useQueryClient();
return useMutation({
mutationFn: (enabled: boolean) => apiPut<unknown>('query', '/api/admin/competitive/toggle', { enabled }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['competitive-status'] }),
});
}
// ---------------------------------------------------------------------------
// Macro: Global Events (Task 17.1, 17.2)
// ---------------------------------------------------------------------------
export interface GlobalEvent {
id: string;
event_types: string[];
severity: string;
affected_regions: string[];
affected_sectors: string[];
affected_commodities: string[];
summary: string;
key_facts: string[];
estimated_duration: string;
confidence: number;
source_document_id: string | null;
model_provider: string | null;
model_name: string | null;
created_at: string;
}
export interface MacroImpactRecord {
id: string;
event_id: string;
company_id: string;
ticker: string;
macro_impact_score: number;
impact_direction: string;
contributing_factors: string[];
confidence: number;
computed_at: string;
}
export interface GlobalEventDetail extends GlobalEvent {
impacts: MacroImpactRecord[];
}
export interface ExposureProfile {
id: string;
company_id: string;
geographic_revenue_mix: Record<string, number>;
supply_chain_regions: string[];
key_input_commodities: string[];
regulatory_jurisdictions: string[];
market_position_tier: string;
export_dependency_pct: number;
source: string;
confidence: number;
version: number;
active: boolean;
created_at: string;
updated_at: string;
}
export interface CompanyMacroImpacts {
exposure_profile: ExposureProfile | null;
impacts: MacroImpactRecord[];
}
export function useGlobalEvents(params?: { severity?: string; region?: string; sector?: string; limit?: number }) {
const qs = new URLSearchParams();
if (params?.severity) qs.set('severity', params.severity);
if (params?.region) qs.set('region', params.region);
if (params?.sector) qs.set('sector', params.sector);
if (params?.limit) qs.set('limit', String(params.limit));
const path = `/api/macro/events${qs.toString() ? '?' + qs : ''}`;
return useGet<GlobalEvent[]>(['global-events', params], 'query', path);
}
export function useGlobalEvent(id: string | undefined) {
return useGet<GlobalEventDetail>(['global-event', id], 'query', `/api/macro/events/${id}`, !!id);
}
// ---------------------------------------------------------------------------
// Macro: Company Impacts (Task 17.3)
// ---------------------------------------------------------------------------
export function useCompanyMacroImpacts(ticker: string | undefined) {
return useGet<CompanyMacroImpacts>(['company-macro-impacts', ticker], 'query', `/api/macro/impacts/${ticker}`, !!ticker);
}
// ---------------------------------------------------------------------------
// Macro: Trend Projection (Task 17.5)
// ---------------------------------------------------------------------------
export interface TrendProjection {
id: string;
trend_window_id: string;
projected_direction: string;
projected_strength: number;
projected_confidence: number;
projection_horizon: string;
driving_factors: string[];
macro_contribution_pct: number;
diverges_from_current: boolean;
computed_at: string;
}
export function useTrendProjection(trendId: string | undefined) {
return useGet<TrendProjection>(['trend-projection', trendId], 'query', `/api/trends/${trendId}/projection`, !!trendId);
}
// ---------------------------------------------------------------------------
// Macro: Admin Toggle (Task 17.6)
// ---------------------------------------------------------------------------
export interface MacroStatus {
enabled: boolean;
toggled_at: string | null;
toggled_by: string | null;
}
export function useMacroStatus() {
return useGet<MacroStatus>(['macro-status'], 'query', '/api/admin/macro/status');
}
export function useToggleMacro() {
const qc = useQueryClient();
return useMutation({
mutationFn: (enabled: boolean) => apiPut<unknown>('query', `/api/admin/macro/toggle`, { enabled }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['macro-status'] }),
});
}
+2
View File
@@ -16,6 +16,7 @@ import {
Terminal,
LayoutDashboard,
List,
Globe,
} from 'lucide-react';
interface NavItem {
@@ -32,6 +33,7 @@ const navItems: NavItem[] = [
{ to: '/documents', label: 'Documents', icon: <FileText size={18} />, group: 'Data' },
{ to: '/trends', label: 'Trends', icon: <TrendingUp size={18} />, group: 'Intelligence' },
{ to: '/recommendations', label: 'Recommendations', icon: <Lightbulb size={18} />, group: 'Intelligence' },
{ to: '/macro/events', label: 'Global Events', icon: <Globe size={18} />, group: 'Intelligence' },
{ to: '/orders', label: 'Orders', icon: <ShoppingCart size={18} />, group: 'Trading' },
{ to: '/positions', label: 'Positions', icon: <Wallet size={18} />, group: 'Trading' },
{ to: '/trading', label: 'Trading Controls', icon: <ShieldCheck size={18} />, group: 'Trading' },
+351 -9
View File
@@ -1,10 +1,20 @@
import { useParams } from '@tanstack/react-router';
import { useParams, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useCompany, useCompanySources, useCreateAlias, useCreateSource } from '../api/hooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
import {
useCompany,
useCompanySources,
useCreateAlias,
useCreateSource,
useCompanyMacroImpacts,
useCompanyCompetitors,
useInferCompetitors,
useHistoricalPatterns,
useCompetitiveSignals,
useCorporateDecisions,
} from '../api/hooks';
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
import { DataTable, type Column } from '../components/DataTable';
import type { Source } from '../api/hooks';
import type { Alias } from '../api/hooks';
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision } from '../api/hooks';
const sourceCols: Column<Source>[] = [
{ key: 'source_type', header: 'Type' },
@@ -15,12 +25,21 @@ const sourceCols: Column<Source>[] = [
export function CompanyDetailPage() {
const { id } = useParams({ from: '/companies/$id' });
const navigate = useNavigate();
const { data: company, isLoading } = useCompany(id);
const { data: sources } = useCompanySources(id);
const [tab, setTab] = useState<'aliases' | 'sources'>('sources');
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 [tab, setTab] = useState<'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('sources');
if (isLoading || !company) return <LoadingSpinner />;
const tabs = ['sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
@@ -40,12 +59,12 @@ export function CompanyDetailPage() {
</Card>
{/* Tabs */}
<div className="flex gap-4 border-b border-surface-700">
{(['sources', 'aliases'] as const).map((t) => (
<div className="flex gap-4 border-b border-surface-700 overflow-x-auto">
{tabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`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'}`}
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>
@@ -65,6 +84,28 @@ export function CompanyDetailPage() {
<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>
);
</div>
);
}
@@ -188,3 +229,304 @@ function AddSourceForm({ companyId }: { companyId: string }) {
</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.company_b_id.slice(0, 8)}</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>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useParams, useNavigate } from '@tanstack/react-router';
import { useGlobalEvent } from '../api/hooks';
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
import { DataTable, type Column } from '../components/DataTable';
import type { MacroImpactRecord } from '../api/hooks';
const severityColors: Record<string, string> = {
critical: 'bg-red-900/40 text-red-400 border-red-700/50',
high: 'bg-orange-900/40 text-orange-400 border-orange-700/50',
moderate: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
low: 'bg-green-900/40 text-green-400 border-green-700/50',
};
const impactCols: Column<MacroImpactRecord>[] = [
{ key: 'ticker', header: 'Ticker', render: (r) => <span className="font-mono font-semibold text-brand-300">{r.ticker}</span> },
{ key: 'macro_impact_score', header: 'Impact Score', render: (r) => <ConfidenceBar value={r.macro_impact_score} /> },
{ key: 'impact_direction', header: 'Direction', render: (r) => <StatusBadge status={r.impact_direction} /> },
{
key: 'contributing_factors',
header: 'Contributing Factors',
render: (r) => (
<div className="flex flex-wrap gap-1">
{r.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>
),
},
{ key: 'confidence', header: 'Confidence', render: (r) => <ConfidenceBar value={r.confidence} /> },
{ key: 'computed_at', header: 'Computed', render: (r) => <span className="text-xs">{new Date(r.computed_at).toLocaleString()}</span> },
];
export function GlobalEventDetailPage() {
const { id } = useParams({ from: '/macro/events/$id' });
const navigate = useNavigate();
const { data: event, isLoading } = useGlobalEvent(id);
if (isLoading || !event) return <LoadingSpinner />;
const sevCls = severityColors[event.severity] ?? 'bg-gray-800/40 text-gray-400 border-gray-700/50';
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-100">Global Event</h1>
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${sevCls}`}>{event.severity}</span>
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{event.estimated_duration.replace(/_/g, ' ')}</span>
</div>
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Summary</h2>
<p className="text-sm text-gray-200">{event.summary}</p>
</Card>
<Card>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-gray-500">Impact Types</dt>
<dd className="flex flex-wrap gap-1 mt-1">
{event.event_types.map((t) => (
<span key={t} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{t.replace(/_/g, ' ')}</span>
))}
</dd>
</div>
<div>
<dt className="text-gray-500">Regions</dt>
<dd className="flex flex-wrap gap-1 mt-1">
{event.affected_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">Sectors</dt>
<dd className="flex flex-wrap gap-1 mt-1">
{event.affected_sectors.map((s) => (
<span key={s} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{s}</span>
))}
</dd>
</div>
<div>
<dt className="text-gray-500">Confidence</dt>
<dd><ConfidenceBar value={event.confidence} /></dd>
</div>
</dl>
</Card>
{event.key_facts && event.key_facts.length > 0 && (
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Key Facts</h2>
<ul className="ml-4 list-disc text-sm text-gray-300">
{event.key_facts.map((f, i) => <li key={i}>{f}</li>)}
</ul>
</Card>
)}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Affected Companies ({event.impacts?.length ?? 0})</h2>
<DataTable<MacroImpactRecord>
data={event.impacts ?? []}
columns={impactCols}
keyField="id"
onRowClick={(row) => navigate({ to: '/companies/$id', params: { id: row.company_id } })}
filterFn={(row, q) => row.ticker.toLowerCase().includes(q.toLowerCase())}
/>
</Card>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useGlobalEvents } from '../api/hooks';
import { DataTable, type Column } from '../components/DataTable';
import { StatusBadge, LoadingSpinner } from '../components/ui';
import type { GlobalEvent } from '../api/hooks';
const SEVERITIES = ['low', 'moderate', 'high', 'critical'];
const severityColors: Record<string, string> = {
critical: 'bg-red-900/40 text-red-400 border-red-700/50',
high: 'bg-orange-900/40 text-orange-400 border-orange-700/50',
moderate: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
low: 'bg-green-900/40 text-green-400 border-green-700/50',
};
function SeverityBadge({ severity }: { severity: string }) {
const cls = severityColors[severity] ?? 'bg-gray-800/40 text-gray-400 border-gray-700/50';
return (
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${cls}`}>
{severity}
</span>
);
}
const columns: Column<GlobalEvent>[] = [
{
key: 'summary',
header: 'Summary',
render: (r) => <span className="line-clamp-1 max-w-sm">{r.summary}</span>,
},
{
key: 'event_types',
header: 'Impact Types',
render: (r) => (
<div className="flex flex-wrap gap-1">
{r.event_types.map((t) => (
<span key={t} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{t.replace(/_/g, ' ')}</span>
))}
</div>
),
},
{
key: 'severity',
header: 'Severity',
render: (r) => <SeverityBadge severity={r.severity} />,
},
{
key: 'affected_regions',
header: 'Regions',
render: (r) => (
<div className="flex flex-wrap gap-1">
{r.affected_regions.slice(0, 4).map((reg) => (
<span key={reg} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{reg}</span>
))}
{r.affected_regions.length > 4 && <span className="text-[10px] text-gray-500">+{r.affected_regions.length - 4}</span>}
</div>
),
},
{
key: 'affected_sectors',
header: 'Sectors',
render: (r) => (
<div className="flex flex-wrap gap-1">
{r.affected_sectors.slice(0, 3).map((s) => (
<span key={s} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{s}</span>
))}
{r.affected_sectors.length > 3 && <span className="text-[10px] text-gray-500">+{r.affected_sectors.length - 3}</span>}
</div>
),
},
{
key: 'created_at',
header: 'Event Date',
render: (r) => <span className="text-xs">{new Date(r.created_at).toLocaleDateString()}</span>,
},
];
export function GlobalEventsPage() {
const navigate = useNavigate();
const [severity, setSeverity] = useState('');
const { data, isLoading, error } = useGlobalEvents({
severity: severity || undefined,
limit: 100,
});
if (isLoading) return <LoadingSpinner />;
if (error) return <div className="text-red-400">Failed to load global events</div>;
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-100">Global Events</h1>
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Severity filter">
<button
onClick={() => setSeverity('')}
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${!severity ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
>
All
</button>
{SEVERITIES.map((s) => (
<button
key={s}
onClick={() => setSeverity(s)}
className={`px-2 py-1 text-xs font-medium capitalize last:rounded-r-md ${severity === s ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
>
{s}
</button>
))}
</div>
</div>
<DataTable<GlobalEvent>
data={data ?? []}
columns={columns}
keyField="id"
onRowClick={(row) => navigate({ to: '/macro/events/$id', params: { id: row.id } })}
filterFn={(row, q) => {
const lq = q.toLowerCase();
return row.summary.toLowerCase().includes(lq) || row.event_types.some((t) => t.toLowerCase().includes(lq));
}}
/>
</div>
);
}
+29 -16
View File
@@ -1,4 +1,4 @@
import { useParams } from '@tanstack/react-router';
import { useParams, Link } from '@tanstack/react-router';
import { useRecommendation } from '../api/hooks';
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
@@ -67,23 +67,36 @@ export function RecommendationDetailPage() {
<p className="text-sm text-gray-500">No evidence linked</p>
) : (
<div className="space-y-2">
{rec.evidence.map((ev) => (
<div key={ev.id} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusBadge status={ev.evidence_type} />
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
{rec.evidence.map((ev) => {
const isMacro = ev.document_type === 'macro_event' || ev.evidence_type === 'macro_event';
return (
<div key={ev.id} className={`rounded-lg border p-3 ${isMacro ? 'border-purple-700/50 bg-purple-900/10' : 'border-surface-700 bg-surface-950'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMacro && (
<Link
to="/macro/events/$id"
params={{ id: ev.document_id }}
className="rounded bg-purple-900/40 border border-purple-700/50 px-1.5 py-0.5 text-[10px] font-medium text-purple-400 hover:text-purple-300"
onClick={(e) => e.stopPropagation()}
>
MACRO
</Link>
)}
<StatusBadge status={ev.evidence_type} />
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
</div>
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
</div>
<div className="mt-1 flex gap-4 text-xs text-gray-500">
<span>{ev.document_type}</span>
<span>{ev.source_type}</span>
{ev.publisher && <span>{ev.publisher}</span>}
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
</div>
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
</div>
<div className="mt-1 flex gap-4 text-xs text-gray-500">
<span>{ev.document_type}</span>
<span>{ev.source_type}</span>
{ev.publisher && <span>{ev.publisher}</span>}
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
</div>
</div>
))}
);
})}
</div>
)}
</Card>
+130
View File
@@ -5,6 +5,10 @@ import {
usePendingApprovals,
useReviewApproval,
useActiveLockouts,
useMacroStatus,
useToggleMacro,
useCompetitiveStatus,
useToggleCompetitive,
} from '../api/hooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
@@ -12,9 +16,15 @@ export function TradingPage() {
const { data: config, isLoading: configLoading } = useTradingConfig();
const { data: approvals } = usePendingApprovals();
const { data: lockouts } = useActiveLockouts();
const { data: macroStatus } = useMacroStatus();
const { data: competitiveStatus } = useCompetitiveStatus();
const setMode = useSetTradingMode();
const reviewApproval = useReviewApproval();
const toggleMacro = useToggleMacro();
const toggleCompetitive = useToggleCompetitive();
const [confirmMode, setConfirmMode] = useState<string | null>(null);
const [confirmMacroToggle, setConfirmMacroToggle] = useState(false);
const [confirmCompetitiveToggle, setConfirmCompetitiveToggle] = useState(false);
if (configLoading) return <LoadingSpinner />;
@@ -73,6 +83,126 @@ export function TradingPage() {
)}
</Card>
{/* Macro Signal Layer Toggle */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Macro Signal Layer</h2>
<div className="flex items-center gap-4">
<button
onClick={() => setConfirmMacroToggle(true)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
macroStatus?.enabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={macroStatus?.enabled ?? false}
aria-label="Toggle macro signal layer"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
macroStatus?.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-300">
{macroStatus?.enabled ? 'Enabled' : 'Disabled'}
</span>
{macroStatus?.toggled_at && (
<span className="text-xs text-gray-500">
Last changed: {new Date(macroStatus.toggled_at).toLocaleString()}
{macroStatus.toggled_by && ` by ${macroStatus.toggled_by}`}
</span>
)}
</div>
{/* Confirmation dialog for macro toggle */}
{confirmMacroToggle && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to {macroStatus?.enabled ? 'disable' : 'enable'} the macro signal layer?
{macroStatus?.enabled
? ' Disabling will exclude macro signals from trend summaries and recommendations.'
: ' Enabling will include global event macro signals in trend summaries and recommendations.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
toggleMacro.mutate(!macroStatus?.enabled);
setConfirmMacroToggle(false);
}}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmMacroToggle(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>
</div>
)}
</Card>
{/* Competitive Signal Layer Toggle */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Competitive Signal Layer</h2>
<div className="flex items-center gap-4">
<button
onClick={() => setConfirmCompetitiveToggle(true)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
competitiveStatus?.enabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={competitiveStatus?.enabled ?? false}
aria-label="Toggle competitive signal layer"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
competitiveStatus?.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-300">
{competitiveStatus?.enabled ? 'Enabled' : 'Disabled'}
</span>
{competitiveStatus?.toggled_at && (
<span className="text-xs text-gray-500">
Last changed: {new Date(competitiveStatus.toggled_at).toLocaleString()}
{competitiveStatus.toggled_by && ` by ${competitiveStatus.toggled_by}`}
</span>
)}
</div>
{/* Confirmation dialog for competitive toggle */}
{confirmCompetitiveToggle && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to {competitiveStatus?.enabled ? 'disable' : 'enable'} the competitive signal layer?
{competitiveStatus?.enabled
? ' Disabling will exclude historical pattern and competitive signals from trend summaries and recommendations.'
: ' Enabling will include historical pattern and competitive signals in trend summaries and recommendations.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
toggleCompetitive.mutate(!competitiveStatus?.enabled);
setConfirmCompetitiveToggle(false);
}}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmCompetitiveToggle(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>
</div>
)}
</Card>
{/* Pending Approvals */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
+96 -22
View File
@@ -1,11 +1,13 @@
import { useParams } from '@tanstack/react-router';
import { useTrend, useTrendEvidence } from '../api/hooks';
import { useState } from 'react';
import { useTrend, useTrendEvidence, useTrendProjection } from '../api/hooks';
import { TrendArrow, ConfidenceBar, StatusBadge, LoadingSpinner, Card } from '../components/ui';
export function TrendDetailPage() {
const { id } = useParams({ from: '/trends/$id' });
const { data: trend, isLoading } = useTrend(id);
const { data: evidenceData } = useTrendEvidence(id);
const { data: projection } = useTrendProjection(id);
if (isLoading || !trend) return <LoadingSpinner />;
@@ -68,6 +70,9 @@ export function TrendDetailPage() {
</Card>
)}
{/* Trend Projection (Task 17.5) */}
{projection && <TrendProjectionPanel projection={projection} />}
{/* Evidence drill-down */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Contributing Evidence ({evidence.length})</h2>
@@ -75,32 +80,101 @@ export function TrendDetailPage() {
<p className="text-sm text-gray-500">No evidence records</p>
) : (
<div className="space-y-2">
{evidence.map((ev, i) => (
<div key={i} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusBadge status={String(ev.evidence_type)} />
<span className="text-sm text-gray-200">{String(ev.title ?? 'Untitled')}</span>
{evidence.map((ev, i) => {
const isMacro = String(ev.document_type) === 'macro_event' || String(ev.evidence_type) === 'macro_event';
const isPattern = String(ev.evidence_type) === 'pattern_signal' || String(ev.document_type) === 'pattern_signal';
const isCompetitive = String(ev.evidence_type) === 'competitive_signal' || String(ev.document_type) === 'competitive_signal';
let borderClass = 'border-surface-700 bg-surface-950';
if (isMacro) borderClass = 'border-purple-700/50 bg-purple-900/10';
else if (isCompetitive) borderClass = 'border-cyan-700/50 bg-cyan-900/10';
else if (isPattern) borderClass = 'border-amber-700/50 bg-amber-900/10';
return (
<div key={i} className={`rounded-lg border p-3 ${borderClass}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isMacro && <span className="rounded bg-purple-900/40 border border-purple-700/50 px-1.5 py-0.5 text-[10px] font-medium text-purple-400">MACRO</span>}
{isPattern && <span className="rounded bg-amber-900/40 border border-amber-700/50 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">PATTERN</span>}
{isCompetitive && <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>}
<StatusBadge status={String(ev.evidence_type)} />
<span className="text-sm text-gray-200">{String(ev.title ?? 'Untitled')}</span>
</div>
<span className="font-mono text-xs text-gray-500">rank: {((ev.rank_score as number) ?? 0).toFixed(3)}</span>
</div>
<span className="font-mono text-xs text-gray-500">rank: {((ev.rank_score as number) ?? 0).toFixed(3)}</span>
</div>
<div className="mt-1 flex gap-4 text-xs text-gray-500">
<span>{String(ev.document_type)}</span>
<span>{String(ev.source_type)}</span>
{ev.publisher ? <span>{String(ev.publisher)}</span> : null}
{ev.published_at ? <span>{new Date(String(ev.published_at)).toLocaleDateString()}</span> : null}
</div>
{ev.intelligence ? (
<div className="mt-2 text-xs text-gray-400">
<span className="text-gray-500">Summary: </span>
{String((ev.intelligence as Record<string, unknown>).summary ?? '—')}
<div className="mt-1 flex gap-4 text-xs text-gray-500">
<span>{String(ev.document_type)}</span>
<span>{String(ev.source_type)}</span>
{ev.publisher ? <span>{String(ev.publisher)}</span> : null}
{ev.published_at ? <span>{new Date(String(ev.published_at)).toLocaleDateString()}</span> : null}
</div>
) : null}
</div>
))}
{ev.intelligence ? (
<div className="mt-2 text-xs text-gray-400">
<span className="text-gray-500">Summary: </span>
{String((ev.intelligence as Record<string, unknown>).summary ?? '—')}
</div>
) : null}
</div>
);
})}
</div>
)}
</Card>
</div>
);
}
function TrendProjectionPanel({ projection }: { projection: import('../api/hooks').TrendProjection }) {
const [expanded, setExpanded] = useState(false);
return (
<Card className={projection.diverges_from_current ? 'border-yellow-700/50' : ''}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-gray-400">
Trend Projection
{projection.diverges_from_current && (
<span className="ml-2 rounded bg-yellow-900/40 border border-yellow-700/50 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">DIVERGENCE</span>
)}
</h2>
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{projection.projection_horizon}</span>
</div>
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-gray-500">Projected Direction</dt>
<dd className="flex items-center gap-1 text-gray-200">
<TrendArrow direction={projection.projected_direction} /> {projection.projected_direction}
</dd>
</div>
<div>
<dt className="text-gray-500">Projected Strength</dt>
<dd><ConfidenceBar value={projection.projected_strength} /></dd>
</div>
<div>
<dt className="text-gray-500">Projection Confidence</dt>
<dd><ConfidenceBar value={projection.projected_confidence} /></dd>
</div>
<div>
<dt className="text-gray-500">Macro Contribution</dt>
<dd className="text-gray-200">{(projection.macro_contribution_pct * 100).toFixed(0)}%</dd>
</div>
</dl>
{projection.driving_factors.length > 0 && (
<div className="mt-3">
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-brand-400 hover:underline"
>
{expanded ? 'Hide' : 'Show'} driving factors ({projection.driving_factors.length})
</button>
{expanded && (
<ul className="mt-2 ml-4 list-disc text-sm text-gray-300">
{projection.driving_factors.map((f, i) => <li key={i}>{f}</li>)}
</ul>
)}
</div>
)}
</Card>
);
}
+15
View File
@@ -26,6 +26,8 @@ import { OpsCoveragePage } from './pages/OpsCoverage';
import { SqlExplorerPage } from './pages/SqlExplorer';
import { DashboardsPage } from './pages/Dashboards';
import { HomePage } from './pages/Home';
import { GlobalEventsPage } from './pages/GlobalEvents';
import { GlobalEventDetailPage } from './pages/GlobalEventDetail';
// Root route wraps everything in the app shell layout
const rootRoute = createRootRoute({
@@ -138,6 +140,17 @@ const analyticsDashboardsRoute = createRoute({
component: DashboardsPage,
});
const globalEventsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/macro/events',
component: GlobalEventsPage,
});
const globalEventDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/macro/events/$id',
component: GlobalEventDetailPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
companiesRoute,
@@ -159,6 +172,8 @@ const routeTree = rootRoute.addChildren([
opsCoverageRoute,
analyticsQueryRoute,
analyticsDashboardsRoute,
globalEventsRoute,
globalEventDetailRoute,
]);
export const router = createRouter({ routeTree });
+54
View File
@@ -26,6 +26,34 @@ export const mockPositions = [
{ id: 'p1', broker_account_id: null, ticker: 'AAPL', quantity: 10, avg_entry_price: 185.50, current_price: 188.20, unrealized_pnl: 27.00, realized_pnl: 0, updated_at: '2026-04-11T12:00:00Z' },
];
export const mockMacroEvents = [
{ id: 'me1', event_types: ['trade_barrier', 'cost_increase'], severity: 'high', affected_regions: ['US', 'CN'], affected_sectors: ['Technology'], affected_commodities: ['semiconductors'], summary: 'US tariffs on Chinese semiconductors', key_facts: ['25% tariff', 'Effective in 30 days'], estimated_duration: 'medium_term', confidence: 0.85, source_document_id: 'd1', created_at: '2026-05-15T14:00:00Z' },
];
export const mockMacroImpacts = [
{ id: 'mi1', event_id: 'me1', company_id: '1', ticker: 'AAPL', macro_impact_score: 0.45, impact_direction: 'negative', contributing_factors: ['geographic_overlap:0.650'], confidence: 0.8, computed_at: '2026-05-15T14:00:00Z', legal_name: 'Apple Inc.', sector: 'Technology', event_summary: 'US tariffs on Chinese semiconductors', event_severity: 'high', event_types: ['trade_barrier'], affected_regions: ['US', 'CN'] },
];
export const mockTrendProjection = {
id: 'tp1', trend_window_id: 't1', projected_direction: 'bearish', projected_strength: 0.6, projected_confidence: 0.5, projection_horizon: '7d', driving_factors: ['Macro signals project bearish impact'], macro_contribution_pct: 0.3, diverges_from_current: true, computed_at: '2026-05-15T14:00:00Z',
};
export const mockCompetitors = [
{ id: 'cr1', company_a_id: '1', company_b_id: '2', relationship_type: 'direct_rival', strength: 0.85, bidirectional: true, source: 'manual', active: true, created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-01T00:00:00Z', ticker: 'MSFT', legal_name: 'Microsoft Corporation' },
];
export const mockHistoricalPatterns = [
{ source_ticker: 'AAPL', target_ticker: 'AAPL', catalyst_type: 'earnings', time_horizon: '7d', sample_count: 12, bullish_pct: 0.75, bearish_pct: 0.25, avg_strength: 0.6, avg_time_to_resolution: 3.5, pattern_confidence: 0.72, data_start: '2025-01-01T00:00:00Z', data_end: '2026-04-01T00:00:00Z', tier: 'routine_signal', insufficient_data: false },
];
export const mockCompetitiveSignals = [
{ id: 'cs1', source_document_id: 'd1', source_ticker: 'MSFT', target_ticker: 'AAPL', catalyst_type: 'product_launch', pattern_confidence: 0.65, signal_direction: 'bearish', signal_strength: 0.4, relationship_strength: 0.85, computed_at: '2026-04-10T15:00:00Z' },
];
export const mockCorporateDecisions = [
{ catalyst_type: 'm_and_a', date: '2026-03-15T00:00:00Z', summary: 'Acquisition of AI startup for $2B', trend_direction: 'bullish', trend_strength: 0.7, sample_count: 5, pattern_confidence: 0.68, document_id: 'd1' },
];
export const handlers = [
// Query API (proxied at /api/)
http.get('/api/companies', () => HttpResponse.json(mockCompanies)),
@@ -69,4 +97,30 @@ export const handlers = [
// Health
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
// Macro events and impacts
http.get('/api/macro/events', () => HttpResponse.json(mockMacroEvents)),
http.get('/api/macro/events/:id', ({ params }) => {
const ev = mockMacroEvents.find((e) => e.id === params.id);
return ev ? HttpResponse.json({ ...ev, model_provider: 'ollama', model_name: 'test-model', prompt_version: 'event-v1', schema_version: '1.0.0', affected_companies: mockMacroImpacts }) : new HttpResponse(null, { status: 404 });
}),
http.get('/api/macro/impacts/:ticker', () => HttpResponse.json(mockMacroImpacts)),
http.get('/api/admin/macro/status', () => HttpResponse.json({ macro_enabled: true, source: 'default' })),
http.put('/api/admin/macro/toggle', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ macro_enabled: body.enabled, previous_enabled: true, toggled_by: body.operator ?? 'operator' });
}),
http.get('/api/trends/:id/projection', () => HttpResponse.json(mockTrendProjection)),
// Competitive intelligence endpoints
http.get('/registry/companies/:id/competitors', () => HttpResponse.json(mockCompetitors)),
http.post('/registry/companies/:id/competitors/infer', () => HttpResponse.json(mockCompetitors)),
http.get('/api/patterns/:ticker', () => HttpResponse.json(mockHistoricalPatterns)),
http.get('/api/patterns/:ticker/competitive-signals', () => HttpResponse.json(mockCompetitiveSignals)),
http.get('/api/patterns/:ticker/decisions', () => HttpResponse.json(mockCorporateDecisions)),
http.get('/api/admin/competitive/status', () => HttpResponse.json({ enabled: true, toggled_at: '2026-05-15T14:00:00Z', toggled_by: 'operator' })),
http.put('/api/admin/competitive/toggle', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ enabled: body.enabled, previous_enabled: true, toggled_by: 'operator' });
}),
];
+16
View File
@@ -152,3 +152,19 @@ describe('Watchlists page', () => {
});
});
});
describe('Global Events page', () => {
it('renders global events list with severity filter', async () => {
renderRoute('/macro/events');
await waitFor(() => {
expect(screen.getByText('Global Events')).toBeInTheDocument();
});
});
it('renders event summary from mock data', async () => {
renderRoute('/macro/events');
await waitFor(() => {
expect(screen.getByText(/US tariffs on Chinese semiconductors/)).toBeInTheDocument();
});
});
});