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.
- 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.
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, reproducibleStockAnalysisfrom 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.
Beyond SEC filings, the index now ingests:
-
News —
transcripts 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.txtis honoured and publishers that block automated access degrade to headline + snippet rather than being routed around.On-demand refresh.
/api/analyzeand/api/analyze/streamtop 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. -
Media —
transcripts 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), Groqwhisper-large-v3-turbo(GROQ_API_KEY, cheapest), or OpenAIwhisper-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 needsYTDLP_COOKIES. -
Corroboration —
transcripts corroborate [TICKERS...]. Links a primary claim to independent confirmation in other sources, weighted by tier and recency, and raises apromotional_coveragerisk flag when a burst of promo-tier coverage has no primary or reputable confirmation. -
Signal quality —
transcripts 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.
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 belowCrypto 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.
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.
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.
MKRmigrated 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.
MATICmigrated 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 (1Min…1Week) |
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"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 BTCUSDT → BTC/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.
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.
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'sliquiditycomponent) 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.
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 latestInstall globally as transcripts:
bun link # then: transcripts discover "robotics" --price-max 5Config 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) |
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.
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.BThe 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 |
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.
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 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 isnull, 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.
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 period —
digest_sendshas aUNIQUE(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>&1bun 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 frequencyThe 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.
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).
bun test # deterministic unit tests (indicators, parsing)
bun run typecheck # tsc --noEmitEvery pull request is scanned by ThreatCrush. Two workflows are involved and they do different jobs:
threatcrush-scan.ymlreports 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.ymldecides whether findings stop the merge. It runsthreatcrush 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.
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
innerHTMLassignments reported as XSS - the escaper guard matching
escapeHtml(but notesc(, so the code that escapes most rigorously was reported most often searchParamscounted 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.
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;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.
MIT © Profullstack, Inc.