feat(sec-lint): lint+backfill system for SEC data gaps (B1-B4)

- SecLintAdapter implements SourceFetch, runs via shared queue/drain loop
- Two new SourceKinds: sec-lint-holders, sec-lint-insiders (weekly schedules)
- No-op cache handlers so drain->cache.set doesn't throw on lint keys
- tRPC admin.queueLint(symbol, kind) — run lint for one symbol, returns LintResult
- tRPC admin.queueLintAll(kind) — backfill ALL watched symbols at once
- tRPC admin.dataQualityList() — query data_quality rows (filterable by symbol/kind)
- InstitutionalDashboard: 'Lint holders' button + status badge in detail panel header
- Admin queue page: Data Quality section with per-row status badges, 'Lint all' buttons
- DEFAULT_RATE_MS includes 167ms (~6 req/s) for lint kinds matching EDGAR limiter
This commit is contained in:
Investor Flow Build
2026-07-12 20:19:12 -04:00
parent ca385c1960
commit 5ef2b2f060
9 changed files with 1154 additions and 47 deletions
+100 -1
View File
@@ -98,18 +98,35 @@ export default function QueuePage() {
const [newScheduleInterval, setNewScheduleInterval] = useState("86400000");
const [addingSchedule, setAddingSchedule] = useState(false);
// B4: Data quality state
interface DataQualityRow {
symbol: string;
kind: string;
last_checked_at: string;
stored_count: number;
discovered_count: number;
missing_count: number;
stale: number;
status: string;
detail: string;
}
const [dataQuality, setDataQuality] = useState<DataQualityRow[]>([]);
const [lintLoading, setLintLoading] = useState<string | null>(null); // tracks which kind is running: "holders" | "insiders"
const [pausing, setPausing] = useState(false);
const loadData = useCallback(async () => {
try {
const [queueData, statusData, scheduleData] = await Promise.all([
const [queueData, statusData, scheduleData, qualityData] = await Promise.all([
api.admin.queueHealth(),
api.admin.queueStatus(),
api.admin.queueSchedules(),
api.admin.dataQualityList() as Promise<DataQualityRow[]>,
]);
setQueue(queueData);
setStatus(statusData);
setSchedules(scheduleData);
setDataQuality(qualityData ?? []);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load queue data");
@@ -142,6 +159,20 @@ export default function QueuePage() {
}
};
// B4: Run lint for all watched symbols for a given kind
const handleRunLintAll = useCallback(async (kind: 'sec-lint-holders' | 'sec-lint-insiders') => {
setLintLoading(kind === 'sec-lint-holders' ? 'holders' : 'insiders');
try {
await api.admin.queueLintAll({ kind });
setSuccess(`Lint ${kind === 'sec-lint-holders' ? 'holders' : 'insiders'} completed`);
} catch (e) {
setError(e instanceof Error ? e.message : "Lint failed");
} finally {
setLintLoading(null);
await loadData();
}
}, []);
const handleClearDone = async () => {
try {
const result = await api.admin.queueClearDone(24);
@@ -608,6 +639,74 @@ export default function QueuePage() {
</button>
</form>
</section>
{/* Data Quality Section (B4) */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-accent" />
Data Quality
</h2>
<div className="flex items-center gap-2">
<button onClick={() => handleRunLintAll('sec-lint-holders')} disabled={lintLoading === 'holders'} className="inline-flex items-center gap-1.5 rounded border border-dashed border-line-strong px-3 py-1 text-xs font-medium text-fg-muted hover:border-green-600 hover:text-green-400 transition-colors disabled:opacity-30">
{lintLoading === 'holders' ? <span className="h-3 w-3 border border-current border-t-transparent rounded-full animate-spin" /> : null}
Lint holders (all)
</button>
<button onClick={() => handleRunLintAll('sec-lint-insiders')} disabled={lintLoading === 'insiders'} className="inline-flex items-center gap-1.5 rounded border border-dashed border-line-strong px-3 py-1 text-xs font-medium text-fg-muted hover:border-blue-600 hover:text-blue-400 transition-colors disabled:opacity-30">
{lintLoading === 'insiders' ? <span className="h-3 w-3 border border-current border-t-transparent rounded-full animate-spin" /> : null}
Lint insiders (all)
</button>
</div>
</div>
<div className="overflow-x-auto">
{dataQuality.length === 0 ? (
<div className="p-6 text-center text-sm text-fg-muted">No data quality records yet. Run a lint to populate.</div>
) : (
<table className="w-full text-xs" role="table" aria-label="Data quality status for watched symbols">
<thead>
<tr className="border-b border-line-strong text-fg-muted">
<th className="text-left py-2 px-3 font-medium">Symbol</th>
<th className="text-left py-2 px-3 font-medium">Kind</th>
<th className="text-right py-2 px-3 font-medium">Stored</th>
<th className="text-right py-2 px-3 font-medium">Discovered</th>
<th className="text-right py-2 px-3 font-medium">Missing</th>
<th className="text-center py-2 px-3 font-medium">Stale</th>
<th className="text-left py-2 px-3 font-medium">Status</th>
<th className="text-left py-2 px-3 font-medium">Last checked</th>
</tr>
</thead>
<tbody>
{dataQuality.map((row, idx) => (
<tr key={`${row.symbol}-${row.kind}-${idx}`} className="border-b border-line hover:bg-surface-raised/50 transition-colors">
<td className="py-2 px-3 font-mono text-fg">{row.symbol}</td>
<td className="py-2 px-3 text-fg-muted">{row.kind === 'institution_filings' ? '13F holders' : 'Form 4'}</td>
<td className="py-2 px-3 text-right font-mono text-fg">{row.stored_count}</td>
<td className="py-2 px-3 text-right font-mono text-fg">{row.discovered_count}</td>
<td className={`py-2 px-3 text-right font-mono ${row.missing_count > 0 ? 'text-orange-400' : 'text-green-400'}`}>{row.missing_count}</td>
<td className="py-2 px-3 text-center">
{row.stale ? (
<span className="inline-block h-2 w-2 rounded-full bg-yellow-500" title="Stale — more than 2 quarters old" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" title="Fresh" />
)}
</td>
<td className="py-2 px-3">
{(() => {
const s = row.status;
if (s === 'ok') return <span className="text-xs px-1.5 py-0.5 rounded border border-green-700 text-green-400 bg-green-950/30">{s}</span>;
if (s === 'gaps_found') return <span className="text-xs px-1.5 py-0.5 rounded border border-orange-600 text-orange-400 bg-orange-950/30">{s}</span>;
if (s === 'stale') return <span className="text-xs px-1.5 py-0.5 rounded border border-yellow-700 text-yellow-400 bg-yellow-950/30">{s}</span>;
return <span className="text-xs px-1.5 py-0.5 rounded border border-red-700 text-red-400 bg-red-950/30">{s}</span>;
})()}
</td>
<td className="py-2 px-3 text-fg-muted font-mono text-[10px]">{new Date(row.last_checked_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</div>
</AdminLayout>
);
+751 -32
View File
@@ -1,6 +1,10 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { api } from "@/lib/trpc";
import { useState, useMemo, useEffect, useRef, useCallback } from "react";
import { api, type LintResult } from "@/lib/trpc";
import { BarChart, Bar, Cell, LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
import { chart as CHART } from "@/lib/chart-theme";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { TablePager, DEFAULT_PAGE_SIZE } from "@/components/TablePager";
// Local type alias matching server-side DashboardRollupRow (ADR-0007: neutral labels only).
type ConvictionDelta = 'increasing' | 'reducing' | 'flat' | 'mixed';
@@ -19,6 +23,45 @@ interface DashboardRollupRow {
type SortField = 'symbol' | 'convictionDelta' | 'insiderRecencyDays' | 'classRollFlag';
type SortDir = 'asc' | 'desc';
type ChartType = 'bar' | 'line';
type GroupBy = 'quarter' | 'month';
interface FlowRow {
filerCik: string;
filerName: string | null;
form: string;
prevShares: number;
currShares: number;
delta: number;
classification: string;
reportedQuarter: string;
}
interface FilingRow {
filerCik: string;
filerName: string | null;
shares: number;
valueUsd: number;
reportedQuarter: string;
filedAt: string;
form: string;
}
interface QuarterlyAgg {
quarter: string;
netDelta: number;
}
interface MonthlyAgg {
month: string;
netDelta: number;
}
interface InsiderMonthlyAgg {
month: string;
buys: number;
sells: number;
}
const CONViction_WEIGHT: Record<string, number> = {
mixed: 3,
@@ -29,11 +72,117 @@ const CONViction_WEIGHT: Record<string, number> = {
const DELTA_BADGE: Record<string, { label: string; color: string; shape: string }> = {
mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]', shape: '?' },
increasing: { label: 'Increasing', color: 'text-[#34d399] border-[#064e3b]', shape: '▲' },
reducing: { label: 'Reducing', color: 'text-[#f87171] border-[#7f1d1d]', shape: '▼' },
flat: { label: 'Flat', color: 'text-[#8a8b9a] border-[#2a2b3a]', shape: '—' },
increasing: { label: 'Increasing', color: 'text-up border-[#064e3b]', shape: '▲' },
reducing: { label: 'Reducing', color: 'text-down border-[#7f1d1d]', shape: '▼' },
flat: { label: 'Flat', color: 'text-fg-muted border-line', shape: '—' },
};
const CLASSIFICATION_BADGE: Record<string, string> = {
'new position': 'bg-[#064e3b] text-[#6ee7b7] border border-[#065f46]',
'added to position': 'bg-[#064e3b] text-[#6ee7b7] border border-[#065f46]',
'exited': 'bg-[#7f1d1d] text-[#fca5a5] border border-[#991b1b]',
'reduced position': 'bg-[#7f1d1d] text-[#fca5a5] border border-[#991b1b]',
};
const ALERT_BADGE: Record<string, string> = {
'accumulation': 'bg-[#064e3b] text-[#6ee7b7] border border-[#065f46]',
'distribution': 'bg-[#7f1d1d] text-[#fca5a5] border border-[#991b1b]',
'mixed signal': 'bg-[#78350f] text-[#fcd34d] border border-[#78350f]',
};
// Codes that are not voluntary market buys/sells (tax withholding, gifts, etc.)
const NON_DISCRETIONARY_CODES = new Set(['F', 'G']);
const TRANSACTION_CODE_COLORS: Record<string, string> = {
'S': 'text-down', // sell — red
'P': 'text-up', // purchase — green
'A': 'text-[#fcd34d]', // award/grant — yellow
'M': 'text-[#a78bfa]', // exercise — purple
'X': 'text-[#60a5fa]', // other — blue
'F': 'text-[#fb923c]', // tax/withholding — orange (non-discretionary)
'G': 'text-[#fb923c]', // gift — orange (non-discretionary)
};
function formatQuarter(q: string): string {
const match = q.match(/(\d{4})-Q?([1-4])/);
if (!match) return q;
const [, year, quarter] = match;
return `Q${quarter} ${year}`;
}
function formatShares(n: number): string {
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
return n.toLocaleString();
}
function aggregateFlowByMonth(filings: FilingRow[], flow: FlowRow[]): MonthlyAgg[] {
// For each filing event, compute the filer's delta from their prior filing.
// Group those deltas by the filing's month (YYYY-MM from filedAt).
const byFiler = new Map<string, FilingRow[]>();
for (const f of filings) {
const arr = byFiler.get(f.filerCik) ?? [];
arr.push(f);
byFiler.set(f.filerCik, arr);
}
const monthDelta = new Map<string, number>();
for (const [, filerFilings] of byFiler) {
const sorted = [...filerFilings].sort((a, b) => a.filedAt.localeCompare(b.filedAt));
for (let i = 0; i < sorted.length; i++) {
if (i === 0) {
const month = sorted[0].filedAt.slice(0, 7);
monthDelta.set(month, (monthDelta.get(month) ?? 0) + sorted[0].shares);
} else {
const prev = sorted[i - 1];
const curr = sorted[i];
const delta = curr.shares - prev.shares;
if (delta === 0) continue;
const month = curr.filedAt.slice(0, 7);
monthDelta.set(month, (monthDelta.get(month) ?? 0) + delta);
}
}
}
return [...monthDelta.entries()]
.map(([month, netDelta]) => ({ month, netDelta }))
.sort((a, b) => b.month.localeCompare(a.month));
}
function formatMonth(m: string): string {
const d = new Date(m + '-01');
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
}
function aggregateFlowByQuarter(flow: FlowRow[]): QuarterlyAgg[] {
const quarterMap = new Map<string, number>();
for (const row of flow) {
const existing = quarterMap.get(row.reportedQuarter) ?? 0;
quarterMap.set(row.reportedQuarter, existing + row.delta);
}
return [...quarterMap.entries()]
.map(([quarter, netDelta]) => ({ quarter, netDelta }))
.sort((a, b) => {
const [yA, qA] = a.quarter.split('-');
const [yB, qB] = b.quarter.split('-');
if (yA !== yB) return Number(yB) - Number(yA);
return Number(qB) - Number(qA);
});
}
function aggregateInsiderByMonth(events: Array<{ transactionDate: string; transactionCode: string; shares: number }>): InsiderMonthlyAgg[] {
const byMonth = new Map<string, { buys: number; sells: number }>();
for (const e of events) {
const month = e.transactionDate.slice(0, 7);
if (month.length !== 7) continue;
const acc = byMonth.get(month) ?? { buys: 0, sells: 0 };
if (e.transactionCode === 'P') acc.buys += e.shares;
else if (e.transactionCode === 'S') acc.sells += e.shares;
byMonth.set(month, acc);
}
return [...byMonth.entries()]
.map(([month, v]) => ({ month, buys: v.buys, sells: v.sells }))
.sort((a, b) => b.month.localeCompare(a.month));
}
export function InstitutionalDashboard() {
const [sortField, setSortField] = useState<SortField>('convictionDelta');
const [sortDir, setSortDir] = useState<SortDir>('desc');
@@ -42,14 +191,148 @@ export function InstitutionalDashboard() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Fetch rollup data
// Detail panel state (symbol drill-down)
const detailRef = useRef<HTMLDivElement>(null);
const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
const [chartType, setChartType] = useState<ChartType>('bar');
const [groupBy, setGroupBy] = useState<GroupBy>('quarter');
const [showInstitutionBreakdown, setShowInstitutionBreakdown] = useState(true);
const [flowData, setFlowData] = useState<{ symbol: string; flow: FlowRow[]; quarters: string[]; filings: FilingRow[] } | null>(null);
interface InsiderEvent {
reporter: string;
relationship?: string | null;
transactionDate: string;
transactionCode: string;
shares: number;
price: number;
}
const [insiderData, setInsiderData] = useState<{ events: InsiderEvent[]; netShares: number; direction: string | null; transactionType: string } | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
const [insiderLoading, setInsiderLoading] = useState(false);
const [flowError, setFlowError] = useState<string | null>(null);
const [insiderError, setInsiderError] = useState<string | null>(null);
const [insiderPage, setInsiderPage] = useState(1);
const [insiderPageSize, setInsiderPageSize] = useState(DEFAULT_PAGE_SIZE);
// Data quality / lint state (B4)
const [lintLoading, setLintLoading] = useState(false);
const [lintResult, setLintResult] = useState<{ holders?: LintResult; insiders?: LintResult } | null>(null);
// Fetch rollup data (always quarter-over-quarter — 13F data is only reported quarterly)
useEffect(() => {
api.dashboard.rollup()
.then(setRollupRows)
.then((rows) => setRollupRows(rows as unknown as DashboardRollupRow[]))
.catch((e: Error) => setError(e))
.finally(() => setIsLoading(false));
}, []);
// Keyboard listener: Escape closes detail panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedSymbol) {
setSelectedSymbol(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedSymbol]);
// Fetch flow + insider data for selected symbol
const fetchDetailData = useCallback(async (symbol: string) => {
setChartType('bar');
setGroupBy('quarter');
setShowInstitutionBreakdown(true);
setFlowLoading(true);
setInsiderLoading(true);
setFlowError(null);
setInsiderError(null);
setInsiderPage(1);
try {
const [flowResult, insiderResult] = await Promise.all([
api.institutional.flow(symbol).catch((e: Error) => {
setFlowError(e.message);
return null;
}),
api.institutional.insiderStream(symbol).catch((e: Error) => {
setInsiderError(e.message);
return null;
}),
]);
if (flowResult && flowResult.flow.length > 0) {
setFlowData({
symbol: flowResult.symbol,
flow: (flowResult.flow as Array<{
filerCik: string;
filerName: string | null;
form: string;
prevShares: number;
currShares: number;
delta: number;
classification: string;
reportedQuarter: string;
}>).map(r => ({ ...r, prevShares: r.prevShares ?? 0, currShares: r.currShares ?? 0, delta: r.delta ?? 0 })),
quarters: flowResult.quarters,
filings: ((flowResult as Record<string, unknown>).filings as FilingRow[]) ?? [],
});
} else {
setFlowData(null);
}
if (insiderResult && insiderResult.events.length > 0) {
const raw = insiderResult as Record<string, unknown>;
setInsiderData({
events: (insiderResult.events as Array<{
reporter: string;
relationship?: string | null;
transactionDate: string;
transactionCode: string;
shares: number;
price: number;
}>),
netShares: insiderResult.netShares,
direction: (raw.direction as string | null) ?? null,
transactionType: (raw.transactionType as string) ?? 'Routine',
});
} else {
setInsiderData(null);
}
} finally {
setFlowLoading(false);
setInsiderLoading(false);
}
}, []);
// Sync selectedSymbol with global activeSymbol
useEffect(() => {
if (activeSymbol) setSelectedSymbol(activeSymbol);
}, [activeSymbol]);
// Fetch detail data when selectedSymbol changes
useEffect(() => {
if (!selectedSymbol) return;
// Defer to avoid cascading render warning from multiple setState calls
const id = setTimeout(() => fetchDetailData(selectedSymbol), 0);
return () => clearTimeout(id);
}, [selectedSymbol]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCloseDetail = () => setSelectedSymbol(null);
// B4: Run lint for selected symbol (holders + insiders)
const handleRunLint = useCallback(async (kind: 'sec-lint-holders' | 'sec-lint-insiders') => {
if (!selectedSymbol) return;
setLintLoading(true);
try {
const result = await api.admin.queueLint(selectedSymbol, kind);
setLintResult((prev) => ({ ...(prev ?? {}), [kind === 'sec-lint-holders' ? 'holders' : 'insiders']: result }));
} catch (e) {
// swallow — the detail panel will show stale/missing data as-is; lint runs in background via schedule too
} finally {
setLintLoading(false);
}
}, [selectedSymbol]);
const rows = useMemo(() => {
if (!rollupRows || rollupRows.length === 0) return [];
let filtered = rollupRows;
@@ -83,17 +366,138 @@ export function InstitutionalDashboard() {
}
};
const chartSection = useMemo(() => {
if (flowLoading) {
return <div className="flex items-center justify-center h-full"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent" /><span className="ml-3 text-sm text-fg-muted">Loading institutional data...</span></div>;
}
if (flowError) {
return <div className="flex items-center justify-center h-full text-sm text-danger">{flowError}</div>;
}
if (!flowData || flowData.quarters.length === 0) {
return <div className="flex items-center justify-center h-full text-sm text-fg-muted">No institutional ownership data available for {selectedSymbol}.</div>;
}
const chartData = aggregateFlowByQuarter(flowData.flow);
return (
<ResponsiveContainer width="100%" height="100%">
{chartType === 'bar' ? (
<BarChart data={chartData as any} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
<XAxis dataKey="quarter" tickFormatter={formatQuarter} tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatShares(v)} width={80} />
<Tooltip contentStyle={CHART.tooltip}
formatter={((value: number) => [`${formatShares(value)}`, 'Net Shares']) as any}
labelFormatter={(label) => `Quarter: ${formatQuarter(label as string)}`}
/>
<Bar dataKey="netDelta" radius={[3, 3, 0, 0]}>
{(chartData as unknown as Array<Record<string, unknown>>).map((entry) => (
<Cell key={String(entry.quarter)} fill={(entry.netDelta as number) >= 0 ? CHART.up : CHART.down} />
))}
</Bar>
</BarChart>
) : (
<LineChart data={chartData as any} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
<XAxis dataKey="quarter" tickFormatter={formatQuarter} tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatShares(v)} width={80} />
<Tooltip contentStyle={CHART.tooltip}
formatter={((value: number) => [`${formatShares(value)}`, 'Net Shares']) as any}
labelFormatter={(label) => `Quarter: ${formatQuarter(label as string)}`}
/>
<Line type="monotone" dataKey="netDelta" stroke={CHART.accent} strokeWidth={2} dot={{ fill: CHART.accent, r: 3 }} />
</LineChart>
)}
</ResponsiveContainer>
);
}, [flowLoading, flowError, flowData, chartType, selectedSymbol]);
const sortedInsiderEvents = useMemo(() => {
if (!insiderData) return [];
return [...insiderData.events].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime()
);
}, [insiderData]);
const pagedInsiderEvents = useMemo(() => {
if (insiderPageSize === -1) return sortedInsiderEvents;
const startIdx = (insiderPage - 1) * insiderPageSize;
return sortedInsiderEvents.slice(startIdx, startIdx + insiderPageSize);
}, [sortedInsiderEvents, insiderPage, insiderPageSize]);
const insiderMonthlyData = useMemo(() => {
if (!insiderData) return [];
return aggregateInsiderByMonth(insiderData.events);
}, [insiderData]);
const insiderTable =
insiderLoading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-accent" />
<span className="ml-3 text-sm text-fg-muted">Loading insider data...</span>
</div>
) : insiderError ? (
<div className="p-3 bg-[#1e1e2e] rounded border border-[#ef4444]/30 text-xs text-danger">{insiderError}</div>
) : !insiderData || insiderData.events.length === 0 ? (
<p className="text-sm text-fg-muted py-4">No insider activity for {selectedSymbol}.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs" role="table" aria-label={`Insider transactions for ${selectedSymbol}`}>
<thead>
<tr className="border-b border-[#3f3f46] text-fg-muted">
<th className="text-left py-1.5 px-2">Reporter</th>
<th className="text-left py-1.5 px-2">Relationship</th>
<th className="text-left py-1.5 px-2">Date</th>
<th className="text-left py-1.5 px-2">Code</th>
<th className="text-right py-1.5 px-2">Shares</th>
<th className="text-right py-1.5 px-2">Price</th>
</tr>
</thead>
<tbody>
{pagedInsiderEvents.map((event, idx) => (
<tr key={`${event.reporter}-${event.transactionDate}-${idx}`} className="border-b border-line">
<td className="py-1.5 px-2 text-fg">{event.reporter}</td>
<td className="py-1.5 px-2 text-fg-muted">{event.relationship ?? '\u2014'}</td>
<td className="py-1.5 px-2 text-fg">{event.transactionDate}</td>
<td className={`py-1.5 px-2 font-mono ${TRANSACTION_CODE_COLORS[event.transactionCode] ?? 'text-fg-muted'}`}>
{event.transactionCode}
</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(event.shares)}</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">${event.price?.toFixed(2) ?? '\u2014'}</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 px-2 text-[10px] text-fg-muted">
<span className="text-up font-mono">P</span><span>Purchase</span>
<span className="text-down font-mono">S</span><span>Sale</span>
<span className="text-[#fcd34d] font-mono">A</span><span>Award / Grant</span>
<span className="text-[#a78bfa] font-mono">M</span><span>Option Exercise</span>
<span className="text-[#60a5fa] font-mono">X</span><span>Exercise (in-the-money)</span>
<span className="text-[#fb923c] font-mono">F</span><span>Tax / Withholding <span className="opacity-60" title="Share withholding to cover exercise price or taxes — not a voluntary sale">⚠ non-discretionary</span></span>
<span className="text-[#fb923c] font-mono">G</span><span>Gift <span className="opacity-60" title="Bona fide gift of securities — not a market transaction">⚠ non-discretionary</span></span>
</div>
</div>
<TablePager
page={insiderPage}
pageSize={insiderPageSize}
total={sortedInsiderEvents.length}
onPageChange={setInsiderPage}
onPageSizeChange={setInsiderPageSize}
/>
</>
);
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
<section className="rounded-md border border-line bg-surface-raised p-5 transition-shadow duration-300">
<header className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-base font-semibold text-[#e6e7ec]">Institutional Dashboard</h2>
<div className="flex items-center gap-2">
<label htmlFor="inst-filter" className="text-xs text-[#8a8b9a]">Filter:</label>
<h2 className="text-base font-semibold text-fg">Institutional Dashboard</h2>
<div className="flex items-center gap-3">
<label htmlFor="inst-filter" className="text-xs text-fg-muted">Filter:</label>
<select
id="inst-filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="text-xs bg-[#1e1f2a] border border-[#2a2b3a] text-[#e6e7ec] rounded-lg px-2 py-1 focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
className="text-xs bg-[#1e1f2a] border border-line text-fg rounded-lg px-2 py-1 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
>
<option value="all">All</option>
<option value="mixed">Mixed</option>
@@ -105,36 +509,70 @@ export function InstitutionalDashboard() {
</header>
{isLoading ? (
<p className="text-sm text-[#5a5b6a] py-8">Loading rollup...</p>
<p className="text-sm text-fg-muted py-8">Loading rollup...</p>
) : error ? (
<p className="text-sm text-[#ef4444] py-8">Error loading rollup data.</p>
<p className="text-sm text-danger py-8">Error loading rollup data.</p>
) : rows.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-[#5a5b6a]">No symbols in watchlist or portfolio.</p>
<p className="text-xs text-[#5a5b6a] mt-1">Add symbols to your watchlist to see institutional data.</p>
<p className="text-sm text-fg-muted">No symbols in watchlist or portfolio.</p>
<p className="text-xs text-fg-muted mt-1">Add symbols to your watchlist to see institutional data.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a2b3a] text-[#8a8b9a]">
<th className="text-left py-2 px-3">Symbol</th>
<th className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" tabIndex={0} onClick={() => handleSort('convictionDelta')} onKeyDown={(e) => e.key === 'Enter' && handleSort('convictionDelta')}>
<tr className="border-b border-line text-fg-muted">
<th
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
tabIndex={0}
onClick={() => handleSort('symbol')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('symbol')}
title="Sort by ticker symbol."
>
Symbol {sortField === 'symbol' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
tabIndex={0}
onClick={() => handleSort('convictionDelta')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('convictionDelta')}
title={'Conviction = institutions (13F, reported quarterly) + insiders (Form 4, reported within 2 days) combined. Quarter-over-quarter: latest vs prior, +/-10% threshold. Increasing ▲ = net buying. Reducing ▼ = net selling. Mixed ? = institutions and insiders disagree. Flat — = little change.'}
>
Conviction Δ {sortField === 'convictionDelta' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" tabIndex={0} onClick={() => handleSort('insiderRecencyDays')} onKeyDown={(e) => e.key === 'Enter' && handleSort('insiderRecencyDays')}>
<th
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
tabIndex={0}
onClick={() => handleSort('insiderRecencyDays')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('insiderRecencyDays')}
title="Days since the last insider transaction of any kind (buys, sells, or grants). Blank if the company has no insider filings on record."
>
Insider Recency {sortField === 'insiderRecencyDays' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" tabIndex={0} onClick={() => handleSort('classRollFlag')} onKeyDown={(e) => e.key === 'Enter' && handleSort('classRollFlag')}>
<th
className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
tabIndex={0}
onClick={() => handleSort('classRollFlag')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('classRollFlag')}
title="Whether the mix of institutional holder types (by industry/SIC) changed between the last two quarters. 'Yes' means new types of holders appeared or left."
>
Class Roll {sortField === 'classRollFlag' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</th>
<th className="text-left py-2 px-3">Alert</th>
<th className="text-left py-2 px-3" title="Attention signal derived from conviction (quarter-over-quarter, combining 13F and Form 4). Accumulation = institutions net buying. Distribution = net selling. Mixed signal = institutions and insiders disagree. Blank when conviction is flat.">
Alert
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.symbol} className="border-b border-[#1e1f2a] hover:bg-[#1a1b24] transition-colors">
<td className="py-2 px-3 font-mono text-[#e6e7ec]">{row.symbol}</td>
<tr
key={row.symbol}
onClick={() => setSelectedSymbol(row.symbol)}
className={`border-b border-[#1e1f2a] hover:bg-surface-sunken transition-colors cursor-pointer ${
selectedSymbol === row.symbol ? 'bg-accent/10 border-l-2 border-l-[#4f8cff]' : ''
}`}
>
<td className="py-2 px-3 font-mono text-fg">{row.symbol}</td>
<td className="py-2 px-3">
{(() => {
const badge = DELTA_BADGE[row.convictionDelta];
@@ -146,31 +584,312 @@ export function InstitutionalDashboard() {
);
})()}
</td>
<td className="py-2 px-3 text-[#8a8b9a]">
<td className="py-2 px-3 text-fg-muted">
{row.insiderRecencyDays !== null ? `${row.insiderRecencyDays}d` : '—'}
</td>
<td className="py-2 px-3">
{row.classRollFlag ? (
<span className="text-[#fcd34d] text-xs">Yes</span>
) : (
<span className="text-[#5a5b6a]">—</span>
<span className="text-fg-muted">—</span>
)}
</td>
<td className="py-2 px-3">
{/* AlertEngine stub (slice 17) — wire later. */}
<span className="text-[#5a5b6a] text-xs">—</span>
</td>
<td className="py-2 px-3">
{row.alert ? (
<span className={`text-xs px-2 py-0.5 rounded border ${ALERT_BADGE[row.alert] ?? 'text-fg-muted border-line'}`}>
{row.alert}
</span>
) : (
<span className="text-fg-muted text-xs">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[10px] text-fg-muted">
<span className="font-semibold uppercase tracking-wide text-fg-muted">Conviction:</span>
<span className="font-semibold uppercase tracking-wide text-fg-muted">(quarter-over-quarter — 13F + Form 4)</span>
<span className="inline-flex items-center gap-1">
<span className="text-up" aria-hidden="true">▲</span> Increasing — net buying vs prior quarter
</span>
<span className="inline-flex items-center gap-1">
<span className="text-down" aria-hidden="true">▼</span> Reducing — net selling vs prior quarter
</span>
<span className="inline-flex items-center gap-1">
<span className="text-[#fcd34d]" aria-hidden="true">?</span> Mixed — institutions &amp; insiders disagree
</span>
<span className="inline-flex items-center gap-1">
<span className="text-fg-muted" aria-hidden="true">—</span> Flat — little change
</span>
</div>
</div>
)}
{/* Detail Panel: Symbol drill-down with institutional flow + insider stream */}
{selectedSymbol && (
<div ref={detailRef} className="mt-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between border-b border-line pb-3">
<h3 className="text-base font-semibold text-fg flex items-center gap-2">
<span className="font-mono">{selectedSymbol}</span>
{(() => {
const row = rollupRows.find(r => r.symbol === selectedSymbol);
const deltaKey = row?.convictionDelta ?? 'flat';
const badge = DELTA_BADGE[deltaKey];
return (
<span className={`text-xs px-2 py-0.5 border rounded-lg ${badge?.color ?? 'text-fg-muted border-line'}`}>
{badge?.label ?? '—'}
</span>
);
})()}
</h3>
<div className="flex items-center gap-2">
{lintResult?.holders && (
<span className={`text-[10px] px-2 py-0.5 border rounded-lg ${lintResult.holders.status === 'ok' ? 'border-green-700 text-green-400 bg-green-950/30' : lintResult.holders.status === 'gaps_found' ? 'border-orange-600 text-orange-400 bg-orange-950/30' : lintResult.holders.stale ? 'border-yellow-700 text-yellow-400 bg-yellow-950/30' : 'border-red-700 text-red-400 bg-red-950/30'}`}>
holders {lintResult.holders.status} ({lintResult.holders.backfilled} backfilled)
</span>
)}
<button onClick={() => handleRunLint('sec-lint-holders')} disabled={lintLoading} className="text-[10px] px-2 py-0.5 border border-dashed border-line-strong text-fg-muted hover:border-accent/60 hover:text-accent rounded transition-colors disabled:opacity-30" title="Reconcile institutional holders for this symbol (backfills missing 13F filings)">
{lintLoading ? '...' : 'Lint holders'}
</button>
<button onClick={() => handleCloseDetail()} aria-label="Close detail panel" className="text-fg-muted hover:text-fg text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded px-1.5 focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]">
✕
</button>
</div>
</div>
{/* Institutional Flow Section (13F data — quarterly only) */}
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-fg" title="Institutional holdings from 13F filings, reported quarterly 45 days after quarter end. Shows net position changes per filer.">Institutional Ownership (13F)</h4>
<div className="flex items-center gap-2">
<span className="text-[10px] text-fg-muted px-2 py-1 border border-[#3f3f46] rounded-lg">By Quarter</span>
{/* Chart type toggle */}
<div className="inline-flex rounded-lg border border-[#3f3f46] overflow-hidden" role="group" aria-label="Chart type">
{(['bar', 'line'] as const).map((type) => (
<button
key={type}
onClick={() => setChartType(type)}
className={`px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
chartType === type
? 'bg-accent text-white'
: 'text-fg-muted hover:text-fg'
}`}
aria-pressed={chartType === type}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</button>
))}
</div>
</div>
</div>
{/* Chart area */}
<div className="h-56">
{chartSection}
</div>
{/* Institution breakdown toggle */}
{flowData && flowData.flow.length > 0 && (
<button
onClick={() => setShowInstitutionBreakdown(!showInstitutionBreakdown)}
className="text-xs text-accent hover:text-[#6ba3ff] underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded px-1"
>
{showInstitutionBreakdown ? 'Hide' : 'Show'} {groupBy === 'month' ? 'filings' : 'institutions'} ({groupBy === 'month' ? flowData.filings.length : flowData.flow.length})
</button>
)}
{/* Institution breakdown table */}
{showInstitutionBreakdown && flowData && (
<div className="overflow-x-auto mt-2">
{groupBy === 'quarter' ? (
<table className="w-full text-xs" role="table" aria-label={`Institutional ownership breakdown for ${selectedSymbol}`}>
<thead>
<tr className="border-b border-[#3f3f46] text-fg-muted">
<th className="text-left py-1.5 px-2">Filer</th>
<th className="text-left py-1.5 px-2">Classification</th>
<th className="text-right py-1.5 px-2">Prior Shares</th>
<th className="text-right py-1.5 px-2">Current Shares</th>
<th className="text-right py-1.5 px-2">Δ</th>
<th className="text-left py-1.5 px-2">Quarter</th>
<th className="py-1.5 px-2"></th>
</tr>
</thead>
<tbody>
{[...flowData.flow].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)).map((inst, idx) => (
<tr key={`${inst.filerCik}-${idx}`} className="border-b border-line">
<td className="py-1.5 px-2 text-fg">{inst.filerName ?? inst.filerCik}</td>
<td className="py-1.5 px-2">
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${CLASSIFICATION_BADGE[inst.classification] ?? 'text-fg-muted border-[#3f3f46]'}`}>
{inst.classification}
</span>
</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(inst.prevShares)}</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(inst.currShares)}</td>
<td className={`py-1.5 px-2 text-right font-mono ${inst.delta >= 0 ? 'text-up' : 'text-down'}`}>
{inst.delta >= 0 ? '+' : ''}{formatShares(inst.delta)}
</td>
<td className="py-1.5 px-2 text-fg-muted">{formatQuarter(inst.reportedQuarter)}</td>
<td className="py-1.5 px-2">
{inst.filerCik && (
<a
href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${inst.filerCik}&type=&date=&owner=include&count=40`}
target="_blank" rel="noopener noreferrer"
aria-label={`View ${inst.filerName ?? inst.filerCik} filings on SEC EDGAR`}
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
>↗</a>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="w-full text-xs" role="table" aria-label={`Individual filings for ${selectedSymbol}`}>
<thead>
<tr className="border-b border-[#3f3f46] text-fg-muted">
<th className="text-left py-1.5 px-2">Filer</th>
<th className="text-right py-1.5 px-2">Shares</th>
<th className="text-right py-1.5 px-2">Value ($)</th>
<th className="text-left py-1.5 px-2">Filed</th>
<th className="text-left py-1.5 px-2">Quarter</th>
<th className="py-1.5 px-2"></th>
</tr>
</thead>
<tbody>
{flowData.filings.length === 0 ? (
<tr><td colSpan={99} className="py-4 text-center text-fg-muted text-xs">No individual filing data available.</td></tr>
) : (
[...flowData.filings]
.sort((a, b) => b.filedAt.localeCompare(a.filedAt))
.map((f, idx) => (
<tr key={`${f.filerCik}-${f.filedAt}-${idx}`} className="border-b border-line">
<td className="py-1.5 px-2 text-fg">{f.filerName ?? f.filerCik}</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(f.shares)}</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{(f.valueUsd / 1000).toFixed(0)}K</td>
<td className="py-1.5 px-2 text-fg">{f.filedAt}</td>
<td className="py-1.5 px-2 text-fg-muted">{formatQuarter(f.reportedQuarter)}</td>
<td className="py-1.5 px-2">
{f.filerCik && (
<a href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${f.filerCik}&type=&date=&owner=include&count=40`}
target="_blank" rel="noopener noreferrer"
aria-label={`View ${f.filerName ?? f.filerCik} filings on SEC EDGAR`}
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
>↗</a>
)}
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
)}
</div>
{/* Significant Ownership (>5%) Section — 13D/13G filings */}
{flowData && flowData.filings.filter(f => f.form === '13D' || f.form === '13G').length > 0 && (
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
<h4 className="text-sm font-semibold text-fg" title="13D (activist) and 13G (passive) filings — triggered when a holder crosses 5% ownership. Filed within 10 days (13D) or 45 days after year end (13G).">Significant Ownership (&gt;5%)</h4>
<div className="overflow-x-auto">
<table className="w-full text-xs" role="table" aria-label="Significant ownership filings for selectedSymbol">
<thead>
<tr className="border-b border-[#3f3f46] text-fg-muted">
<th className="text-left py-1.5 px-2">Filer</th>
<th className="text-right py-1.5 px-2">Shares</th>
<th className="text-right py-1.5 px-2">Value ($)</th>
<th className="text-left py-1.5 px-2">Form</th>
<th className="text-left py-1.5 px-2">Quarter</th>
<th className="py-1.5 px-2"></th>
</tr>
</thead>
<tbody>
{flowData.filings
.filter(f => f.form === '13D' || f.form === '13G')
.sort((a, b) => b.filedAt.localeCompare(a.filedAt))
.map((f, idx) => (
<tr key={`sig-${f.filerCik}-${idx}`} className="border-b border-line">
<td className="py-1.5 px-2 text-fg">{f.filerName ?? f.filerCik}</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{formatShares(f.shares)}</td>
<td className="py-1.5 px-2 text-right font-mono text-fg">{(f.valueUsd / 1000).toFixed(0)}K</td>
<td className="py-1.5 px-2">
<span className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium border ${
f.form === '13D' ? 'text-[#fcd34d] border-[#78350f]' : 'text-fg-muted border-[#3f3f46]'
}`}>{f.form}</span>
</td>
<td className="py-1.5 px-2 text-fg-muted">{formatQuarter(f.reportedQuarter)}</td>
<td className="py-1.5 px-2">
{f.filerCik && (
<a href={`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${f.filerCik}&type=&date=&owner=include&count=40`}
target="_blank" rel="noopener noreferrer"
aria-label={`View ${f.filerName ?? f.filerCik} filings on SEC EDGAR`}
className="text-accent hover:text-[#6ba3ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
>↗</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Insider Stream Section (Form 4 data — executive/director trades) */}
<div className="rounded-lg bg-surface-sunken border border-line p-4 space-y-3">
<h4 className="text-sm font-semibold text-fg" title="Form 4 filings — company insiders (executives, directors, >10% beneficial owners) report their personal trades within 2 business days. P = open-market purchase, S = open-market sale. F = tax/withholding (share withholding for taxes or exercise price) and G = gift are non-discretionary — not voluntary buys or sells.">Insider Activity (Form 4)</h4>
{/* Net shares summary */}
{insiderData && (
<div className="flex items-center gap-3 text-xs px-2 py-1.5 bg-surface rounded border border-line">
<span className="text-fg-muted">Net shares:</span>
<span className={`font-mono font-semibold ${insiderData.netShares >= 0 ? 'text-up' : 'text-down'}`}>
{insiderData.netShares >= 0 ? '+' : ''}{formatShares(insiderData.netShares)}
</span>
<span className="text-fg-muted">|</span>
<span className={`px-2 py-0.5 rounded border ${insiderData.direction === 'decreasing' ? 'text-down border-[#7f1d1d]' : 'text-up border-[#064e3b]'}`}>
{insiderData.direction ?? '—'}
</span>
<span className="text-fg-muted">|</span>
<span className="text-fg-muted">{insiderData.events.length} transactions</span>
</div>
)}
{/* Monthly insider flow bar chart (real M-o-M data — Form 4 filed within 2 days) */}
{insiderMonthlyData.length > 0 && (
<div className="space-y-1">
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={insiderMonthlyData as any} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
<XAxis dataKey="month" tickFormatter={formatMonth} tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: CHART.axis }} tickFormatter={(v) => formatShares(v)} width={70} />
<Tooltip contentStyle={CHART.tooltip}
formatter={((value: number, name: string) => [`${formatShares(value)}`, name === 'buys' ? 'Buys' : 'Sells']) as any}
labelFormatter={(label) => formatMonth(label as string)}
/>
<Bar dataKey="buys" fill={CHART.up} radius={[2, 2, 0, 0]} name="buys" />
<Bar dataKey="sells" fill={CHART.down} radius={[2, 2, 0, 0]} name="sells" />
</BarChart>
</ResponsiveContainer>
</div>
<p className="text-[10px] text-fg-muted px-1">
Insider trades by month (Form 4 — filed within 2 days of transaction). Buys <span className="text-up">■</span> Sells <span className="text-down">■</span>
</p>
</div>
)}
{insiderTable}
</div>
</div>
)}
{/* LLM summary — rendered server-side via tRPC query */}
{rollupRows && rollupRows.length > 0 && (
<div className="mt-4 p-3 bg-[#1a1b24] border border-[#2a2b3a] rounded-lg">
<p className="text-xs text-[#8a8b9a]">
<div className="mt-4 p-3 bg-surface-sunken border border-line rounded-lg">
<p className="text-xs text-fg-muted">
Summary will be provided via the rollup query.
</p>
</div>
+38 -2
View File
@@ -135,6 +135,7 @@ export interface Form13fHolding {
export interface Form13fResult {
holdings: Form13fHolding[];
total: number;
accession: string;
}
@@ -174,8 +175,13 @@ export const api = {
trpcQuery<{ meta: FilerMeta; cik: string }>("edgar.filer_cik_meta", { cik }),
full_text_search: (q: string) =>
trpcQuery<FilingsSearchResult>("edgar.full_text_search", { q }),
form13f_holdings: (cik: string, accession: string) =>
trpcQuery<{ holdings: Form13fResult }>("edgar.form13f_holdings", { cik, accession }),
form13f_holdings: (cik: string, accession: string, opts?: { limit?: number; offset?: number }) =>
trpcQuery<Form13fResult>("edgar.form13f_holdings", {
cik,
accession,
...(opts?.limit != null ? { limit: opts.limit } : {}),
...(opts?.offset != null ? { offset: opts.offset } : {}),
}),
form4_tx: (cik: string, accession: string) =>
trpcQuery<{ transactions: Form4Result }>("edgar.form4_tx", { cik, accession }),
},
@@ -249,9 +255,39 @@ export const api = {
queueRetrySource: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.queueRetrySource", { sourceKind }),
auditLog: (limit = 50, offset = 0, actor?: string, action?: string) =>
trpcQuery<Array<{ id: string; actor: string; action: string; target: string | null; ts: string; meta: string | null }>>("admin.auditLog", { limit, offset, ...(actor ? { actor } : {}), ...(action ? { action } : {}) }),
queueLint: (symbol: string, kind: 'sec-lint-holders' | 'sec-lint-insiders') =>
trpcMutate<LintResult>("admin.queueLint", { symbol, kind }),
queueLintAll: (input: { kind: 'sec-lint-holders' | 'sec-lint-insiders' }) =>
trpcMutate<{ total: number; results: LintResult[] }>("admin.queueLintAll", input),
dataQualityList: (input?: { symbol?: string; kind?: 'institution_filings' | 'insider_transactions' }) =>
trpcQuery<DataQualityRow[]>("admin.dataQualityList", input ?? {}),
},
};
export interface LintResult {
symbol: string;
kind: string;
discoveredCount: number;
storedCount: number;
missingCount: number;
backfilled: number;
stale: number;
status: 'ok' | 'gaps_found' | 'stale' | 'error';
detail: Record<string, unknown>;
}
export interface DataQualityRow {
symbol: string;
kind: string;
last_checked_at: string;
stored_count: number;
discovered_count: number;
missing_count: number;
stale: number;
status: string;
detail: string;
}
export interface WatchlistEntry {
symbol: string;
added_at: string;