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

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};
}
}