mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
32
proto/worldmonitor/market/v1/get_insider_transactions.proto
Normal file
32
proto/worldmonitor/market/v1/get_insider_transactions.proto
Normal 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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
145
server/worldmonitor/market/v1/get-insider-transactions.ts
Normal file
145
server/worldmonitor/market/v1/get-insider-transactions.ts
Normal 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: '' };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
14
src/services/insider-transactions.ts
Normal file
14
src/services/insider-transactions.ts
Normal 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 });
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
235
tests/insider-transactions.test.mts
Normal file
235
tests/insider-transactions.test.mts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user