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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 & 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 (>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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user