Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ APCA_API_SECRET_KEY=
# Optional: override the data base URL (defaults to https://data.alpaca.markets)
APCA_API_DATA_URL=

# --- Shared market data from nichedb.dev ---
# Set to 1 to read daily bars, SEC fundamentals, the symbol directory and the
# market-news wire from nichedb's public `markets` collection (keyless) instead
# of fetching them here. Every read falls back to the live Alpaca/Yahoo/SEC
# path when nichedb has nothing for a ticker or its window is older than five
# days. Snapshots (latest trade/quote) and the SEC filings list stay live.
# Unset or 0: no request to nichedb is ever made.
NICHEDB_MARKETS=
# Optional: a different nichedb deployment (defaults to https://nichedb.dev).
NICHEDB_URL=

# --- Database (libSQL / Turso) ---
# For a local embedded database use: file:./data/transcripts.sqlite
# For Turso use the libsql:// URL for your database and an auth token.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,27 @@ environment variables (see `.env.example`):
| `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](https://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

Expand Down
9 changes: 9 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# transcript-search config (PRD §23). Copy to ~/.config/transcripts/config.toml
# Secrets (API keys, DB auth token) go in the environment, NOT here.
#
# Shared market data is also an environment switch, not a TOML key:
# NICHEDB_MARKETS=1 read daily bars, SEC fundamentals, the symbol directory
# and the market-news wire from nichedb.dev (keyless),
# falling back to the live Alpaca/Yahoo/SEC path below
# whenever nichedb has nothing fresh for a ticker.
# NICHEDB_URL= another nichedb deployment (default https://nichedb.dev).
# The [alpaca] section still governs snapshots (latest trade/quote), which
# nichedb does not carry, and every fallback read.

database = "~/.local/share/transcripts/transcripts.sqlite"
downloads = "~/.local/share/transcripts/downloads"
Expand Down
20 changes: 20 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,9 +496,29 @@ symbols
await withApp(async ({ db, registry }) => {
const { fetchAlpacaDirectory } = await import("./symbols/providers.ts");
const { upsertSymbols, directoryAge } = await import("./symbols/directory.ts");
const { nichedbMarketsFromEnv } = await import("./providers/nichedb-markets.ts");
const before = await directoryAge(db);
console.log(`Directory before: ${before.count} symbol(s)`);

// NICHEDB_MARKETS=1: walk nichedb's `kind=symbol` mirror from the stored
// cursor — the whole directory the first time, only what moved after
// that — and only reach for the Alpaca asset list when nichedb fails.
const nichedb = nichedbMarketsFromEnv();
if (nichedb) {
const { syncSymbolsFromNichedb } = await import("./symbols/nichedb-sync.ts");
try {
const r = await syncSymbolsFromNichedb(db, nichedb.client, { onProgress: (m) => console.log(` ${m}`) });
const after = await directoryAge(db);
console.log(
`nichedb: ${r.items} item(s) over ${r.pages} page(s)${r.since ? ` since ${r.since}` : " (full walk)"}, ` +
`wrote ${r.written} symbol(s). Directory now: ${after.count}. Cursor: ${r.cursor ?? "none"}.`,
);
return;
} catch (err) {
console.error(`nichedb symbol mirror unavailable, falling back to Alpaca: ${String(err).slice(0, 200)}`);
}
}

let rows: Awaited<ReturnType<typeof fetchAlpacaDirectory>> = [];
try {
rows = await fetchAlpacaDirectory(registry.alpaca);
Expand Down
12 changes: 11 additions & 1 deletion src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS symbols (
asset_class TEXT,
status TEXT, -- active | inactive
tradable INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL, -- alpaca | yahoo
source TEXT NOT NULL, -- alpaca | yahoo | nichedb
updated_at TEXT NOT NULL,
-- Name in its match form: lowercased with punctuation collapsed to spaces, so
-- "coca cola" finds The Coca-Cola Company. Stored rather than computed per
Expand Down Expand Up @@ -520,6 +520,16 @@ CREATE TABLE IF NOT EXISTS auth_attempts (
created_at TEXT NOT NULL
);

-- Where a nichedb.dev mirror walk left off (NICHEDB_MARKETS=1). One row per
-- walk, keyed by what is mirrored: `symbols.since` holds the newest
-- `updated_at` seen on the last complete walk of `kind=symbol`, so the next
-- sync asks nichedb only for what moved.
CREATE TABLE IF NOT EXISTS nichedb_cursor (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);

-- Helpful indexes
CREATE INDEX IF NOT EXISTS idx_signals_ticker ON signals(ticker);
CREATE INDEX IF NOT EXISTS idx_corrob_ticker ON corroborations(ticker, relation);
Expand Down
24 changes: 20 additions & 4 deletions src/pipeline/news-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import type { Client } from "@libsql/client";
import type { AppConfig } from "../config.ts";
import { NewsProvider } from "../providers/news/index.ts";
import { nichedbMarketsFromEnv, type NichedbMarkets } from "../providers/nichedb-markets.ts";
import { ingest } from "./ingest.ts";

export interface NewsRefreshOptions {
Expand All @@ -34,6 +35,12 @@ export interface NewsRefreshOptions {
useValueSerp?: boolean;
/** Only consider articles published on/after this ISO date. */
from?: string;
/**
* The shared market-news mirror to consult before the RSS feeds. Resolved
* from `NICHEDB_MARKETS` when not given; pass `null` to force the RSS-only
* path regardless of the environment.
*/
nichedb?: NichedbMarkets | null;
onProgress?: (message: string) => void;
}

Expand Down Expand Up @@ -103,12 +110,22 @@ export async function refreshTickerNews(
return base;
}

// Default window: a quarter of coverage is what a 1-2 quarter horizon needs.
const from = opts.from ?? new Date(Date.now() - 90 * 86_400_000).toISOString().slice(0, 10);

// The shared wire first, when the switch is on: one keyless request for the
// ticker's whole window, dated and attributed, before any RSS feed is read.
// The feeds still run after it for anything the wire did not carry.
const nichedb = opts.nichedb === undefined ? nichedbMarketsFromEnv() : opts.nichedb;
const sinceDays = Math.max(1, Math.ceil((Date.now() - Date.parse(from)) / 86_400_000));

const provider = new NewsProvider({
downloadsDir: config.downloadsDir,
// Deliberately blank unless asked: RSS discovery is free, ValueSERP is not,
// and this runs on a user-facing click.
valueSerpKey: opts.useValueSerp ? config.secrets.valueSerpApiKey : "",
perTicker: opts.perTicker ?? 8,
discover: nichedb ? (t) => nichedb.newsHits(t, { sinceDays }) : undefined,
});
const name = await companyNameFor(db, ticker);
if (name) provider.setCompanyNames(new Map([[ticker, name]]));
Expand All @@ -121,10 +138,9 @@ export async function refreshTickerNews(
});
provider.setKnownHeadlines(titles.rows.map((r) => String(r.title ?? "")));

opts.onProgress?.(`Searching news for ${name ? `${name} (${ticker})` : ticker}`);

// Default window: a quarter of coverage is what a 1-2 quarter horizon needs.
const from = opts.from ?? new Date(Date.now() - 90 * 86_400_000).toISOString().slice(0, 10);
opts.onProgress?.(
`Searching news for ${name ? `${name} (${ticker})` : ticker}${nichedb ? " (nichedb wire first)" : ""}`,
);

const run = ingest(
db,
Expand Down
18 changes: 18 additions & 0 deletions src/providers/news/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export interface NewsProviderOptions {
* appears alongside the ticker (see `isAboutSubject`).
*/
requireSubject?: boolean;
/**
* Hits to consider before the RSS feeds: a shared source that already holds
* dated, publisher-attributed stories for the ticker (nichedb's market-news
* wire when `NICHEDB_MARKETS` is on). They go through the same subject
* check, headline dedupe, tiering and per-ticker cap as every other hit, and
* take the first places in it, so the feeds only add what this did not.
*/
discover?: (ticker: string, from?: string) => Promise<NewsHit[]>;
}

/**
Expand Down Expand Up @@ -126,6 +134,16 @@ export class NewsProvider extends BaseTranscriptProvider {
const hits: NewsHit[] = [];
const name = this.companyNames.get(ticker);

// 0. A shared mirror, when one is wired: already dated and attributed,
// and one request for the ticker's whole window.
if (this.options.discover) {
try {
hits.push(...(await this.options.discover(ticker, query.from)));
} catch {
/* the mirror is an accelerator, not a precondition */
}
}

// 1. Keyless per-ticker headline feed — cheapest broad coverage.
try {
const items = await fetchFeed(yahooTickerFeed(ticker));
Expand Down
Loading
Loading