Skip to content

Repository files navigation

advis0r.com — Executive Transcript Stock Discovery CLI

Research aid, not financial advice. This tool converts public executive communications into a reproducible, evidence-backed stock research workflow. It never guarantees a stock will rise, executes trades, or uses non-public information. Small-cap and low-priced stocks are especially risky. See Compliance.

A Bun + TypeScript CLI that discovers, downloads, normalizes, indexes, and analyzes public communications from leaders of publicly traded technology companies — earnings calls, investor days, keynotes, fireside chats, interviews, podcasts, SEC exhibits, blog posts, and captioned video — then combines transcript-derived signals with Alpaca market data and SEC fundamentals to produce a ranked, cited research watchlist for a one- to two-quarter horizon.

CLI binary: transcripts.

Live

  • Web dashboard + PWA: https://advis0r.up.railway.app (Discover / Watchlist / Search / Signals / Crypto / About; installable). Every tab is a real path — /watchlist, /search — so it can be linked to. API root at /api.
  • Deployed on Railway (Bun, src/server.ts), backed by the same Turso database the CLI uses.

Status

This repository implements the architecture in docs/PRD.md. What is fully implemented and working end-to-end:

  • Live transcript ingestion via SEC EDGAR full-text search (transcripts sync "<topic>") — keyless; indexes real 8-K/EX-99 exhibits & prepared remarks into Turso with FTS5 and deterministic signal extraction.
  • Offline analysis provider (--provider offline): zero-dependency, grounded, reproducible StockAnalysis from extracted signals — discover, analyze-company, and the web watchlist produce real ranked output with no external LLM keys.
  • Web dashboard + PWA and read-only HTTP API (src/server.ts).
  • Persistent report pages at /ticker/<SYMBOL> — every generated report is stored and served from storage rather than rebuilt on each view. See Report pages.
  • Watchlist email digests — a market summary of the previous session (or the previous week) delivered as pre-market trading opens, 04:00 ET. See Email digests.

What is fully implemented:

  • Bun CLI with the full command surface (init, search, discover, analyze-company, compare, screen, models, providers, stats, export, backtest).
  • libSQL / Turso storage with the full schema + FTS5 (works locally as an embedded file or against a remote Turso DB).
  • Alpaca Market Data client (snapshots, trades, quotes, bars, assets, calendar) with provenance tagging (feed, delayed flag, request id).
  • Local, deterministic technical-indicator engine (SMA/EMA/RSI/MACD/ Bollinger/ATR/momentum/relative-volume/trend) — the LLM never computes these.
  • Deterministic filter engine (price, market cap, liquidity, exchange, technical, risk) and scoring engine (versioned weights, overall + confidence, risk penalties).
  • SEC EDGAR fundamentals/filings provider (ticker→CIK, company facts, point-in-time filings).
  • OpenAI and Anthropic analysis providers behind a provider-neutral interface, with dynamic model listing, alias resolution (fast/balanced/deep/latest), no silent fallback, validated structured output (Zod), grounding rules, and consensus mode.
  • Ranked watchlist rendering in terminal / Markdown / JSON with the mandatory disclaimer.

Multi-source ingestion (PRD v3)

Beyond SEC filings, the index now ingests:

  • Newstranscripts news <TICKERS...>, and automatically on every AI analysis (see below). Keyless discovery (Yahoo per-ticker RSS, Bing News RSS, Google News RSS, newswire feeds) plus optional ValueSERP search; article bodies are fetched and parsed by us, never taken from a vendor summary. A headline must name the company for the article to be ingested — a per-ticker feed is otherwise half syndicated market commentary about other issuers. Every document carries a reputation tier (0 primary / 1 reputable press / 2 analysis / 3 excluded) that decides its evidentiary weight. robots.txt is honoured and publishers that block automated access degrade to headline + snippet rather than being routed around.

    On-demand refresh. /api/analyze and /api/analyze/stream top up news for the ticker being analyzed immediately before the model call, so "Sharpen with AI" reasons over current coverage instead of whatever a past CLI run left behind. That path is keyless (RSS only — an interactive click never spends search credits), skipped when the ticker was refreshed in the last 6 hours, and abandoned after 25s so a slow publisher cannot hold up an analysis.

  • Mediatranscripts media <TICKERS...>. Earnings calls, keynotes, conference talks and podcasts via YouTube captions (free, already timestamped) with ASR as the fallback. Segments keep millisecond offsets, so a quote can link to the exact second it was said.

    ASR is provider-neutral and picks the best available credential: ElevenLabs Scribe (ELEVENLABS_API_KEY, preferred — word-level timestamps and speaker diarization), Groq whisper-large-v3-turbo (GROQ_API_KEY, cheapest), or OpenAI whisper-1 (OPENAI_API_KEY). Anthropic has no speech-to-text endpoint, so transcription is always a third-party call. On a datacenter host YouTube also needs YTDLP_COOKIES.

  • Corroborationtranscripts corroborate [TICKERS...]. Links a primary claim to independent confirmation in other sources, weighted by tier and recency, and raises a promotional_coverage risk flag when a burst of promo-tier coverage has no primary or reputable confirmation.

  • Signal qualitytranscripts reclassify. Deterministic boilerplate model (disclaimer sections, hypothetical framing, and language repeated across issuers). Applied to the production corpus it flagged 43.8% of stored signals as filing boilerplate rather than executive claims.

See docs/PRD-v3-media-news.md.

What is partial / Phase 2: the point-in-time backtest engine is implemented and ranks candidates deterministically (transcripts backtest); realized-return metrics need Alpaca historical bars (set APCA_*). YouTube caption import is the one remaining ingestion source still stubbed. The OpenAI/Anthropic analysis providers are complete (dynamic model listing, alias resolution, schema-repair retry) but require a funded key; without one, use --provider offline.

Quick web/API tour

bun run start                        # serve dashboard + API on :8080 (PORT)
curl localhost:8080/api/stats
curl "localhost:8080/api/discover?topic=AI%20infrastructure&limit=10"
curl localhost:8080/crypto/BTC-USD   # crypto: see below

Crypto

Crypto market data is served from the same Alpaca account as equities — the crypto feed carries no additional subscription and needs no additional vendor, so APCA_API_KEY_ID / APCA_API_SECRET_KEY are the only credentials involved. The feed also answers unauthenticated, so /crypto/** keeps working on a deployment where those keys are missing, expired, or rate-limited; requests are signed when the keys are present and unsigned when they are not. That is why there is no Yahoo-style fallback on this path — the primary source degrades to itself rather than to a second vendor with different provenance.

In the web dashboard this is the Crypto tab: a live grid of the majors with 24h/7d sparklines, a name-or-symbol picker, and an opt-in 30s auto-refresh that only ticks while that tab is actually on screen.

The sparklines have their own endpoint rather than reusing /crypto/bars: twelve cards do not need thousands of OHLCV objects to draw twelve lines a couple of hundred pixels wide, and Alpaca'''s multi-symbol bars endpoint paginates, so one grid load is several upstream requests. The series is downsampled server-side (24 points for 24h, 56 for 7d) and cached as a unit, so a whole grid costs one set of requests per minute rather than one per visitor — 14KB on the wire for all twelve. A pair without enough history is drawn without a line rather than as a flat one, and the summary says how many those were. Prices are fetched when the tab is first opened rather than on boot, so a visitor who never looks at it costs no upstream calls.

Pages vs JSON

Every detail view is a real page at a real URL, server-rendered, so it can be pasted into a chat, crawled, or previewed without running JavaScript:

Path Serves
/crypto the pair directory, with live prices
/crypto/<PAIR> one pair: pricing, sparkline, technicals, analysis
/stocks/<TICKER> the stored stock report
/api/crypto the JSON index
/api/crypto/<PAIR> the same pair data as JSON

/ticker/<TICKER> permanently redirects to /stocks/<TICKER> — those URLs are in sitemaps, digest emails, and anywhere a report was already shared, so they keep resolving. Pair paths canonicalize too: /crypto/btc/crypto/BTC-USD.

The named data endpoints below answer JSON under either prefix; only the directory and a pair have a page form.

One surface per pair. /?pair=BTC-USD used to open an in-app modal — a second, weaker view of the same pair, with no analysis and a URL nobody could share. It permanently redirects to /crypto/BTC-USD now, and the modal is gone. The page carries everything it had (order book included) plus the analysis and multi-period performance it never had.

A pair page shows: price and spread, performance over 24h/7d/30d/90d/1y, the 52-week range with dates, session volume in the quote currency, market cap and supply, the analysis, the order book, and the technical indicators.

The second data source

Market capitalisation cannot be derived from a price without a circulating supply, and Alpaca's market-data API carries neither. So CoinGecko is the one non-Alpaca source on this path — keyless like the rest, cached for five minutes, one batched request covering every asset, and a page that degrades to "—" rather than failing when it is unreachable.

Its figures are labelled as its own wherever they appear. They are market-wide and priced by CoinGecko; everything else on a crypto page is Alpaca's US venue. The 24h volume under Supply & valuation is aggregate market volume and is not comparable to the venue volume under Performance — conflating them would overstate liquidity by orders of magnitude.

Two guards exist because CoinGecko keeps serving records for tokens that have moved on, and a plausible-looking number is worse than a blank:

  • Non-positive supply → not shown. MKR migrated to SKY and now reports zero circulating supply against a live price; rendering "$0.00 market cap" would be a false statement.
  • Stale records → not shown. MATIC migrated to POL and its record stopped updating in February; a six-month-old supply figure beside a live price is the same failure this codebase avoids everywhere else.

In both cases the page names the reason rather than showing a dash that reads as a bug.

Endpoint Returns
GET /crypto index of the crypto surface, including which auth mode is in effect
GET /crypto/assets the 49 supported pairs, each marked live/idle from a real probe
GET /crypto/lookup?q=bitcoin name or ticker → pair (BTC/USD)
GET /crypto/snapshot?symbols=BTC/USD,ETH/USD latest trade, quote, daily + previous bars, session change
GET /crypto/quote?symbol=BTC/USD latest trade/quote with spread and spread in basis points
GET /crypto/bars?symbol=&timeframe=&start=&end=&limit= historical OHLCV (1Min1Week)
GET /crypto/orderbook?symbol=&depth= top of book, both sides
`GET /crypto/sparklines?symbols=&period=24h 7d`
GET /crypto/technicals?symbol=&horizon=1|2 locally computed indicators + technical score
GET /crypto/report?symbol= snapshot + technicals + score in one call
GET /crypto/<PAIR> the same report by path, e.g. /crypto/BTC-USD
curl "localhost:8080/crypto/lookup?q=bitcoin"
curl "localhost:8080/crypto/quote?symbols=btc,ETH-USD,SOLUSD"
curl "localhost:8080/crypto/BTC-USD"

Symbols

Alpaca writes pairs BASE/QUOTE, and a slash is hostile in a URL path, so every route accepts four spellings and answers with the canonical one:

You send Resolves to
BTC/USD BTC/USD (canonical)
BTC-USD BTC/USD (URL-safe — use this in paths)
BTC BTC/USD (bare asset defaults to the USD pair)
BTCUSD BTC/USD (longest quote wins, so BTCUSDTBTC/USDT)

A pair Alpaca does not serve is rejected with a suggestion rather than forwarded upstream (?symbol=bitcoin → 400 with didYouMean: BTC/USD). In a multi-symbol basket the valid symbols still return, and the dropped ones are named in rejected — a basket that silently returned 2 of 3 would read as "no data for that pair" when the truth is "we did not accept that spelling".

The pair directory in src/crypto/pairs.ts is a probed seed, not a guess: every entry returned a real bar from Alpaca on 2026-08-06. /crypto/assets re-probes hourly and marks each pair live or idle, so a delisting surfaces without a code change; if the probe itself fails the response says liveness: unverified rather than reporting everything idle.

Analysis

Crypto gets its own analyzer (src/crypto/analysis.ts), not the equity one. The stock analyzer reasons about executive communications, SEC filings and fundamentals; a digital asset has none of those. Running it here would produce a confident-looking report grounded in nothing — catalystScore falls out of an empty transcript evidence set as 0, and every fundamentals-derived component silently defaults.

So the crypto analysis reads only the locally computed indicators, states what they say in prose, and lists what it structurally cannot see (no issuer disclosure, venue-only volume, no on-chain data). It invents no numbers: every figure in the output was passed into it. When there is not enough history to compute indicators, it returns nothing and the page says so, rather than rendering an empty Analysis heading.

Reading the technical score

The indicator and scoring engines are shared with equities and every value is still computed locally — but two components mean something different here, and each crypto response repeats this in its caveats:

  • Volume-derived values (relativeVolume, avgDollarVolume, and hence the score's liquidity component) reflect Alpaca's US venue alone, not aggregate market volume. A low liquidity component here is not evidence that the asset is thinly traded.
  • Calendar windows. Crypto trades 24/7, so a 200-day window spans fewer market events per bar than 200 equity sessions.

Crypto responses carry CRYPTO_DISCLAIMER rather than the equity one: no issuer, no listing standards, no circuit breakers, and venue-specific pricing.

Quick start

bun install
cp .env.example .env   # fill in keys (see below)

bun run cli init                    # create schema (FTS5)
bun run cli providers               # list configured providers
bun run cli models list --provider openai
bun run cli screen --tickers NVDA,AMD --price-max 2000

# Discover from an explicit candidate set (transcript crawlers land in Phase 1):
bun run cli discover "AI infrastructure" \
  --tickers SOUN,BBAI,AITX \
  --price-max 10 --market-cap-min 25m --horizon-quarters 2 \
  --provider openai --model latest

Install globally as transcripts:

bun link      # then: transcripts discover "robotics" --price-max 5

Configuration

Config file (TOML) at ~/.config/transcripts/config.toml (override with $TRANSCRIPTS_CONFIG). See config.example.toml for all options and profiles. Secrets are never stored in TOML — they come from environment variables (see .env.example):

Variable Purpose
OPENAI_API_KEY OpenAI analysis provider
ANTHROPIC_API_KEY Anthropic analysis provider
APCA_API_KEY_ID / APCA_API_SECRET_KEY Alpaca Market Data
DATABASE_URL file:./data/transcripts.sqlite or libsql://… (Turso)
DATABASE_AUTH_TOKEN Turso auth token (remote only)
SEC_USER_AGENT Required descriptive UA for SEC EDGAR
RESEND_API_KEY / MAILGUN_API_KEY Transactional + digest email transport
APP_URL Public base URL used for links in emails
DIGEST_SCHEDULER 0 disables the built-in 04:00 ET digest scheduler
NICHEDB_MARKETS 1 reads shared market data from nichedb.dev (below)
NICHEDB_URL Another nichedb deployment (default https://nichedb.dev)

Shared market data (nichedb.dev)

With NICHEDB_MARKETS=1 the site reads what every site needs from nichedb.dev's public markets collection instead of fetching it itself: no key, one request per read, and the same shapes the rest of the app already consumes.

Read nichedb item Falls back to
Daily bars for a report build kind=history&tags=symbol:<sym> (last 400 bars) Alpaca → Yahoo, when there is no item or its last bar is older than 5 days
Company facts for a report build kind=fundamentals&tags=symbol:<sym> Live SEC companyfacts
Symbol directory (symbols sync) kind=symbol, paged with a stored since= cursor Alpaca asset list
News refresh, before the RSS feeds kind=market-news&tags=<sym> (90-day window) The RSS feeds still run for anything the wire lacks

What stays live regardless: snapshots (latest trade and quote — nichedb has no quotes, so the price on a report is always the provider's), the SEC filings list, the per-miss Yahoo symbol search, and ValueSERP. Off, no request to nichedb is ever made.

Ticker lookup

Type a company name anywhere a ticker is asked for and it resolves: rivian → RIVN.

Before this, knowing the ticker was a precondition for using a tool whose job is to find tickers. rivian was rejected by the watchlist (over five letters), unusable in the signals box (which wants an exact symbol), and answered by full-text search with Amazon's 10-Q — because that filing mentions their Rivian stake.

curl "localhost:8080/api/lookup?q=rivian"      # -> RIVN
curl "localhost:8080/api/lookup?q=coca+cola"   # -> KO
bun run cli symbols find "berkshire hathaway"  # -> BRK.A, BRK.B

The watchlist and signals boxes are typeaheads over this, and the recovery paths are wired too: /ticker/rivian redirects to /ticker/RIVN, and /api/ticker?symbol=rivian answers with a didYouMean instead of a bare error.

Local-first. bun run cli symbols sync loads the full tradable-asset list (~11k rows, Alpaca) so lookup is one indexed query with no third-party call — fast enough for a typeahead. Without Alpaca credentials it still works: a keyless Yahoo search covers the miss and the result is cached, so a given gap is paid for once. Single-character queries never leave the box.

Ranking is the feature — exact symbol > symbol prefix > name prefix > word prefix > substring, with ties broken toward the primary listing (preferred exchange, then shorter symbol). That is what puts RIVN above its warrants, AAPL above Apple Hospitality REIT, and KO above Coca-Cola Consolidated.

Command
symbols sync Load the full asset list into the directory
symbols find <query> Look up a ticker by name or symbol
symbols status Directory size and freshness

Search: transcripts, the web, and a pasted link

The Search tab takes one box and works out what you meant.

Paste a URL and the page is read back to you: title, publisher and source tier, author, date, the phrases it keeps using, the tickers it names, and any feeds it advertises. Retrieval is the same code the news pipeline uses, so the same rules apply — robots.txt is honoured, a publisher that blocks automated access is reported as blocked, and no body text is ever invented.

Type a phrase and Auto searches the indexed transcripts and the open web together; News adds dates and publishers. Alongside the results you get the things a list of links does not tell you: Google's related searches, the recurring phrases shared across the titles that came back, and the niches those results cluster into. Every one of them is clickable, so following a thread never means retyping it.

curl "localhost:8080/api/web?q=ai+infrastructure&kind=web"
curl "localhost:8080/api/web?q=SoundHound&kind=news&time=last_week"
curl "localhost:8080/api/parse?url=https://example.com/story"

Phrases and niches are computed by document frequency — a phrase scores by how many separate results contain it, so one long article repeating its own keyword twelve times cannot invent a trend. No LLM is involved; the whole thing is deterministic and free to run.

Two costs are worth knowing about. Web and news search is ValueSERP, one credit per page out of a monthly bucket shared with other properties, so identical queries are cached and each IP is throttled; without VALUESERP_API_KEY the tab still searches transcripts and still parses a URL, and /api/web answers 503 rather than pretending. And /api/parse fetches a URL chosen by an anonymous caller, so the target is checked first: http(s) only, standard ports only, and the hostname must resolve exclusively to public addresses — a name pointing at 169.254.169.254 or 10.0.0.5 is refused before any request is made.

Report pages

Every ticker that has been looked at has a report at /ticker/<SYMBOL> — a server-rendered page you can share, bookmark, or hand to a crawler.

A report is a stored snapshot, not a live view. Building one costs a bars fetch, a quote snapshot, an asset lookup, a SEC EDGAR company-facts call, an evidence build and an offline analysis — seconds of latency and a handful of third-party requests. The snapshot is written once and read back thereafter:

first view of NVDA   1.86s   (builds and stores)
every view after     0.19s   (one row read, zero external calls)
Route What it is
GET /ticker/<SYMBOL> The report as a shareable page. 404 + a build CTA when none exists
GET /reports?sort=recent|score|ticker Index of every stored report
GET /sitemap.xml, /robots.txt Report URLs for crawlers
GET /api/ticker?symbol= The same snapshot as JSON
GET /api/reports?limit=&sort= The index as JSON
POST /api/report/regenerate Rebuild one snapshot — watchlist members only

A snapshot is never refreshed on a timer. Rebuilding on a cache age would reintroduce exactly the cost this removes. It is rebuilt when it does not exist, when a watchlist member asks, or automatically after a paid AI analysis (so the page reflects the new run). What keeps that honest is that every surface — page, modal, index — renders how old the snapshot is. A stale price is fine; a stale price dressed up as a live one is not.

Reading is public and free. Writing is not, so regeneration requires a signed-in user, a ticker on their own watchlist, and survives a per-account throttle (30/hour). The button is a courtesy; the server check is the control.

The pages need no JavaScript — the price history is inline SVG — so they work in a crawler, a link preview, or a text browser. The interactive candlestick view stays in the app's modal, one click away.

In the app, watchlist rows link straight to /stocks/<SYMBOL>, so a ticker is somewhere you can send someone rather than a modal that leaves the URL alone. Discover cards still open the modal, which shows the snapshot age, a permalink, and — for watchlist tickers — a ↻ Regenerate button. Regenerating refreshes the report's data and is free; re-running the LLM is the separate, credit-metered Re-run AI button, so a free action never silently spends a credit.

The watchlist dashboard

The saved watchlist lives at /watchlist — a path, not a fragment, so it can be linked to, bookmarked, crawled and reloaded. Every tab is a path now (/discover, /watchlist, /search, /signals, /about); the older /#watchlist form is rewritten to the path on arrival, so existing links keep working. The server already answers an unknown path with the app shell, so the routing needed no new server route.

The tab is a dashboard rather than a list of links:

Layer What it shows
Six summary tiles Count and how many are priced · last session's average move with the up/down split · equal-weight change over the window against SPY · best and worst mover · average score
One line chart The watchlist, equal-weight and rebased to 100, drawn against SPY on the same base. Hovering reports both lines at that session
A sortable table Ticker, company, note, price, 1D/1W/1M/window change, a sparkline, score, distance from the 52-week high, and the date it was saved

Sort, filter, risk-class and window live in the URL as well as in localStorage: the address bar makes a configured table shareable, storage makes it the way you left it. /watchlist?sort=range&dir=desc&q=ai&range=1Y opens already arranged.

GET /api/watchlist/overview?range=1M|3M|6M|1Y   (signed in; 401 otherwise)
  -> { range, asOf, source, items[], stats, index }

Three rules shape the payload, and they are the reason it is a separate endpoint from /api/watchlist:

  • One upstream fetch for the whole list. Bars for every saved ticker plus the benchmark come back in a single batched request, cached for ten minutes and shared across viewers, so a 200-ticker watchlist is not 200 round trips per load. Adding a ticker fetches that ticker, not the list.
  • Nothing is invented. A ticker the provider has no bars for stays on the list, is reported as unpriced and is named in stats.missing — it is never filled in from its stored report price. A period longer than the history available is null, not extrapolated.
  • The freshness is part of the answer. Daily bars are end-of-session data, so the payload carries the date of the last bar it used, and the page prints it. The equal-weight line names the tickers it covers and the ones it left out for want of history over the window.

Losing the overview never costs the list: it is fetched alongside the membership rather than instead of it, and the tab falls back to the plain rows when market data is unavailable.

Email digests

Signed-in users get a market summary of the tickers on their saved watchlist, delivered when US pre-market trading opens (04:00 America/New_York) on trading days. Frequency is set on the Watchlist tab:

Choice When it arrives What it covers
daily (default) Every trading day The previous trading session
weekly The week's first trading day Every session of the week just closed
off Nothing

Each message contains broad-market context (SPY/QQQ/IWM/DIA), a per-ticker table (close, change vs. the pre-window close, volume or weekly range), any indexed news for those tickers, the mandatory disclaimer, and a working unsubscribe link plus RFC 8058 one-click headers.

Delivery rules, all enforced server-side:

  • Only verified, enabled accounts with a non-empty watchlist are mailed.
  • Delivery is at-most-once per perioddigest_sends has a UNIQUE(user_id, period_key) interlock, so a duplicated cron run, a restart, or two servers cannot send the same summary twice.
  • A run more than 6 hours past the open is skipped rather than sent late.
  • A failed send releases its claim so a later run inside that window retries.
  • A ticker with no market data is reported as unavailable; nothing is interpolated.

The server runs the schedule itself — no cron needed. Set DIGEST_SCHEDULER=0 to drive it externally instead:

# Every 15 minutes; the ledger makes the extra runs no-ops.
*/15 8-14 * * 1-5  cd /srv/advis0r && bun run cli digest send >> /var/log/digest.log 2>&1
bun run cli digest send                       # send whatever is due now
bun run cli digest send --dry-run --force     # build it, send nothing
bun run cli digest preview you@example.com    # print the exact email
bun run cli digest status                     # subscriber counts
bun run cli digest status you@example.com     # one account's history
bun run cli digest set you@example.com weekly # change a frequency

The API surface is GET /api/digest and POST /api/digest {frequency} (both require a session), plus GET|POST /unsubscribe?token=…, which deliberately needs no sign-in.

Architecture

CLI → Query Planner → Transcript/SEC/Media providers → Downloader/Extractor
  → Normalizer → SQLite+FTS5 → Entity Resolver → Alpaca/SEC data
  → Deterministic Filter Engine → Evidence Builder → OpenAI/Anthropic
  → Consensus & Scoring → Risk/Contradiction checks → Ranked Watchlist

Grounding contract (PRD §8.4): prices, financials, dates, quotes, market cap, volume, and estimates come only from deterministic providers. The model interprets that evidence and must cite stored evidence IDs; it may never invent facts. Source text is treated as untrusted input (prompt-injection defense).

Development

bun test          # deterministic unit tests (indicators, parsing)
bun run typecheck # tsc --noEmit

Security scanning

Every pull request is scanned by ThreatCrush. Two workflows are involved and they do different jobs:

  • threatcrush-scan.yml reports findings and uploads SARIF to the Security tab. It is managed by the sh1pt Actions Fleet and carries a content hash, so do not edit it locally — a pack update will overwrite it.
  • security-gate.yml decides whether findings stop the merge. It runs threatcrush scan . --fail-on high, so a high-or-critical finding fails the build.

It fails closed in both directions: a scan that produces no findings file is reported as not scanned rather than as clean, because an unexamined diff is not a clean one.

Why the gate is high and not medium

The scan reported 56 findings here, all 56 false positives, six of them high-severity — which made any --fail-on setting unusable, since it would have blocked every pull request. Triaging them showed the fault was in the rules rather than in this repository, and the fixes shipped in ThreatCrush 0.4.0 (threatcrush#76):

  • static innerHTML assignments reported as XSS
  • the escaper guard matching escapeHtml( but not esc(, so the code that escapes most rigorously was reported most often
  • searchParams counted as untrusted input even when writing an outbound URL, which fired the SSRF rule on constant hosts
  • credentials in test fixtures treated as live

That took this repository to zero high-severity findings, so the plain gate now works and the reviewed-baseline machinery it replaced (~200 lines) is gone.

The findings that remain are medium and deliberately do not block. They are innerHTML sinks inside multi-line templates whose interpolations are escaped, just on a different line from the assignment — which a line-oriented scanner cannot see.

What this gate does not catch

Severity depends on whether the scanner can see the taint source near the sink, so the same vulnerability is rated differently depending on how the code is arranged. Both of these were measured against this repository:

shape severity blocks?
innerHTML = '<b>' + new URL(location).searchParams.get('q') + '</b>' high yes
innerHTML = '<b>' + q + '</b>', where q is a parameter medium no

So the gate stops a vulnerability written in one place and misses one whose source sits in another function. --fail-on medium would close the gap and today costs 31 false positives, which is why it is not set. Treat this as a floor, not a proof — it is not a substitute for review.

To silence a finding you have established is safe, use the scanner's own directive on the line above it, with the rule named so a different rule firing there still surfaces:

// threatcrush-disable-next-line js-unescaped-html-sink
el.innerHTML = template;

Compliance

Every ranking includes:

This output is generated from public information and automated analysis. It is a research aid, not a guarantee, personalized recommendation, or substitute for professional financial advice. Small-cap and low-priced stocks may be highly volatile, illiquid, subject to dilution, manipulation, delisting, and total loss.

License

MIT © Profullstack, Inc.

About

Executive-transcript stock discovery CLI: turns public exec communications + Alpaca market data + SEC fundamentals into a ranked, evidence-backed research watchlist. Research aid, not financial advice.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages