feat(stocks): add insider transaction tracking to stock analysis panel (#2928)

* feat(stocks): add insider transaction tracking to stock analysis panel

Shows 6-month insider buy/sell activity from Finnhub: total buys,
sells, net value, and recent named-exec transactions. Gracefully
skips when FINNHUB_API_KEY is unavailable.

* fix: add cache tier entry for get-insider-transactions route

* fix(stocks): add insider RPC to premium paths + fix empty/stale states

* fix(stocks): add insider RPC to premium paths + fix empty/stale states

- Add /api/market/v1/get-insider-transactions to PREMIUM_RPC_PATHS
- Return unavailable:false with empty transactions when Finnhub has no data
  (panel shows "No insider transactions" instead of "unavailable")
- Mark stale insider data on refresh failures to avoid showing outdated info
- Update test to match new empty-data behavior

* fix(stocks): unblock stock-analysis render and surface exercise-only insider activity

- loadStockAnalysis no longer awaits loadInsiderDataForPanel before
  panel.renderAnalyses. The insider fetch now fires in parallel after
  the primary render at both the cached-snapshot and live-fetch call
  sites. When insider data arrives, loadInsiderDataForPanel re-renders
  the panel so the section fills in asynchronously without holding up
  the analyst report on a secondary Finnhub RPC.
- Add transaction code 'M' (exercise / conversion of derivative) to
  the allowed set in get-insider-transactions so symbols whose only
  recent Form 4 activity is option/RSU exercises no longer appear as
  "No insider transactions in the last 6 months". Exercises do not
  contribute to buys/sells dollar totals because transactionPrice is
  the strike price, not a market transaction.
- Panel table now uses a neutral (dim) color for non-buy/non-sell
  rows (M rows) instead of the buy/sell green/red binary.
- Tests cover: exercise-only activity producing non-empty transactions
  with zero buys/sells, and blended P/S/M activity preserving all
  three rows.

* fix(stocks): prevent cached insider fetch from clobbering live render

- Cached-path insider enrichment only runs when no live fetch is coming
- Added generation counter to guard against concurrent loadStockAnalysis calls
- Stale insider fetches now no-op instead of reverting panel state

* fix(stocks): hide transient insider-unavailable flash and zero out strike-derived values

- renderInsiderSection returns empty string when insider data is not yet
  fetched, so the transient "Insider data unavailable" card no longer
  flashes on initial render before the RPC completes
- Exercise rows (code M) now carry value: 0 on the server and render a
  dash placeholder in the Value cell, matching how the buy/sell totals
  already exclude strike-derived dollar amounts

* fix(stocks): exclude non-market Form 4 codes (A/D/F) from insider buy/sell totals

Form 4 codes A (grant/award), D (disposition to issuer), and F (tax/exercise
payment) are not open-market trades and should not drive insider conviction
totals. Only P (open-market purchase) and S (open-market sale) now feed the
buy/sell aggregates. A/D/F rows are still surfaced in the transaction list
alongside M (exercise) with value zeroed out so the panel does not look empty.
This commit is contained in:
Elie Habib
2026-04-11 16:44:25 +04:00
committed by GitHub
parent 2b189b77b6
commit 55c9c36de2
14 changed files with 794 additions and 7 deletions

File diff suppressed because one or more lines are too long

View File

@@ -612,6 +612,38 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/market/v1/get-insider-transactions:
get:
tags:
- MarketService
summary: GetInsiderTransactions
description: GetInsiderTransactions retrieves SEC insider buy/sell activity from Finnhub.
operationId: GetInsiderTransactions
parameters:
- name: symbol
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetInsiderTransactionsResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -1727,3 +1759,50 @@ components:
netPct:
type: number
format: double
GetInsiderTransactionsRequest:
type: object
properties:
symbol:
type: string
maxLength: 32
minLength: 1
required:
- symbol
GetInsiderTransactionsResponse:
type: object
properties:
unavailable:
type: boolean
symbol:
type: string
totalBuys:
type: number
format: double
totalSells:
type: number
format: double
netValue:
type: number
format: double
transactions:
type: array
items:
$ref: '#/components/schemas/InsiderTransaction'
fetchedAt:
type: string
InsiderTransaction:
type: object
properties:
name:
type: string
shares:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
value:
type: number
format: double
transactionCode:
type: string
transactionDate:
type: string

View File

@@ -0,0 +1,32 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "buf/validate/validate.proto";
import "sebuf/http/annotations.proto";
message InsiderTransaction {
string name = 1;
int64 shares = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
double value = 3;
string transaction_code = 4;
string transaction_date = 5;
}
message GetInsiderTransactionsRequest {
string symbol = 1 [(sebuf.http.query) = { name: "symbol" },
(buf.validate.field).required = true,
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 32
];
}
message GetInsiderTransactionsResponse {
bool unavailable = 1;
string symbol = 2;
double total_buys = 3;
double total_sells = 4;
double net_value = 5;
repeated InsiderTransaction transactions = 6;
string fetched_at = 7;
}

View File

@@ -22,6 +22,7 @@ import "worldmonitor/market/v1/list_other_tokens.proto";
import "worldmonitor/market/v1/get_fear_greed_index.proto";
import "worldmonitor/market/v1/list_earnings_calendar.proto";
import "worldmonitor/market/v1/get_cot_positioning.proto";
import "worldmonitor/market/v1/get_insider_transactions.proto";
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
service MarketService {
@@ -121,4 +122,9 @@ service MarketService {
rpc GetCotPositioning(GetCotPositioningRequest) returns (GetCotPositioningResponse) {
option (sebuf.http.config) = {path: "/get-cot-positioning", method: HTTP_METHOD_GET};
}
// GetInsiderTransactions retrieves SEC insider buy/sell activity from Finnhub.
rpc GetInsiderTransactions(GetInsiderTransactionsRequest) returns (GetInsiderTransactionsResponse) {
option (sebuf.http.config) = {path: "/get-insider-transactions", method: HTTP_METHOD_GET};
}
}

View File

@@ -199,6 +199,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/market/v1/list-earnings-calendar': 'slow',
'/api/market/v1/get-cot-positioning': 'slow',
'/api/market/v1/get-insider-transactions': 'slow',
'/api/economic/v1/get-economic-calendar': 'slow',
'/api/intelligence/v1/list-market-implications': 'slow',
'/api/economic/v1/get-ecb-fx-rates': 'slow',

View File

@@ -0,0 +1,145 @@
import type {
ServerContext,
GetInsiderTransactionsRequest,
GetInsiderTransactionsResponse,
InsiderTransaction,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { cachedFetchJson } from '../../../_shared/redis';
import { CHROME_UA, finnhubGate } from '../../../_shared/constants';
import { UPSTREAM_TIMEOUT_MS, sanitizeSymbol } from './_shared';
const CACHE_TTL_SECONDS = 86_400;
const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1_000;
// Only genuine open-market Form 4 codes count toward buy/sell conviction:
// P = open-market or private purchase
// S = open-market or private sale
const PURCHASE_CODES = new Set(['P']);
const SALE_CODES = new Set(['S']);
// Non-market Form 4 codes we still surface in the transactions list so the
// panel does not look empty, but which do NOT contribute to buy/sell totals
// because their transactionPrice is not a market execution price:
// M = exercise/conversion of derivative (price = strike)
// A = grant/award (compensation, not a purchase)
// D = disposition to issuer (e.g. buyback redemption)
// F = payment of exercise price or tax withholding (mechanical)
const NEUTRAL_CODES = new Set(['M', 'A', 'D', 'F']);
interface FinnhubTransaction {
name: string;
share: number;
change: number;
transactionPrice: number;
transactionCode: string;
transactionDate: string;
filingDate: string;
}
interface FinnhubInsiderResponse {
data?: FinnhubTransaction[];
symbol?: string;
}
export async function getInsiderTransactions(
_ctx: ServerContext,
req: GetInsiderTransactionsRequest,
): Promise<GetInsiderTransactionsResponse> {
const symbol = sanitizeSymbol(req.symbol);
if (!symbol) {
return { unavailable: true, symbol: '', totalBuys: 0, totalSells: 0, netValue: 0, transactions: [], fetchedAt: '' };
}
const apiKey = process.env.FINNHUB_API_KEY;
if (!apiKey) {
return { unavailable: true, symbol, totalBuys: 0, totalSells: 0, netValue: 0, transactions: [], fetchedAt: '' };
}
const cacheKey = `insider:${symbol}:v1`;
try {
const result = await cachedFetchJson<{
totalBuys: number;
totalSells: number;
netValue: number;
transactions: InsiderTransaction[];
fetchedAt: string;
}>(cacheKey, CACHE_TTL_SECONDS, async () => {
await finnhubGate();
const url = `https://finnhub.io/api/v1/stock/insider-transactions?symbol=${encodeURIComponent(symbol)}&token=${apiKey}`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
if (!resp.ok) return null;
const raw = (await resp.json()) as FinnhubInsiderResponse;
if (!raw.data || raw.data.length === 0) return {
totalBuys: 0,
totalSells: 0,
netValue: 0,
transactions: [] as InsiderTransaction[],
fetchedAt: new Date().toISOString(),
};
const cutoff = Date.now() - SIX_MONTHS_MS;
const recent = raw.data.filter(tx => {
const txDate = new Date(tx.transactionDate).getTime();
return Number.isFinite(txDate) && txDate >= cutoff;
});
let totalBuys = 0;
let totalSells = 0;
for (const tx of recent) {
const val = Math.abs((tx.change ?? 0) * (tx.transactionPrice ?? 0));
if (PURCHASE_CODES.has(tx.transactionCode)) totalBuys += val;
else if (SALE_CODES.has(tx.transactionCode)) totalSells += val;
}
const mapped: InsiderTransaction[] = recent
.filter(tx =>
PURCHASE_CODES.has(tx.transactionCode)
|| SALE_CODES.has(tx.transactionCode)
|| NEUTRAL_CODES.has(tx.transactionCode),
)
.sort((a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime())
.slice(0, 20)
.map(tx => ({
name: String(tx.name ?? ''),
shares: Math.abs(tx.change ?? 0),
// For exercise/conversion (code M), transactionPrice is the option
// strike price, not a market execution price, so the derived
// dollar amount would be misleading. Zero it out and let the UI
// render a placeholder. The buy/sell totals above already
// exclude M rows.
value: NEUTRAL_CODES.has(tx.transactionCode)
? 0
: Math.abs((tx.change ?? 0) * (tx.transactionPrice ?? 0)),
transactionCode: tx.transactionCode,
transactionDate: tx.transactionDate,
}));
return {
totalBuys: Math.round(totalBuys),
totalSells: Math.round(totalSells),
netValue: Math.round(totalBuys - totalSells),
transactions: mapped,
fetchedAt: new Date().toISOString(),
};
});
if (!result) {
return { unavailable: true, symbol, totalBuys: 0, totalSells: 0, netValue: 0, transactions: [], fetchedAt: '' };
}
return {
unavailable: false,
symbol,
totalBuys: result.totalBuys,
totalSells: result.totalSells,
netValue: result.netValue,
transactions: result.transactions,
fetchedAt: result.fetchedAt,
};
} catch {
return { unavailable: true, symbol, totalBuys: 0, totalSells: 0, netValue: 0, transactions: [], fetchedAt: '' };
}
}

View File

@@ -32,6 +32,7 @@ import { listOtherTokens } from './list-other-tokens';
import { getFearGreedIndex } from './get-fear-greed-index';
import { listEarningsCalendar } from './list-earnings-calendar';
import { getCotPositioning } from './get-cot-positioning';
import { getInsiderTransactions } from './get-insider-transactions';
export const marketHandler: MarketServiceHandler = {
listMarketQuotes,
@@ -53,4 +54,5 @@ export const marketHandler: MarketServiceHandler = {
getFearGreedIndex,
listEarningsCalendar,
getCotPositioning,
getInsiderTransactions,
};

View File

@@ -82,6 +82,7 @@ import {
} from '@/services';
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
import { fetchStockAnalysesForTargets, getStockAnalysisTargets, type StockAnalysisResult } from '@/services/stock-analysis';
import { fetchInsiderTransactions } from '@/services/insider-transactions';
import {
fetchStockBacktestsForTargets,
fetchStoredStockBacktests,
@@ -94,6 +95,7 @@ import {
hasFreshStockAnalysisHistory,
getLatestStockAnalysisSnapshots,
mergeStockAnalysisHistory,
type StockAnalysisHistory,
} from '@/services/stock-analysis-history';
import { checkBatchForBreakingAlerts, dispatchOrefBreakingAlert } from '@/services/breaking-news-alerts';
import { mlWorker } from '@/services/ml-worker';
@@ -273,6 +275,7 @@ export class DataLoaderManager implements AppModule {
private boundMarketWatchlistHandler: (() => void) | null = null;
private satellitePropagationCleanup: (() => void) | null = null;
private dailyBriefGeneration = 0;
private _stockAnalysisGeneration = 0;
private dailyBriefFrameworkUnsubscribe: (() => void) | null = null;
private marketImplicationsFrameworkUnsubscribe: (() => void) | null = null;
private cachedSatRecs: SatRecEntry[] | null = null;
@@ -1204,16 +1207,32 @@ export class DataLoaderManager implements AppModule {
const panel = this.ctx.panels['stock-analysis'] as StockAnalysisPanel | undefined;
if (!panel) return;
// Bump generation so any in-flight insider fetch from a prior invocation
// of loadStockAnalysis no-ops instead of re-rendering stale snapshots on
// top of the current render.
const generation = ++this._stockAnalysisGeneration;
try {
const targets = getStockAnalysisTargets();
const targetSymbols = targets.map((target) => target.symbol);
const storedHistory = await fetchStockAnalysisHistory(targets.length);
const cachedSnapshots = getLatestStockAnalysisSnapshots(storedHistory, targets.length);
const historyIsFresh = hasFreshStockAnalysisHistory(storedHistory, targetSymbols);
if (cachedSnapshots.length > 0) {
panel.renderAnalyses(cachedSnapshots, storedHistory, 'cached');
}
if (hasFreshStockAnalysisHistory(storedHistory, targetSymbols)) {
if (historyIsFresh) {
// No live fetch coming — safe to enrich the cached render with
// insiders now. This is the only cached-path insider fetch; when a
// live fetch is about to run we defer insider enrichment until after
// the live render so we never re-render stale cached snapshots over
// fresh live data.
if (cachedSnapshots.length > 0) {
void this.loadInsiderDataForPanel(panel, targetSymbols, cachedSnapshots, storedHistory, 'cached', generation)
.catch((error) => console.error('[StockAnalysis] insider fetch failed:', error));
}
return;
}
@@ -1223,7 +1242,13 @@ export class DataLoaderManager implements AppModule {
if (results.length === 0) {
if (cachedSnapshots.length === 0) {
panel.showRetrying('Stock analysis is waiting for eligible watchlist symbols.');
return;
}
// Live fetch returned nothing but we already rendered cachedSnapshots
// above. Enrich the displayed cached snapshots with insider data so
// the user still sees the insider section.
void this.loadInsiderDataForPanel(panel, targetSymbols, cachedSnapshots, storedHistory, 'cached', generation)
.catch((error) => console.error('[StockAnalysis] insider fetch failed:', error));
return;
}
const nextHistory = mergeStockAnalysisHistory(storedHistory, results);
@@ -1241,11 +1266,10 @@ export class DataLoaderManager implements AppModule {
const cached = storedHistory[target.symbol]?.[0];
if (cached?.available) combined.push(cached);
}
if (combined.length > 0) {
panel.renderAnalyses(combined, nextHistory, 'live');
} else {
panel.renderAnalyses(results, nextHistory, 'live');
}
const snapshotsToRender = combined.length > 0 ? combined : results;
panel.renderAnalyses(snapshotsToRender, nextHistory, 'live');
void this.loadInsiderDataForPanel(panel, targetSymbols, snapshotsToRender, nextHistory, 'live', generation)
.catch((error) => console.error('[StockAnalysis] insider fetch failed:', error));
} catch (error) {
console.error('[StockAnalysis] failed:', error);
const cachedHistory = await fetchStockAnalysisHistory().catch(() => ({}));
@@ -1258,6 +1282,34 @@ export class DataLoaderManager implements AppModule {
}
}
private async loadInsiderDataForPanel(
panel: StockAnalysisPanel,
symbols: string[],
snapshotsToReRender: StockAnalysisResult[],
historyForReRender: StockAnalysisHistory,
source: 'live' | 'cached',
generation: number,
): Promise<void> {
const results = await Promise.allSettled(symbols.map(s => fetchInsiderTransactions(s)));
// If another loadStockAnalysis invocation has started while this fetch
// was in flight, drop the result entirely — both setInsiderData and the
// re-render would clobber the current state.
if (generation !== this._stockAnalysisGeneration) return;
for (let i = 0; i < symbols.length; i++) {
const r = results[i];
if (r && r.status === 'fulfilled') {
panel.setInsiderData(symbols[i]!, r.value);
} else {
panel.setInsiderData(symbols[i]!, { unavailable: true, symbol: symbols[i]!, totalBuys: 0, totalSells: 0, netValue: 0, transactions: [], fetchedAt: '' });
}
}
// Re-render the panel so the insider section becomes visible now that
// setInsiderData has populated insiderBySymbol. Guard once more in case
// something awaited between the setInsiderData calls above.
if (generation !== this._stockAnalysisGeneration) return;
panel.renderAnalyses(snapshotsToReRender, historyForReRender, source);
}
async loadStockBacktest(): Promise<void> {
const panel = this.ctx.panels['stock-backtest'] as StockBacktestPanel | undefined;
if (!panel) return;

View File

@@ -2,6 +2,7 @@ import { Panel } from './Panel';
import { t } from '@/services/i18n';
import type { StockAnalysisResult } from '@/services/stock-analysis';
import type { AnalystConsensus, PriceTarget, UpgradeDowngrade } from '@/generated/client/worldmonitor/market/v1/service_client';
import type { InsiderTransactionsResult } from '@/services/insider-transactions';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
import type { StockAnalysisHistory } from '@/services/stock-analysis-history';
import { sparkline } from '@/utils/sparkline';
@@ -28,11 +29,35 @@ function list(items: string[], cssClass: string): string {
return `<ul class="${cssClass}" style="margin:8px 0 0;padding-left:18px;font-size:12px;line-height:1.5">${items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`;
}
function formatDollarCompact(value: number): string {
const abs = Math.abs(value);
if (abs >= 1e9) return `$${(value / 1e9).toFixed(1)}B`;
if (abs >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
if (abs >= 1e3) return `$${(value / 1e3).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
}
function txCodeLabel(code: string): string {
if (code === 'P') return 'Buy';
if (code === 'S') return 'Sell';
if (code === 'M') return 'Exercise';
if (code === 'A') return 'Award';
if (code === 'D') return 'Disposition';
if (code === 'F') return 'Tax/Fee';
return code;
}
export class StockAnalysisPanel extends Panel {
private insiderBySymbol: Record<string, InsiderTransactionsResult> = {};
constructor() {
super({ id: 'stock-analysis', title: 'Premium Stock Analysis', infoTooltip: t('components.stockAnalysis.infoTooltip'), premium: 'locked' });
}
public setInsiderData(symbol: string, data: InsiderTransactionsResult): void {
this.insiderBySymbol[symbol] = data;
}
public renderAnalyses(items: StockAnalysisResult[], historyBySymbol: StockAnalysisHistory = {}, source: 'live' | 'cached' = 'live'): void {
if (items.length === 0) {
this.setDataBadge('unavailable');
@@ -180,6 +205,7 @@ export class StockAnalysisPanel extends Panel {
`).join('')}
</div>
` : ''}
${this.renderInsiderSection(item.symbol)}
${headlines ? `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">${headlines}</div>` : ''}
${this.renderAnalystConsensus(item)}
</section>
@@ -291,4 +317,81 @@ export class StockAnalysisPanel extends Panel {
</div>
`;
}
private renderInsiderSection(symbol: string): string {
const data = this.insiderBySymbol[symbol];
// Unknown / not yet fetched: omit the section entirely. A later
// re-render from loadInsiderDataForPanel fills it in, so we avoid a
// transient "Insider data unavailable" flash on initial render before
// the RPC completes.
if (data === undefined) {
return '';
}
if (data.unavailable) {
return `
<div style="font-size:11px;color:var(--text-dim);padding:8px;border:1px solid var(--border)">
Insider data unavailable
</div>`;
}
if (data.transactions.length === 0 && data.totalBuys === 0 && data.totalSells === 0) {
return `
<div style="font-size:11px;color:var(--text-dim);padding:8px;border:1px solid var(--border)">
No insider transactions in the last 6 months
</div>`;
}
const buysStr = formatDollarCompact(data.totalBuys);
const sellsStr = formatDollarCompact(data.totalSells);
const netStr = `${data.netValue >= 0 ? '+' : ''}${formatDollarCompact(data.netValue)}`;
const netColor = data.netValue >= 0 ? 'var(--semantic-normal)' : 'var(--semantic-critical)';
const summary = `
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:12px;font-family:var(--font-mono)">
<span>Buys: <span style="color:var(--semantic-normal)">${escapeHtml(buysStr)}</span></span>
<span>Sells: <span style="color:var(--semantic-critical)">${escapeHtml(sellsStr)}</span></span>
<span>Net: <span style="color:${netColor};font-weight:600">${escapeHtml(netStr)}</span></span>
</div>`;
const rows = data.transactions.slice(0, 5);
const table = rows.length > 0 ? `
<table style="width:100%;border-collapse:collapse;font-size:11px;margin-top:6px">
<thead>
<tr style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em;text-align:left">
<th style="padding:4px 6px;border-bottom:1px solid var(--border)">Name</th>
<th style="padding:4px 6px;border-bottom:1px solid var(--border)">Type</th>
<th style="padding:4px 6px;border-bottom:1px solid var(--border);text-align:right">Shares</th>
<th style="padding:4px 6px;border-bottom:1px solid var(--border);text-align:right">Value</th>
<th style="padding:4px 6px;border-bottom:1px solid var(--border)">Date</th>
</tr>
</thead>
<tbody>
${rows.map(tx => {
const isBuy = tx.transactionCode === 'P';
const isSell = tx.transactionCode === 'S';
const typeColor = isBuy ? 'var(--semantic-normal)' : isSell ? 'var(--semantic-critical)' : 'var(--text-dim)';
// Non-market rows (M/A/D/F) carry value: 0 from the server because
// their transactionPrice is not a market execution price (strike,
// grant price, buyback redemption, or tax withholding). Render a
// dash so users do not read a misleading dollar figure that
// contradicts the buy/sell totals (which only count P and S).
const valueCell = tx.value === 0 ? '—' : formatDollarCompact(tx.value);
return `
<tr>
<td style="padding:4px 6px;border-bottom:1px solid var(--border);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(tx.name)}</td>
<td style="padding:4px 6px;border-bottom:1px solid var(--border);color:${typeColor}">${escapeHtml(txCodeLabel(tx.transactionCode))}</td>
<td style="padding:4px 6px;border-bottom:1px solid var(--border);text-align:right;font-family:var(--font-mono)">${Number.isFinite(tx.shares) ? tx.shares.toLocaleString() : '0'}</td>
<td style="padding:4px 6px;border-bottom:1px solid var(--border);text-align:right;font-family:var(--font-mono)">${valueCell}</td>
<td style="padding:4px 6px;border-bottom:1px solid var(--border);color:var(--text-dim)">${escapeHtml(tx.transactionDate)}</td>
</tr>`;
}).join('')}
</tbody>
</table>` : '';
return `
<div style="display:grid;gap:6px">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Insider Activity (6 months)</div>
${summary}
${table}
</div>`;
}
}

View File

@@ -462,6 +462,28 @@ export interface CotInstrument {
netPct: number;
}
export interface GetInsiderTransactionsRequest {
symbol: string;
}
export interface GetInsiderTransactionsResponse {
unavailable: boolean;
symbol: string;
totalBuys: number;
totalSells: number;
netValue: number;
transactions: InsiderTransaction[];
fetchedAt: string;
}
export interface InsiderTransaction {
name: string;
shares: number;
value: number;
transactionCode: string;
transactionDate: string;
}
export interface FieldViolation {
field: string;
description: string;
@@ -977,6 +999,31 @@ export class MarketServiceClient {
return await resp.json() as GetCotPositioningResponse;
}
async getInsiderTransactions(req: GetInsiderTransactionsRequest, options?: MarketServiceCallOptions): Promise<GetInsiderTransactionsResponse> {
let path = "/api/market/v1/get-insider-transactions";
const params = new URLSearchParams();
if (req.symbol != null && req.symbol !== "") params.set("symbol", String(req.symbol));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetInsiderTransactionsResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -462,6 +462,28 @@ export interface CotInstrument {
netPct: number;
}
export interface GetInsiderTransactionsRequest {
symbol: string;
}
export interface GetInsiderTransactionsResponse {
unavailable: boolean;
symbol: string;
totalBuys: number;
totalSells: number;
netValue: number;
transactions: InsiderTransaction[];
fetchedAt: string;
}
export interface InsiderTransaction {
name: string;
shares: number;
value: number;
transactionCode: string;
transactionDate: string;
}
export interface FieldViolation {
field: string;
description: string;
@@ -526,6 +548,7 @@ export interface MarketServiceHandler {
getFearGreedIndex(ctx: ServerContext, req: GetFearGreedIndexRequest): Promise<GetFearGreedIndexResponse>;
listEarningsCalendar(ctx: ServerContext, req: ListEarningsCalendarRequest): Promise<ListEarningsCalendarResponse>;
getCotPositioning(ctx: ServerContext, req: GetCotPositioningRequest): Promise<GetCotPositioningResponse>;
getInsiderTransactions(ctx: ServerContext, req: GetInsiderTransactionsRequest): Promise<GetInsiderTransactionsResponse>;
}
export function createMarketServiceRoutes(
@@ -1354,6 +1377,53 @@ export function createMarketServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/market/v1/get-insider-transactions",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: GetInsiderTransactionsRequest = {
symbol: params.get("symbol") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getInsiderTransactions", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getInsiderTransactions(ctx, body);
return new Response(JSON.stringify(result as GetInsiderTransactionsResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -0,0 +1,14 @@
import { getRpcBaseUrl } from '@/services/rpc-client';
import {
MarketServiceClient,
type GetInsiderTransactionsResponse,
} from '@/generated/client/worldmonitor/market/v1/service_client';
import { premiumFetch } from '@/services/premium-fetch';
const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: premiumFetch });
export type InsiderTransactionsResult = GetInsiderTransactionsResponse;
export async function fetchInsiderTransactions(symbol: string): Promise<InsiderTransactionsResult> {
return client.getInsiderTransactions({ symbol });
}

View File

@@ -8,6 +8,7 @@ export const PREMIUM_RPC_PATHS = new Set<string>([
'/api/market/v1/analyze-stock',
'/api/market/v1/get-stock-analysis-history',
'/api/market/v1/backtest-stock',
'/api/market/v1/get-insider-transactions',
'/api/market/v1/list-stored-stock-backtests',
'/api/intelligence/v1/deduct-situation',
'/api/intelligence/v1/list-market-implications',

View File

@@ -0,0 +1,235 @@
import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import { getInsiderTransactions } from '../server/worldmonitor/market/v1/get-insider-transactions.ts';
const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };
function mockFinnhubResponse(data: unknown[]) {
return new Response(JSON.stringify({ data, symbol: 'AAPL' }), { status: 200 });
}
function recentDate(daysAgo: number): string {
const d = new Date(Date.now() - daysAgo * 86_400_000);
return d.toISOString().split('T')[0]!;
}
afterEach(() => {
globalThis.fetch = originalFetch;
process.env.FINNHUB_API_KEY = originalEnv.FINNHUB_API_KEY;
});
describe('getInsiderTransactions handler', () => {
it('returns unavailable when FINNHUB_API_KEY is missing', async () => {
delete process.env.FINNHUB_API_KEY;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, true);
assert.equal(resp.symbol, 'AAPL');
});
it('returns unavailable when symbol is empty', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
const resp = await getInsiderTransactions({} as never, { symbol: '' });
assert.equal(resp.unavailable, true);
});
it('aggregates purchase and sale totals for recent transactions', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
{ name: 'Tim Cook', share: 10000, change: 10000, transactionPrice: 150, transactionCode: 'P', transactionDate: recentDate(10), filingDate: recentDate(8) },
{ name: 'Jeff Williams', share: 5000, change: -5000, transactionPrice: 155, transactionCode: 'S', transactionDate: recentDate(20), filingDate: recentDate(18) },
{ name: 'Luca Maestri', share: 2000, change: 2000, transactionPrice: 148, transactionCode: 'P', transactionDate: recentDate(30), filingDate: recentDate(28) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, false);
assert.equal(resp.symbol, 'AAPL');
assert.equal(resp.totalBuys, 10000 * 150 + 2000 * 148);
assert.equal(resp.totalSells, 5000 * 155);
assert.equal(resp.netValue, resp.totalBuys - resp.totalSells);
assert.equal(resp.transactions.length, 3);
assert.equal(resp.transactions[0]!.name, 'Tim Cook');
});
it('filters out transactions older than 6 months', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
{ name: 'Recent Exec', share: 1000, change: 1000, transactionPrice: 100, transactionCode: 'P', transactionDate: recentDate(30), filingDate: recentDate(28) },
{ name: 'Old Exec', share: 5000, change: 5000, transactionPrice: 100, transactionCode: 'P', transactionDate: recentDate(200), filingDate: recentDate(198) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, false);
assert.equal(resp.transactions.length, 1);
assert.equal(resp.transactions[0]!.name, 'Recent Exec');
assert.equal(resp.totalBuys, 100000);
});
it('returns unavailable on upstream failure', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return new Response('error', { status: 500 });
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, true);
});
it('returns no-activity when Finnhub returns empty data', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, false);
assert.equal(resp.transactions.length, 0);
});
it('passes the symbol in the Finnhub URL', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
let requestedUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return mockFinnhubResponse([
{ name: 'Exec', share: 100, change: 100, transactionPrice: 50, transactionCode: 'P', transactionDate: recentDate(5), filingDate: recentDate(3) },
]);
}) as typeof fetch;
await getInsiderTransactions({} as never, { symbol: 'MSFT' });
assert.match(requestedUrl, /symbol=MSFT/);
assert.match(requestedUrl, /token=test-key/);
});
it('sorts transactions by date descending', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
{ name: 'Older', share: 100, change: 100, transactionPrice: 50, transactionCode: 'P', transactionDate: recentDate(60), filingDate: recentDate(58) },
{ name: 'Newer', share: 200, change: 200, transactionPrice: 50, transactionCode: 'S', transactionDate: recentDate(10), filingDate: recentDate(8) },
{ name: 'Middle', share: 150, change: 150, transactionPrice: 50, transactionCode: 'P', transactionDate: recentDate(30), filingDate: recentDate(28) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.transactions[0]!.name, 'Newer');
assert.equal(resp.transactions[1]!.name, 'Middle');
assert.equal(resp.transactions[2]!.name, 'Older');
});
it('surfaces exercise-only (code M) activity so panels do not show empty', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
{ name: 'CFO Exercise', share: 5000, change: 5000, transactionPrice: 10, transactionCode: 'M', transactionDate: recentDate(15), filingDate: recentDate(13) },
{ name: 'CTO Exercise', share: 3000, change: 3000, transactionPrice: 8, transactionCode: 'M', transactionDate: recentDate(25), filingDate: recentDate(23) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, false);
assert.equal(resp.transactions.length, 2, 'exercise-only activity must reach the client so panels render the table');
assert.equal(resp.transactions[0]!.transactionCode, 'M');
// Exercise activity does not contribute to buys/sells dollar totals because
// transactionPrice is the option strike, not a market purchase/sale price.
assert.equal(resp.totalBuys, 0);
assert.equal(resp.totalSells, 0);
assert.equal(resp.netValue, 0);
});
it('zeros out per-row value for exercise (code M) rows so UI can render a dash placeholder', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
{ name: 'CFO Exercise', share: 5000, change: 5000, transactionPrice: 10, transactionCode: 'M', transactionDate: recentDate(15), filingDate: recentDate(13) },
{ name: 'Buyer', share: 1000, change: 1000, transactionPrice: 100, transactionCode: 'P', transactionDate: recentDate(5), filingDate: recentDate(3) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
const mRow = resp.transactions.find(t => t.transactionCode === 'M');
const pRow = resp.transactions.find(t => t.transactionCode === 'P');
assert.ok(mRow, 'M row should be present');
assert.ok(pRow, 'P row should be present');
// Shares should still be populated for exercise rows.
assert.equal(mRow!.shares, 5000);
// But the dollar value must be zero because transactionPrice is the
// strike price, not a market execution price. Rendering the naive
// product would be misleading and contradict the buy/sell totals.
assert.equal(mRow!.value, 0, 'exercise row must carry value: 0');
// Regular buys still carry a real dollar value.
assert.equal(pRow!.value, 100_000);
});
it('excludes non-market Form 4 codes (A/D/F) from buy/sell totals', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
// Grant/award — compensation, not a market purchase.
{ name: 'Awardee', share: 10000, change: 10000, transactionPrice: 150, transactionCode: 'A', transactionDate: recentDate(5), filingDate: recentDate(3) },
// Disposition to issuer — e.g. buyback redemption.
{ name: 'Dispositioner', share: 5000, change: -5000, transactionPrice: 160, transactionCode: 'D', transactionDate: recentDate(10), filingDate: recentDate(8) },
// Payment of exercise price / tax withholding — mechanical, not discretionary.
{ name: 'TaxPayer', share: 2000, change: -2000, transactionPrice: 155, transactionCode: 'F', transactionDate: recentDate(15), filingDate: recentDate(13) },
// One real buy so we can assert only P counts toward totalBuys.
{ name: 'Buyer', share: 1000, change: 1000, transactionPrice: 100, transactionCode: 'P', transactionDate: recentDate(20), filingDate: recentDate(18) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.unavailable, false);
// Only the P row contributes to totalBuys; A/D/F contribute nothing.
assert.equal(resp.totalBuys, 100_000);
assert.equal(resp.totalSells, 0);
assert.equal(resp.netValue, 100_000);
// A/D/F rows still reach the client so the panel does not look empty,
// but their per-row dollar value is zeroed out (rendered as a dash).
assert.equal(resp.transactions.length, 4);
const aRow = resp.transactions.find(t => t.transactionCode === 'A');
const dRow = resp.transactions.find(t => t.transactionCode === 'D');
const fRow = resp.transactions.find(t => t.transactionCode === 'F');
assert.ok(aRow && dRow && fRow, 'A/D/F rows should be surfaced');
assert.equal(aRow!.value, 0);
assert.equal(dRow!.value, 0);
assert.equal(fRow!.value, 0);
});
it('blends exercise codes with buys and sells', async () => {
process.env.FINNHUB_API_KEY = 'test-key';
globalThis.fetch = (async () => {
return mockFinnhubResponse([
{ name: 'Buyer', share: 1000, change: 1000, transactionPrice: 100, transactionCode: 'P', transactionDate: recentDate(5), filingDate: recentDate(3) },
{ name: 'Exerciser', share: 500, change: 500, transactionPrice: 10, transactionCode: 'M', transactionDate: recentDate(10), filingDate: recentDate(8) },
{ name: 'Seller', share: 2000, change: -2000, transactionPrice: 105, transactionCode: 'S', transactionDate: recentDate(15), filingDate: recentDate(13) },
]);
}) as typeof fetch;
const resp = await getInsiderTransactions({} as never, { symbol: 'AAPL' });
assert.equal(resp.transactions.length, 3);
assert.equal(resp.totalBuys, 100000);
assert.equal(resp.totalSells, 210000);
const codes = resp.transactions.map(t => t.transactionCode).sort();
assert.deepEqual(codes, ['M', 'P', 'S']);
});
});
describe('MarketServiceClient getInsiderTransactions', () => {
it('serializes the query parameters using generated names', async () => {
const { MarketServiceClient } = await import('../src/generated/client/worldmonitor/market/v1/service_client.ts');
let requestedUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify({ unavailable: true }), { status: 200 });
}) as typeof fetch;
const client = new MarketServiceClient('');
await client.getInsiderTransactions({ symbol: 'TSLA' });
assert.match(requestedUrl, /\/api\/market\/v1\/get-insider-transactions\?/);
assert.match(requestedUrl, /symbol=TSLA/);
});
});