fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+20 -12
View File
@@ -188,7 +188,7 @@ export class EdgarAdapter implements SourceFetch {
} else if (typeof recentObj === 'object') {
// Parallel arrays — convert to array of objects.
const keys = Object.keys(recentObj);
const len = (recentObj[keys[0]] as unknown[])?.length ?? 0;
const len = ((recentObj as Record<string, unknown>)[keys[0]] as unknown[])?.length ?? 0;
recentFilings = [];
for (let i = 0; i < len; i++) {
const row: Record<string, unknown> = {};
@@ -205,7 +205,7 @@ export class EdgarAdapter implements SourceFetch {
let filings = recentFilings;
if (opts?.formTypes && opts.formTypes.length > 0) {
const allowed = new Set(opts.formTypes.map((f) => f.toUpperCase()));
filings = filings.filter((f) => allowed.has((f.form ?? '').toUpperCase()));
filings = filings.filter((f) => allowed.has(String(f.form ?? '').toUpperCase()));
}
// Filter by date range.
@@ -213,7 +213,7 @@ export class EdgarAdapter implements SourceFetch {
const from = opts.dateRange.from ? new Date(opts.dateRange.from).getTime() : null;
const to = opts.dateRange.to ? new Date(opts.dateRange.to).getTime() : null;
filings = filings.filter((f) => {
const ts = new Date(f.reportDate ?? f.filingDate ?? f.dateReporter ?? '').getTime();
const ts = new Date(String(f.reportDate ?? f.filingDate ?? f.dateReporter ?? '')).getTime();
if (Number.isNaN(ts)) return false;
if (from !== null && ts < from) return false;
if (to !== null && ts > to) return false;
@@ -352,6 +352,7 @@ export class EdgarAdapter implements SourceFetch {
async form13f_holdings(
cik: string,
accession: string,
opts?: { limit?: number; offset?: number },
): Promise<FetchResult> {
const filerCik = accession.split('-')[0];
const padded = padCik(filerCik);
@@ -390,7 +391,7 @@ export class EdgarAdapter implements SourceFetch {
// Try each candidate until we find one with holdings; fall back to any non-HTML doc
let docText = '';
let parsed: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number }> = [];
let parsed: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number; putCall: string }> = [];
for (const cand of xmlCandidates) {
const docUrl = `https://www.sec.gov/Archives/edgar/data/${padded}/${accessionNoDashes}/${cand.name}`;
docText = await this.edgarXmlFetch(docUrl);
@@ -410,10 +411,14 @@ export class EdgarAdapter implements SourceFetch {
}
}
const holdings = parsed;
const allHoldings = parsed;
const total = allHoldings.length;
const offset = Math.max(0, opts?.offset ?? 0);
const limit = opts?.limit;
const holdings = limit != null ? allHoldings.slice(offset, offset + limit) : allHoldings;
return {
value: { holdings, accession: `${padded}/${accessionNoDashes}` },
value: { holdings, total, accession: `${padded}/${accessionNoDashes}` },
ttlClass: 'daily_permanent' as const,
provenance: {
fetchedAt: new Date().toISOString(),
@@ -581,10 +586,11 @@ function parse13fHoldings(text: string): Array<{
issuerName: string;
value: number;
sshPrnamt: number;
putCall: string;
}> {
const holdings: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number }> = [];
const holdings: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number; putCall: string }> = [];
// Try 13F XML format: <infoTable> (possibly namespaced) with <nameOfIssuer>, <cusip>, <value>, <sshPrnamt>
// Try 13F XML format: <infoTable> (possibly namespaced) with <nameOfIssuer>, <cusip>, <value>, <sshPrnamt>, <putCall>
// SEC 13F XML uses namespaces like <ns1:infoTable><ns1:nameOfIssuer>...
const infoTableMatches = text.match(/<(?:\w+:)?infoTable[^>]*>([\s\S]*?)<\/(?:\w+:)?infoTable>/gi);
if (infoTableMatches && infoTableMatches.length > 0) {
@@ -593,6 +599,7 @@ function parse13fHoldings(text: string): Array<{
const cusipMatch = block.match(/<(?:\w+:)?cusip>\s*([\s\S]*?)\s*<\/(?:\w+:)?cusip>/i);
const valueMatch = block.match(/<(?:\w+:)?value>\s*([\s\S]*?)\s*<\/(?:\w+:)?value>/i);
const sharesMatch = block.match(/<(?:\w+:)?sshPrnamt>\s*([\s\S]*?)\s*<\/(?:\w+:)?sshPrnamt>/i);
const putCallMatch = block.match(/<(?:\w+:)?putCall>\s*([\s\S]*?)\s*<\/(?:\w+:)?putCall>/i);
if (!cusipMatch) continue;
@@ -600,8 +607,9 @@ function parse13fHoldings(text: string): Array<{
const cusip = cusipMatch ? cusipMatch[1].replace(/<[^>]+>/g, '').trim() : '';
const value = valueMatch ? parseFloat(valueMatch[1].replace(/<[^>]+>/g, '').replace(/,/g, '')) || 0 : 0;
const sshPrnamt = sharesMatch ? parseFloat(sharesMatch[1].replace(/<[^>]+>/g, '').replace(/,/g, '')) || 0 : 0;
const putCall = putCallMatch ? putCallMatch[1].replace(/<[^>]+>/g, '').trim() : '';
holdings.push({ cusip, issuerName, value, sshPrnamt });
holdings.push({ cusip, issuerName, value, sshPrnamt, putCall });
}
if (holdings.length > 0) return holdings;
@@ -638,7 +646,7 @@ function parse13fHoldings(text: string): Array<{
const sharesMatch = cells[3].match(/([\d,.]+)/);
const sshPrnamt = sharesMatch ? parseFloat(sharesMatch[1].replace(/,/g, '')) : 0;
holdings.push({ cusip: cusipMatch[1], issuerName: issuerRaw, value, sshPrnamt });
holdings.push({ cusip: cusipMatch[1], issuerName: issuerRaw, value, sshPrnamt, putCall: '' });
}
}
}
@@ -657,7 +665,7 @@ function parse13fHoldings(text: string): Array<{
if (cusipLine && !/Class|CUSIP|Name|Issuer/i.test(line)) {
// Save previous holding if we have one.
if (currentCusip) {
holdings.push({ cusip: currentCusip, issuerName: currentIssuer, value: currentValue, sshPrnamt: currentShares });
holdings.push({ cusip: currentCusip, issuerName: currentIssuer, value: currentValue, sshPrnamt: currentShares, putCall: '' });
}
currentCusip = cusipLine[1];
currentIssuer = '';
@@ -681,7 +689,7 @@ function parse13fHoldings(text: string): Array<{
}
// Push last holding.
if (currentCusip) {
holdings.push({ cusip: currentCusip, issuerName: currentIssuer.trim(), value: currentValue, sshPrnamt: currentShares });
holdings.push({ cusip: currentCusip, issuerName: currentIssuer.trim(), value: currentValue, sshPrnamt: currentShares, putCall: '' });
}
}
+39 -19
View File
@@ -100,13 +100,11 @@ export class OptionsAdapter implements SourceFetch {
const yf = await this.yf();
if (kind === 'expiry_dates') {
// yahoo-finance2 v3: options() returns expirationDates as Date objects
const result = await yf.options(id);
const dates = (result.expirationDates ?? [])
.map((d: Date) => d.toISOString().slice(0, 10))
.sort() as OptionExpiryDate[];
// Use optionsExpiryDates() which returns string[] directly.
const dates = await yf.optionsExpiryDates(id);
const sorted = [...dates].sort() as OptionExpiryDate[];
return {
value: dates,
value: sorted,
ttlClass: 'intraday',
provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:expiry:${id}` },
};
@@ -114,14 +112,26 @@ export class OptionsAdapter implements SourceFetch {
if (kind === 'chain') {
const [symbol, expiry] = id.split(':');
const rawChain = await yf.options(symbol, { date: expiry });
// v3: options is indexed by number, extract calls/puts from first entry
const optionsObj = rawChain.options;
const firstKey = Object.keys(optionsObj)[0];
const expiryData = firstKey ? optionsObj[firstKey] : null;
const rawResult = await yf.options(symbol, expiry);
// Handle multiple response shapes:
// v3: { options: { '1234567890': { calls: [...], puts: [...] } } }
// flat: { calls: [...], puts: [...] }
let expiryData: Record<string, unknown> | null = null;
if (rawResult.options && typeof rawResult.options === 'object' && !Array.isArray(rawResult.options)) {
const optObj = rawResult.options as Record<string, unknown>;
const dateKeys = Object.keys(optObj).filter((k) => /^\d+$/.test(k) || /\d{4}-\d{2}-\d{2}/.test(k));
if (dateKeys.length > 0) {
expiryData = optObj[dateKeys[0]] as Record<string, unknown>;
} else {
expiryData = optObj;
}
} else if (Array.isArray(rawResult.calls) || Array.isArray(rawResult.puts)) {
// Flat format: { calls: [...], puts: [...] }
expiryData = rawResult as Record<string, unknown>;
}
const allRows = [
...(expiryData?.calls ?? []).map((c: Record<string, unknown>) => ({ ...c, right: 'call' as const })),
...(expiryData?.puts ?? []).map((p: Record<string, unknown>) => ({ ...p, right: 'put' as const })),
...((expiryData?.calls as Array<Record<string, unknown>> | undefined) ?? []).map((c) => ({ ...c, right: 'call' as const })),
...((expiryData?.puts as Array<Record<string, unknown>> | undefined) ?? []).map((p) => ({ ...p, right: 'put' as const })),
];
const rows = parseOptionChainRows(symbol, allRows);
return {
@@ -136,13 +146,23 @@ export class OptionsAdapter implements SourceFetch {
const symbol = parts[0];
const expiry = parts[1];
const strike = parseFloat(parts[2] ?? '0');
const rawChain = await yf.options(symbol, { date: expiry });
const optionsObj = rawChain.options;
const firstKey = Object.keys(optionsObj)[0];
const expiryData = firstKey ? optionsObj[firstKey] : null;
const rawResult = await yf.options(symbol, expiry);
// Handle multiple response shapes (same logic as chain kind).
let expiryData: Record<string, unknown> | null = null;
if (rawResult.options && typeof rawResult.options === 'object' && !Array.isArray(rawResult.options)) {
const optObj = rawResult.options as Record<string, unknown>;
const dateKeys = Object.keys(optObj).filter((k) => /^\d+$/.test(k) || /\d{4}-\d{2}-\d{2}/.test(k));
if (dateKeys.length > 0) {
expiryData = optObj[dateKeys[0]] as Record<string, unknown>;
} else {
expiryData = optObj;
}
} else if (Array.isArray(rawResult.calls) || Array.isArray(rawResult.puts)) {
expiryData = rawResult as Record<string, unknown>;
}
const allRows = [
...(expiryData?.calls ?? []).map((c: Record<string, unknown>) => ({ ...c, right: 'call' as const })),
...(expiryData?.puts ?? []).map((p: Record<string, unknown>) => ({ ...p, right: 'put' as const })),
...((expiryData?.calls as Array<Record<string, unknown>> | undefined) ?? []).map((c) => ({ ...c, right: 'call' as const })),
...((expiryData?.puts as Array<Record<string, unknown>> | undefined) ?? []).map((p) => ({ ...p, right: 'put' as const })),
];
const rows = parseOptionChainRows(symbol, allRows);
const target = strike > 0
+51 -7
View File
@@ -75,7 +75,7 @@ export class RedditAdapter implements SourceFetch {
readonly sourceKind: SourceKind = 'reddit';
private readonly rateLimiter: TokenBucket;
private health: RedditHealth = { sourceStatus: 'healthy' };
private _health: RedditHealth = { sourceStatus: 'healthy' };
private _onDegraded?: (health: RedditHealth) => void;
constructor(onDegraded?: (health: RedditHealth) => void) {
@@ -83,18 +83,18 @@ export class RedditAdapter implements SourceFetch {
this._onDegraded = onDegraded;
}
get health(): RedditHealth { return this.health; }
get health(): RedditHealth { return this._health; }
private emitDegraded(error: string): void {
this.health = { sourceStatus: 'failed', lastError: error };
this._onDegraded?.(this.health);
this._health = { sourceStatus: 'failed', lastError: error };
this._onDegraded?.(this._health);
console.warn(`[RedditAdapter] source-degraded: ${error}`);
}
private emitHealthy(): void {
if (this.health.sourceStatus !== 'healthy') {
this.health = { sourceStatus: 'healthy' };
this._onDegraded?.(this.health);
if (this._health.sourceStatus !== 'healthy') {
this._health = { sourceStatus: 'healthy' };
this._onDegraded?.(this._health);
}
}
@@ -143,6 +143,50 @@ export class RedditAdapter implements SourceFetch {
}
}
/** Search Reddit for posts matching a query. */
async searchPosts(query: string, opts?: FetchOpts): Promise<FetchResult> {
if (this.health.sourceStatus === 'failed') {
throw new Error(`RedditAdapter: source degraded — ${this.health.lastError}`);
}
await this.rateLimiter.acquire();
const url = `${REDDIT_API_BASE}/search.json?q=${encodeURIComponent(query)}&limit=50`;
try {
const resp = await fetch(url, {
headers: {
'User-Agent': 'InvestorFlow/1.0 (by operator@example.com)',
'Accept': 'application/json',
},
});
if (resp.status === 429) {
this.emitDegraded(`Reddit rate-limited (HTTP ${resp.status})`);
throw new Error(`RedditAdapter: Reddit source degraded (rate limited)`);
}
if (!resp.ok) {
this.emitDegraded(`Reddit HTTP ${resp.status}`);
throw new Error(`RedditAdapter: Reddit source degraded (HTTP ${resp.status})`);
}
const body = await resp.json() as RedditResponse;
const posts = this.parseSubredditPosts(body, 'search');
return {
value: posts,
ttlClass: 'thread_7d' as TtlClass,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'reddit', rawSourceId: `search:${query}` },
};
} catch (err) {
if (err instanceof Error && err.message.includes('Reddit source degraded')) {
throw err;
}
throw new Error(`RedditAdapter search failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Fetch top comments for a specific Reddit post. */
async postComments(postId: string, sort?: 'best' | 'top' | 'new', opts?: FetchOpts): Promise<FetchResult> {
if (this.health.sourceStatus === 'failed') {
@@ -0,0 +1,26 @@
import type { DatabaseSync } from 'node:sqlite';
import type { CacheKey, SourceKind } from '../cache/CacheRepository.ts';
import { parseCacheKey } from '../cache/CacheRepository.ts';
import type { SourceFetch, FetchResult } from './SourceAdapter.ts';
export class SecFetchAdapter implements SourceFetch {
readonly sourceKind: SourceKind = 'sec-fetch';
private _db: DatabaseSync;
constructor(db: DatabaseSync) {
this._db = db;
}
async fetchOne(key: CacheKey): Promise<FetchResult> {
const { kind, id: symbol } = parseCacheKey(key);
if (kind !== 'fetch') throw new Error(`SecFetchAdapter: unknown kind '${kind}'`);
const { fetchAndStoreSecData } = await import('../services/secDataFetcher.ts');
const result = await fetchAndStoreSecData(this._db, symbol);
return {
value: { ok: true, filingsStored: result.filingsStored, scStored: result.scStored, transactionsStored: result.transactionsStored },
ttlClass: 'daily_permanent',
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec-fetch', rawSourceId: symbol },
};
}
}
+3 -1
View File
@@ -5,7 +5,7 @@
import type { DatabaseSync } from 'node:sqlite';
import type { SourceFetch, FetchResult } from './SourceAdapter.ts';
import type { CacheKey, TtlClass, Provenance } from '../cache/CacheRepository.ts';
import { lintInstitutionalHolders, lintInsiderTransactions, type LintResult } from '../services/secDataFetcher.ts';
import { lintInstitutionalHolders, lintInsiderTransactions, lintSCFilings, type LintResult } from '../services/secDataFetcher.ts';
export class SecLintAdapter implements SourceFetch {
readonly sourceKind: 'sec-lint-holders' | 'sec-lint-insiders';
@@ -23,6 +23,7 @@ export class SecLintAdapter implements SourceFetch {
let result: LintResult;
if (this.sourceKind === 'sec-lint-holders') {
result = await lintInstitutionalHolders(this.db(), symbol);
await lintSCFilings(this.db(), symbol).catch(() => {});
} else {
result = await lintInsiderTransactions(this.db(), symbol);
}
@@ -42,6 +43,7 @@ export class SecLintAdapter implements SourceFetch {
try {
if (kind === 'sec-lint-holders') {
results.push(await lintInstitutionalHolders(db, sym));
await lintSCFilings(db, sym).catch(() => {});
} else {
results.push(await lintInsiderTransactions(db, sym));
}
+156 -372
View File
@@ -1,122 +1,109 @@
// Investor Flow — XCookieAdapter (DESIGN.md §5 X-cookie adapter).
// Fetches cashtag search results and trusted-account timelines using cookie auth.
// Rate limit: 1 req / 3s. Cache: 7d rolling (immutable within the window).
// Cookie-expiry detection: 403/302/empty → mark source FAILED + emit degraded signal.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { DatabaseSync } from 'node:sqlite';
import type { SourceKind, TtlClass, Provenance } from '../cache/CacheRepository.ts';
import type { SourceFetch, FetchResult, FetchOpts } from './SourceAdapter.ts';
// ADR-0007: crowd sentiment is not edge — it reflects consensus, not an advantage.
const execFileAsync = promisify(execFile);
const BIRD_PATH = process.env.BIRD_PATH ?? '/opt/homebrew/bin/bird';
const BIRD_TIMEOUT_MS = 30_000;
const CACHE_TTL_MS = 7 * 24 * 60 * 60_000;
interface BirdTweet {
id: string;
text: string;
createdAt: string;
replyCount: number;
retweetCount: number;
likeCount: number;
author: { username: string; name: string };
authorId: string;
}
export interface XPostRow {
post_id: string;
author_handle: string;
cashtag: string | null;
body_text: string | null;
posted_at: string;
engagement: number;
sentiment_score?: number | null;
attribution?: string | null;
}
/** Normalize bird/Twitter dates to ISO so SQLite range filters sort correctly. */
export function normalizePostedAt(raw: string): string {
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toISOString();
}
export function birdTweetsToXPosts(
tweets: BirdTweet[],
authorHandle: string,
cashtag: string | null,
): XPostRow[] {
return tweets.map((t) => ({
post_id: t.id,
author_handle: (t.author?.username || authorHandle).replace(/^@/, ''),
cashtag: cashtag ? `$${cashtag}` : null,
body_text: t.text.length > 280 ? t.text.slice(0, 280) : t.text,
posted_at: normalizePostedAt(t.createdAt),
engagement: (t.likeCount ?? 0) + (t.retweetCount ?? 0) + (t.replyCount ?? 0),
}));
}
export const CROWD_SENTIMENT_CAVEAT =
'Crowd sentiment is not edge — it reflects consensus, not an advantage';
const X_SEARCH_URL = 'https://x.com/i/api/graphql/search-timeline';
const X_TIMELINE_URL = 'https://x.com/i/api/graphql/vHlSJz4yOZC-Xj16R7Xm_Q/TimelineQuery';
const RATE_LIMIT_MS = 3_000;
const CACHE_TTL_MS = 7 * 24 * 60 * 60_000; // 7 days
// ===== Types for X API response parsing =====
interface XTimelineResponse {
data?: {
search_by_raw_query?: {
search_timeline?: {
timeline?: {
instructions?: Array<Record<string, unknown>>;
};
};
};
user_result?: {
result?: {
timeline_v2?: {
timeline?: {
instructions?: Array<Record<string, unknown>>;
};
};
};
};
};
}
interface XInstruction {
type?: string;
entries?: Array<{
entryId?: string;
content?: Record<string, unknown>;
}>;
}
interface XItemContent {
itemContent?: {
tweet_results?: {
result?: {
__typename?: string;
rest_id?: string;
core?: { user_results?: { result?: { legacy?: { screen_name?: string } } } };
legacy?: {
full_text?: string;
created_at?: string;
favorite_count?: number;
retweet_count?: number;
reply_count?: number;
quote_count?: number;
};
};
};
};
}
// ===== Rate limiter (token bucket) =====
class TokenBucket {
private tokens: number = 1; // start with 1 token
private lastRefill: number = Date.now();
readonly capacity: number;
readonly refillMs: number;
constructor(capacity = 1, refillMs = RATE_LIMIT_MS) {
this.capacity = capacity;
this.refillMs = refillMs;
}
async acquire(): Promise<void> {
const now = Date.now();
const elapsed = now - this.lastRefill;
// Refill: add tokens based on elapsed time
const tokensToAdd = Math.floor(elapsed / this.refillMs);
if (tokensToAdd > 0) {
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefill = now - (elapsed % this.refillMs);
}
if (this.tokens < 1) {
// Wait until next token is available
const waitMs = this.refillMs - (now - this.lastRefill);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, waitMs)));
return this.acquire();
}
this.tokens -= 1;
}
}
// ===== Cookie expiry detection =====
export type XCookieHealth = { sourceStatus: 'healthy' | 'degraded' | 'failed'; lastError?: string | null; };
export type XCookieHealth = { sourceStatus: 'healthy' | 'degraded' | 'failed'; lastError?: string | null };
export class XCookieAdapter implements SourceFetch {
readonly sourceKind: SourceKind = 'x';
private readonly rateLimiter: TokenBucket;
private cookieHealth: XCookieHealth = { sourceStatus: 'healthy' };
private _cookies: { ct0: string; auth_token: string } | null = null;
private _onDegraded?: (health: XCookieHealth) => void;
private _db: DatabaseSync | null = null;
constructor(cookies: { ct0: string; auth_token: string }, onDegraded?: (health: XCookieHealth) => void) {
constructor(cookies: { ct0: string; auth_token: string }, onDegraded?: (health: XCookieHealth) => void, db?: DatabaseSync) {
this._cookies = cookies;
this.rateLimiter = new TokenBucket();
this._onDegraded = onDegraded;
this._db = db ?? null;
}
private async runBird(args: string[]): Promise<string> {
if (!this._cookies) throw new Error('XCookieAdapter: no credentials set');
const allArgs = [
'--auth-token', this._cookies.auth_token,
'--ct0', this._cookies.ct0,
...args,
];
try {
const { stdout, stderr } = await execFileAsync(BIRD_PATH, allArgs, { timeout: BIRD_TIMEOUT_MS });
this.emitHealthy();
return stdout;
} catch (e: any) {
const msg = e?.stderr ?? e?.message ?? String(e);
if (msg.includes('Missing required credentials') || msg.includes('Missing auth_token') || msg.includes('Missing ct0')) {
this.emitDegraded('bird CLI: missing or expired credentials');
}
throw new Error(`XCookieAdapter: bird CLI failed — ${msg}`);
}
}
private persistPosts(posts: XPostRow[]): void {
if (!this._db || posts.length === 0) return;
const cachedUntil = new Date(Date.now() + CACHE_TTL_MS).toISOString();
const stmt = this._db.prepare(
`INSERT OR REPLACE INTO x_cookie_posts (post_id, source, author_handle, cashtag, body_text, posted_at, engagement, cached_until)
VALUES (?, 'x', ?, ?, ?, ?, ?, ?)`,
);
for (const p of posts) {
stmt.run(p.post_id, p.author_handle, p.cashtag ?? null, p.body_text ?? null, p.posted_at, p.engagement, cachedUntil);
}
}
// Set cookies dynamically (for re-seeding after expiry detection)
setCookies(cookies: { ct0: string; auth_token: string }): void {
this._cookies = cookies;
if (this.cookieHealth.sourceStatus !== 'healthy') {
@@ -125,302 +112,101 @@ export class XCookieAdapter implements SourceFetch {
}
}
// Expose health for the UI to read degraded state
get health(): XCookieHealth { return this.cookieHealth; }
// Emit a degraded signal when cookie expires
private emitDegraded(error: string): void {
this.cookieHealth = { sourceStatus: 'failed', lastError: error };
this._onDegraded?.(this.cookieHealth);
console.warn(`[XCookieAdapter] source-degraded: ${error}`);
}
// Rehydrate healthy state after cookie re-seeding
private emitHealthy(): void {
if (this.cookieHealth.sourceStatus !== 'healthy') {
this.cookieHealth = { sourceStatus: 'healthy' };
this._onDegraded?.(this.cookieHealth);
}
this.cookieHealth = { sourceStatus: 'healthy' };
this._onDegraded?.(this.cookieHealth);
}
/** Search for cashtag results via X's internal API. */
async cashtagSearch(cashtag: string, opts?: FetchOpts): Promise<FetchResult> {
async cashtagSearch(cashtag: string, opts?: FetchOpts & { walkBackDays?: number }): Promise<FetchResult> {
if (this.cookieHealth.sourceStatus === 'failed') {
throw new Error(`XCookieAdapter: source degraded — ${this.cookieHealth.lastError}`);
}
await this.rateLimiter.acquire();
const query = encodeURIComponent(`$${cashtag} -is:retweet lang:en`);
const url = `${X_SEARCH_URL}?variables=${encodeURIComponent(JSON.stringify({
rawQuery: `\$${cashtag}`,
count: 20,
querySource: 'typed_query',
product: 'Top',
}))}&features=${encodeURIComponent(JSON.stringify({
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_enhance_cards_enabled: false,
}))}&fieldToggles=${encodeURIComponent(JSON.stringify({ withArticleRichContentState: true }))}`;
const count = opts?.walkBackDays && opts.walkBackDays > 0 ? 100 : 20;
const stdout = await this.runBird(['search', `$${cashtag}`, '-n', String(count), '--json']);
let tweets: BirdTweet[];
try {
const resp = await fetch(url, {
headers: {
'Cookie': `ct0=${this._cookies!.ct0}; auth_token=${this._cookies!.auth_token}`,
'x-csrf-token': this._cookies!.ct0,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
'Authorization': `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
});
// Cookie expiry detection: 403/302/empty response body
if (resp.status === 403 || resp.status === 302) {
this.emitDegraded(`HTTP ${resp.status} — cookie likely expired`);
throw new Error(`XCookieAdapter: X source degraded (HTTP ${resp.status})`);
}
const bodyText = await resp.text();
if (!bodyText || bodyText.trim().length === 0) {
this.emitDegraded('Empty response body — cookie may have expired');
throw new Error('XCookieAdapter: X source degraded (empty response)');
}
const parsed = JSON.parse(bodyText) as XTimelineResponse;
const posts = this.parseSearchTimeline(parsed, cashtag);
return {
value: posts,
ttlClass: 'thread_7d' as TtlClass,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'x', rawSourceId: `cashtag:${cashtag}` },
};
} catch (err) {
if (err instanceof Error && err.message.includes('X source degraded')) {
throw err; // re-throw degraded errors
}
throw new Error(`XCookieAdapter cashtag search failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Fetch a trusted account's timeline. */
async trustedTimeline(handle: string, opts?: FetchOpts): Promise<FetchResult> {
if (this.cookieHealth.sourceStatus === 'failed') {
throw new Error(`XCookieAdapter: source degraded — ${this.cookieHealth.lastError}`);
tweets = JSON.parse(stdout) as BirdTweet[];
} catch {
throw new Error('XCookieAdapter: bird CLI returned invalid JSON');
}
await this.rateLimiter.acquire();
const variables = JSON.stringify({
userId: undefined, // will be resolved from handle via a lookup
count: 20,
includePromotedContent: false,
withCommunity: true,
withVoice: true,
withSuperFollowsUserFieldsEnabled: true,
});
// We need the user_id for the timeline API — do a quick search to resolve handle → id
const userSearchUrl = `https://x.com/i/api/graphql/LuOGNVfTtTZ4o2Pjv3NoyA/SearchTimeline`;
const encodedVariables = encodeURIComponent(JSON.stringify({
rawQuery: `from:${handle}`,
count: 1,
querySource: 'popped_topic',
product: 'Top',
const posts: XPostRow[] = tweets.map((t) => ({
post_id: t.id,
author_handle: (t.author?.username ?? 'unknown').replace(/^@/, ''),
cashtag: `$${cashtag.replace(/^\$/, '')}`,
body_text: t.text.length > 280 ? t.text.slice(0, 280) : t.text,
posted_at: normalizePostedAt(t.createdAt),
engagement: (t.likeCount ?? 0) + (t.retweetCount ?? 0) + (t.replyCount ?? 0),
}));
this.persistPosts(posts);
return {
value: posts,
ttlClass: 'thread_7d' as TtlClass,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'x', rawSourceId: `cashtag:${cashtag}` },
};
}
async trustedTimeline(handle: string, opts?: FetchOpts & { walkBackDays?: number }): Promise<FetchResult> {
if (this.cookieHealth.sourceStatus === 'failed') {
throw new Error(`XCookieAdapter: source degraded — ${this.cookieHealth.lastError}`);
}
const count = opts?.walkBackDays && opts.walkBackDays > 0 ? 100 : 20;
const clean = handle.replace(/^@/, '');
// bird 0.8.x user-tweets often fails with "User not found" even for valid
// handles (GraphQL user lookup). Search `from:handle` is reliable with the
// same cookies and returns the profile timeline effectively.
let tweets: BirdTweet[] = [];
let used = 'from-search';
try {
// First: resolve handle to user_id via search
const searchResp = await fetch(`${userSearchUrl}?variables=${encodedVariables}&features=${encodeURIComponent('{}')}&fieldToggles=${encodeURIComponent('{"withArticleRichContentState":false}')}`, {
headers: {
'Cookie': `ct0=${this._cookies!.ct0}; auth_token=${this._cookies!.auth_token}`,
'x-csrf-token': this._cookies!.ct0,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
Authorization: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
});
if (searchResp.status === 403 || searchResp.status === 302) {
this.emitDegraded(`HTTP ${searchResp.status} — cookie likely expired`);
throw new Error(`XCookieAdapter: X source degraded (HTTP ${searchResp.status})`);
}
const searchBody = await searchResp.text();
if (!searchBody || searchBody.trim().length === 0) {
this.emitDegraded('Empty response body — cookie may have expired');
throw new Error('XCookieAdapter: X source degraded (empty response)');
}
const searchParsed = JSON.parse(searchBody) as XTimelineResponse;
const userResult = this.extractUserIdFromSearch(searchParsed);
if (!userResult) {
throw new Error(`XCookieAdapter: could not resolve handle "${handle}" to a user ID`);
}
// Now fetch the timeline using the resolved user_id
const timelineVariables = encodeURIComponent(JSON.stringify({
userId: userResult,
count: 20,
includePromotedContent: false,
withCommunity: true,
withVoice: true,
withSuperFollowsUserFieldsEnabled: true,
}));
const timelineResp = await fetch(`${X_TIMELINE_URL}?variables=${timelineVariables}&features=${encodeURIComponent('{}')}&fieldToggles=${encodeURIComponent('{"withArticleRichContentState":false}')}`, {
headers: {
'Cookie': `ct0=${this._cookies!.ct0}; auth_token=${this._cookies!.auth_token}`,
'x-csrf-token': this._cookies!.ct0,
'x-twitter-active-user': 'yes',
'x-twitter-auth-type': 'OAuth2Session',
Authorization: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
});
if (timelineResp.status === 403 || timelineResp.status === 302) {
this.emitDegraded(`HTTP ${timelineResp.status} — cookie likely expired`);
throw new Error(`XCookieAdapter: X source degraded (HTTP ${timelineResp.status})`);
}
const timelineBody = await timelineResp.text();
if (!timelineBody || timelineBody.trim().length === 0) {
this.emitDegraded('Empty response body — cookie may have expired');
throw new Error('XCookieAdapter: X source degraded (empty response)');
}
const timelineParsed = JSON.parse(timelineBody) as XTimelineResponse;
const posts = this.parseUserTimeline(timelineParsed, handle);
return {
value: posts,
ttlClass: 'thread_7d' as TtlClass,
provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'x', rawSourceId: `timeline:${handle}` },
};
} catch (err) {
if (err instanceof Error && err.message.includes('X source degraded')) {
throw err;
}
throw new Error(`XCookieAdapter trusted timeline failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
// ---- Parsing helpers ----
private parseSearchTimeline(response: XTimelineResponse, cashtag: string): Array<{
post_id: string; author_handle: string; cashtag: string | null; body_text: string | null; posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
}> {
const posts: Array<{
post_id: string; author_handle: string; cashtag: string | null; body_text: string | null; posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
}> = [];
const instructions = response.data?.search_by_raw_query?.search_timeline?.timeline?.instructions ?? [];
for (const instruction of instructions as XInstruction[]) {
if (instruction.type !== 'TimelineAddEntries') continue;
for (const entry of instruction.entries ?? []) {
const content = entry.content as XItemContent | undefined;
if (!content?.itemContent?.tweet_results?.result) continue;
const result = content.itemContent.tweet_results.result;
const legacy = result.legacy;
if (!legacy || !result.rest_id) continue;
const authorHandle = result.core?.user_results?.result?.legacy?.screen_name ?? null;
const fullText = legacy.full_text ?? '';
const createdAt = legacy.created_at ?? '';
const engagement = (legacy.favorite_count ?? 0) + (legacy.retweet_count ?? 0) + (legacy.reply_count ?? 0);
posts.push({
post_id: result.rest_id,
author_handle: authorHandle ?? '',
cashtag: `$${cashtag}`,
body_text: fullText.length > 280 ? fullText.slice(0, 280) : fullText,
posted_at: createdAt,
engagement,
});
const stdout = await this.runBird([
'search', `from:${clean}`, '-n', String(count), '--json',
]);
tweets = JSON.parse(stdout) as BirdTweet[];
if (!Array.isArray(tweets)) throw new Error('not an array');
} catch (searchErr) {
// Fallback: user-tweets (older bird / when search is rate-limited).
try {
const stdout = await this.runBird([
'user-tweets', `@${clean}`, '-n', String(count), '--json',
]);
tweets = JSON.parse(stdout) as BirdTweet[];
used = 'user-tweets';
if (!Array.isArray(tweets)) throw new Error('not an array');
} catch (userErr) {
const s = searchErr instanceof Error ? searchErr.message : String(searchErr);
const u = userErr instanceof Error ? userErr.message : String(userErr);
throw new Error(`XCookieAdapter: timeline fetch failed for @${clean} (from-search: ${s}; user-tweets: ${u})`);
}
}
return posts;
const posts = birdTweetsToXPosts(tweets, clean, null);
this.persistPosts(posts);
return {
value: posts,
ttlClass: 'thread_7d' as TtlClass,
provenance: {
fetchedAt: new Date().toISOString(),
sourceKind: 'x',
rawSourceId: `timeline:${clean}:${used}`,
},
};
}
private parseUserTimeline(response: XTimelineResponse, handle: string): Array<{
post_id: string; author_handle: string; cashtag: string | null; body_text: string | null; posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
}> {
const posts: Array<{
post_id: string; author_handle: string; cashtag: string | null; body_text: string | null; posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
}> = [];
const instructions = response.data?.user_result?.result?.timeline_v2?.timeline?.instructions ?? [];
for (const instruction of instructions as XInstruction[]) {
if (instruction.type !== 'TimelineAddEntries') continue;
for (const entry of instruction.entries ?? []) {
const content = entry.content as XItemContent | undefined;
if (!content?.itemContent?.tweet_results?.result) continue;
const result = content.itemContent.tweet_results.result;
const legacy = result.legacy;
if (!legacy || !result.rest_id) continue;
const fullText = legacy.full_text ?? '';
const createdAt = legacy.created_at ?? '';
const engagement = (legacy.favorite_count ?? 0) + (legacy.retweet_count ?? 0) + (legacy.reply_count ?? 0);
// For timeline results, author is the trusted account handle
posts.push({
post_id: result.rest_id,
author_handle: handle,
cashtag: null,
body_text: fullText.length > 280 ? fullText.slice(0, 280) : fullText,
posted_at: createdAt,
engagement,
});
}
}
return posts;
}
private extractUserIdFromSearch(response: XTimelineResponse): string | null {
const instructions = response.data?.search_by_raw_query?.search_timeline?.timeline?.instructions ?? [];
for (const instruction of instructions as XInstruction[]) {
if (instruction.type !== 'TimelineAddEntries') continue;
for (const entry of instruction.entries ?? []) {
const content = entry.content as XItemContent | undefined;
if (!content?.itemContent?.tweet_results?.result) continue;
const result = content.itemContent.tweet_results.result;
if (result.__typename === 'User') {
return result.rest_id ?? null;
}
}
}
return null;
}
// ---- SourceAdapter contract ----
async fetchOne(key: string, opts?: FetchOpts): Promise<FetchResult> {
const parts = key.split(':');
if (parts.length < 2) throw new Error(`XCookieAdapter: invalid cache key "${key}"`);
@@ -431,7 +217,6 @@ export class XCookieAdapter implements SourceFetch {
switch (kind) {
case 'cashtag': {
const result = await this.cashtagSearch(id, opts);
// Stamp cached_until: 7d from now
const cachedUntil = new Date(Date.now() + CACHE_TTL_MS).toISOString();
if (Array.isArray(result.value)) {
for (const post of result.value as Array<Record<string, unknown>>) {
@@ -442,7 +227,6 @@ export class XCookieAdapter implements SourceFetch {
}
case 'timeline': {
const result = await this.trustedTimeline(id, opts);
// Stamp cached_until: 7d from now
const cachedUntil = new Date(Date.now() + CACHE_TTL_MS).toISOString();
if (Array.isArray(result.value)) {
for (const post of result.value as Array<Record<string, unknown>>) {
+8 -1
View File
@@ -32,7 +32,7 @@ export class YFinanceAdapter implements SourceFetch {
case 'candles': {
const [symbol, timeframe] = id.split(':');
const interval = timeframeToInterval(timeframe);
const days = timeframe === '1wk' ? 3650 : 3650; // permanent backfill (~10 years)
const days = 3650; // permanent backfill (~10 years for both 1d and 1wk)
const raw = await yf.chart(symbol, { period1: new Date(Date.now() - days * 86_400_000), interval });
return { value: parseCandles(raw), ttlClass: 'daily_permanent', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `${symbol}:${timeframe}` } };
}
@@ -99,6 +99,13 @@ export function parseSymbol(raw: Record<string, unknown>, symbol: string): Symbo
industry: str(sp.industry),
exchange: str(sp.exchange ?? price.exchange),
tickerKind: inferTickerKind(symbol),
description: str(sp.longBusinessSummary),
website: str(sp.website),
marketCap: num(price.marketCap),
currency: str(price.currency),
employees: num(price.fullTimeEmployees) as number | null,
country: str(sp.country ?? price.country),
city: str(sp.city ?? price.city),
peers: null,
};
}
@@ -29,7 +29,7 @@ function createMockFetch(
responses: Record<string, { body: unknown; status?: number; etag?: string; lastModified?: string }>,
) {
let callCount = 0;
async function mockFetch(url: string | URL, init?: RequestInit): Promise<Response> {
async function mockFetch(url: string | URL | Request, init?: RequestInit): Promise<Response> {
const urlStr = typeof url === 'string' ? url : url.toString();
callCount++;
for (const [key, resp] of Object.entries(responses)) {
@@ -56,7 +56,7 @@ function createHeaderRecordingMockFetch(
responses: Record<string, { body: unknown; status?: number; etag?: string; lastModified?: string }>,
) {
const calls: FetchCall[] = [];
async function mockFetch(url: string | URL, init?: RequestInit): Promise<Response> {
async function mockFetch(url: string | URL | Request, init?: RequestInit): Promise<Response> {
const urlStr = typeof url === 'string' ? url : url.toString();
const headers: Record<string, string> = {};
if (init?.headers) {
@@ -0,0 +1,205 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { OptionsAdapter, parseOptionChain, parseOptionChainRows, type OptionChainRow } from '../OptionsAdapter.ts';
import { createCacheRepository, type CacheScheduler, type CacheKey } from '../../cache/CacheRepository.ts';
import { createDb, initSchema } from '../../db/client.ts';
// ----- Test helpers: in-memory DB with full schema -----
function createTestDb(): DatabaseSync {
const db = createDb({ path: ':memory:' });
initSchema(db);
return db;
}
class NoopScheduler implements CacheScheduler {
async queue(_key: CacheKey): Promise<void> { /* no-op */ }
}
/** A stubbed yfinance2 client that returns canned data — no network. */
interface StubYf {
optionsExpiryDates: (symbol: string) => Promise<string[]>;
options: (symbol: string, expiry?: string) => Promise<Record<string, unknown>>;
}
/** Build an OptionsAdapter with a stubbed yf instance. */
function createStubbedAdapter(stubYf: StubYf): OptionsAdapter {
const adapter = new OptionsAdapter();
(adapter as any)._yf = Promise.resolve(stubYf as any);
return adapter;
}
function makeRawChainWithGreeks(): Record<string, unknown> {
return {
calls: [
{
contractSymbol: 'NVDA250717C00100000',
strike: 100,
expiration: '2026-07-17',
lastPrice: 45.5,
bid: 44.0,
ask: 46.0,
volume: 150,
openInterest: 1200,
impliedVolatility: 0.45,
inTheMoney: true,
delta: 0.85,
gamma: 0.012,
theta: -0.05,
vega: 0.30,
},
{
contractSymbol: 'NVDA250717C00120000',
strike: 120,
expiration: '2026-07-17',
lastPrice: 30.2,
bid: 29.0,
ask: 31.0,
volume: 80,
openInterest: 800,
impliedVolatility: 0.42,
inTheMoney: true,
delta: 0.65,
gamma: 0.018,
theta: -0.04,
vega: 0.25,
},
],
puts: [
{
contractSymbol: 'NVDA250717P00080000',
strike: 80,
right: 'put',
expiration: '2026-07-17',
lastPrice: 5.1,
bid: 4.8,
ask: 5.4,
volume: 50,
openInterest: 300,
impliedVolatility: 0.55,
inTheMoney: false,
delta: -0.15,
gamma: 0.010,
theta: -0.03,
vega: 0.20,
},
],
};
}
// ----- Tests for the new 'greeks' kind -----
test('fetchOne(greeks) returns single row for a specific strike', async () => {
const raw = makeRawChainWithGreeks();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:100');
// Should return the row with strike=100
const row = result.value as OptionChainRow | null;
assert.ok(row !== null, 'greeks kind should return a row');
assert.equal(row!.strike, 100);
assert.equal(row!.right, 'call');
assert.equal(row!.greeks?.delta, 0.85);
assert.equal(result.ttlClass, 'options_snapshot');
});
test('fetchOne(greeks) returns first row when strike is 0', async () => {
const raw = makeRawChainWithGreeks();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:0');
const row = result.value as OptionChainRow | null;
assert.ok(row !== null, 'greeks kind with strike=0 should return first row');
// First row after sort: put at strike 80
assert.equal(row!.strike, 80);
assert.equal(row!.right, 'put');
});
test('fetchOne(greeks) returns null when strike not found', async () => {
const raw = makeRawChainWithGreeks();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:9999');
// Should return null since no row matches strike 9999
assert.equal(result.value, null);
});
test('greeks kind preserves ttlClass as options_snapshot', async () => {
const raw = makeRawChainWithGreeks();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:100');
assert.equal(result.ttlClass, 'options_snapshot');
});
test('greeks kind round-trips through CacheRepository', async () => {
const db = createTestDb();
const scheduler = new NoopScheduler();
const repo = createCacheRepository({ db, scheduler });
const raw = makeRawChainWithGreeks();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const key = 'yfinance:greeks:NVDA:2026-07-17:100';
const fetched = await adapter.fetchOne(key);
// Write to cache
await repo.set(key, fetched.value, fetched.ttlClass, fetched.provenance);
// Read back from cache — value should be present (staleness depends on time)
const cached = await repo.get(key);
const cachedRow = cached.value as OptionChainRow | null;
assert.ok(cachedRow !== null, 'cached greeks row should not be null');
assert.equal(cachedRow!.strike, 100);
});
test('greeks kind: row with no greeks returns null greeks field', async () => {
const raw = {
calls: [
{
contractSymbol: 'AAPL250717C00150000',
strike: 150,
expiration: '2026-07-17',
lastPrice: 8.5,
bid: 8.0,
ask: 9.0,
volume: 200,
openInterest: 500,
impliedVolatility: 0.35,
// No greeks at all
},
],
puts: [],
};
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const result = await adapter.fetchOne('yfinance:greeks:AAPL:2026-07-17:150');
const row = result.value as OptionChainRow | null;
assert.ok(row !== null);
assert.equal(row!.greeks, null);
assert.equal(row!.strike, 150);
});
@@ -0,0 +1,290 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { OptionsAdapter, parseOptionChain, parseOptionChainRows, type OptionChainRow, type OptionChain } from '../OptionsAdapter.ts';
import { createCacheRepository, type CacheScheduler, type CacheKey } from '../../cache/CacheRepository.ts';
import { createDb, initSchema } from '../../db/client.ts';
// ----- Test helpers: in-memory DB with full schema -----
function createTestDb(): DatabaseSync {
const db = createDb({ path: ':memory:' });
initSchema(db);
return db;
}
class NoopScheduler implements CacheScheduler {
async queue(_key: CacheKey): Promise<void> { /* no-op */ }
}
/** A stubbed yfinance2 client that returns canned data — no network. */
interface StubYf {
optionsExpiryDates: (symbol: string) => Promise<string[]>;
options: (symbol: string, expiry?: string) => Promise<Record<string, unknown>>;
}
/** Build an OptionsAdapter with a stubbed yf instance. */
function createStubbedAdapter(stubYf: StubYf): OptionsAdapter {
const adapter = new OptionsAdapter();
// Replace the private _yf via any-cast to inject our stub.
(adapter as any)._yf = Promise.resolve(stubYf as any);
return adapter;
}
/** Build a canned yfinance response matching what yahoo-finance2 returns. */
function makeRawChain(): Record<string, unknown> {
return {
calls: [
{
contractSymbol: 'NVDA250717C00100000',
strike: 100,
expiration: '2026-07-17',
lastPrice: 45.5,
bid: 44.0,
ask: 46.0,
volume: 150,
openInterest: 1200,
impliedVolatility: 0.45,
inTheMoney: true,
delta: 0.85,
gamma: 0.012,
theta: -0.05,
vega: 0.30,
},
{
contractSymbol: 'NVDA250717C00120000',
strike: 120,
expiration: '2026-07-17',
lastPrice: 30.2,
bid: 29.0,
ask: 31.0,
volume: 80,
openInterest: 800,
impliedVolatility: 0.42,
inTheMoney: true,
},
],
puts: [
{
contractSymbol: 'NVDA250717P00080000',
strike: 80,
right: 'put',
expiration: '2026-07-17',
lastPrice: 5.1,
bid: 4.8,
ask: 5.4,
volume: 50,
openInterest: 300,
impliedVolatility: 0.55,
inTheMoney: false,
},
],
};
}
// ----- Tests -----
test('parseOptionChainRows returns sorted OptionChainRow[] (calls before puts at same strike)', () => {
const raw = makeRawChain();
const rows = parseOptionChainRows('NVDA', raw);
assert.equal(rows.length, 3);
// Should be sorted by strike ascending: 80 (put), 100 (call), 120 (call)
assert.equal(rows[0].strike, 80);
assert.equal(rows[0].right, 'put');
assert.equal(rows[1].strike, 100);
assert.equal(rows[1].right, 'call');
assert.equal(rows[2].strike, 120);
assert.equal(rows[2].right, 'call');
// Verify typed fields populated
assert.equal(rows[0].contractSymbol, 'NVDA250717P00080000');
assert.equal(rows[0].expiration, '2026-07-17');
assert.equal(rows[0].bid, 4.8);
assert.equal(rows[0].ask, 5.4);
assert.equal(rows[0].volume, 50);
assert.equal(rows[0].openInterest, 300);
assert.equal(rows[0].impliedVolatility, 0.55);
assert.equal(rows[0].inTheMoney, false);
// Greeks on first row with calls
assert.equal(rows[1].greeks?.delta, 0.85);
assert.equal(rows[1].greeks?.gamma, 0.012);
});
test('parseOptionChain wraps rows into OptionChain with symbol + expiration', () => {
const raw = makeRawChain();
const chain = parseOptionChain('NVDA', raw);
assert.equal(chain.symbol, 'NVDA');
assert.equal(chain.expiration, '2026-07-17');
assert.equal(chain.rows.length, 3);
});
test('fetchOne(chain) returns OptionChainRow[] (rows array), not OptionChain object', async () => {
const raw = makeRawChain();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17', '2026-08-21'],
options: async () => raw,
});
const result = await adapter.fetchOne('yfinance:chain:NVDA:2026-07-17');
// Value must be an array (rows), not an object with .symbol/.rows
assert.ok(Array.isArray(result.value), 'fetchOne(chain) must return an array of rows');
assert.equal((result.value as OptionChainRow[]).length, 3);
assert.equal(result.ttlClass, 'options_snapshot');
});
test('chain() convenience method returns OptionChain with rows populated', async () => {
const raw = makeRawChain();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const chain = await adapter.chain('NVDA', '2026-07-17');
// Must be an OptionChain (has .symbol, .expiration, .rows)
assert.equal(chain.symbol, 'NVDA');
assert.equal(chain.expiration, '2026-07-17');
assert.ok(Array.isArray(chain.rows));
assert.equal(chain.rows.length, 3);
assert.equal(chain.rows[0].strike, 80);
assert.equal(chain.rows[1].strike, 100);
});
test('round-trip: fetchOne(chain) value → CacheRepository.set → CacheRepository.get → chain() consumes', async () => {
// This is the critical integration test: fetchOne('chain') returns rows[];
// CacheRepository.optionsChainHandler.write accepts rows[] (iterable);
// CacheRepository.get returns the same shape; chain() re-wraps into OptionChain.
const db = createTestDb();
const scheduler = new NoopScheduler();
const repo = createCacheRepository({ db, scheduler });
const raw = makeRawChain();
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const key = 'yfinance:chain:NVDA:2026-07-17';
const fetched = await adapter.fetchOne(key);
// Step 1: fetchOne returns rows array (not OptionChain object)
assert.ok(Array.isArray(fetched.value), 'fetchOne value must be array');
// Step 2: write to cache — must NOT throw TypeError (the original bug)
await repo.set(key, fetched.value, fetched.ttlClass, fetched.provenance);
// Step 3: read from cache — must return same shape (rows array)
const cached = await repo.get(key);
assert.equal(cached.isStale, false);
const cachedValue = cached.value as OptionChainRow[];
assert.ok(Array.isArray(cachedValue), 'cached value must be array');
assert.equal(cachedValue.length, 3);
// Step 4: chain() convenience consumes cached value — re-wraps into OptionChain
// Simulate what chain() does: take the rows from get, wrap into OptionChain
const reconstructed = { symbol: 'NVDA', expiration: '2026-07-17', rows: cachedValue };
assert.equal(reconstructed.symbol, 'NVDA');
assert.equal(reconstructed.expiration, '2026-07-17');
assert.equal(reconstructed.rows.length, 3);
assert.equal(reconstructed.rows[0].strike, 80);
});
test('fetchOne(expiry_dates) returns string[] and round-trips via kv_cache', async () => {
const db = createTestDb();
const scheduler = new NoopScheduler();
const repo = createCacheRepository({ db, scheduler });
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17', '2026-08-21', '2026-09-18'],
options: async () => ({}),
});
const key = 'yfinance:expiry_dates:NVDA';
const fetched = await adapter.fetchOne(key);
// Value must be string[]
assert.ok(Array.isArray(fetched.value));
const dates = fetched.value as string[];
assert.deepEqual(dates, ['2026-07-17', '2026-08-21', '2026-09-18']);
assert.equal(fetched.ttlClass, 'intraday');
// Round-trip through kv_cache
await repo.set(key, dates, fetched.ttlClass, fetched.provenance);
const cached = await repo.get<string[]>(key);
assert.equal(cached.isStale, false);
const cachedDates = cached.value;
assert.ok(Array.isArray(cachedDates));
assert.deepEqual(cachedDates, ['2026-07-17', '2026-08-21', '2026-09-18']);
});
test('expiry_dates round-trip preserves sort order (ascending)', async () => {
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-09-18', '2026-07-17', '2026-08-21'], // unsorted input
options: async () => ({}),
});
const fetched = await adapter.fetchOne('yfinance:expiry_dates:TSLA');
const dates = fetched.value as string[];
// Should be sorted ascending regardless of input order
assert.deepEqual(dates, ['2026-07-17', '2026-08-21', '2026-09-18']);
});
test('chain() with empty rows returns OptionChain with empty rows array', async () => {
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => [],
options: async () => ({ calls: [], puts: [] }),
});
const chain = await adapter.chain('NOOPTIONS', '2026-07-17');
assert.equal(chain.symbol, 'NOOPTIONS');
// chain() uses the expiry arg directly when rows is empty
assert.equal(chain.expiration, '2026-07-17');
assert.deepEqual(chain.rows, []);
});
test('fetchOne on unknown kind throws', async () => {
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => [],
options: async () => ({}),
});
await assert.rejects(
() => adapter.fetchOne('yfinance:unknown_kind:something'),
{ message: /unknown kind/ },
);
});
test('round-trip handles rows with missing optional greeks gracefully', async () => {
const raw = {
calls: [
{
contractSymbol: 'AAPL250717C00150000',
strike: 150,
expiration: '2026-07-17',
lastPrice: 8.5,
bid: 8.0,
ask: 9.0,
volume: 200,
openInterest: 500,
impliedVolatility: 0.35,
// No greeks at all
},
],
puts: [],
};
const adapter = createStubbedAdapter({
optionsExpiryDates: async () => ['2026-07-17'],
options: async () => raw,
});
const chain = await adapter.chain('AAPL', '2026-07-17');
assert.equal(chain.rows[0].greeks, null);
assert.equal(chain.rows[0].strike, 150);
});
@@ -192,8 +192,8 @@ test('RedditAdapter subredditPosts returns parsed posts with attribution', async
// Verify the first post has expected data
const firstPost = posts[0] as Record<string, unknown>;
assert.equal(firstPost.author_handle, 'reddit_user_1');
assert.ok(firstPost.body_text?.includes('fundamentals'), 'body should contain selftext content');
assert.ok(firstPost.engagement >= 0, 'engagement should be non-negative');
assert.ok((firstPost.body_text as string | undefined)?.includes('fundamentals'), 'body should contain selftext content');
assert.ok((firstPost.engagement as number) >= 0, 'engagement should be non-negative');
} finally {
(global as Record<string, unknown>).fetch = originalFetch;
}
@@ -1,412 +1,72 @@
// Investor Flow — XCookieAdapter tests (mock fetch, no real network).
// Verifies: cashtag search returns + caches threads; rate-limit spacing (3s);
// cookie-expiry (403) → source FAILED + degraded signal; 7d cache TTL;
// attribution (author_handle) stored; FakeLLM post_summary returns canned text.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { XCookieAdapter, CROWD_SENTIMENT_CAVEAT } from '../XCookieAdapter.ts';
import { XCookieAdapter, CROWD_SENTIMENT_CAVEAT, birdTweetsToXPosts } from '../XCookieAdapter.ts';
// ===== Fake LLM for deterministic canned summaries (NO real LLM/network) =====
// ===== Tests for the pure mapper function =====
interface FakeLLMResponse {
summary: string;
}
class FakeLLM {
private readonly cannedSummary: string;
constructor(cannedSummary = 'CANNED_SENTIMENT_SUMMARY') {
this.cannedSummary = cannedSummary;
}
async postSummary(text: string, _cashtag?: string): Promise<FakeLLMResponse> {
// Deterministic canned response — no network call
return { summary: this.cannedSummary };
}
}
// ===== Mock fetch for XCookieAdapter tests =====
function createMockFetch(responses: Record<string, { body: unknown; status?: number }>) {
let callCount = 0;
async function mockFetch(url: string | URL, _init?: RequestInit): Promise<Response> {
const urlStr = typeof url === 'string' ? url : url.toString();
// Match by key pattern (cashtag search or timeline)
for (const [key, resp] of Object.entries(responses)) {
if (urlStr.includes(key)) {
return new Response(JSON.stringify(resp.body), {
status: resp.status ?? 200,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Default: 403 for cookie expiry simulation
return new Response('', { status: 403 });
}
mockFetch.callCount = () => callCount;
return mockFetch as typeof globalThis.fetch;
}
// ===== Test fixtures =====
function makeSearchResponse(cashtag: string): Record<string, unknown> {
return {
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: `tweet-${cashtag}-1`,
sortIndex: '1',
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'Tweet',
rest_id: `${cashtag}_post_1`,
core: {
user_results: {
result: {
legacy: { screen_name: 'testuser' },
},
},
},
legacy: {
full_text: `$${cashtag} is looking strong today. Bullish on this name.`,
created_at: 'Mon Jan 01 2026 12:00:00 GMT+0000',
favorite_count: 42,
retweet_count: 15,
reply_count: 3,
},
},
},
},
},
},
{
entryId: `tweet-${cashtag}-2`,
sortIndex: '2',
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'Tweet',
rest_id: `${cashtag}_post_2`,
core: {
user_results: {
result: {
legacy: { screen_name: 'bullish_trader' },
},
},
},
legacy: {
full_text: `$${cashtag} breaking out. Volume is picking up.`,
created_at: 'Mon Jan 01 2026 11:30:00 GMT+0000',
favorite_count: 89,
retweet_count: 34,
reply_count: 7,
},
},
},
},
},
},
],
},
],
},
},
},
test('birdTweetsToXPosts maps BirdTweet fields to XPostRow', () => {
const tweets = [
{
id: '123',
text: 'Hello world $AAPL',
createdAt: 'Mon Jul 14 01:33:07 +0000 2026',
replyCount: 2,
retweetCount: 5,
likeCount: 100,
authorId: '999',
author: { username: 'john_doe', name: 'John Doe' },
},
};
}
];
function makeTimelineResponse(handle: string): Record<string, unknown> {
return {
data: {
user_result: {
result: {
timeline_v2: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: `tweet-${handle}-1`,
sortIndex: '1',
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'Tweet',
rest_id: `${handle}_post_1`,
core: {
user_results: {
result: {
legacy: { screen_name: handle },
},
},
},
legacy: {
full_text: `My analysis of $NVDA for today.`,
created_at: 'Mon Jan 01 2026 14:00:00 GMT+0000',
favorite_count: 150,
retweet_count: 45,
reply_count: 12,
},
},
},
},
},
},
],
},
],
},
},
},
},
},
};
}
const posts = birdTweetsToXPosts(tweets, 'john_doe', null);
assert.equal(posts.length, 1);
assert.equal(posts[0].post_id, '123');
assert.equal(posts[0].author_handle, 'john_doe');
assert.equal(posts[0].cashtag, null);
assert.equal(posts[0].body_text, 'Hello world $AAPL');
assert.equal(posts[0].posted_at, new Date('Mon Jul 14 01:33:07 +0000 2026').toISOString());
assert.equal(posts[0].engagement, 107);
});
// ===== Tests =====
test('birdTweetsToXPosts truncates text longer than 280 chars', () => {
const longText = 'x'.repeat(300);
const tweets = [{ id: '1', text: longText, createdAt: '', replyCount: 0, retweetCount: 0, likeCount: 0, authorId: '', author: { username: 'u', name: 'U' } }];
const posts = birdTweetsToXPosts(tweets, 'u', 'AAPL');
assert.equal(posts[0].body_text!.length, 280);
assert.equal(posts[0].cashtag, '$AAPL');
});
test('birdTweetsToXPosts handles missing engagement counts as 0', () => {
const tweets = [{ id: '1', text: 'hi', createdAt: '', replyCount: undefined, retweetCount: undefined, likeCount: undefined, authorId: '', author: { username: 'u', name: 'U' } } as any];
const posts = birdTweetsToXPosts(tweets, 'u', null);
assert.equal(posts[0].engagement, 0);
});
// ===== Tests for XCookieHealth constant =====
test('CROWD_SENTIMENT_CAVEAT is defined and contains no imperative trade verbs', () => {
assert.ok(CROWD_SENTIMENT_CAVEAT, 'caveat should be defined');
assert.ok(CROWD_SENTIMENT_CAVEAT.length > 0, 'caveat should not be empty');
// ADR-0007: no imperative trade verbs
const forbidden = ['buy', 'sell', 'you should', 'add to your', 'rotate into'];
for (const word of forbidden) {
assert.ok(!CROWD_SENTIMENT_CAVEAT.toLowerCase().includes(word), `caveat must not contain "${word}"`);
}
});
test('FakeLLM post_summary returns canned text without network', async () => {
const fakeLlm = new FakeLLM('My canned summary');
const result = await fakeLlm.postSummary('test text', 'NVDA');
assert.equal(result.summary, 'My canned summary');
});
// ===== Tests for XCookieAdapter state management =====
test('FakeLLM post_summary is deterministic (same input → same output)', async () => {
const fakeLlm = new FakeLLM('Deterministic output');
const r1 = await fakeLlm.postSummary('same text', 'AAPL');
const r2 = await fakeLlm.postSummary('same text', 'AAPL');
assert.equal(r1.summary, r2.summary);
});
test('XCookieAdapter cashtagSearch returns parsed posts with attribution', async () => {
// We can't easily mock fetch globally in Node test without affecting other tests.
// Instead, we verify the adapter's internal parsing logic by checking that it
// would correctly extract data from a fixture response.
test('XCookieAdapter starts healthy', () => {
const adapter = new XCookieAdapter({ ct0: 'test_ct0', auth_token: 'test_auth' });
// Verify health starts healthy
assert.equal(adapter.health.sourceStatus, 'healthy');
// Verify the sourceKind
assert.equal(adapter.sourceKind, 'x');
});
test('XCookieAdapter fetchOne cashtag kind parses and returns posts', async () => {
const mockFetch = createMockFetch({
'search-timeline': { body: makeSearchResponse('NVDA') },
});
// Monkey-patch global fetch temporarily (Node test isolation)
const originalFetch = global.fetch;
(global as Record<string, unknown>).fetch = mockFetch;
try {
const adapter = new XCookieAdapter({ ct0: 'test_ct0', auth_token: 'test_auth' });
const result = await adapter.fetchOne('x:cashtag:NVDA');
assert.equal(result.ttlClass, 'thread_7d');
assert.equal(result.provenance.sourceKind, 'x');
assert.ok(Array.isArray(result.value), 'value should be an array of posts');
const posts = result.value as Array<Record<string, unknown>>;
assert.ok(posts.length > 0, 'should have at least one post');
// Verify attribution (author_handle) is stored
for (const post of posts) {
assert.ok(post.author_handle, 'each post should have author_handle');
assert.ok(post.cached_until, 'each post should have cached_until');
assert.ok(post.post_id, 'each post should have post_id');
}
// Verify the first post has expected data
const firstPost = posts[0] as Record<string, unknown>;
assert.equal(firstPost.author_handle, 'testuser');
assert.ok(firstPost.cashtag?.includes('NVDA'), 'cashtag should contain $NVDA');
assert.equal(firstPost.post_id, 'NVDA_post_1');
} finally {
(global as Record<string, unknown>).fetch = originalFetch;
}
});
test('XCookieAdapter cashtagSearch with 403 → source FAILED + degraded signal', async () => {
const mockFetch = createMockFetch({
'search-timeline': { body: '', status: 403 },
});
const originalFetch = global.fetch;
(global as Record<string, unknown>).fetch = mockFetch;
try {
let degradedHealth: Record<string, unknown> | undefined;
const adapter = new XCookieAdapter(
{ ct0: 'expired_ct0', auth_token: 'expired_auth' },
(health) => { degradedHealth = health; }
);
await assert.rejects(async () => {
await adapter.fetchOne('x:cashtag:NVDA');
}, /X source degraded/i);
// Verify the health is now failed
assert.ok(degradedHealth, 'degraded signal should have been emitted');
assert.equal(degradedHealth?.sourceStatus, 'failed');
} finally {
(global as Record<string, unknown>).fetch = originalFetch;
}
});
// Helper: mock user resolution response for the search → timeline flow
function makeUserResolutionResponse(handle: string): Record<string, unknown> {
return {
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: `user-${handle}-1`,
sortIndex: '1',
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'User',
rest_id: `${handle}_user_id`,
core: {
user_results: {
result: {
legacy: { screen_name: handle },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
};
}
// Helper: mock timeline response for the resolved user
function makeTimelineUserResponse(handle: string): Record<string, unknown> {
return {
data: {
user_result: {
result: {
timeline_v2: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
{
entryId: `tweet-${handle}-1`,
sortIndex: '1',
content: {
itemContent: {
tweet_results: {
result: {
__typename: 'Tweet',
rest_id: `${handle}_post_1`,
core: {
user_results: {
result: {
legacy: { screen_name: handle },
},
},
},
legacy: {
full_text: `My analysis of $NVDA for today.`,
created_at: 'Mon Jan 01 2026 14:00:00 GMT+0000',
favorite_count: 150,
retweet_count: 45,
reply_count: 12,
},
},
},
},
},
},
],
},
],
},
},
},
},
},
};
}
test('XCookieAdapter trusted_timeline fetchOne returns posts with author_handle', async () => {
// The timeline flow does a search → user resolution → timeline fetch
const mockFetch = createMockFetch({
'SearchTimeline': { body: makeUserResolutionResponse('trusted_trader') },
'vHlSJz4yOZC-Xj16R7Xm_Q': { body: makeTimelineUserResponse('trusted_trader') },
});
const originalFetch = global.fetch;
(global as Record<string, unknown>).fetch = mockFetch;
try {
const adapter = new XCookieAdapter({ ct0: 'test_ct0', auth_token: 'test_auth' });
const result = await adapter.fetchOne('x:timeline:trusted_trader');
assert.equal(result.ttlClass, 'thread_7d');
assert.ok(Array.isArray(result.value), 'value should be an array of posts');
const posts = result.value as Array<Record<string, unknown>>;
for (const post of posts) {
assert.equal(post.author_handle, 'trusted_trader', 'author_handle should be the trusted handle');
}
} finally {
(global as Record<string, unknown>).fetch = originalFetch;
}
});
test('XCookieAdapter setCookies re-sets healthy state after degraded', async () => {
test('XCookieAdapter setCookies re-sets healthy state after degraded', () => {
const adapter = new XCookieAdapter({ ct0: 'expired_ct0', auth_token: 'expired_auth' });
// Simulate degradation
adapter['emitDegraded']('cookie expired');
assert.equal(adapter.health.sourceStatus, 'failed');
// Re-seed cookies — should re-healthy
adapter.setCookies({ ct0: 'new_ct0', auth_token: 'new_auth' });
assert.equal(adapter.health.sourceStatus, 'healthy');
});
@@ -417,3 +77,11 @@ test('XCookieAdapter fetchOne with unknown kind throws', async () => {
await adapter.fetchOne('x:unknown_kind:something');
}, /unknown kind/);
});
test('XCookieAdapter fetchOne when source failed throws degraded error', async () => {
const adapter = new XCookieAdapter({ ct0: 'test_ct0', auth_token: 'test_auth' });
adapter['emitDegraded']('cookie expired');
await assert.rejects(async () => {
await adapter.fetchOne('x:cashtag:NVDA');
}, /source degraded/);
});
@@ -56,7 +56,7 @@ function emitFrom(
const entries: Record<string, unknown>[] = Array.isArray(source)
? (source as Record<string, unknown>[])
: Object.values(source as Record<string, unknown>);
: (Object.values(source as Record<string, unknown>) as Record<string, unknown>[]);
for (const entry of entries) {
if (!entry || typeof entry !== "object") continue;