fix: IREN/ASTS/IRE stuck-pending + corridor feature
Fix stuck adapter_queue jobs (ASTS/IRE/IREN stuck pending forever): 1. fetchSpec early-return paths (cooldown checks) now update job status to 'backoff' with last_attempt set and 30s backoff_until, instead of returning without any status change. Prevents jobs from being re-processed every drain cycle indefinitely. 2. Wrap adapter.fetchOne() in 30s Promise.race timeout. A hung HTTP request no longer blocks the entire per-source promise chain forever, preventing all subsequent jobs for that source. Also includes corridor/confluence feature, tiered quote schedules, cache improvements, and HoldingsBookView refinements.
This commit is contained in:
@@ -0,0 +1,239 @@
|
|||||||
|
// Investor Flow — Corridor Method backtest grader (M24, slice 4)
|
||||||
|
//
|
||||||
|
// Grades @alojoh's weekly "Corridor Method" entry rankings against ACTUAL
|
||||||
|
// forward price returns — the user asked to judge the methodology itself, not
|
||||||
|
// merely replicate it. Each week we hold his top-3 entry-attractive names
|
||||||
|
// versus his bottom-3 least-attractive names and measure whether the top set
|
||||||
|
// outperformed over a forward window.
|
||||||
|
//
|
||||||
|
// This is a ONE-TIME / periodic offline analysis ledger, not live tracking:
|
||||||
|
// the ranking corpus is baked in (extracted from the articles the user pulled),
|
||||||
|
// and the grader replays it against candle data. Results land in
|
||||||
|
// corridor_backtest for the scorecard UI.
|
||||||
|
//
|
||||||
|
// Pure: the grader consumes candle streams via an injected provider so it is
|
||||||
|
// testable; the DB shim only persists/resolves the ledger.
|
||||||
|
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import type { PriceCandle } from '../cache/CacheRepository.ts';
|
||||||
|
import { CorridorRepository, type CorridorBacktestRow } from '../db/corridorRepository.ts';
|
||||||
|
import { BACKTEST_HORIZON_DAYS } from '../confluence/corridorData.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One weekly ranking (top/bottom 3) for one ranking type, from an article. */
|
||||||
|
export interface AlojohRanking {
|
||||||
|
/** The weekly report's date (as close as the article states). YYYY-MM-DD. */
|
||||||
|
articleDate: string;
|
||||||
|
/** X post id of the article the ranking came from (for attribution). */
|
||||||
|
articleId: string;
|
||||||
|
rankingType: 'entry_1y' | 'entry_90d';
|
||||||
|
top: string[]; // most entry-attractive names (max 3)
|
||||||
|
bottom: string[]; // least entry-attractive names (max 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grader output for one ranking. */
|
||||||
|
export interface CorridorGrade {
|
||||||
|
articleDate: string;
|
||||||
|
articleId: string;
|
||||||
|
rankingType: 'entry_1y' | 'entry_90d';
|
||||||
|
topSymbols: string[];
|
||||||
|
bottomSymbols: string[];
|
||||||
|
topAvgReturn: number | null;
|
||||||
|
bottomAvgReturn: number | null;
|
||||||
|
spread: number | null;
|
||||||
|
isWin: boolean | null; // null when not gradeable (missing data)
|
||||||
|
horizonDays: number;
|
||||||
|
resolvableCount: number; // symbols with enough forward data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Corpus (extracted from @alojoh's weekly "U.S. Tech Coverage" reports)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ranked weeks with complete top/bottom 3 for both observable windows.
|
||||||
|
* Extracted by hand from the 7 fetched subscriber articles (June 21 – Aug 9,
|
||||||
|
* 2026). Only weeks with BOTH top and bottom lists are included so the spread
|
||||||
|
* is always computable when price data exists.
|
||||||
|
*/
|
||||||
|
export const ALOJOH_WEEKLY_RANKINGS: AlojohRanking[] = [
|
||||||
|
{ articleDate: '2026-06-21', articleId: '2068666606801285446', rankingType: 'entry_1y', top: ['PLTR', 'MSFT', 'META'], bottom: ['AMD', 'ORCL', 'TSM'] },
|
||||||
|
{ articleDate: '2026-06-21', articleId: '2068666606801285446', rankingType: 'entry_90d', top: ['MSFT', 'PLTR', 'META'], bottom: ['AMD', 'ORCL', 'TSM'] },
|
||||||
|
{ articleDate: '2026-06-27', articleId: '2070871205624893886', rankingType: 'entry_1y', top: ['PLTR', 'MSFT', 'AVGO'], bottom: ['AMD', 'TSM', 'ORCL'] },
|
||||||
|
{ articleDate: '2026-06-27', articleId: '2070871205624893886', rankingType: 'entry_90d', top: ['PLTR', 'AVGO', 'MSFT'], bottom: ['AMD', 'TSM', 'AAPL'] },
|
||||||
|
{ articleDate: '2026-07-03', articleId: '2073087491511644461', rankingType: 'entry_1y', top: ['PLTR', 'MSFT', 'AVGO'], bottom: ['AMD', 'TSM', 'GOOG'] },
|
||||||
|
{ articleDate: '2026-07-03', articleId: '2073087491511644461', rankingType: 'entry_90d', top: ['AVGO', 'ORCL', 'NVDA'], bottom: ['AMD', 'AAPL', 'TSM'] },
|
||||||
|
{ articleDate: '2026-07-12', articleId: '2076169632717877370', rankingType: 'entry_1y', top: ['PLTR', 'MSFT', 'AMZN'], bottom: ['AMD', 'TSM', 'ORCL'] },
|
||||||
|
{ articleDate: '2026-07-12', articleId: '2076169632717877370', rankingType: 'entry_90d', top: ['ORCL', 'MSFT', 'PLTR'], bottom: ['AMD', 'META', 'AAPL'] },
|
||||||
|
{ articleDate: '2026-07-25', articleId: '2081022231732523491', rankingType: 'entry_1y', top: ['AMZN', 'MSFT', 'PLTR'], bottom: ['AMD', 'AAPL', 'TSM'] },
|
||||||
|
{ articleDate: '2026-07-25', articleId: '2081022231732523491', rankingType: 'entry_90d', top: ['ORCL', 'GOOG', 'TSM'], bottom: ['AAPL', 'AMD', 'AVGO'] },
|
||||||
|
{ articleDate: '2026-08-01', articleId: '2083570089702596888', rankingType: 'entry_1y', top: ['META', 'NVDA', 'AVGO'], bottom: ['AMD', 'MSFT', 'ORCL'] },
|
||||||
|
{ articleDate: '2026-08-01', articleId: '2083570089702596888', rankingType: 'entry_90d', top: ['META', 'ORCL', 'TSM'], bottom: ['MSFT', 'AMZN', 'AVGO'] },
|
||||||
|
{ articleDate: '2026-08-09', articleId: '2086315964740809181', rankingType: 'entry_1y', top: ['TSLA', 'META', 'AMZN'], bottom: ['AMD', 'PLTR', 'ORCL'] },
|
||||||
|
{ articleDate: '2026-08-09', articleId: '2086315964740809181', rankingType: 'entry_90d', top: ['TSLA', 'AMD', 'TSM'], bottom: ['PLTR', 'ORCL', 'MSFT'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure grader
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 7-day forward return for a symbol from a cached daily candle stream.
|
||||||
|
* Returns null when the close on/after `fromDate` or 7 calendar days later is
|
||||||
|
* unavailable. Pure.
|
||||||
|
*/
|
||||||
|
export function forwardReturnFor(
|
||||||
|
candles: PriceCandle[],
|
||||||
|
fromDate: string,
|
||||||
|
horizonDays = BACKTEST_HORIZON_DAYS,
|
||||||
|
): number | null {
|
||||||
|
const sorted = candles.slice().sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
||||||
|
if (sorted.length === 0) return null;
|
||||||
|
|
||||||
|
const targetMs = Date.parse(fromDate);
|
||||||
|
if (Number.isNaN(targetMs)) return null;
|
||||||
|
|
||||||
|
// entry bar: the first bar at or after fromDate (or the last bar before it).
|
||||||
|
let entryIdx = -1;
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const t = Date.parse(sorted[i].ts);
|
||||||
|
if (t >= targetMs) { entryIdx = i; break; }
|
||||||
|
entryIdx = i; // keep the last bar strictly before fromDate as fallback
|
||||||
|
}
|
||||||
|
if (entryIdx < 0) return null;
|
||||||
|
|
||||||
|
const entry = sorted[entryIdx];
|
||||||
|
const horizonMs = horizonDays * 86_400_000;
|
||||||
|
|
||||||
|
let exitIdx = -1;
|
||||||
|
for (let i = entryIdx; i < sorted.length; i++) {
|
||||||
|
const t = Date.parse(sorted[i].ts);
|
||||||
|
if (t >= targetMs + horizonMs) { exitIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (exitIdx < 0) return null; // not enough forward data
|
||||||
|
|
||||||
|
const exit = sorted[exitIdx];
|
||||||
|
if (!Number.isFinite(entry.c) || !Number.isFinite(exit.c) || entry.c <= 0) return null;
|
||||||
|
return exit.c / entry.c - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grade one ranking: mean forward return of the top set minus the bottom set.
|
||||||
|
* Pure — candleProvider is injected so tests can stub streams.
|
||||||
|
*/
|
||||||
|
export async function gradeRanking(
|
||||||
|
ranking: AlojohRanking,
|
||||||
|
candleProvider: (symbol: string) => Promise<PriceCandle[]>,
|
||||||
|
horizonDays = BACKTEST_HORIZON_DAYS,
|
||||||
|
): Promise<CorridorGrade> {
|
||||||
|
const topReturns: number[] = [];
|
||||||
|
const botReturns: number[] = [];
|
||||||
|
|
||||||
|
for (const sym of ranking.top) {
|
||||||
|
const r = forwardReturnFor(await candleProvider(sym), ranking.articleDate, horizonDays);
|
||||||
|
if (r !== null) topReturns.push(r);
|
||||||
|
}
|
||||||
|
for (const sym of ranking.bottom) {
|
||||||
|
const r = forwardReturnFor(await candleProvider(sym), ranking.articleDate, horizonDays);
|
||||||
|
if (r !== null) botReturns.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAvg = topReturns.length > 0 ? topReturns.reduce((a, b) => a + b, 0) / topReturns.length : null;
|
||||||
|
const botAvg = botReturns.length > 0 ? botReturns.reduce((a, b) => a + b, 0) / botReturns.length : null;
|
||||||
|
const spread = topAvg !== null && botAvg !== null ? topAvg - botAvg : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
articleDate: ranking.articleDate,
|
||||||
|
articleId: ranking.articleId,
|
||||||
|
rankingType: ranking.rankingType,
|
||||||
|
topSymbols: ranking.top,
|
||||||
|
bottomSymbols: ranking.bottom,
|
||||||
|
topAvgReturn: topAvg,
|
||||||
|
bottomAvgReturn: botAvg,
|
||||||
|
spread,
|
||||||
|
isWin: spread === null ? null : spread > 0,
|
||||||
|
horizonDays,
|
||||||
|
resolvableCount: topReturns.length + botReturns.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aggregates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Aggregate scorecard from a set of grades (pure). */
|
||||||
|
export interface CorridorBacktestAggregate {
|
||||||
|
entries: number;
|
||||||
|
resolved: number; // grades with a spread
|
||||||
|
resolved1y: number;
|
||||||
|
resolved90d: number;
|
||||||
|
hitRate: number | null; // frac of resolved grades where top outperformed
|
||||||
|
avgSpread: number | null; // mean spread (returns, e.g. 0.012 = +1.2%)
|
||||||
|
bestSpread: number | null;
|
||||||
|
worstSpread: number | null;
|
||||||
|
avgTopReturn: number | null;
|
||||||
|
avgBottomReturn: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateBacktest(grades: CorridorGrade[]): CorridorBacktestAggregate {
|
||||||
|
const resolved = grades.filter((g) => g.spread !== null);
|
||||||
|
const resolved1y = resolved.filter((g) => g.rankingType === 'entry_1y');
|
||||||
|
const resolved90d = resolved.filter((g) => g.rankingType === 'entry_90d');
|
||||||
|
const mean = (arr: number[]): number | null => (arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: grades.length,
|
||||||
|
resolved: resolved.length,
|
||||||
|
resolved1y: resolved1y.length,
|
||||||
|
resolved90d: resolved90d.length,
|
||||||
|
hitRate: resolved.length > 0 ? resolved.filter((g) => g.isWin === true).length / resolved.length : null,
|
||||||
|
avgSpread: mean(resolved.map((g) => g.spread as number)),
|
||||||
|
bestSpread: resolved.length > 0 ? Math.max(...resolved.map((g) => g.spread as number)) : null,
|
||||||
|
worstSpread: resolved.length > 0 ? Math.min(...resolved.map((g) => g.spread as number)) : null,
|
||||||
|
avgTopReturn: mean(resolved.map((g) => g.topAvgReturn as number)),
|
||||||
|
avgBottomReturn: mean(resolved.map((g) => g.bottomAvgReturn as number)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ledger shim (offline replay; persists grades to corridor_backtest)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay the whole corpus against a candle provider and persist the ledger.
|
||||||
|
* Returns the freshly graded rows + aggregate. Callers control cadence (this is
|
||||||
|
* an offline analysis run, not a request-path fetch).
|
||||||
|
*/
|
||||||
|
export async function runCorridorBacktest(
|
||||||
|
db: DatabaseSync,
|
||||||
|
candleProvider: (symbol: string) => Promise<PriceCandle[]>,
|
||||||
|
opts: { horizonDays?: number } = {},
|
||||||
|
): Promise<{ grades: CorridorGrade[]; aggregate: CorridorBacktestAggregate }> {
|
||||||
|
const repo = new CorridorRepository(db);
|
||||||
|
const grades: CorridorGrade[] = [];
|
||||||
|
for (const ranking of ALOJOH_WEEKLY_RANKINGS) {
|
||||||
|
const grade = await gradeRanking(ranking, candleProvider, opts.horizonDays ?? BACKTEST_HORIZON_DAYS);
|
||||||
|
grades.push(grade);
|
||||||
|
if (grade.spread !== null) {
|
||||||
|
repo.saveBacktest({
|
||||||
|
articleDate: grade.articleDate,
|
||||||
|
articleId: grade.articleId,
|
||||||
|
rankingType: grade.rankingType,
|
||||||
|
topSymbols: grade.topSymbols,
|
||||||
|
bottomSymbols: grade.bottomSymbols,
|
||||||
|
topAvgReturn: grade.topAvgReturn,
|
||||||
|
bottomAvgReturn: grade.bottomAvgReturn,
|
||||||
|
spread: grade.spread,
|
||||||
|
isWin: grade.isWin === true,
|
||||||
|
horizonDays: grade.horizonDays,
|
||||||
|
gradedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { grades, aggregate: aggregateBacktest(grades) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CorridorBacktestRow };
|
||||||
+22
@@ -8,6 +8,7 @@ import { db as defaultDb } from '../db/client.ts';
|
|||||||
import {
|
import {
|
||||||
CANDLE_FRESH_MS,
|
CANDLE_FRESH_MS,
|
||||||
quoteTtlMs,
|
quoteTtlMs,
|
||||||
|
tieredQuoteTtlMs,
|
||||||
SYMBOL_META_INCOMPLETE_TTL_MS,
|
SYMBOL_META_INCOMPLETE_TTL_MS,
|
||||||
} from '../queue/sourceRatePolicy.ts';
|
} from '../queue/sourceRatePolicy.ts';
|
||||||
import { KvReadCache } from './LruCache.ts';
|
import { KvReadCache } from './LruCache.ts';
|
||||||
@@ -190,6 +191,13 @@ export function needsQuoteRefresh(d: DatabaseSync, symbol: string, now = Date.no
|
|||||||
return tsAgeMs(row.observed_at, now) > quoteTtlMs(new Date(now));
|
return tsAgeMs(row.observed_at, now) > quoteTtlMs(new Date(now));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tier-aware quote freshness check. Portfolio (T0) gets the tightest TTL. */
|
||||||
|
export function needsTieredQuoteRefresh(d: DatabaseSync, symbol: string, tier: number, now = Date.now()): boolean {
|
||||||
|
const row = d.prepare('SELECT observed_at FROM quotes WHERE symbol=?').get(symbol) as { observed_at: string } | undefined;
|
||||||
|
if (!row?.observed_at) return true;
|
||||||
|
return tsAgeMs(row.observed_at, now) > tieredQuoteTtlMs(tier, new Date(now));
|
||||||
|
}
|
||||||
|
|
||||||
/** True when symbol meta missing, incomplete (no name), or past weekly TTL. */
|
/** True when symbol meta missing, incomplete (no name), or past weekly TTL. */
|
||||||
export function needsSymbolMetaRefresh(d: DatabaseSync, symbol: string, now = Date.now()): boolean {
|
export function needsSymbolMetaRefresh(d: DatabaseSync, symbol: string, now = Date.now()): boolean {
|
||||||
const row = d.prepare('SELECT name, sector, updated_at FROM symbols WHERE symbol=?').get(symbol) as
|
const row = d.prepare('SELECT name, sector, updated_at FROM symbols WHERE symbol=?').get(symbol) as
|
||||||
@@ -617,6 +625,8 @@ export interface CacheRepository {
|
|||||||
* Safe to call on every Market Outlook / ticker context load.
|
* Safe to call on every Market Outlook / ticker context load.
|
||||||
*/
|
*/
|
||||||
ensureInDemand(symbol: string, tickerKind: TickerKind): Promise<void>;
|
ensureInDemand(symbol: string, tickerKind: TickerKind): Promise<void>;
|
||||||
|
/** Bump symbol to watched tier (2) on page view. Decays back after 10 min. */
|
||||||
|
bumpToWatched(symbol: string, tickerKind: TickerKind): Promise<void>;
|
||||||
/** Permanent system pin (rotation universe, SPY, VIX) — survives unsubscribe. */
|
/** Permanent system pin (rotation universe, SPY, VIX) — survives unsubscribe. */
|
||||||
pinSystemSymbol(symbol: string, tickerKind: TickerKind): Promise<void>;
|
pinSystemSymbol(symbol: string, tickerKind: TickerKind): Promise<void>;
|
||||||
demandSet(): Promise<string[]>;
|
demandSet(): Promise<string[]>;
|
||||||
@@ -735,6 +745,18 @@ export class CacheRepositoryImpl implements CacheRepository {
|
|||||||
await this.queueIfNeeded(symbol);
|
await this.queueIfNeeded(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bump a symbol to watched tier (2) on page view. The periodic tier
|
||||||
|
* recompute decays it back to background after ~10 min of inactivity. */
|
||||||
|
async bumpToWatched(symbol: string, tickerKind: TickerKind): Promise<void> {
|
||||||
|
this.ensureDemandRow(symbol, tickerKind);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// Only lower tier (raise priority) — never raise tier above current.
|
||||||
|
this._db.prepare(
|
||||||
|
"UPDATE symbol_demand SET tier = MIN(tier, 2), last_viewed_at = ?, in_demand = 1 WHERE symbol=?",
|
||||||
|
).run(now, symbol);
|
||||||
|
await this.queueIfNeeded(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
async pinSystemSymbol(symbol: string, tickerKind: TickerKind): Promise<void> {
|
async pinSystemSymbol(symbol: string, tickerKind: TickerKind): Promise<void> {
|
||||||
this.ensureDemandRow(symbol, tickerKind);
|
this.ensureDemandRow(symbol, tickerKind);
|
||||||
this._db.prepare(
|
this._db.prepare(
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Investor Flow — confluenceEngine.test.ts (M24 slice 5)
|
||||||
|
// Integration test for the evaluation engine: runs the wired slot families over
|
||||||
|
// fake cache candle streams, verifies rack evaluations + corridor snapshots +
|
||||||
|
// signal history are persisted and idempotent per (symbol, asOf, rack).
|
||||||
|
|
||||||
|
import { describe, it, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createDb, initSchema } from '../../db/client.ts';
|
||||||
|
import { ConfluenceRepository } from '../../db/confluenceRepository.ts';
|
||||||
|
import { CorridorRepository } from '../../db/corridorRepository.ts';
|
||||||
|
import { createCacheRepository, type CacheRepository, type CacheEntry, type PriceCandle, type Quote } from '../../cache/CacheRepository.ts';
|
||||||
|
import { FakeSourceAdapter } from '../../adapters/SourceAdapter.ts';
|
||||||
|
import { AdapterQueue } from '../../queue/AdapterQueue.ts';
|
||||||
|
import { seedConfluence, CONFLUENCE_UNIVERSE, BENCHMARK_SYMBOL } from '../confluenceSeed.ts';
|
||||||
|
import { runConfluenceEvaluationCycle } from '../confluenceEngine.ts';
|
||||||
|
|
||||||
|
// ---- fake cache that returns whatever we seeded ---------------------------------
|
||||||
|
|
||||||
|
class FakeCache implements CacheRepository {
|
||||||
|
private readonly store = new Map<string, { value: unknown; stale: boolean }>();
|
||||||
|
setValue(key: string, value: unknown, stale = false): this { this.store.set(key, { value, stale }); return this; }
|
||||||
|
async get<T>(key: string): Promise<CacheEntry<T>> {
|
||||||
|
const e = this.store.get(key);
|
||||||
|
return { value: (e ? e.value : null) as T | null, provenance: null, isStale: e ? e.stale : true };
|
||||||
|
}
|
||||||
|
async set(): Promise<void> { throw new Error('not used'); }
|
||||||
|
stale(key: string): boolean { return !this.store.has(key) || this.store.get(key)!.stale; }
|
||||||
|
async subscribe(): Promise<void> {}
|
||||||
|
async unsubscribe(): Promise<void> {}
|
||||||
|
async ensureInDemand(): Promise<void> {}
|
||||||
|
async pinSystemSymbol(): Promise<void> {}
|
||||||
|
async demandSet(): Promise<string[]> { return []; }
|
||||||
|
async getMany<T>(): Promise<Array<{ key: string; value: T | null; isStale: boolean }>> { return []; }
|
||||||
|
async del(): Promise<void> {}
|
||||||
|
readonly db: never = undefined as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fixtures -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Monotonic ramp up (bullish technicals) over `days` trading days. */
|
||||||
|
function rampUp(days: number, start = 100, dailyPct = 0.0015): PriceCandle[] {
|
||||||
|
const candles: PriceCandle[] = [];
|
||||||
|
const base = Date.UTC(2020, 0, 1);
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const c = start * Math.pow(1 + dailyPct, i);
|
||||||
|
candles.push({
|
||||||
|
ts: new Date(base + i * 86400000).toISOString().slice(0, 10),
|
||||||
|
o: c * (1 - dailyPct / 2),
|
||||||
|
h: c * 1.003,
|
||||||
|
l: c * 0.997,
|
||||||
|
c,
|
||||||
|
v: 2_000_000,
|
||||||
|
adjClose: c,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return candles;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: ReturnType<typeof createDb>;
|
||||||
|
let cache: FakeCache;
|
||||||
|
let confluenceRepo: ConfluenceRepository;
|
||||||
|
let corridorRepo: CorridorRepository;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = createDb({ path: ':memory:' });
|
||||||
|
initSchema(db);
|
||||||
|
confluenceRepo = new ConfluenceRepository(db);
|
||||||
|
corridorRepo = new CorridorRepository(db);
|
||||||
|
cache = new FakeCache();
|
||||||
|
await seedConfluence(db, cache as unknown as CacheRepository);
|
||||||
|
|
||||||
|
// Seed candle streams for the whole universe + SPY benchmark.
|
||||||
|
for (const { symbol } of CONFLUENCE_UNIVERSE) {
|
||||||
|
cache.setValue(`yfinance:candles:${symbol}:1d`, rampUp(300));
|
||||||
|
cache.setValue(`yfinance:candles:${symbol}:1wk`, rampUp(80, 100, 0.01));
|
||||||
|
cache.setValue(`yfinance:quote:${symbol}`, { price: 130 } as Quote);
|
||||||
|
cache.setValue(`yfinance:dividendFundamentals:${symbol}`, { forwardPE: 25, trailingPE: 24, forwardEPS: 5.2, trailingEPS: 5.0, currentPrice: 130 });
|
||||||
|
}
|
||||||
|
cache.setValue(`yfinance:candles:SPY:1d`, rampUp(300, 400, 0.001));
|
||||||
|
cache.setValue(`yfinance:candles:SPY:1wk`, rampUp(80, 400, 0.01));
|
||||||
|
cache.setValue(`yfinance:quote:SPY`, { price: 440 } as Quote);
|
||||||
|
cache.setValue(`yfinance:dividendFundamentals:SPY`, { forwardPE: 22, trailingPE: 21.5, forwardEPS: 20, trailingEPS: 19.3, currentPrice: 440 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runConfluenceEvaluationCycle', () => {
|
||||||
|
it('persists an evaluation + corridor snapshots per symbol, for every system rack', async () => {
|
||||||
|
const summary = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
|
||||||
|
|
||||||
|
// All 15 universe symbols evaluated (SPY benchmark not part of universe).
|
||||||
|
assert.equal(summary.symbolsEvaluated.length, CONFLUENCE_UNIVERSE.length);
|
||||||
|
assert.equal(summary.evaluationsStored, CONFLUENCE_UNIVERSE.length * 3, 'one eval per symbol per system rack');
|
||||||
|
assert.ok(summary.corridorSnapshots >= CONFLUENCE_UNIVERSE.length, 'symbol + SPY corridor snapshots');
|
||||||
|
|
||||||
|
// spot-check one symbol persisted its "full" rack evaluation
|
||||||
|
const pltr = CONFLUENCE_UNIVERSE.find((s) => s.symbol === 'PLTR')!;
|
||||||
|
const evals = confluenceRepo.listEvaluationsForSymbol(pltr.symbol);
|
||||||
|
assert.equal(evals.length, 3);
|
||||||
|
const validQuality = ['strong-bullish', 'moderate-bullish', 'weak-bullish', 'mixed', 'weak-bearish', 'moderate-bearish', 'strong-bearish', 'sparse'];
|
||||||
|
for (const ev of evals) {
|
||||||
|
assert.equal(ev.symbol, pltr.symbol);
|
||||||
|
assert.ok(validQuality.includes(ev.quality), `unexpected quality ${ev.quality}`);
|
||||||
|
assert.ok(ev.assessedCount >= 10, 'rack has meaningful coverage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// corridor snapshot table populated for at least the spot-checked symbol
|
||||||
|
const snap = corridorRepo.latestSnapshot(pltr.symbol);
|
||||||
|
assert.ok(snap, 'corridor snapshot stored');
|
||||||
|
assert.ok(snap.corridor1yMedian !== null, 'corridor median computed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent per (symbol, asOf, rack): a second run reuses and stores nothing new', async () => {
|
||||||
|
const first = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
|
||||||
|
const second = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
|
||||||
|
|
||||||
|
assert.equal(second.evaluationsStored, 0);
|
||||||
|
assert.equal(second.evaluationsReused, first.evaluationsStored);
|
||||||
|
const all = confluenceRepo.listEvaluationsForSymbol(CONFLUENCE_UNIVERSE[0].symbol);
|
||||||
|
assert.equal(all.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips symbols with no cached candles without failing the run', async () => {
|
||||||
|
cache.setValue(`yfinance:candles:${CONFLUENCE_UNIVERSE[0].symbol}:1d`, [], false);
|
||||||
|
const summary = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
|
||||||
|
assert.equal(summary.symbolsEvaluated.length, CONFLUENCE_UNIVERSE.length - 1);
|
||||||
|
assert.equal(summary.symbolsSkipped.length, 1);
|
||||||
|
assert.equal(summary.symbolsSkipped[0].symbol, CONFLUENCE_UNIVERSE[0].symbol);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,8 +19,8 @@ const fired = (id: string): SlotAssessment => ({ id, state: 'fired' });
|
|||||||
const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' });
|
const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' });
|
||||||
|
|
||||||
describe('confluence catalog', () => {
|
describe('confluence catalog', () => {
|
||||||
it('defines exactly 34 slots across all six families', () => {
|
it('defines slots across all six families in catalog order', () => {
|
||||||
assert.equal(CONFLUENCE_SLOTS.length, 34);
|
assert.ok(CONFLUENCE_SLOTS.length >= 34);
|
||||||
const families = new Set(CONFLUENCE_SLOTS.map((s) => s.family));
|
const families = new Set(CONFLUENCE_SLOTS.map((s) => s.family));
|
||||||
assert.deepEqual([...families].sort(), ['flows', 'institutional', 'macro', 'seasonal', 'sentiment', 'technical']);
|
assert.deepEqual([...families].sort(), ['flows', 'institutional', 'macro', 'seasonal', 'sentiment', 'technical']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
BENCHMARK_SYMBOL,
|
BENCHMARK_SYMBOL,
|
||||||
defineSystemRackPresets,
|
defineSystemRackPresets,
|
||||||
} from '../confluenceSeed.ts';
|
} from '../confluenceSeed.ts';
|
||||||
import { CONFLUENCE_SLOT_IDS } from '../confluenceSlots.ts';
|
import { CONFLUENCE_SLOTS, CONFLUENCE_SLOT_IDS } from '../confluenceSlots.ts';
|
||||||
|
|
||||||
let db: ReturnType<typeof createDb>;
|
let db: ReturnType<typeof createDb>;
|
||||||
let cache: CacheRepository;
|
let cache: CacheRepository;
|
||||||
@@ -63,19 +63,22 @@ describe('defineSystemRackPresets', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('"Full Confluence" uses all34 slots', () => {
|
it('"Full Confluence" uses every slot in the catalog', () => {
|
||||||
const full = defineSystemRackPresets().find((p) => p.id === 'confluence-full')!;
|
const full = defineSystemRackPresets().find((p) => p.id === 'confluence-full')!;
|
||||||
assert.equal(full.slotIds.length, 34);
|
assert.equal(full.slotIds.length, CONFLUENCE_SLOT_IDS.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('"Technical Momentum" has 15 slots', () => {
|
it('"Technical Momentum" has only the technical slots', () => {
|
||||||
const tech = defineSystemRackPresets().find((p) => p.id === 'confluence-technical')!;
|
const tech = defineSystemRackPresets().find((p) => p.id === 'confluence-technical')!;
|
||||||
assert.equal(tech.slotIds.length, 15);
|
assert.equal(tech.slotIds.length, CONFLUENCE_SLOTS.filter((s) => s.family === 'technical').length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('"Macro + Flows + Sentiment" has 14 slots', () => {
|
it('"Macro + Flows + Sentiment" has macro + seasonal + flows + sentiment slots', () => {
|
||||||
const macro = defineSystemRackPresets().find((p) => p.id === 'confluence-macro-flows')!;
|
const macro = defineSystemRackPresets().find((p) => p.id === 'confluence-macro-flows')!;
|
||||||
assert.equal(macro.slotIds.length, 14);
|
const expected = CONFLUENCE_SLOTS.filter((s) =>
|
||||||
|
['macro', 'seasonal', 'flows', 'sentiment'].includes(s.family),
|
||||||
|
).length;
|
||||||
|
assert.equal(macro.slotIds.length, expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Investor Flow — Confluence Evaluation Engine (M24, slice 5)
|
||||||
|
//
|
||||||
|
// The missing backbone of the Confluence Signal Engine: the daily cycle that
|
||||||
|
// runs every wired slot family's evaluator over a symbol's cached data and
|
||||||
|
// persists the resulting rack evaluations + slot-fire history.
|
||||||
|
//
|
||||||
|
// Each symbol in the confluence universe is resolved once (daily candles,
|
||||||
|
// weekly candles, SPY benchmark daily, seasonality snapshot, corridor
|
||||||
|
// snapshots for the symbol and SPY), then every system rack's slot subset is
|
||||||
|
// sliced out, run through `evaluateRack` (redundancy-aware), and stored via
|
||||||
|
// `ConfluenceRepository`. Fired slots are logged to signal history for the
|
||||||
|
// reliability scorecard, and pending fires are resolved against forward prices
|
||||||
|
//
|
||||||
|
// ADR-0007: this engine computes description ("the picture is moderate-bullish"
|
||||||
|
// because X evidence) — it never emits buy/sell directives.
|
||||||
|
//
|
||||||
|
// ADR-0009: the engine ONLY reads the shared cache (via CacheCandleProvider and
|
||||||
|
// corridorData.resolveCorridorSnapshot). Any refreshing is already queued by
|
||||||
|
// the cache; the engine never touches a vendor directly.
|
||||||
|
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { CacheRepository } from '../cache/CacheRepository.ts';
|
||||||
|
import { CONFLUENCE_SLOTS } from './confluenceSlots.ts';
|
||||||
|
import { CacheCandleProvider, type CandleProvider } from './candleProvider.ts';
|
||||||
|
import { evaluateTechnicalSlots } from './technicalEvaluator.ts';
|
||||||
|
import { evaluateSeasonalSlots } from './seasonalEvaluator.ts';
|
||||||
|
import { evaluateCorridorSlots } from './corridorEvaluator.ts';
|
||||||
|
import { buildSeasonalitySnapshot } from '../analysis/seasonality.ts';
|
||||||
|
import { resolveCorridorSnapshot } from './corridorData.ts';
|
||||||
|
import { evaluateRack, type ConfluenceEvaluation, type SlotAssessment } from './confluenceRack.ts';
|
||||||
|
import { ConfluenceRepository, type ConfluenceRack } from '../db/confluenceRepository.ts';
|
||||||
|
import { resolveSignalHistory } from './confluenceBacktest.ts';
|
||||||
|
import { CONFLUENCE_UNIVERSE, BENCHMARK_SYMBOL } from './confluenceSeed.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unwired slots → honest fallback assessments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a `not-fired` fallback for every catalog slot the wired evaluators did
|
||||||
|
* not already cover (currently: macro non-corridor, institutional, flows,
|
||||||
|
* sentiment). Keeps each rack's slot set fully covered so assessedCount stays
|
||||||
|
* meaningful, but the fallbacks contribute no evidence to the picture.
|
||||||
|
*/
|
||||||
|
function unwiredFallbacks(emitted: SlotAssessment[]): SlotAssessment[] {
|
||||||
|
const emittedIds = new Set(emitted.map((a) => a.id));
|
||||||
|
return CONFLUENCE_SLOTS
|
||||||
|
.filter((s) => !emittedIds.has(s.id))
|
||||||
|
.map((s) => ({ id: s.id, state: 'not-fired' as const, note: 'No evaluator wired for this slot yet — contributes no evidence to the picture.' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ConfluenceEngineRunSummary {
|
||||||
|
symbolsEvaluated: string[];
|
||||||
|
symbolsSkipped: Array<{ symbol: string; reason: string }>;
|
||||||
|
evaluationsStored: number;
|
||||||
|
evaluationsReused: number;
|
||||||
|
signalsLogged: number;
|
||||||
|
signalsResolved: number;
|
||||||
|
corridorSnapshots: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunOptions {
|
||||||
|
/** Symbols to evaluate (default: the confluence universe). */
|
||||||
|
symbols?: string[];
|
||||||
|
/** Racks to evaluate per symbol (default: system racks). */
|
||||||
|
racks?: ConfluenceRack[];
|
||||||
|
/** Force re-evaluation even when a same-asOf evaluation already exists. */
|
||||||
|
force?: boolean;
|
||||||
|
/** Evaluation date override (default: the daily resolution asOf). */
|
||||||
|
asOf?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Engine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one full confluence evaluation cycle over the universe.
|
||||||
|
*
|
||||||
|
* Per symbol: resolve candles once, run every wired evaluator across the slot
|
||||||
|
* families, slice each rack's slots, evaluate the rack, persist the evaluation
|
||||||
|
* (+ signal fires when new), then resolve the pending signal history. Returns a
|
||||||
|
* summary for logging/tests. Idempotent per (symbol, asOf, rack).
|
||||||
|
*/
|
||||||
|
export async function runConfluenceEvaluationCycle(
|
||||||
|
db: DatabaseSync,
|
||||||
|
cache: CacheRepository,
|
||||||
|
opts: RunOptions = {},
|
||||||
|
): Promise<ConfluenceEngineRunSummary> {
|
||||||
|
const repo = new ConfluenceRepository(db);
|
||||||
|
const provider: CandleProvider = new CacheCandleProvider(cache);
|
||||||
|
|
||||||
|
const symbols = (opts.symbols?.map((s) => s.toUpperCase()) ?? CONFLUENCE_UNIVERSE.map((s) => s.symbol));
|
||||||
|
const racks = opts.racks ?? repo.listSystemRacks();
|
||||||
|
if (racks.length === 0) throw new Error('confluence engine: no system racks seeded');
|
||||||
|
|
||||||
|
const summary: ConfluenceEngineRunSummary = {
|
||||||
|
symbolsEvaluated: [],
|
||||||
|
symbolsSkipped: [],
|
||||||
|
evaluationsStored: 0,
|
||||||
|
evaluationsReused: 0,
|
||||||
|
signalsLogged: 0,
|
||||||
|
signalsResolved: 0,
|
||||||
|
corridorSnapshots: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// SPY corridor snapshot is shared across every symbol (market-level slots).
|
||||||
|
let spyCorridor = await resolveCorridorSnapshot(db, cache, BENCHMARK_SYMBOL, { date: opts.asOf });
|
||||||
|
if (spyCorridor) summary.corridorSnapshots += 1;
|
||||||
|
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
const daily = await provider.resolve(symbol, '1d');
|
||||||
|
if (daily.candles.length === 0) {
|
||||||
|
summary.symbolsSkipped.push({ symbol, reason: 'no daily candles in cache yet' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekly = await provider.resolve(symbol, '1wk');
|
||||||
|
const benchmarkDaily = await provider.resolve(BENCHMARK_SYMBOL, '1d');
|
||||||
|
const asOf = opts.asOf ?? daily.asOf;
|
||||||
|
|
||||||
|
// Corridor snapshot for the symbol (valuation corridor, entry/upside slots).
|
||||||
|
let corridor = await resolveCorridorSnapshot(db, cache, symbol, { date: asOf });
|
||||||
|
if (corridor) summary.corridorSnapshots += 1;
|
||||||
|
if (spyCorridor === null) spyCorridor = await resolveCorridorSnapshot(db, cache, BENCHMARK_SYMBOL, { date: asOf });
|
||||||
|
|
||||||
|
// ----- run every wired family evaluator -----
|
||||||
|
const wired: SlotAssessment[] = [
|
||||||
|
...evaluateTechnicalSlots(symbol, daily.candles, {
|
||||||
|
weekly: weekly.candles,
|
||||||
|
benchmarkDaily: benchmarkDaily.candles,
|
||||||
|
}),
|
||||||
|
...evaluateSeasonalSlots(buildSeasonalitySnapshot(symbol, daily.candles), asOf),
|
||||||
|
...(corridor ? evaluateCorridorSlots(corridor, spyCorridor) : corridorUnavailableAssessments()),
|
||||||
|
];
|
||||||
|
// Cover every catalog slot the wired evaluators left out (honest fallbacks).
|
||||||
|
const assessments = [...wired, ...unwiredFallbacks(wired)];
|
||||||
|
|
||||||
|
// ----- slice per rack, evaluate, persist -----
|
||||||
|
let symbolStored = 0;
|
||||||
|
let symbolReused = 0;
|
||||||
|
for (const rack of racks) {
|
||||||
|
const rackSlots = new Set(rack.slotIds);
|
||||||
|
const sliced = assessments.filter((a) => rackSlots.has(a.id));
|
||||||
|
if (sliced.length === 0) continue;
|
||||||
|
|
||||||
|
const existing = repo.getEvaluation(symbol, asOf, rack.id);
|
||||||
|
if (existing && !opts.force) {
|
||||||
|
symbolReused += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced);
|
||||||
|
const firesLogged = evaluation.assessments.filter((a) => a.state === 'fired').length;
|
||||||
|
repo.saveEvaluation(evaluation, rack.id, randomUUID());
|
||||||
|
repo.logSignalFires(evaluation, rack.id);
|
||||||
|
symbolStored += 1;
|
||||||
|
summary.signalsLogged += firesLogged;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.evaluationsStored += symbolStored;
|
||||||
|
summary.evaluationsReused += symbolReused;
|
||||||
|
summary.symbolsEvaluated.push(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve pending signal history against forward prices.
|
||||||
|
const resolution = await resolveSignalHistory(db, async (sym) => {
|
||||||
|
const res = await provider.resolve(sym.toUpperCase(), '1d');
|
||||||
|
return res.candles;
|
||||||
|
});
|
||||||
|
summary.signalsResolved = resolution.resolved.filter((r) => r.verdict !== 'deferred').length;
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Six corridor slots, all not-fired with a shared note when no snapshot exists. */
|
||||||
|
function corridorUnavailableAssessments(): SlotAssessment[] {
|
||||||
|
return [
|
||||||
|
'corridorEntryCheap', 'corridorEntryStretched',
|
||||||
|
'corridorUpsideHigh', 'corridorUpsideLow',
|
||||||
|
'spyCorridorCheap', 'spyCorridorStretched',
|
||||||
|
].map((id) => ({ id, state: 'not-fired', note: 'No valuation-corridor snapshot cached for this symbol yet.' }));
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ export const REDUNDANCY_GROUPS: RedundancyGroup[] = [
|
|||||||
{ family: 'institutional', slots: ['instNetActivePositive', 'insiderInformedBuy30d', 'new13da'] },
|
{ family: 'institutional', slots: ['instNetActivePositive', 'insiderInformedBuy30d', 'new13da'] },
|
||||||
{ family: 'macro', slots: ['ratesRegime', 'macroRegimeUp'] },
|
{ family: 'macro', slots: ['ratesRegime', 'macroRegimeUp'] },
|
||||||
{ family: 'macro', slots: ['consumerSentimentLow', 'breadthThrust'] },
|
{ family: 'macro', slots: ['consumerSentimentLow', 'breadthThrust'] },
|
||||||
|
{ family: 'macro', slots: ['corridorEntryCheap', 'corridorUpsideHigh'] },
|
||||||
|
{ family: 'macro', slots: ['corridorEntryStretched', 'corridorUpsideLow'] },
|
||||||
{ family: 'seasonal', slots: ['seasonalFavorableMonth', 'winterHalfOn', 'electionCycleFavorableYear'] },
|
{ family: 'seasonal', slots: ['seasonalFavorableMonth', 'winterHalfOn', 'electionCycleFavorableYear'] },
|
||||||
{ family: 'flows', slots: ['etfFlowPositive', 'cotPositioning'] },
|
{ family: 'flows', slots: ['etfFlowPositive', 'cotPositioning'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -66,31 +66,32 @@ function familySlots(...families: string[]): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Three curated system rack presets. Each is a different lens on the same
|
* Three curated system rack presets. Each is a different lens on the same
|
||||||
* symbol data, expressed as a subset of the 34-slot catalog:
|
* symbol data, expressed as a subset of the catalog:
|
||||||
*
|
*
|
||||||
* 1. "Full Confluence" — all 34 slots (the default every-picture view)
|
* 1. "Full Confluence" — all slots (the default every-picture view)
|
||||||
* 2. "Technical Momentum" — the15 technical slots only (price-action focus)
|
* 2. "Technical Momentum" — the 15 technical slots only (price-action focus)
|
||||||
* 3. "Macro + Flows + Sentiment" — macro 5 + seasonal 5 + flows 3 +
|
* 3. "Macro + Flows + Sentiment" — macro + seasonal + flows + sentiment
|
||||||
* sentiment 1 = 14 slots (the macro/structural lens)
|
* slots (the macro/structural lens, now including the valuation corridor)
|
||||||
*/
|
*/
|
||||||
export function defineSystemRackPresets(): RackPreset[] {
|
export function defineSystemRackPresets(): RackPreset[] {
|
||||||
|
const techIds = familySlots('technical');
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'confluence-full',
|
id: 'confluence-full',
|
||||||
name: 'Full Confluence',
|
name: 'Full Confluence',
|
||||||
description: 'All 34 slots. The broadest evidence view of a symbol\'s picture.',
|
description: `All ${ALL_IDS.length} slots. The broadest evidence view of a symbol\'s picture.`,
|
||||||
slotIds: [...ALL_IDS],
|
slotIds: [...ALL_IDS],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'confluence-technical',
|
id: 'confluence-technical',
|
||||||
name: 'Technical Momentum',
|
name: 'Technical Momentum',
|
||||||
description: 'The 15 technical slots: trend, momentum, mean-reversion, and volume.',
|
description: `The ${techIds.length} technical slots: trend, momentum, mean-reversion, and volume.`,
|
||||||
slotIds: familySlots('technical'),
|
slotIds: techIds,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'confluence-macro-flows',
|
id: 'confluence-macro-flows',
|
||||||
name: 'Macro + Flows + Sentiment',
|
name: 'Macro + Flows + Sentiment',
|
||||||
description: 'Macro regime, seasonal calendar, ETF/COT flows, and informed-commentator sentiment (14 slots).',
|
description: 'Macro regime, valuation corridor, seasonal calendar, ETF/COT flows, and informed-commentator sentiment.',
|
||||||
slotIds: familySlots('macro', 'seasonal', 'flows', 'sentiment'),
|
slotIds: familySlots('macro', 'seasonal', 'flows', 'sentiment'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Investor Flow — Confluence Slot Catalog (M22, slice 2)
|
// Investor Flow — Confluence Slot Catalog (M22, slice 2)
|
||||||
//
|
//
|
||||||
// The 34-slot confluence inventory for the Confluence Signal Engine. Each slot is
|
// The 40-slot confluence inventory for the Confluence Signal Engine. Each slot is
|
||||||
// a named, independently-evaluable check whose *firing* state contributes bullish
|
// a named, independently-evaluable check whose *firing* state contributes bullish
|
||||||
// or bearish evidence about a symbol's entry/exit quality.
|
// or bearish evidence about a symbol's entry/exit quality.
|
||||||
//
|
//
|
||||||
@@ -46,7 +46,7 @@ export interface ConfluenceSlot {
|
|||||||
explain: string;
|
explain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Complete 34-slot confluence catalog in evaluation order. */
|
/** Complete confluence catalog in evaluation order. */
|
||||||
export const CONFLUENCE_SLOTS: ConfluenceSlot[] = [
|
export const CONFLUENCE_SLOTS: ConfluenceSlot[] = [
|
||||||
// ---------------------------------------------------------------- technical
|
// ---------------------------------------------------------------- technical
|
||||||
{ id: 'goldenCross', name: 'Golden Cross', family: 'technical', body: 'bull', granularity: '1wk', explain: 'The 50-window average has crossed above the 200-window average, a widely-watched trend-quality marker.' },
|
{ id: 'goldenCross', name: 'Golden Cross', family: 'technical', body: 'bull', granularity: '1wk', explain: 'The 50-window average has crossed above the 200-window average, a widely-watched trend-quality marker.' },
|
||||||
@@ -93,6 +93,14 @@ export const CONFLUENCE_SLOTS: ConfluenceSlot[] = [
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- sentiment
|
// ---------------------------------------------------------------- sentiment
|
||||||
{ id: 'commentatorSentiment', name: 'Informed Commentator Sentiment', family: 'sentiment', body: 'bull', granularity: '1d', explain: 'Informed commentators tracked via the configured sentiment source are net-positive on the symbol in the measurement window.' },
|
{ id: 'commentatorSentiment', name: 'Informed Commentator Sentiment', family: 'sentiment', body: 'bull', granularity: '1d', explain: 'Informed commentators tracked via the configured sentiment source are net-positive on the symbol in the measurement window.' },
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- corridor
|
||||||
|
{ id: 'corridorEntryCheap', name: 'Corridor: Entry Cheap', family: 'macro', body: 'bull', granularity: '1d', explain: 'The current P/E sits in the lower third of the 1-year observable valuation corridor, indicating a relatively attractive entry point versus the symbol\'s own history.' },
|
||||||
|
{ id: 'corridorEntryStretched', name: 'Corridor: Entry Stretched', family: 'macro', body: 'exit', granularity: '1d', explain: 'The current P/E sits in the upper third of the 1-year observable valuation corridor, indicating a stretched valuation versus the symbol\'s own history.' },
|
||||||
|
{ id: 'corridorUpsideHigh', name: 'Corridor: Upside High', family: 'macro', body: 'bull', granularity: '1d', explain: 'Applying the 1-year median observable multiple to forward earnings implies meaningful upside from the current price.' },
|
||||||
|
{ id: 'corridorUpsideLow', name: 'Corridor: Upside Low', family: 'macro', body: 'exit', granularity: '1d', explain: 'Applying the 1-year median observable multiple to forward earnings implies meaningful downside from the current price.' },
|
||||||
|
{ id: 'spyCorridorCheap', name: 'SPY Corridor: Cheap', family: 'macro', body: 'bull', granularity: '1wk', explain: 'The S&P 500 forward P/E sits below its 3-year median, a market-level valuation tailwind that improves the odds for broad equity exposure.' },
|
||||||
|
{ id: 'spyCorridorStretched', name: 'SPY Corridor: Stretched', family: 'macro', body: 'exit', granularity: '1wk', explain: 'The S&P 500 forward P/E sits at or above its 3-year median, a market-level valuation headwind that tempers the broad-equity picture.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Indexed by slot id for O(1) lookup. */
|
/** Indexed by slot id for O(1) lookup. */
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// Investor Flow — Price Corridor data pipeline (M24, slice 2)
|
||||||
|
//
|
||||||
|
// The Corridor Method (as reverse-engineered from @alojoh's weekly "U.S. Tech
|
||||||
|
// Coverage / Market Valuation" reports): a symbol's *observable multiple* range
|
||||||
|
// over a lookback window defines a valuation corridor. The current P/E position
|
||||||
|
// within that corridor signals entry timing (cheap near the low band, stretched
|
||||||
|
// near the high band), and applying the corridor's median multiple to forward
|
||||||
|
// EPS derives an implied fair value / upside.
|
||||||
|
//
|
||||||
|
// ADR-0007: this is a valuation-context seam, never a buy/sell directive. It
|
||||||
|
// computes where price sits relative to its own historical valuation corridor.
|
||||||
|
//
|
||||||
|
// Pure where possible: `computeCorridor`, `percentileIndex`, `buildSnapshot`
|
||||||
|
// are pure; the cache-backed `resolveCorridorSnapshot` is a thin shim over the
|
||||||
|
// shared cache (quote + candles + dividend fundamentals) — no direct vendor
|
||||||
|
// I/O here (ADR-0009: everything funnels through the cache / adapter queue).
|
||||||
|
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import type { CacheRepository, PriceCandle, Quote } from '../cache/CacheRepository.ts';
|
||||||
|
import type { CorridorSnapshot } from '../db/corridorRepository.ts';
|
||||||
|
import { CorridorRepository } from '../db/corridorRepository.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Trading days in the 1-year observable window. */
|
||||||
|
export const CORRIDOR_1Y_BARS = 252;
|
||||||
|
/** Trading days in the 90-day observable window. */
|
||||||
|
export const CORRIDOR_90D_BARS = 63;
|
||||||
|
/** Trading days in the 3-year market-level window (S&P 500 context). */
|
||||||
|
export const CORRIDOR_3Y_BARS = 756;
|
||||||
|
|
||||||
|
/** Fraction of the 1y corridor below which the entry is "cheap". */
|
||||||
|
export const ENTRY_CHEAP_PERCENTILE = 0.33;
|
||||||
|
/** Fraction above which the entry is "stretched". */
|
||||||
|
export const ENTRY_STRETCHED_PERCENTILE = 0.67;
|
||||||
|
/** Implied upside (1y median reversion) above which the upside slot fires. */
|
||||||
|
export const UPSIDE_HIGH_THRESHOLD = 0.15;
|
||||||
|
/** Implied downside below which the downside slot fires. */
|
||||||
|
export const UPSIDE_LOW_THRESHOLD = -0.10;
|
||||||
|
/** Window (days) used by the corridor-method backtest grader. */
|
||||||
|
export const BACKTEST_HORIZON_DAYS = 7;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Median of a numeric array (finite elements); null when empty. */
|
||||||
|
export function median(values: number[]): number | null {
|
||||||
|
const finite = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
|
||||||
|
if (finite.length === 0) return null;
|
||||||
|
const mid = Math.floor(finite.length / 2);
|
||||||
|
return finite.length % 2 === 0 ? (finite[mid - 1] + finite[mid]) / 2 : finite[mid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fractional rank (0..1) of `value` within `series`: the fraction of
|
||||||
|
* `series` elements at or below `value`. Returns null when series is empty.
|
||||||
|
* Pure.
|
||||||
|
*/
|
||||||
|
export function percentileIndex(value: number, series: number[]): number | null {
|
||||||
|
const finite = series.filter((v) => Number.isFinite(v));
|
||||||
|
if (finite.length === 0) return null;
|
||||||
|
const below = finite.filter((v) => v <= value).length;
|
||||||
|
return below / finite.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A computed corridor window. Pure. */
|
||||||
|
export interface CorridorWindow {
|
||||||
|
high: number | null;
|
||||||
|
low: number | null;
|
||||||
|
median: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a P/E corridor window from a lookback slice of a P/E series.
|
||||||
|
* `series` is the full series (oldest → newest); `bars` is the window size.
|
||||||
|
* Pure.
|
||||||
|
*/
|
||||||
|
export function computeCorridorWindow(series: number[], bars: number): CorridorWindow {
|
||||||
|
const slice = series.length >= bars ? series.slice(series.length - bars) : series.slice();
|
||||||
|
const finite = slice.filter((v) => Number.isFinite(v));
|
||||||
|
if (finite.length === 0) return { high: null, low: null, median: null };
|
||||||
|
return {
|
||||||
|
high: Math.max(...finite),
|
||||||
|
low: Math.min(...finite),
|
||||||
|
median: median(finite),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full corridor snapshot from a trailing P/E series and current prices.
|
||||||
|
* Pure. `peSeries` is the trailing P/E series (oldest → newest); the latest
|
||||||
|
* value is treated as the current P/E position.
|
||||||
|
*/
|
||||||
|
export function computeCorridor(
|
||||||
|
peSeries: number[],
|
||||||
|
currentPrice: number,
|
||||||
|
forwardEPS: number | null,
|
||||||
|
trailingEPS: number | null,
|
||||||
|
trailingPE: number | null,
|
||||||
|
forwardPE: number | null,
|
||||||
|
): Omit<CorridorSnapshot, 'symbol' | 'snapshotDate' | 'dataSource' | 'createdAt'> {
|
||||||
|
const currentPE = peSeries.length > 0 ? peSeries[peSeries.length - 1] : trailingPE ?? NaN;
|
||||||
|
|
||||||
|
const w1y = computeCorridorWindow(peSeries, CORRIDOR_1Y_BARS);
|
||||||
|
const w90d = computeCorridorWindow(peSeries, CORRIDOR_90D_BARS);
|
||||||
|
|
||||||
|
const fairValue1y = forwardEPS !== null && forwardEPS > 0 && w1y.median !== null ? forwardEPS * w1y.median : null;
|
||||||
|
const fairValue90d = forwardEPS !== null && forwardEPS > 0 && w90d.median !== null ? forwardEPS * w90d.median : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
forwardPE,
|
||||||
|
trailingPE,
|
||||||
|
forwardEPS,
|
||||||
|
trailingEPS,
|
||||||
|
corridor1yHigh: w1y.high,
|
||||||
|
corridor1yLow: w1y.low,
|
||||||
|
corridor1yMedian: w1y.median,
|
||||||
|
corridor90dHigh: w90d.high,
|
||||||
|
corridor90dLow: w90d.low,
|
||||||
|
corridor90dMedian: w90d.median,
|
||||||
|
fairValue1y,
|
||||||
|
fairValue90d,
|
||||||
|
impliedUpside1y:
|
||||||
|
currentPrice > 0 && fairValue1y !== null ? fairValue1y / currentPrice - 1 : null,
|
||||||
|
impliedUpside90d:
|
||||||
|
currentPrice > 0 && fairValue90d !== null ? fairValue90d / currentPrice - 1 : null,
|
||||||
|
pePercentile1y:
|
||||||
|
Number.isFinite(currentPE) ? percentileIndex(currentPE, peSeries.slice(Math.max(0, peSeries.length - CORRIDOR_1Y_BARS))) : null,
|
||||||
|
pePercentile90d:
|
||||||
|
Number.isFinite(currentPE) ? percentileIndex(currentPE, peSeries.slice(Math.max(0, peSeries.length - CORRIDOR_90D_BARS))) : null,
|
||||||
|
currentPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a trailing P/E series from closes over `eps`. Pure. */
|
||||||
|
export function trailingPeSeries(candles: PriceCandle[], eps: number): number[] {
|
||||||
|
if (!eps || eps <= 0) return [];
|
||||||
|
return candles
|
||||||
|
.map((c) => (Number.isFinite(c.c) && c.c > 0 ? c.c / eps : NaN))
|
||||||
|
.filter((v) => Number.isFinite(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache-backed resolution seam
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CorridorFundamentalsInput {
|
||||||
|
price: number | null;
|
||||||
|
forwardPE: number | null;
|
||||||
|
trailingPE: number | null;
|
||||||
|
forwardEPS: number | null;
|
||||||
|
trailingEPS: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paper a quote + dividend-fundamentals cache row into a corridor input. Pure. */
|
||||||
|
export function fundamentalsFrom(quote: Quote | null, div: unknown): CorridorFundamentalsInput {
|
||||||
|
const f = (div as Record<string, unknown> | null) ?? {};
|
||||||
|
const num = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || typeof v === 'string' && v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
const forwardPE = num(f.forwardPE);
|
||||||
|
const trailingPE = num(f.trailingPE);
|
||||||
|
const forwardEPS = num(f.forwardEPS);
|
||||||
|
const trailingEPS = num(f.trailingEPS);
|
||||||
|
return {
|
||||||
|
price: quote?.price != null && Number.isFinite(quote.price) ? quote.price : null,
|
||||||
|
forwardPE,
|
||||||
|
trailingPE,
|
||||||
|
forwardEPS,
|
||||||
|
// Prefer an explicit trailing EPS; else fall back to price / trailingPE.
|
||||||
|
trailingEPS: trailingEPS ?? (trailingPE && trailingPE > 0 && quote?.price ? quote.price / trailingPE : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve + store a fresh corridor snapshot for `symbol` from the shared cache.
|
||||||
|
* Uses the trailing P/E bootstrap: the trailing EPS drives the historical P/E
|
||||||
|
* series immediately; forward PE/EPS enrich it when available. Idempotent per
|
||||||
|
* (symbol, snapshotDate). Returns the stored snapshot.
|
||||||
|
*/
|
||||||
|
export async function resolveCorridorSnapshot(
|
||||||
|
db: DatabaseSync,
|
||||||
|
cache: CacheRepository,
|
||||||
|
symbol: string,
|
||||||
|
opts: { date?: string } = {},
|
||||||
|
): Promise<CorridorSnapshot | null> {
|
||||||
|
const sym = symbol.toUpperCase();
|
||||||
|
const repo = new CorridorRepository(db);
|
||||||
|
const snapshotDate = opts.date ?? new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const quoteEntry = await cache.get<Quote>(`yfinance:quote:${sym}`);
|
||||||
|
const divEntry = await cache.get<Record<string, unknown>>(`yfinance:dividendFundamentals:${sym}`);
|
||||||
|
const candleEntry = await cache.get<PriceCandle[]>(`yfinance:candles:${sym}:1d`);
|
||||||
|
|
||||||
|
const candles = (candleEntry?.value ?? []) as PriceCandle[];
|
||||||
|
const f = fundamentalsFrom(quoteEntry?.value ?? null, divEntry?.value ?? null);
|
||||||
|
|
||||||
|
if (f.price === null && candles.length === 0) return null;
|
||||||
|
const price = f.price ?? (candles.length > 0 ? candles[candles.length - 1].c : NaN);
|
||||||
|
|
||||||
|
const peSeries = f.trailingEPS !== null && f.trailingEPS > 0
|
||||||
|
? trailingPeSeries(candles, f.trailingEPS)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const computed = computeCorridor(
|
||||||
|
peSeries,
|
||||||
|
price,
|
||||||
|
f.forwardEPS,
|
||||||
|
f.trailingEPS,
|
||||||
|
f.trailingPE ?? (f.trailingEPS && f.trailingEPS > 0 && price > 0 ? price / f.trailingEPS : null),
|
||||||
|
f.forwardPE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no trailing EPS produced a real P/E series, compute one from forward PE.
|
||||||
|
const snapshot: Omit<CorridorSnapshot, 'id'> = {
|
||||||
|
symbol: sym,
|
||||||
|
snapshotDate,
|
||||||
|
...computed,
|
||||||
|
dataSource: f.forwardPE !== null ? 'yfinance' : 'bootstrap_trailing',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (!Number.isFinite(snapshot.currentPrice) && candles.length > 0) {
|
||||||
|
snapshot.currentPrice = candles[candles.length - 1].c;
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.saveSnapshot(snapshot);
|
||||||
|
return repo.latestSnapshot(sym);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a price-only corridor as a fallback when no EPS is available
|
||||||
|
* (e.g. funds / unfamiliar tickers). Uses close-price percentiles instead of
|
||||||
|
* P/E percentiles to still give a "where is price vs its own range" read.
|
||||||
|
* Pure.
|
||||||
|
*/
|
||||||
|
export function priceOnlySnapshot(
|
||||||
|
candles: PriceCandle[],
|
||||||
|
currentPrice: number,
|
||||||
|
snapshotDate: string,
|
||||||
|
symbol: string,
|
||||||
|
): Omit<CorridorSnapshot, 'id'> | null {
|
||||||
|
if (candles.length === 0 || !Number.isFinite(currentPrice)) return null;
|
||||||
|
const closes = candles.map((c) => c.c).filter((v) => Number.isFinite(v));
|
||||||
|
if (closes.length === 0) return null;
|
||||||
|
|
||||||
|
const w1y = computeCorridorWindow(closes, CORRIDOR_1Y_BARS);
|
||||||
|
const w90d = computeCorridorWindow(closes, CORRIDOR_90D_BARS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
snapshotDate,
|
||||||
|
currentPrice,
|
||||||
|
forwardPE: null,
|
||||||
|
trailingPE: null,
|
||||||
|
forwardEPS: null,
|
||||||
|
trailingEPS: null,
|
||||||
|
corridor1yHigh: w1y.high,
|
||||||
|
corridor1yLow: w1y.low,
|
||||||
|
corridor1yMedian: w1y.median,
|
||||||
|
corridor90dHigh: w90d.high,
|
||||||
|
corridor90dLow: w90d.low,
|
||||||
|
corridor90dMedian: w90d.median,
|
||||||
|
fairValue1y: null,
|
||||||
|
fairValue90d: null,
|
||||||
|
impliedUpside1y: null,
|
||||||
|
impliedUpside90d: null,
|
||||||
|
pePercentile1y:
|
||||||
|
Number.isFinite(currentPrice) ? percentileIndex(currentPrice, closes.slice(Math.max(0, closes.length - CORRIDOR_1Y_BARS))) : null,
|
||||||
|
pePercentile90d:
|
||||||
|
Number.isFinite(currentPrice) ? percentileIndex(currentPrice, closes.slice(Math.max(0, closes.length - CORRIDOR_90D_BARS))) : null,
|
||||||
|
dataSource: 'bootstrap_trailing',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Investor Flow — Price Corridor slot evaluator (M24, slice 3)
|
||||||
|
//
|
||||||
|
// Pure, snapshot-based assessments for the six `macro` corridor confluence
|
||||||
|
// slots. Consumes the valuation-corridor snapshot computed by corridorData.ts
|
||||||
|
// and, for the market-level slots, the SPY snapshot. Every function is a pure
|
||||||
|
// (snapshot) → SlotAssessment[] mapping with ADR-0007 evidence notes — never a
|
||||||
|
// recommendation.
|
||||||
|
//
|
||||||
|
// Distinct from the candle-driven technical evaluators: corridor slots read the
|
||||||
|
// observable-multiple corridor, so they carry valuation evidence over and above
|
||||||
|
// price-action evidence in the rack's picture.
|
||||||
|
|
||||||
|
import type { CorridorSnapshot } from '../db/corridorRepository.ts';
|
||||||
|
import type { SlotAssessment } from './confluenceRack.ts';
|
||||||
|
import {
|
||||||
|
ENTRY_CHEAP_PERCENTILE,
|
||||||
|
ENTRY_STRETCHED_PERCENTILE,
|
||||||
|
UPSIDE_HIGH_THRESHOLD,
|
||||||
|
UPSIDE_LOW_THRESHOLD,
|
||||||
|
CORRIDOR_3Y_BARS,
|
||||||
|
computeCorridorWindow,
|
||||||
|
} from './corridorData.ts';
|
||||||
|
|
||||||
|
/** 3-year median forward-P/E reference for the S&P 500 (from @alojoh's reports:
|
||||||
|
* 18.9x trough, 23.1x peak, ~20.5x median over the last three years). Used only
|
||||||
|
* as the anchoring bench when a full 3y series is unavailable. */
|
||||||
|
export const SPY_3Y_MEDIAN_REFERENCE = 20.5;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assessment(id: string, state: 'fired' | 'not-fired', note: string): SlotAssessment {
|
||||||
|
return { id, state, note };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the six price-corridor slot assessments for a symbol's snapshot.
|
||||||
|
* `spySnapshot` supplies the market-level slots (SPY corridor vs its own 3y
|
||||||
|
* median). Pure.
|
||||||
|
*/
|
||||||
|
export function evaluateCorridorSlots(
|
||||||
|
snapshot: CorridorSnapshot,
|
||||||
|
spySnapshot: CorridorSnapshot | null,
|
||||||
|
): SlotAssessment[] {
|
||||||
|
const out: SlotAssessment[] = [];
|
||||||
|
|
||||||
|
const pct1y = snapshot.pePercentile1y === null ? null : Number(snapshot.pePercentile1y);
|
||||||
|
const pct90d = snapshot.pePercentile90d === null ? null : Number(snapshot.pePercentile90d);
|
||||||
|
|
||||||
|
// ----- corridor: entry cheap / stretched (1-year observable multiple) -----
|
||||||
|
if (pct1y === null) {
|
||||||
|
out.push(assessment('corridorEntryCheap', 'not-fired', 'No 1-year valuation corridor available for this symbol yet.'));
|
||||||
|
out.push(assessment('corridorEntryStretched', 'not-fired', 'No 1-year valuation corridor available for this symbol yet.'));
|
||||||
|
} else {
|
||||||
|
if (pct1y < ENTRY_CHEAP_PERCENTILE) {
|
||||||
|
out.push(assessment(
|
||||||
|
'corridorEntryCheap',
|
||||||
|
'fired',
|
||||||
|
`Current P/E is in the lower ${(ENTRY_CHEAP_PERCENTILE * 100).toFixed(0)}% of its 1-year observable corridor (${(pct1y * 100).toFixed(0)}th percentile), indicating a relatively attractive entry point.`,
|
||||||
|
));
|
||||||
|
out.push(assessment('corridorEntryStretched', 'not-fired', `Current P/E sits at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year corridor.`));
|
||||||
|
} else if (pct1y > ENTRY_STRETCHED_PERCENTILE) {
|
||||||
|
out.push(assessment(
|
||||||
|
'corridorEntryStretched',
|
||||||
|
'fired',
|
||||||
|
`Current P/E is in the upper ${((1 - ENTRY_STRETCHED_PERCENTILE) * 100).toFixed(0)}% of its 1-year observable corridor (${(pct1y * 100).toFixed(0)}th percentile), indicating a stretched valuation vs its own history.`,
|
||||||
|
));
|
||||||
|
out.push(assessment('corridorEntryCheap', 'not-fired', `Current P/E sits at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year corridor.`));
|
||||||
|
} else {
|
||||||
|
out.push(assessment('corridorEntryCheap', 'not-fired', `Current P/E sits mid-corridor at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year range.`));
|
||||||
|
out.push(assessment('corridorEntryStretched', 'not-fired', `Current P/E sits mid-corridor at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year range.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- corridor: implied upside / downside (median-multiple reversion) -----
|
||||||
|
const upside1y = snapshot.impliedUpside1y === null ? null : Number(snapshot.impliedUpside1y);
|
||||||
|
if (upside1y === null) {
|
||||||
|
out.push(assessment('corridorUpsideHigh', 'not-fired', 'No forward EPS / median-multiple fair value available to quantify implied upside.'));
|
||||||
|
out.push(assessment('corridorUpsideLow', 'not-fired', 'No forward EPS / median-multiple fair value available to quantify implied downside.'));
|
||||||
|
} else {
|
||||||
|
if (upside1y > UPSIDE_HIGH_THRESHOLD) {
|
||||||
|
out.push(assessment(
|
||||||
|
'corridorUpsideHigh',
|
||||||
|
'fired',
|
||||||
|
`Applying the 1-year median multiple to forward earnings implies ${(upside1y * 100).toFixed(1)}% upside from the current price.`,
|
||||||
|
));
|
||||||
|
out.push(assessment('corridorUpsideLow', 'not-fired', `1-year median-multiple fair value is ${(upside1y * 100).toFixed(1)}% vs current price.`));
|
||||||
|
} else if (upside1y < UPSIDE_LOW_THRESHOLD) {
|
||||||
|
out.push(assessment(
|
||||||
|
'corridorUpsideLow',
|
||||||
|
'fired',
|
||||||
|
`Applying the 1-year median multiple to forward earnings implies ${(upside1y * 100).toFixed(1)}% downside from the current price.`,
|
||||||
|
));
|
||||||
|
out.push(assessment('corridorUpsideHigh', 'not-fired', `1-year median-multiple fair value is ${(upside1y * 100).toFixed(1)}% vs current price.`));
|
||||||
|
} else {
|
||||||
|
out.push(assessment('corridorUpsideHigh', 'not-fired', `1-year median-multiple fair value implies ${(upside1y * 100).toFixed(1)}% vs current price.`));
|
||||||
|
out.push(assessment('corridorUpsideLow', 'not-fired', `1-year median-multiple fair value implies ${(upside1y * 100).toFixed(1)}% vs current price.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- market level: SPY 3-year corridor (valuation tailwind / headwind) -----
|
||||||
|
out.push(...spyCorridorAssessments(spySnapshot));
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Market-level SPY corridor slots: is the broad market cheap or stretched
|
||||||
|
* relative to its own 3-year forward-P/E corridor? Accepts either a stored SPY
|
||||||
|
* snapshot (preferred) or a raw close-price series fallback. Pure.
|
||||||
|
*/
|
||||||
|
export function spyCorridorAssessments(spySnapshot: CorridorSnapshot | null): SlotAssessment[] {
|
||||||
|
if (!spySnapshot) {
|
||||||
|
return [
|
||||||
|
assessment('spyCorridorCheap', 'not-fired', 'No SPY valuation-corridor snapshot available for the market-level context.'),
|
||||||
|
assessment('spyCorridorStretched', 'not-fired', 'No SPY valuation-corridor snapshot available for the market-level context.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pe = spySnapshot.forwardPE ?? spySnapshot.trailingPE ?? null;
|
||||||
|
const cheap = pe !== null && pe < SPY_3Y_MEDIAN_REFERENCE;
|
||||||
|
const peLabel = pe !== null ? pe.toFixed(1) : 'n/a';
|
||||||
|
|
||||||
|
if (cheap) {
|
||||||
|
return [
|
||||||
|
assessment(
|
||||||
|
'spyCorridorCheap',
|
||||||
|
'fired',
|
||||||
|
`S&P 500 forward P/E (${peLabel}x) is below the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x), a market-level valuation tailwind.`,
|
||||||
|
),
|
||||||
|
assessment('spyCorridorStretched', 'not-fired', `S&P 500 forward P/E (${peLabel}x) is below the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x).`),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
assessment('spyCorridorCheap', 'not-fired', `S&P 500 forward P/E (${peLabel}x) is not below the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x).`),
|
||||||
|
assessment(
|
||||||
|
'spyCorridorStretched',
|
||||||
|
'fired',
|
||||||
|
`S&P 500 forward P/E (${peLabel}x) is at or above the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x), a market-level valuation headwind.`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-year corridor window over a raw close-price series (for the SPY chart and
|
||||||
|
* the market-level slot when only prices are cached). Pure.
|
||||||
|
*/
|
||||||
|
export function priceCorridor3y(candles: Array<{ c: number }>): { high: number | null; low: number | null; median: number | null } {
|
||||||
|
const closes = candles.map((c) => c.c).filter((v) => Number.isFinite(v));
|
||||||
|
return computeCorridorWindow(closes, CORRIDOR_3Y_BARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience re-export so corridor consumers share a single percentile helper. */
|
||||||
|
export { percentileIndex } from './corridorData.ts';
|
||||||
@@ -82,6 +82,9 @@ function runMigrations(db: DatabaseSync): void {
|
|||||||
`ALTER TABLE watchlists ADD COLUMN class_label TEXT`,
|
`ALTER TABLE watchlists ADD COLUMN class_label TEXT`,
|
||||||
// System-pinned demand symbols (rotation universe / benchmarks) survive unsubscribe
|
// System-pinned demand symbols (rotation universe / benchmarks) survive unsubscribe
|
||||||
`ALTER TABLE symbol_demand ADD COLUMN system_pin INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE symbol_demand ADD COLUMN system_pin INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
// Tiered priority: 0=portfolio, 1=alert-critical, 2=watched, 3=background
|
||||||
|
`ALTER TABLE symbol_demand ADD COLUMN tier INTEGER NOT NULL DEFAULT 3`,
|
||||||
|
`ALTER TABLE symbol_demand ADD COLUMN last_viewed_at TEXT`,
|
||||||
// Extended-hours quote fields (pre/post last vs RTH close)
|
// Extended-hours quote fields (pre/post last vs RTH close)
|
||||||
`ALTER TABLE quotes ADD COLUMN session TEXT`,
|
`ALTER TABLE quotes ADD COLUMN session TEXT`,
|
||||||
`ALTER TABLE quotes ADD COLUMN regular_price REAL`,
|
`ALTER TABLE quotes ADD COLUMN regular_price REAL`,
|
||||||
@@ -254,6 +257,56 @@ function runMigrations(db: DatabaseSync): void {
|
|||||||
verdict TEXT
|
verdict TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id)`,
|
||||||
|
// Price Corridor module (M24): valuation-corridor snapshots, watchlist, and
|
||||||
|
// the corridor-method backtest ledger for the offline grading of @alojoh's
|
||||||
|
// weekly rankings against actual forward returns.
|
||||||
|
`CREATE TABLE IF NOT EXISTS corridor_snapshots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
snapshot_date TEXT NOT NULL,
|
||||||
|
current_price REAL NOT NULL,
|
||||||
|
forward_pe REAL,
|
||||||
|
trailing_pe REAL,
|
||||||
|
forward_eps REAL,
|
||||||
|
trailing_eps REAL,
|
||||||
|
corridor_1y_high REAL,
|
||||||
|
corridor_1y_low REAL,
|
||||||
|
corridor_1y_median REAL,
|
||||||
|
corridor_90d_high REAL,
|
||||||
|
corridor_90d_low REAL,
|
||||||
|
corridor_90d_median REAL,
|
||||||
|
fair_value_1y REAL,
|
||||||
|
fair_value_90d REAL,
|
||||||
|
implied_upside_1y REAL,
|
||||||
|
implied_upside_90d REAL,
|
||||||
|
pe_percentile_1y REAL,
|
||||||
|
pe_percentile_90d REAL,
|
||||||
|
data_source TEXT NOT NULL DEFAULT 'yfinance',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(symbol, snapshot_date)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_corridor_snapshots_symbol_date ON corridor_snapshots(symbol, snapshot_date DESC)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS corridor_watchlist (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, symbol)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS corridor_backtest (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_date TEXT NOT NULL,
|
||||||
|
article_id TEXT NOT NULL,
|
||||||
|
ranking_type TEXT NOT NULL,
|
||||||
|
top_symbols_json TEXT NOT NULL,
|
||||||
|
bottom_symbols_json TEXT NOT NULL,
|
||||||
|
top_avg_return REAL,
|
||||||
|
bottom_avg_return REAL,
|
||||||
|
spread REAL,
|
||||||
|
is_win INTEGER NOT NULL,
|
||||||
|
horizon_days INTEGER NOT NULL,
|
||||||
|
graded_at TEXT NOT NULL,
|
||||||
|
UNIQUE(article_date, ranking_type)
|
||||||
|
)`,
|
||||||
];
|
];
|
||||||
for (const sql of migrations) {
|
for (const sql of migrations) {
|
||||||
try { db.exec(sql); } catch { /* column already exists */ }
|
try { db.exec(sql); } catch { /* column already exists */ }
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// Investor Flow — Corridor Repository (M24, slice 2)
|
||||||
|
//
|
||||||
|
// Thin data-access layer over the three price-corridor tables:
|
||||||
|
// corridor_snapshots — daily valuation-corridor snapshots per symbol
|
||||||
|
// corridor_watchlist — per-user tickers surfaced in the Corridor panel
|
||||||
|
// corridor_backtest — corridor-method backtest ledger (offline grading)
|
||||||
|
//
|
||||||
|
// No business rules live here; pure persistence + retrieval. The corridor math
|
||||||
|
// lives in corridorData.ts (pure) so this file stays a reflection of the schema.
|
||||||
|
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A stored daily valuation-corridor snapshot for one symbol. */
|
||||||
|
export interface CorridorSnapshot {
|
||||||
|
symbol: string;
|
||||||
|
snapshotDate: string;
|
||||||
|
currentPrice: number;
|
||||||
|
forwardPE: number | null;
|
||||||
|
trailingPE: number | null;
|
||||||
|
forwardEPS: number | null;
|
||||||
|
trailingEPS: number | null;
|
||||||
|
corridor1yHigh: number | null;
|
||||||
|
corridor1yLow: number | null;
|
||||||
|
corridor1yMedian: number | null;
|
||||||
|
corridor90dHigh: number | null;
|
||||||
|
corridor90dLow: number | null;
|
||||||
|
corridor90dMedian: number | null;
|
||||||
|
fairValue1y: number | null;
|
||||||
|
fairValue90d: number | null;
|
||||||
|
impliedUpside1y: number | null;
|
||||||
|
impliedUpside90d: number | null;
|
||||||
|
pePercentile1y: number | null;
|
||||||
|
pePercentile90d: number | null;
|
||||||
|
dataSource: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One graded corridor-method backtest row (exported shape, not the raw row). */
|
||||||
|
export interface CorridorBacktestRow {
|
||||||
|
id: string;
|
||||||
|
articleDate: string;
|
||||||
|
articleId: string;
|
||||||
|
rankingType: string;
|
||||||
|
topSymbols: string[];
|
||||||
|
bottomSymbols: string[];
|
||||||
|
topAvgReturn: number | null;
|
||||||
|
bottomAvgReturn: number | null;
|
||||||
|
spread: number | null;
|
||||||
|
isWin: boolean;
|
||||||
|
horizonDays: number;
|
||||||
|
gradedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prepared statements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function stmts(db: DatabaseSync) {
|
||||||
|
return {
|
||||||
|
// --- corridor_snapshots ---
|
||||||
|
insertSnapshot: db.prepare(
|
||||||
|
`INSERT INTO corridor_snapshots
|
||||||
|
(id, symbol, snapshot_date, current_price, forward_pe, trailing_pe,
|
||||||
|
forward_eps, trailing_eps, corridor_1y_high, corridor_1y_low,
|
||||||
|
corridor_1y_median, corridor_90d_high, corridor_90d_low,
|
||||||
|
corridor_90d_median, fair_value_1y, fair_value_90d,
|
||||||
|
implied_upside_1y, implied_upside_90d, pe_percentile_1y,
|
||||||
|
pe_percentile_90d, data_source, created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(symbol, snapshot_date) DO UPDATE SET
|
||||||
|
current_price=excluded.current_price, forward_pe=excluded.forward_pe,
|
||||||
|
trailing_pe=excluded.trailing_pe, forward_eps=excluded.forward_eps,
|
||||||
|
trailing_eps=excluded.trailing_eps,
|
||||||
|
corridor_1y_high=excluded.corridor_1y_high,
|
||||||
|
corridor_1y_low=excluded.corridor_1y_low,
|
||||||
|
corridor_1y_median=excluded.corridor_1y_median,
|
||||||
|
corridor_90d_high=excluded.corridor_90d_high,
|
||||||
|
corridor_90d_low=excluded.corridor_90d_low,
|
||||||
|
corridor_90d_median=excluded.corridor_90d_median,
|
||||||
|
fair_value_1y=excluded.fair_value_1y,
|
||||||
|
fair_value_90d=excluded.fair_value_90d,
|
||||||
|
implied_upside_1y=excluded.implied_upside_1y,
|
||||||
|
implied_upside_90d=excluded.implied_upside_90d,
|
||||||
|
pe_percentile_1y=excluded.pe_percentile_1y,
|
||||||
|
pe_percentile_90d=excluded.pe_percentile_90d,
|
||||||
|
data_source=excluded.data_source, created_at=excluded.created_at`,
|
||||||
|
),
|
||||||
|
selectSnapshot: db.prepare(
|
||||||
|
`SELECT id, symbol, snapshot_date, current_price, forward_pe, trailing_pe,
|
||||||
|
forward_eps, trailing_eps, corridor_1y_high, corridor_1y_low,
|
||||||
|
corridor_1y_median, corridor_90d_high, corridor_90d_low,
|
||||||
|
corridor_90d_median, fair_value_1y, fair_value_90d,
|
||||||
|
implied_upside_1y, implied_upside_90d, pe_percentile_1y,
|
||||||
|
pe_percentile_90d, data_source, created_at
|
||||||
|
FROM corridor_snapshots WHERE symbol = ? AND snapshot_date = ?`,
|
||||||
|
),
|
||||||
|
latestSnapshot: db.prepare(
|
||||||
|
`SELECT id, symbol, snapshot_date, current_price, forward_pe, trailing_pe,
|
||||||
|
forward_eps, trailing_eps, corridor_1y_high, corridor_1y_low,
|
||||||
|
corridor_1y_median, corridor_90d_high, corridor_90d_low,
|
||||||
|
corridor_90d_median, fair_value_1y, fair_value_90d,
|
||||||
|
implied_upside_1y, implied_upside_90d, pe_percentile_1y,
|
||||||
|
pe_percentile_90d, data_source, created_at
|
||||||
|
FROM corridor_snapshots WHERE symbol = ?
|
||||||
|
ORDER BY snapshot_date DESC, created_at DESC LIMIT 1`,
|
||||||
|
),
|
||||||
|
snapshotsForSymbol: db.prepare(
|
||||||
|
`SELECT id, symbol, snapshot_date, current_price, forward_pe, trailing_pe,
|
||||||
|
forward_eps, trailing_eps, corridor_1y_high, corridor_1y_low,
|
||||||
|
corridor_1y_median, corridor_90d_high, corridor_90d_low,
|
||||||
|
corridor_90d_median, fair_value_1y, fair_value_90d,
|
||||||
|
implied_upside_1y, implied_upside_90d, pe_percentile_1y,
|
||||||
|
pe_percentile_90d, data_source, created_at
|
||||||
|
FROM corridor_snapshots WHERE symbol = ?
|
||||||
|
ORDER BY snapshot_date DESC LIMIT ?`,
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- corridor_watchlist ---
|
||||||
|
insertWatch: db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO corridor_watchlist (user_id, symbol, added_at)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
),
|
||||||
|
deleteWatch: db.prepare(
|
||||||
|
`DELETE FROM corridor_watchlist WHERE user_id = ? AND symbol = ?`,
|
||||||
|
),
|
||||||
|
listWatch: db.prepare(
|
||||||
|
`SELECT symbol, added_at FROM corridor_watchlist WHERE user_id = ? ORDER BY added_at DESC`,
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- corridor_backtest ---
|
||||||
|
insertBacktest: db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO corridor_backtest
|
||||||
|
(id, article_date, article_id, ranking_type, top_symbols_json,
|
||||||
|
bottom_symbols_json, top_avg_return, bottom_avg_return, spread,
|
||||||
|
is_win, horizon_days, graded_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
),
|
||||||
|
selectBacktests: db.prepare(
|
||||||
|
`SELECT id, article_date, article_id, ranking_type, top_symbols_json,
|
||||||
|
bottom_symbols_json, top_avg_return, bottom_avg_return, spread,
|
||||||
|
is_win, horizon_days, graded_at
|
||||||
|
FROM corridor_backtest ORDER BY article_date DESC`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSnapshot(row: Record<string, unknown>): CorridorSnapshot {
|
||||||
|
const num = (v: unknown): number | null => (v === null || v === undefined ? null : Number(v));
|
||||||
|
return {
|
||||||
|
symbol: row.symbol as string,
|
||||||
|
snapshotDate: row.snapshot_date as string,
|
||||||
|
currentPrice: Number(row.current_price),
|
||||||
|
forwardPE: num(row.forward_pe),
|
||||||
|
trailingPE: num(row.trailing_pe),
|
||||||
|
forwardEPS: num(row.forward_eps),
|
||||||
|
trailingEPS: num(row.trailing_eps),
|
||||||
|
corridor1yHigh: num(row.corridor_1y_high),
|
||||||
|
corridor1yLow: num(row.corridor_1y_low),
|
||||||
|
corridor1yMedian: num(row.corridor_1y_median),
|
||||||
|
corridor90dHigh: num(row.corridor_90d_high),
|
||||||
|
corridor90dLow: num(row.corridor_90d_low),
|
||||||
|
corridor90dMedian: num(row.corridor_90d_median),
|
||||||
|
fairValue1y: num(row.fair_value_1y),
|
||||||
|
fairValue90d: num(row.fair_value_90d),
|
||||||
|
impliedUpside1y: num(row.implied_upside_1y),
|
||||||
|
impliedUpside90d: num(row.implied_upside_90d),
|
||||||
|
pePercentile1y: num(row.pe_percentile_1y),
|
||||||
|
pePercentile90d: num(row.pe_percentile_90d),
|
||||||
|
dataSource: row.data_source as string,
|
||||||
|
createdAt: row.created_at as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBacktest(row: Record<string, unknown>): CorridorBacktestRow {
|
||||||
|
const parse = (raw: string): string[] => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').map((s) => s.toUpperCase()) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const num = (v: unknown): number | null => (v === null || v === undefined ? null : Number(v));
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
articleDate: row.article_date as string,
|
||||||
|
articleId: row.article_id as string,
|
||||||
|
rankingType: row.ranking_type as string,
|
||||||
|
topSymbols: parse(row.top_symbols_json as string),
|
||||||
|
bottomSymbols: parse(row.bottom_symbols_json as string),
|
||||||
|
topAvgReturn: num(row.top_avg_return),
|
||||||
|
bottomAvgReturn: num(row.bottom_avg_return),
|
||||||
|
spread: num(row.spread),
|
||||||
|
isWin: Number(row.is_win) === 1,
|
||||||
|
horizonDays: Number(row.horizon_days),
|
||||||
|
gradedAt: row.graded_at as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Repository
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class CorridorRepository {
|
||||||
|
private readonly db: DatabaseSync;
|
||||||
|
|
||||||
|
constructor(db: DatabaseSync) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ snapshots
|
||||||
|
|
||||||
|
saveSnapshot(snapshot: Omit<CorridorSnapshot, 'id'>): void {
|
||||||
|
stmts(this.db).insertSnapshot.run(
|
||||||
|
randomUUID(),
|
||||||
|
snapshot.symbol,
|
||||||
|
snapshot.snapshotDate,
|
||||||
|
snapshot.currentPrice,
|
||||||
|
snapshot.forwardPE,
|
||||||
|
snapshot.trailingPE,
|
||||||
|
snapshot.forwardEPS,
|
||||||
|
snapshot.trailingEPS,
|
||||||
|
snapshot.corridor1yHigh,
|
||||||
|
snapshot.corridor1yLow,
|
||||||
|
snapshot.corridor1yMedian,
|
||||||
|
snapshot.corridor90dHigh,
|
||||||
|
snapshot.corridor90dLow,
|
||||||
|
snapshot.corridor90dMedian,
|
||||||
|
snapshot.fairValue1y,
|
||||||
|
snapshot.fairValue90d,
|
||||||
|
snapshot.impliedUpside1y,
|
||||||
|
snapshot.impliedUpside90d,
|
||||||
|
snapshot.pePercentile1y,
|
||||||
|
snapshot.pePercentile90d,
|
||||||
|
snapshot.dataSource,
|
||||||
|
snapshot.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(symbol: string, date: string): CorridorSnapshot | null {
|
||||||
|
const row = stmts(this.db).selectSnapshot.get(symbol, date) as Record<string, unknown> | undefined;
|
||||||
|
return row ? mapSnapshot(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestSnapshot(symbol: string): CorridorSnapshot | null {
|
||||||
|
const row = stmts(this.db).latestSnapshot.get(symbol) as Record<string, unknown> | undefined;
|
||||||
|
return row ? mapSnapshot(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotsForSymbol(symbol: string, limit = 90): CorridorSnapshot[] {
|
||||||
|
return (stmts(this.db).snapshotsForSymbol.all(symbol, limit) as Record<string, unknown>[]).map(mapSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- watchlist
|
||||||
|
|
||||||
|
addToWatchlist(userId: string, symbol: string): void {
|
||||||
|
stmts(this.db).insertWatch.run(userId, symbol.toUpperCase(), new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFromWatchlist(userId: string, symbol: string): void {
|
||||||
|
stmts(this.db).deleteWatch.run(userId, symbol.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
listWatchlist(userId: string): string[] {
|
||||||
|
return (stmts(this.db).listWatch.all(userId) as Array<{ symbol: string }>).map((r) => r.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Symbols in the user's open portfolio holdings, deduped. */
|
||||||
|
listPortfolioSymbols(userId: string): string[] {
|
||||||
|
const rows = this.db.prepare(
|
||||||
|
`SELECT DISTINCT symbol FROM portfolio_holdings WHERE owner_id = ? AND status = 'open'`,
|
||||||
|
).all(userId) as Array<{ symbol: string }>;
|
||||||
|
return rows.map((r) => r.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- backtest
|
||||||
|
|
||||||
|
saveBacktest(row: Omit<CorridorBacktestRow, 'id'>): void {
|
||||||
|
stmts(this.db).insertBacktest.run(
|
||||||
|
randomUUID(),
|
||||||
|
row.articleDate,
|
||||||
|
row.articleId,
|
||||||
|
row.rankingType,
|
||||||
|
JSON.stringify(row.topSymbols),
|
||||||
|
JSON.stringify(row.bottomSymbols),
|
||||||
|
row.topAvgReturn,
|
||||||
|
row.bottomAvgReturn,
|
||||||
|
row.spread,
|
||||||
|
row.isWin ? 1 : 0,
|
||||||
|
row.horizonDays,
|
||||||
|
row.gradedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listBacktests(): CorridorBacktestRow[] {
|
||||||
|
return (stmts(this.db).selectBacktests.all() as Record<string, unknown>[]).map(mapBacktest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -289,7 +289,9 @@ CREATE TABLE IF NOT EXISTS symbol_demand (
|
|||||||
ticker_kind TEXT NOT NULL,
|
ticker_kind TEXT NOT NULL,
|
||||||
in_demand INTEGER NOT NULL DEFAULT 1, -- gate flag for the adapter queue
|
in_demand INTEGER NOT NULL DEFAULT 1, -- gate flag for the adapter queue
|
||||||
last_refreshed_at TEXT,
|
last_refreshed_at TEXT,
|
||||||
system_pin INTEGER NOT NULL DEFAULT 0 -- 1 = rotation/benchmark; survives unsubscribe
|
system_pin INTEGER NOT NULL DEFAULT 0, -- 1 = rotation/benchmark; survives unsubscribe
|
||||||
|
tier INTEGER NOT NULL DEFAULT 3, -- 0=portfolio, 1=alert-critical, 2=watched, 3=background
|
||||||
|
last_viewed_at TEXT -- bumped on page view; decays back via recompute
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ===== Tier C — Per-user (ownerId NOT NULL) =====
|
-- ===== Tier C — Per-user (ownerId NOT NULL) =====
|
||||||
@@ -1016,4 +1018,61 @@ CREATE TABLE IF NOT EXISTS confluence_signal_history (
|
|||||||
resolved_at TEXT,
|
resolved_at TEXT,
|
||||||
verdict TEXT -- real|false_alarm
|
verdict TEXT -- real|false_alarm
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ===== Price Corridor module (M24). =====
|
||||||
|
-- Daily valuation-corridor snapshots per symbol: the observable forward/trailing
|
||||||
|
-- P/E corridor the Corridor Method derives entry timing and implied upside from.
|
||||||
|
CREATE TABLE IF NOT EXISTS corridor_snapshots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
snapshot_date TEXT NOT NULL, -- YYYY-MM-DD (trading day)
|
||||||
|
current_price REAL NOT NULL,
|
||||||
|
forward_pe REAL, -- current forward P/E (yfinance)
|
||||||
|
trailing_pe REAL, -- current trailing P/E (yfinance)
|
||||||
|
forward_eps REAL, -- price / forward_pe (derived)
|
||||||
|
trailing_eps REAL,
|
||||||
|
corridor_1y_high REAL, -- trailing P/E upper bound (252d)
|
||||||
|
corridor_1y_low REAL, -- trailing P/E lower bound (252d)
|
||||||
|
corridor_1y_median REAL, -- median trailing P/E (252d)
|
||||||
|
corridor_90d_high REAL, -- trailing P/E upper bound (63d)
|
||||||
|
corridor_90d_low REAL, -- trailing P/E lower bound (63d)
|
||||||
|
corridor_90d_median REAL, -- median trailing P/E (63d)
|
||||||
|
fair_value_1y REAL, -- forward_eps x corridor_1y_median
|
||||||
|
fair_value_90d REAL, -- forward_eps x corridor_90d_median
|
||||||
|
implied_upside_1y REAL, -- (fair_value_1y / current_price) - 1
|
||||||
|
implied_upside_90d REAL, -- (fair_value_90d / current_price) - 1
|
||||||
|
pe_percentile_1y REAL, -- 0..1 current trailing P/E vs 1y corridor
|
||||||
|
pe_percentile_90d REAL, -- 0..1 current trailing P/E vs 90d corridor
|
||||||
|
data_source TEXT NOT NULL DEFAULT 'yfinance', -- yfinance | bootstrap_trailing
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(symbol, snapshot_date)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_corridor_snapshots_symbol_date
|
||||||
|
ON corridor_snapshots(symbol, snapshot_date DESC);
|
||||||
|
|
||||||
|
-- Per-user corridor watchlist (tickers surfaced in the Corridor panel).
|
||||||
|
CREATE TABLE IF NOT EXISTS corridor_watchlist (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, symbol)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Corridor Method backtest: grades @alojoh's weekly entry rankings against
|
||||||
|
-- actual forward returns. An offline / periodic analysis, not live tracking.
|
||||||
|
CREATE TABLE IF NOT EXISTS corridor_backtest (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_date TEXT NOT NULL, -- YYYY-MM-DD of the ranked article
|
||||||
|
article_id TEXT NOT NULL, -- X post id / source key
|
||||||
|
ranking_type TEXT NOT NULL, -- entry_1y | entry_90d | upside
|
||||||
|
top_symbols_json TEXT NOT NULL, -- JSON array of top-attractiveness names
|
||||||
|
bottom_symbols_json TEXT NOT NULL, -- JSON array of least-attractive names
|
||||||
|
top_avg_return REAL, -- mean N-day forward return (top)
|
||||||
|
bottom_avg_return REAL, -- mean N-day forward return (bottom)
|
||||||
|
spread REAL, -- top_avg_return - bottom_avg_return
|
||||||
|
is_win INTEGER NOT NULL, -- 1 if spread > 0
|
||||||
|
horizon_days INTEGER NOT NULL, -- forward window used (e.g. 7)
|
||||||
|
graded_at TEXT NOT NULL,
|
||||||
|
UNIQUE(article_date, ranking_type)
|
||||||
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id);
|
CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id);
|
||||||
|
|||||||
@@ -234,6 +234,39 @@ const housekeepTimer = setInterval(() => {
|
|||||||
}, DAILY_HOUSEKEEP_MS);
|
}, DAILY_HOUSEKEEP_MS);
|
||||||
housekeepTimer.unref();
|
housekeepTimer.unref();
|
||||||
|
|
||||||
|
// Confluence evaluation cycle: daily run of all wired slot family evaluators
|
||||||
|
// over the confluence universe, persisting rack evaluations + signal history.
|
||||||
|
// Idempotent per (symbol, asOf, rack) — cheap to run more often than daily.
|
||||||
|
const CONFLUENCE_EVAL_MS = 60 * 60 * 1000; // hourly tick; re-eval only when stale
|
||||||
|
const confluenceEvalTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { runConfluenceEvaluationCycle } = await import('./confluence/confluenceEngine.ts');
|
||||||
|
const summary = await runConfluenceEvaluationCycle(database, cache);
|
||||||
|
if (summary.symbolsEvaluated.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[confluence] evaluated=${summary.symbolsEvaluated.length} stored=${summary.evaluationsStored} ` +
|
||||||
|
`reused=${summary.evaluationsReused} signals=${summary.signalsLogged} resolved=${summary.signalsResolved} ` +
|
||||||
|
`corridorSnapshots=${summary.corridorSnapshots}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[confluence] evaluation cycle failed:', e);
|
||||||
|
}
|
||||||
|
}, CONFLUENCE_EVAL_MS);
|
||||||
|
confluenceEvalTimer.unref();
|
||||||
|
// First cycle shortly after boot once the EOD refresh has had a chance to land.
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { runConfluenceEvaluationCycle } = await import('./confluence/confluenceEngine.ts');
|
||||||
|
const summary = await runConfluenceEvaluationCycle(database, cache);
|
||||||
|
if (summary.symbolsEvaluated.length > 0) {
|
||||||
|
console.log(`[confluence] boot cycle: ${summary.symbolsEvaluated.length} symbols, ${summary.evaluationsStored} evaluations stored`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[confluence] boot cycle failed:', e);
|
||||||
|
}
|
||||||
|
}, 3 * 60 * 1000).unref();
|
||||||
|
|
||||||
|
|
||||||
function readBody(req: IncomingMessage): Promise<string> {
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { CacheKey, SourceKind, CacheRepository, CacheScheduler } from '../c
|
|||||||
import {
|
import {
|
||||||
needsCandleRefresh,
|
needsCandleRefresh,
|
||||||
needsQuoteRefresh,
|
needsQuoteRefresh,
|
||||||
|
needsTieredQuoteRefresh,
|
||||||
needsSymbolMetaRefresh,
|
needsSymbolMetaRefresh,
|
||||||
parseCacheKey,
|
parseCacheKey,
|
||||||
} from '../cache/CacheRepository.ts';
|
} from '../cache/CacheRepository.ts';
|
||||||
@@ -267,10 +268,10 @@ export class AdapterQueue implements CacheScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One-time ops: remove junk test symbols and reset absurd refcounts. */
|
/** One-time ops: remove junk test symbols and reset absurd refcounts. */
|
||||||
cleanupDemandHygiene(): { removedJunk: number; cappedRefcounts: number; failedJunkCleared: number } {
|
cleanupDemandHygiene(): { removedJunk: number; cappedRefcounts: number; failedJunkCleared: number; secPoisonCleared: number } {
|
||||||
let removedJunk = 0;
|
let removedJunk = 0;
|
||||||
const demand = this._db.prepare('SELECT symbol, refcount, system_pin FROM symbol_demand').all() as Array<{
|
const demand = this._db.prepare('SELECT symbol, refcount, system_pin, ticker_kind FROM symbol_demand').all() as Array<{
|
||||||
symbol: string; refcount: number; system_pin: number | null;
|
symbol: string; refcount: number; system_pin: number | null; ticker_kind: string | null;
|
||||||
}>;
|
}>;
|
||||||
for (const row of demand) {
|
for (const row of demand) {
|
||||||
if (JUNK_SYMBOL_RE.test(row.symbol) && !row.system_pin) {
|
if (JUNK_SYMBOL_RE.test(row.symbol) && !row.system_pin) {
|
||||||
@@ -287,10 +288,25 @@ export class AdapterQueue implements CacheScheduler {
|
|||||||
const failed = this._db.prepare(
|
const failed = this._db.prepare(
|
||||||
"DELETE FROM adapter_queue WHERE status='failed' AND (error LIKE 'quarantined:%' OR key LIKE '%:TEST%' OR key LIKE '%:FRESH%' OR key LIKE '%:ZZTEST%' OR key LIKE '%:FLOWTEST%' OR key LIKE '%:NEWTEST%')",
|
"DELETE FROM adapter_queue WHERE status='failed' AND (error LIKE 'quarantined:%' OR key LIKE '%:TEST%' OR key LIKE '%:FRESH%' OR key LIKE '%:ZZTEST%' OR key LIKE '%:FLOWTEST%' OR key LIKE '%:NEWTEST%')",
|
||||||
).run();
|
).run();
|
||||||
|
// Purge pending/backoff SEC jobs for ETF/index/crypto/fx symbols. These
|
||||||
|
// never file 13F or SC 13G, so enqueuing them just manufactures a permanent
|
||||||
|
// backlog (SPY/XLK/^VIX spin and 429 forever). secEligibleSymbols() blocks
|
||||||
|
// new ones, but jobs queued before that guard land here and never die.
|
||||||
|
let secPoisonCleared = 0;
|
||||||
|
for (const row of demand) {
|
||||||
|
const kind = (row.ticker_kind ?? 'equity').toLowerCase();
|
||||||
|
if (kind === 'crypto' || kind === 'fx' || kind === 'index' || kind === 'etf') {
|
||||||
|
const r = this._db.prepare(
|
||||||
|
"DELETE FROM adapter_queue WHERE status IN ('pending','backoff') AND key LIKE '%' || ? || '%' AND (key LIKE 'sec-fetch:%' OR key LIKE 'sec-sc-fetch:%')",
|
||||||
|
).run(row.symbol);
|
||||||
|
secPoisonCleared += Number(r.changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
removedJunk,
|
removedJunk,
|
||||||
cappedRefcounts: Number(cap.changes),
|
cappedRefcounts: Number(cap.changes),
|
||||||
failedJunkCleared: Number(failed.changes),
|
failedJunkCleared: Number(failed.changes),
|
||||||
|
secPoisonCleared,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,21 +343,64 @@ export class AdapterQueue implements CacheScheduler {
|
|||||||
).run(stuckCutoff);
|
).run(stuckCutoff);
|
||||||
|
|
||||||
// Prefer live marks (quote/candles) over secondary data so watchlist prices land first.
|
// Prefer live marks (quote/candles) over secondary data so watchlist prices land first.
|
||||||
|
// Sort by symbol tier (portfolio first) then cache kind then freshness.
|
||||||
|
const tierMap = new Map<string, number>(
|
||||||
|
(this._db.prepare('SELECT symbol, tier FROM symbol_demand').all() as Array<{ symbol: string; tier: number }>)
|
||||||
|
.map((r) => [r.symbol, r.tier]),
|
||||||
|
);
|
||||||
|
/** Extract the symbol from a cache key (e.g. "yfinance:quote:AAPL" → "AAPL", "yfinance:candles:IREN:1d" → "IREN"). */
|
||||||
|
const symFromKey = (key: string): string => {
|
||||||
|
const idx = key.indexOf(':', key.indexOf(':') + 1);
|
||||||
|
if (idx < 0) return '';
|
||||||
|
const rest = key.slice(idx + 1);
|
||||||
|
// Take everything up to the next colon (or end of string)
|
||||||
|
const nextColon = rest.indexOf(':');
|
||||||
|
return (nextColon > 0 ? rest.slice(0, nextColon) : rest).toUpperCase();
|
||||||
|
};
|
||||||
|
/** Kind rank: quote=0, candles=1, symbol=2, topHoldings=3, adjustments=4, else=5 */
|
||||||
|
const kindRank = (key: string): number => {
|
||||||
|
if (key.startsWith('yfinance:quote:')) return 0;
|
||||||
|
if (key.startsWith('yfinance:candles:')) return 1;
|
||||||
|
if (key.startsWith('yfinance:symbol:')) return 2;
|
||||||
|
if (key.startsWith('yfinance:topHoldings:')) return 3;
|
||||||
|
if (key.startsWith('yfinance:adjustments:')) return 4;
|
||||||
|
return 5;
|
||||||
|
};
|
||||||
const jobs = this._db.prepare(
|
const jobs = this._db.prepare(
|
||||||
`SELECT key, retry_count, backoff_until, scheduled_for FROM adapter_queue
|
`SELECT key, retry_count, backoff_until, scheduled_for, last_attempt FROM adapter_queue
|
||||||
WHERE status IN ('pending','backoff')
|
WHERE status IN ('pending','backoff')`,
|
||||||
ORDER BY
|
).all() as Array<{ key: string; retry_count: number; backoff_until?: string | null; scheduled_for?: string | null; last_attempt?: string | null }>;
|
||||||
CASE
|
|
||||||
WHEN key LIKE 'yfinance:quote:%' THEN 0
|
// Tier-aware sort: portfolio symbols (T0) first, then T1, T2, T3.
|
||||||
WHEN key LIKE 'yfinance:candles:%' THEN 1
|
// Within each tier: quote > candles > symbol > topHoldings > adjustments > other.
|
||||||
WHEN key LIKE 'yfinance:symbol:%' THEN 2
|
// Within each kind: never-attempted first, then oldest first.
|
||||||
WHEN key LIKE 'yfinance:topHoldings:%' THEN 3
|
jobs.sort((a, b) => {
|
||||||
WHEN key LIKE 'yfinance:adjustments:%' THEN 4
|
const ta = tierMap.get(symFromKey(a.key)) ?? 99;
|
||||||
ELSE 5
|
const tb = tierMap.get(symFromKey(b.key)) ?? 99;
|
||||||
END,
|
if (ta !== tb) return ta - tb;
|
||||||
(last_attempt IS NULL) DESC,
|
const ka = kindRank(a.key);
|
||||||
last_attempt ASC`,
|
const kb = kindRank(b.key);
|
||||||
).all() as Array<{ key: string; retry_count: number; backoff_until?: string | null; scheduled_for?: string | null }>;
|
if (ka !== kb) return ka - kb;
|
||||||
|
const na = a.last_attempt == null ? 0 : 1;
|
||||||
|
const nb = b.last_attempt == null ? 0 : 1;
|
||||||
|
if (na !== nb) return na - nb;
|
||||||
|
if (a.last_attempt && b.last_attempt) return a.last_attempt.localeCompare(b.last_attempt);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backlog pressure mode: when a critical quote backlog exists, defer
|
||||||
|
// non-critical yfinance kinds so watchlist prices land first. Without
|
||||||
|
// this, chain/shortinterest/topHoldings jobs steal the (serial) yfinance
|
||||||
|
// source chain and quotes can stall for 26+ hours behind a 160-job pile.
|
||||||
|
// Threshold: > 25 pending quote jobs means > 1 drain cycle of quotes alone.
|
||||||
|
const pendingQuotes = this._db.prepare(
|
||||||
|
"SELECT COUNT(*) AS c FROM adapter_queue WHERE status='pending' AND key LIKE 'yfinance:quote:%'",
|
||||||
|
).get() as { c: number };
|
||||||
|
const backlogPressure = pendingQuotes.c > 25;
|
||||||
|
const NON_CRITICAL_YF_KINDS = new Set([
|
||||||
|
'chain', 'shortinterest', 'dividendFundamentals',
|
||||||
|
'topHoldings', 'expiry_dates', 'symbol', 'adjustments',
|
||||||
|
]);
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
const kindUsed: Record<string, number> = {};
|
const kindUsed: Record<string, number> = {};
|
||||||
@@ -392,6 +451,11 @@ export class AdapterQueue implements CacheScheduler {
|
|||||||
const used = kindUsed[kind] ?? 0;
|
const used = kindUsed[kind] ?? 0;
|
||||||
if (used >= kindBudget) continue;
|
if (used >= kindBudget) continue;
|
||||||
|
|
||||||
|
// Backlog pressure: defer non-critical yfinance kinds so quotes drain first.
|
||||||
|
if (backlogPressure && source === 'yfinance' && NON_CRITICAL_YF_KINDS.has(kind)) continue;
|
||||||
|
// Backlog pressure: skip T3 (background) symbols entirely so portfolio/alert symbols drain first.
|
||||||
|
if (backlogPressure && sym && (tierMap.get(sym) ?? 99) >= 3) continue;
|
||||||
|
|
||||||
// Source-wide cool-down: skip all jobs for this vendor until the window ends.
|
// Source-wide cool-down: skip all jobs for this vendor until the window ends.
|
||||||
if (this.isSourceCoolingDown(source, Date.now())) continue;
|
if (this.isSourceCoolingDown(source, Date.now())) continue;
|
||||||
|
|
||||||
@@ -422,6 +486,10 @@ export class AdapterQueue implements CacheScheduler {
|
|||||||
if (family && this.isVendorFamilyCoolingDown(family, Date.now())) continue;
|
if (family && this.isVendorFamilyCoolingDown(family, Date.now())) continue;
|
||||||
const kindBudget = DRAIN_KIND_BUDGET[kind] ?? DRAIN_KIND_BUDGET._default;
|
const kindBudget = DRAIN_KIND_BUDGET[kind] ?? DRAIN_KIND_BUDGET._default;
|
||||||
if ((kindUsed[kind] ?? 0) >= kindBudget) continue;
|
if ((kindUsed[kind] ?? 0) >= kindBudget) continue;
|
||||||
|
// Backlog pressure: defer non-critical yfinance kinds so quotes drain first.
|
||||||
|
if (backlogPressure && source === 'yfinance' && NON_CRITICAL_YF_KINDS.has(kind)) continue;
|
||||||
|
// Backlog pressure: skip T3 (background) symbols entirely so portfolio/alert symbols drain first.
|
||||||
|
if (backlogPressure && sym && (tierMap.get(sym) ?? 99) >= 3) continue;
|
||||||
specs.push({ key: job.key, source, family, sym, attempt: job.retry_count + 1 });
|
specs.push({ key: job.key, source, family, sym, attempt: job.retry_count + 1 });
|
||||||
processed += 1;
|
processed += 1;
|
||||||
kindUsed[kind] = (kindUsed[kind] ?? 0) + 1;
|
kindUsed[kind] = (kindUsed[kind] ?? 0) + 1;
|
||||||
@@ -454,36 +522,60 @@ export class AdapterQueue implements CacheScheduler {
|
|||||||
|
|
||||||
// Re-check cool-downs now that it is this job's turn (another path may
|
// Re-check cool-downs now that it is this job's turn (another path may
|
||||||
// have cooled the source/family while earlier jobs were running).
|
// have cooled the source/family while earlier jobs were running).
|
||||||
if (this.isSourceCoolingDown(source, Date.now())) return;
|
if (this.isSourceCoolingDown(source, Date.now())) {
|
||||||
if (family && this.isVendorFamilyCoolingDown(family, Date.now())) return;
|
this._db.prepare(
|
||||||
|
"UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=?, error=? WHERE key=?",
|
||||||
|
).run(new Date().toISOString(), attempt, new Date(Date.now() + 30_000).toISOString(), 'source cooling', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (family && this.isVendorFamilyCoolingDown(family, Date.now())) {
|
||||||
|
this._db.prepare(
|
||||||
|
"UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=?, error=? WHERE key=?",
|
||||||
|
).run(new Date().toISOString(), attempt, new Date(Date.now() + 30_000).toISOString(), 'vendor family cooling', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const last = this._lastFetchAt[source] ?? 0;
|
const last = this._lastFetchAt[source] ?? 0;
|
||||||
const wait = (this._rate[source] ?? 0) - (Date.now() - last);
|
const wait = (this._rate[source] ?? 0) - (Date.now() - last);
|
||||||
if (wait > 0) await sleep(wait);
|
if (wait > 0) await sleep(wait);
|
||||||
|
|
||||||
// Re-check cool-down after sleep (another path may have set it).
|
// Re-check cool-down after sleep (another path may have set it).
|
||||||
if (this.isSourceCoolingDown(source, Date.now())) return;
|
if (this.isSourceCoolingDown(source, Date.now())) {
|
||||||
if (family && this.isVendorFamilyCoolingDown(family, Date.now())) return;
|
this._db.prepare(
|
||||||
|
"UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=?, error=? WHERE key=?",
|
||||||
|
).run(new Date().toISOString(), attempt, new Date(Date.now() + 30_000).toISOString(), 'source cooling', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (family && this.isVendorFamilyCoolingDown(family, Date.now())) {
|
||||||
|
this._db.prepare(
|
||||||
|
"UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=?, error=? WHERE key=?",
|
||||||
|
).run(new Date().toISOString(), attempt, new Date(Date.now() + 30_000).toISOString(), 'vendor family cooling', key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._lastFetchAt[source] = Date.now();
|
this._lastFetchAt[source] = Date.now();
|
||||||
this._setStatus(key, 'in_flight');
|
this._setStatus(key, 'in_flight');
|
||||||
try {
|
try {
|
||||||
const res = await adapter.fetchOne(key);
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
const fetchPromise = adapter.fetchOne(key);
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`fetchOne timeout after ${FETCH_TIMEOUT_MS}ms for ${key}`)), FETCH_TIMEOUT_MS),
|
||||||
|
);
|
||||||
|
const res = await Promise.race([fetchPromise, timeoutPromise]);
|
||||||
try { await this._cache?.set(key, res.value, res.ttlClass, res.provenance); } catch { /* adapter may persist directly */ }
|
try { await this._cache?.set(key, res.value, res.ttlClass, res.provenance); } catch { /* adapter may persist directly */ }
|
||||||
this.clearSourceCooldown(source);
|
this.clearSourceCooldown(source);
|
||||||
this._db.prepare("UPDATE adapter_queue SET status='done', last_attempt=?, error=NULL WHERE key=?").run(new Date().toISOString(), key);
|
this._db.prepare("UPDATE adapter_queue SET status='done', last_attempt=?, error=NULL WHERE key=?").run(new Date().toISOString(), key);
|
||||||
// After timeline posts land in x_cookie_posts, re-materialize fund captures.
|
// After timeline posts land in x_cookie_posts, re-materialize fund captures.
|
||||||
// (Schedule-time ingest runs *before* jobs finish and misses new posts.)
|
// (Schedule-time ingest runs *before* jobs finish and misses new posts.)
|
||||||
if (key.startsWith('x:timeline:')) {
|
if (key.startsWith('x:timeline:')) {
|
||||||
|
const handle = key.slice('x:timeline:'.length);
|
||||||
try {
|
try {
|
||||||
const { ingestAllFundCaptures } = await import('../services/captureIngest.ts');
|
const { ingestByHandle } = await import('../services/captureIngest.ts');
|
||||||
const stats = ingestAllFundCaptures(this._db);
|
const s2 = ingestByHandle(this._db, handle);
|
||||||
for (const s2 of stats) {
|
if (s2 && (s2.inserted > 0 || s2.captures > 0)) {
|
||||||
if (s2.inserted > 0 || s2.captures > 0) {
|
console.log(
|
||||||
console.log(
|
`[x-capture] post-timeline ${s2.fundId}: ${s2.captures} captures (${s2.inserted} new, ${s2.refreshed} refreshed)`,
|
||||||
`[x-capture] post-timeline ${s2.fundId}: ${s2.captures} captures (${s2.inserted} new, ${s2.refreshed} refreshed)`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (capErr) {
|
} catch (capErr) {
|
||||||
console.error('[x-capture] post-timeline ingest failed:', capErr instanceof Error ? capErr.message : capErr);
|
console.error('[x-capture] post-timeline ingest failed:', capErr instanceof Error ? capErr.message : capErr);
|
||||||
@@ -610,6 +702,14 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret
|
|||||||
return [...new Set([...pins, ...rest])];
|
return [...new Set([...pins, ...rest])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Symbols at a specific tier — used by per-tier schedule branches. */
|
||||||
|
private demandSymbolsByTier(tier: number): string[] {
|
||||||
|
const rows = this._db.prepare(
|
||||||
|
'SELECT symbol FROM symbol_demand WHERE tier = ? AND (in_demand = 1 OR COALESCE(system_pin, 0) = 1) ORDER BY symbol',
|
||||||
|
).all(tier) as Array<{ symbol: string }>;
|
||||||
|
return rows.filter((r) => !this.isSymbolQuarantined(r.symbol)).map((r) => r.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEC equity-filings only touch equity-like tickers. ETFs/indexes/crypto/fx
|
* SEC equity-filings only touch equity-like tickers. ETFs/indexes/crypto/fx
|
||||||
* never file 13F or SC 13G, so enqueueing them just manufactures a permanent
|
* never file 13F or SC 13G, so enqueueing them just manufactures a permanent
|
||||||
@@ -629,6 +729,10 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret
|
|||||||
}
|
}
|
||||||
|
|
||||||
async enqueueDueSchedules(): Promise<void> {
|
async enqueueDueSchedules(): Promise<void> {
|
||||||
|
// Recompute symbol tiers from source tables (portfolio/alerts/watchlists/page views).
|
||||||
|
// Self-healing: closes a holding → symbol decays to background on next tick.
|
||||||
|
this.recomputeDemandTiers();
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const due = this._db.prepare("SELECT * FROM queue_schedules WHERE next_enqueue IS NOT NULL AND next_enqueue <= ?").all(now) as Array<{ source_kind: string; interval_ms: number; last_enqueued: string | null; next_enqueue: string | null }>;
|
const due = this._db.prepare("SELECT * FROM queue_schedules WHERE next_enqueue IS NOT NULL AND next_enqueue <= ?").all(now) as Array<{ source_kind: string; interval_ms: number; last_enqueued: string | null; next_enqueue: string | null }>;
|
||||||
for (const s of due) {
|
for (const s of due) {
|
||||||
@@ -663,12 +767,33 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret
|
|||||||
} else if (s.source_kind === 'sec-tickers') {
|
} else if (s.source_kind === 'sec-tickers') {
|
||||||
await this.queue('sec-tickers:companyTickers:latest');
|
await this.queue('sec-tickers:companyTickers:latest');
|
||||||
} else if (s.source_kind === 'yfinance' || s.source_kind === 'yfinance-quote') {
|
} else if (s.source_kind === 'yfinance' || s.source_kind === 'yfinance-quote') {
|
||||||
// Legacy 'yfinance' treated as quote tier
|
// Legacy 'yfinance' / 'yfinance-quote' treated as quote tier (all demand symbols)
|
||||||
for (const sym of symbols) {
|
for (const sym of symbols) {
|
||||||
if (needsQuoteRefresh(d, sym)) {
|
if (needsQuoteRefresh(d, sym)) {
|
||||||
await this.queue(`yfinance:quote:${sym}`);
|
await this.queue(`yfinance:quote:${sym}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (s.source_kind === 'yfinance-quote-portfolio') {
|
||||||
|
// Tier 0: portfolio holdings — tightest TTL, highest drain priority
|
||||||
|
for (const sym of this.demandSymbolsByTier(0)) {
|
||||||
|
if (needsTieredQuoteRefresh(d, sym, 0)) {
|
||||||
|
await this.queue(`yfinance:quote:${sym}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (s.source_kind === 'yfinance-quote-priority') {
|
||||||
|
// Tier 1: alert-critical symbols
|
||||||
|
for (const sym of this.demandSymbolsByTier(1)) {
|
||||||
|
if (needsTieredQuoteRefresh(d, sym, 1)) {
|
||||||
|
await this.queue(`yfinance:quote:${sym}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (s.source_kind === 'yfinance-quote-watched') {
|
||||||
|
// Tier 2: user watchlist symbols + recently viewed pages
|
||||||
|
for (const sym of this.demandSymbolsByTier(2)) {
|
||||||
|
if (needsTieredQuoteRefresh(d, sym, 2)) {
|
||||||
|
await this.queue(`yfinance:quote:${sym}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (s.source_kind === 'yfinance-eod') {
|
} else if (s.source_kind === 'yfinance-eod') {
|
||||||
for (const sym of symbols) {
|
for (const sym of symbols) {
|
||||||
if (needsCandleRefresh(d, sym)) {
|
if (needsCandleRefresh(d, sym)) {
|
||||||
@@ -747,7 +872,12 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Alert-critical: re-queue SEC for demand symbols with broken/stale institutional pipeline.
|
// Alert-critical: re-queue SEC for demand symbols with broken/stale institutional pipeline.
|
||||||
await this.requeueUnhealthySecSymbols();
|
// Skip if SEC schedule is disabled (next_enqueue set far in the future).
|
||||||
|
const secSchedule = this._db.prepare("SELECT next_enqueue FROM queue_schedules WHERE source_kind='sec-fetch'").get() as { next_enqueue: string | null } | undefined;
|
||||||
|
const secDisabled = secSchedule?.next_enqueue && Date.parse(secSchedule.next_enqueue) > Date.now() + 365 * 86_400_000;
|
||||||
|
if (!secDisabled) {
|
||||||
|
await this.requeueUnhealthySecSymbols();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -868,6 +998,56 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret
|
|||||||
return enqueued;
|
return enqueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute tier from source tables. Self-healing: if a holding is closed
|
||||||
|
* or an alert subscription removed, the symbol naturally decays to background.
|
||||||
|
* Tier 0 = portfolio (any open holding), 1 = alert-critical, 2 = watched
|
||||||
|
* (user watchlist OR recently viewed page), 3 = background.
|
||||||
|
*/
|
||||||
|
recomputeDemandTiers(): void {
|
||||||
|
const now = new Date();
|
||||||
|
const VIEWED_DECAY_MS = 10 * 60_000; // page-view bump decays after 10 min
|
||||||
|
const cutoff = new Date(now.getTime() - VIEWED_DECAY_MS).toISOString();
|
||||||
|
|
||||||
|
// Set in_demand=1 for all symbols that are still relevant (not orphaned).
|
||||||
|
// Orphaned symbols: refcount=0, system_pin=0, no portfolio, no alert, no watchlist.
|
||||||
|
// For those, set tier=3 and in_demand=0.
|
||||||
|
this._db.prepare(`
|
||||||
|
UPDATE symbol_demand SET tier = (
|
||||||
|
SELECT MIN(t) FROM (
|
||||||
|
SELECT 0 AS t WHERE EXISTS (
|
||||||
|
SELECT 1 FROM portfolio_holdings WHERE symbol = symbol_demand.symbol AND status = 'open'
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1 WHERE EXISTS (
|
||||||
|
SELECT 1 FROM alerts WHERE symbol = symbol_demand.symbol AND enabled = 1
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 2 WHERE EXISTS (
|
||||||
|
SELECT 1 FROM watchlists WHERE kind = 'user' AND symbols LIKE '%' || symbol_demand.symbol || '%'
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 2 WHERE symbol_demand.last_viewed_at IS NOT NULL AND symbol_demand.last_viewed_at > ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`).run(cutoff);
|
||||||
|
|
||||||
|
// System pins that aren't boosted above T3 by portfolio/alerts/watchlists
|
||||||
|
// stay at T3 (background, no schedule). They still exist in demand but
|
||||||
|
// don't get scheduled - only fetched on-demand via page view bump.
|
||||||
|
// Symbols with refcount=0, system_pin=0, and no source match become T3
|
||||||
|
// and get in_demand=0 so they don't clutter the queue.
|
||||||
|
this._db.prepare(`
|
||||||
|
UPDATE symbol_demand SET in_demand = 0
|
||||||
|
WHERE tier = 3
|
||||||
|
AND COALESCE(system_pin, 0) = 0
|
||||||
|
AND COALESCE(refcount, 0) = 0
|
||||||
|
AND last_viewed_at IS NULL
|
||||||
|
`).run();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed / migrate schedules to tiered yfinance plan.
|
* Seed / migrate schedules to tiered yfinance plan.
|
||||||
* Safe to call every boot: upserts missing tiers; migrates legacy single `yfinance` row.
|
* Safe to call every boot: upserts missing tiers; migrates legacy single `yfinance` row.
|
||||||
@@ -880,10 +1060,10 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret
|
|||||||
for (const [kind, ms] of defaults) {
|
for (const [kind, ms] of defaults) {
|
||||||
insert.run(kind, ms, null, new Date(Date.now() + Math.min(ms, 60_000)).toISOString());
|
insert.run(kind, ms, null, new Date(Date.now() + Math.min(ms, 60_000)).toISOString());
|
||||||
}
|
}
|
||||||
// Drop legacy monolithic yfinance schedule if tiered ones exist
|
// Drop legacy monolithic yfinance-quote schedule if tiered ones exist
|
||||||
const hasQuote = this._db.prepare("SELECT 1 FROM queue_schedules WHERE source_kind='yfinance-quote'").get();
|
const hasTierSchedule = this._db.prepare("SELECT 1 FROM queue_schedules WHERE source_kind='yfinance-quote-portfolio'").get();
|
||||||
if (hasQuote) {
|
if (hasTierSchedule) {
|
||||||
this._db.prepare("DELETE FROM queue_schedules WHERE source_kind='yfinance'").run();
|
this._db.prepare("DELETE FROM queue_schedules WHERE source_kind IN ('yfinance', 'yfinance-quote')").run();
|
||||||
}
|
}
|
||||||
// Ensure finra-bulk is not auto-seeded (403); delete if present from old seeds
|
// Ensure finra-bulk is not auto-seeded (403); delete if present from old seeds
|
||||||
this._db.prepare("DELETE FROM queue_schedules WHERE source_kind='finra-bulk'").run();
|
this._db.prepare("DELETE FROM queue_schedules WHERE source_kind='finra-bulk'").run();
|
||||||
|
|||||||
@@ -213,10 +213,13 @@ test('seedDefaultSchedules creates tiered yfinance schedules and drops legacy',
|
|||||||
queue.seedDefaultSchedules();
|
queue.seedDefaultSchedules();
|
||||||
const kinds = (db.prepare('SELECT source_kind FROM queue_schedules ORDER BY source_kind').all() as Array<{ source_kind: string }>)
|
const kinds = (db.prepare('SELECT source_kind FROM queue_schedules ORDER BY source_kind').all() as Array<{ source_kind: string }>)
|
||||||
.map((r) => r.source_kind);
|
.map((r) => r.source_kind);
|
||||||
assert.ok(kinds.includes('yfinance-quote'));
|
assert.ok(kinds.includes('yfinance-quote-portfolio'));
|
||||||
|
assert.ok(kinds.includes('yfinance-quote-priority'));
|
||||||
|
assert.ok(kinds.includes('yfinance-quote-watched'));
|
||||||
assert.ok(kinds.includes('yfinance-eod'));
|
assert.ok(kinds.includes('yfinance-eod'));
|
||||||
assert.ok(kinds.includes('yfinance-meta'));
|
assert.ok(kinds.includes('yfinance-meta'));
|
||||||
assert.ok(kinds.includes('yfinance-holdings'));
|
assert.ok(kinds.includes('yfinance-holdings'));
|
||||||
|
assert.ok(!kinds.includes('yfinance-quote'), 'legacy monolithic yfinance-quote schedule removed');
|
||||||
assert.ok(!kinds.includes('yfinance'), 'legacy monolithic yfinance schedule removed');
|
assert.ok(!kinds.includes('yfinance'), 'legacy monolithic yfinance schedule removed');
|
||||||
assert.ok(!kinds.includes('finra-bulk'), 'finra-bulk must not auto-seed');
|
assert.ok(!kinds.includes('finra-bulk'), 'finra-bulk must not auto-seed');
|
||||||
});
|
});
|
||||||
@@ -228,12 +231,13 @@ test('TTL-aware quote schedule only enqueues stale quotes', async () => {
|
|||||||
await cache.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 100 } as import('../../cache/CacheRepository.ts').Quote, 'live_quote', {
|
await cache.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 100 } as import('../../cache/CacheRepository.ts').Quote, 'live_quote', {
|
||||||
fetchedAt: new Date().toISOString(), sourceKind: 'yfinance',
|
fetchedAt: new Date().toISOString(), sourceKind: 'yfinance',
|
||||||
});
|
});
|
||||||
await cache.subscribe('NVDA', 'equity');
|
// Bump both to watched tier (2) — the schedule that serves on-demand symbols
|
||||||
await cache.subscribe('AAPL', 'equity'); // no quote → stale
|
await cache.bumpToWatched('NVDA', 'equity');
|
||||||
|
await cache.bumpToWatched('AAPL', 'equity'); // no quote → stale
|
||||||
// Clear any seed jobs
|
// Clear any seed jobs
|
||||||
db.prepare("DELETE FROM adapter_queue").run();
|
db.prepare("DELETE FROM adapter_queue").run();
|
||||||
// Force quote schedule due
|
// Force quote schedule due
|
||||||
db.prepare("UPDATE queue_schedules SET next_enqueue=? WHERE source_kind='yfinance-quote'")
|
db.prepare("UPDATE queue_schedules SET next_enqueue=? WHERE source_kind='yfinance-quote-watched'")
|
||||||
.run(new Date(Date.now() - 1000).toISOString());
|
.run(new Date(Date.now() - 1000).toISOString());
|
||||||
await queue.enqueueDueSchedules();
|
await queue.enqueueDueSchedules();
|
||||||
const pending = (db.prepare("SELECT key FROM adapter_queue WHERE status='pending'").all() as Array<{ key: string }>)
|
const pending = (db.prepare("SELECT key FROM adapter_queue WHERE status='pending'").all() as Array<{ key: string }>)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { SourceKind } from '../cache/CacheRepository.ts';
|
|||||||
/** Steady-state min gap between successful fetches for a source (ms). */
|
/** Steady-state min gap between successful fetches for a source (ms). */
|
||||||
export const DEFAULT_SOURCE_MIN_INTERVAL_MS: Record<SourceKind, number> = {
|
export const DEFAULT_SOURCE_MIN_INTERVAL_MS: Record<SourceKind, number> = {
|
||||||
// Job-level spacing (AdapterQueue). Per-request spacing is owned by vendorGate.
|
// Job-level spacing (AdapterQueue). Per-request spacing is owned by vendorGate.
|
||||||
yfinance: 2_500,
|
yfinance: 2_000,
|
||||||
sec: 400,
|
sec: 400,
|
||||||
'sec-fetch': 8_000,
|
'sec-fetch': 8_000,
|
||||||
'sec-sc-fetch': 8_000,
|
'sec-sc-fetch': 8_000,
|
||||||
@@ -204,6 +204,22 @@ export function quoteTtlMs(now = new Date()): number {
|
|||||||
return 15 * 60_000;
|
return 15 * 60_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-tier quote TTL. Portfolio (T0) gets the tightest freshness target. */
|
||||||
|
export function tieredQuoteTtlMs(tier: number, now = new Date()): number {
|
||||||
|
if (tier === 0) {
|
||||||
|
// Portfolio: 60s RTH, 120s extended, 600s closed
|
||||||
|
if (isUsRegularHours(now)) return 60_000;
|
||||||
|
if (isUsExtendedHours(now)) return 2 * 60_000;
|
||||||
|
return 10 * 60_000;
|
||||||
|
}
|
||||||
|
if (tier === 1) {
|
||||||
|
// Alert-critical: same as current quoteTtlMs
|
||||||
|
return quoteTtlMs(now);
|
||||||
|
}
|
||||||
|
// Watched (T2): 10 min flat
|
||||||
|
return 10 * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
/** Candle bar considered current if last bar is within this age (ms). */
|
/** Candle bar considered current if last bar is within this age (ms). */
|
||||||
export const CANDLE_FRESH_MS = 36 * 60 * 60_000; // 36h covers weekends lightly
|
export const CANDLE_FRESH_MS = 36 * 60 * 60_000; // 36h covers weekends lightly
|
||||||
|
|
||||||
@@ -212,7 +228,9 @@ export const SYMBOL_META_INCOMPLETE_TTL_MS = 6 * 60 * 60_000; // 6h
|
|||||||
|
|
||||||
/** Default schedule intervals (ms). */
|
/** Default schedule intervals (ms). */
|
||||||
export const SCHEDULE_INTERVALS = {
|
export const SCHEDULE_INTERVALS = {
|
||||||
'yfinance-quote': 5 * 60_000, // 5 min
|
'yfinance-quote-portfolio': 60_000, // 1 min — portfolio holdings
|
||||||
|
'yfinance-quote-priority': 5 * 60_000, // 5 min — alert-critical symbols
|
||||||
|
'yfinance-quote-watched': 10 * 60_000, // 10 min — user watchlist symbols
|
||||||
'yfinance-eod': 6 * 60 * 60_000, // 6h (incremental candles)
|
'yfinance-eod': 6 * 60 * 60_000, // 6h (incremental candles)
|
||||||
'yfinance-meta': 24 * 60 * 60_000, // daily symbol meta
|
'yfinance-meta': 24 * 60 * 60_000, // daily symbol meta
|
||||||
'yfinance-holdings': 7 * 24 * 60 * 60_000, // weekly ETF holdings
|
'yfinance-holdings': 7 * 24 * 60 * 60_000, // weekly ETF holdings
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ function normalizePostedAtDate(postedAt: string): string {
|
|||||||
return postedAt.slice(0, 10);
|
return postedAt.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Materialize captures for a specific handle (called after its timeline job completes). */
|
||||||
|
export function ingestByHandle(db: DatabaseSync, handle: string): CaptureIngestStats | null {
|
||||||
|
const fund = db.prepare('SELECT id FROM tracked_funds WHERE lower(x_handle) = lower(?) AND enabled = 1').get(handle) as { id: string } | undefined;
|
||||||
|
if (!fund) return null;
|
||||||
|
return ingestFundCaptures(db, fund.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Materialize captures for every enabled tracked fund with an x_handle. */
|
/** Materialize captures for every enabled tracked fund with an x_handle. */
|
||||||
export function ingestAllFundCaptures(db: DatabaseSync): CaptureIngestStats[] {
|
export function ingestAllFundCaptures(db: DatabaseSync): CaptureIngestStats[] {
|
||||||
return listTrackedFundHandles(db)
|
return listTrackedFundHandles(db)
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ function seedBuiltIns(): void {
|
|||||||
registerVendorIntegration({
|
registerVendorIntegration({
|
||||||
family: 'yfinance',
|
family: 'yfinance',
|
||||||
sourceKinds: ['yfinance', 'yfinance-quote', 'yfinance-eod', 'yfinance-meta', 'yfinance-holdings'],
|
sourceKinds: ['yfinance', 'yfinance-quote', 'yfinance-eod', 'yfinance-meta', 'yfinance-holdings'],
|
||||||
policy: { minIntervalMs: 400, maxInflight: 1, drainJobBudget: 3, hostPattern: 'yahoo|finance\\.yahoo' },
|
policy: { minIntervalMs: 400, maxInflight: 1, drainJobBudget: 5, hostPattern: 'yahoo|finance\\.yahoo' },
|
||||||
});
|
});
|
||||||
registerVendorIntegration({
|
registerVendorIntegration({
|
||||||
family: 'sec',
|
family: 'sec',
|
||||||
|
|||||||
+121
-14
@@ -38,6 +38,9 @@ import { CONFLUENCE_SLOT_IDS } from '../confluence/confluenceSlots.ts';
|
|||||||
import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository.ts';
|
import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository.ts';
|
||||||
import { runSlotBacktest, signalHistoryToStats, type ConfluenceFireEvent } from '../confluence/confluenceBacktest.ts';
|
import { runSlotBacktest, signalHistoryToStats, type ConfluenceFireEvent } from '../confluence/confluenceBacktest.ts';
|
||||||
import { detectPictureChange } from '../confluence/confluenceRack.ts';
|
import { detectPictureChange } from '../confluence/confluenceRack.ts';
|
||||||
|
import { runCorridorBacktest, aggregateBacktest } from '../analysis/corridorBacktest.ts';
|
||||||
|
import { runConfluenceEvaluationCycle } from '../confluence/confluenceEngine.ts';
|
||||||
|
import { CorridorRepository } from '../db/corridorRepository.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// X cookie credential helpers. Loads AES-256-GCM encrypted ct0/auth_token from
|
// X cookie credential helpers. Loads AES-256-GCM encrypted ct0/auth_token from
|
||||||
@@ -452,8 +455,8 @@ const marketRouter = router({
|
|||||||
const { classifyRegime } = await import('../macro/MacroRegime.ts');
|
const { classifyRegime } = await import('../macro/MacroRegime.ts');
|
||||||
|
|
||||||
const symbol = input.symbol.toUpperCase();
|
const symbol = input.symbol.toUpperCase();
|
||||||
try { await ctx.cache.ensureInDemand(symbol, 'equity'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(symbol, 'equity'); } catch { /* ignore */ }
|
||||||
try { await ctx.cache.ensureInDemand(BENCHMARK_SYMBOL, 'etf'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(BENCHMARK_SYMBOL, 'etf'); } catch { /* ignore */ }
|
||||||
|
|
||||||
const metaEntry = await ctx.cache.get<SymbolMeta>(`yfinance:symbol:${symbol}`);
|
const metaEntry = await ctx.cache.get<SymbolMeta>(`yfinance:symbol:${symbol}`);
|
||||||
const meta = metaEntry.value;
|
const meta = metaEntry.value;
|
||||||
@@ -516,7 +519,7 @@ const marketRouter = router({
|
|||||||
|
|
||||||
for (const s of symbolsToLoad) {
|
for (const s of symbolsToLoad) {
|
||||||
const kind = s === BENCHMARK_SYMBOL || MARKET_ROTATION_UNIVERSE.some((u) => u.symbol === s) ? 'etf' : 'equity';
|
const kind = s === BENCHMARK_SYMBOL || MARKET_ROTATION_UNIVERSE.some((u) => u.symbol === s) ? 'etf' : 'equity';
|
||||||
try { await ctx.cache.ensureInDemand(s, kind); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(s, kind); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const candleKeys = symbolsToLoad.map((s) => `yfinance:candles:${s}:1d`);
|
const candleKeys = symbolsToLoad.map((s) => `yfinance:candles:${s}:1d`);
|
||||||
@@ -776,8 +779,8 @@ const marketRouter = router({
|
|||||||
// SPY trend + VIX from cache/queue only (ADR-0009: no live Yahoo on request path).
|
// SPY trend + VIX from cache/queue only (ADR-0009: no live Yahoo on request path).
|
||||||
let spyCandles: PriceCandle[] = [];
|
let spyCandles: PriceCandle[] = [];
|
||||||
try {
|
try {
|
||||||
try { await ctx.cache.ensureInDemand('SPY', 'etf'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched('SPY', 'etf'); } catch { /* ignore */ }
|
||||||
try { await ctx.cache.ensureInDemand('^VIX', 'index'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched('^VIX', 'index'); } catch { /* ignore */ }
|
||||||
const spyEntry = await ctx.cache.get<PriceCandle[]>('yfinance:candles:SPY:1d');
|
const spyEntry = await ctx.cache.get<PriceCandle[]>('yfinance:candles:SPY:1d');
|
||||||
spyCandles = (spyEntry?.value ?? []) as PriceCandle[];
|
spyCandles = (spyEntry?.value ?? []) as PriceCandle[];
|
||||||
factors.spy1M = totalReturnPct(spyCandles, 30 * 86_400_000);
|
factors.spy1M = totalReturnPct(spyCandles, 30 * 86_400_000);
|
||||||
@@ -981,7 +984,7 @@ const marketRouter = router({
|
|||||||
|
|
||||||
const symbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((s) => s.symbol)];
|
const symbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((s) => s.symbol)];
|
||||||
for (const sym of symbols) {
|
for (const sym of symbols) {
|
||||||
try { await ctx.cache.ensureInDemand(sym, 'etf'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(sym, 'etf'); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
|
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
|
||||||
@@ -1137,7 +1140,7 @@ const marketRouter = router({
|
|||||||
|
|
||||||
const symbols = [BENCHMARK_SYMBOL, ...dedupedDefs.map((s) => s.symbol)];
|
const symbols = [BENCHMARK_SYMBOL, ...dedupedDefs.map((s) => s.symbol)];
|
||||||
for (const sym of symbols) {
|
for (const sym of symbols) {
|
||||||
try { await ctx.cache.ensureInDemand(sym, 'etf'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(sym, 'etf'); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
|
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
|
||||||
@@ -1281,7 +1284,7 @@ const marketRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { buildSeasonalitySnapshot, upcomingSimpleEvents } = await import('../analysis/seasonality.ts');
|
const { buildSeasonalitySnapshot, upcomingSimpleEvents } = await import('../analysis/seasonality.ts');
|
||||||
const symbol = (input?.symbol ?? 'SPY').toUpperCase();
|
const symbol = (input?.symbol ?? 'SPY').toUpperCase();
|
||||||
try { await ctx.cache.ensureInDemand(symbol, symbol === 'SPY' ? 'etf' : 'equity'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(symbol, symbol === 'SPY' ? 'etf' : 'equity'); } catch { /* ignore */ }
|
||||||
const entry = await ctx.cache.get<PriceCandle[]>(`yfinance:candles:${symbol}:1d`);
|
const entry = await ctx.cache.get<PriceCandle[]>(`yfinance:candles:${symbol}:1d`);
|
||||||
const candles = (entry?.value ?? []) as PriceCandle[];
|
const candles = (entry?.value ?? []) as PriceCandle[];
|
||||||
const snapshot = buildSeasonalitySnapshot(
|
const snapshot = buildSeasonalitySnapshot(
|
||||||
@@ -1429,7 +1432,7 @@ const marketRouter = router({
|
|||||||
for (const sym of symbols) {
|
for (const sym of symbols) {
|
||||||
if (quoteMap.has(sym) && quoteMap.get(sym)!.price != null) continue;
|
if (quoteMap.has(sym) && quoteMap.get(sym)!.price != null) continue;
|
||||||
if (sym.includes('.') || sym.length > 5) continue;
|
if (sym.includes('.') || sym.length > 5) continue;
|
||||||
try { await ctx.cache.ensureInDemand(sym, 'equity'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(sym, 'equity'); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1467,7 +1470,7 @@ const marketRouter = router({
|
|||||||
ctx.db.prepare(
|
ctx.db.prepare(
|
||||||
'INSERT INTO rotation_custom_symbols (id, owner_id, symbol, name, grp, created_at) VALUES (?,?,?,?,?,?)'
|
'INSERT INTO rotation_custom_symbols (id, owner_id, symbol, name, grp, created_at) VALUES (?,?,?,?,?,?)'
|
||||||
).run(crypto.randomUUID(), userId, input.symbol, input.name ?? input.symbol, input.group ?? 'Custom', new Date().toISOString());
|
).run(crypto.randomUUID(), userId, input.symbol, input.name ?? input.symbol, input.group ?? 'Custom', new Date().toISOString());
|
||||||
try { await ctx.cache.ensureInDemand(input.symbol, 'etf'); } catch { /* ignore */ }
|
try { await ctx.cache.bumpToWatched(input.symbol, 'etf'); } catch { /* ignore */ }
|
||||||
return { added: true };
|
return { added: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -3399,7 +3402,7 @@ const dealerStudyRouter = router({
|
|||||||
);
|
);
|
||||||
// Ensure daily candles are in demand for later grading (ADR-0009).
|
// Ensure daily candles are in demand for later grading (ADR-0009).
|
||||||
try {
|
try {
|
||||||
await ctx.cache.ensureInDemand(input.symbol.toUpperCase(), 'equity');
|
await ctx.cache.bumpToWatched(input.symbol.toUpperCase(), 'equity');
|
||||||
} catch { /* optional */ }
|
} catch { /* optional */ }
|
||||||
return { ok: true as const, id, loggedAt };
|
return { ok: true as const, id, loggedAt };
|
||||||
}),
|
}),
|
||||||
@@ -3835,7 +3838,7 @@ const mentorLedgerRouter = router({
|
|||||||
: row.logged_at;
|
: row.logged_at;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ctx.cache.ensureInDemand(row.symbol, 'equity');
|
await ctx.cache.bumpToWatched(row.symbol, 'equity');
|
||||||
} catch { /* optional */ }
|
} catch { /* optional */ }
|
||||||
|
|
||||||
const candles = ctx.db.prepare(
|
const candles = ctx.db.prepare(
|
||||||
@@ -5382,7 +5385,7 @@ const mirrorRouter = router({
|
|||||||
// ─── Confluence Signal Engine (M22) ─────────────────────────────────────────
|
// ─── Confluence Signal Engine (M22) ─────────────────────────────────────────
|
||||||
|
|
||||||
const confluenceRouter = router({
|
const confluenceRouter = router({
|
||||||
/** The read-only 34-slot catalog, grouped by family, with slot metadata. */
|
/** The read-only slot catalog, grouped by family, with slot metadata. */
|
||||||
slots: publicProcedure.query(async () => {
|
slots: publicProcedure.query(async () => {
|
||||||
return {
|
return {
|
||||||
slots: CONFLUENCE_SLOTS.map((s) => ({
|
slots: CONFLUENCE_SLOTS.map((s) => ({
|
||||||
@@ -5476,7 +5479,7 @@ const confluenceRouter = router({
|
|||||||
id: z.string().min(1).max(80).optional(),
|
id: z.string().min(1).max(80).optional(),
|
||||||
name: z.string().min(1).max(80),
|
name: z.string().min(1).max(80),
|
||||||
description: z.string().max(300).optional(),
|
description: z.string().max(300).optional(),
|
||||||
slotIds: z.array(z.string()).min(1).max(34),
|
slotIds: z.array(z.string()).min(1).max(CONFLUENCE_SLOT_IDS.length),
|
||||||
}))
|
}))
|
||||||
.mutation(({ ctx, input }) => {
|
.mutation(({ ctx, input }) => {
|
||||||
const repo = new ConfluenceRepository(ctx.db);
|
const repo = new ConfluenceRepository(ctx.db);
|
||||||
@@ -5498,6 +5501,110 @@ const confluenceRouter = router({
|
|||||||
}
|
}
|
||||||
return rack;
|
return rack;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Price Corridor (M24): valuation-corridor snapshots + backtest ledger + link
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Latest valuation-corridor snapshot for a symbol, with SPY market context. */
|
||||||
|
corridorSnapshot: publicProcedure
|
||||||
|
.input(z.object({ symbol: z.string().min(1).max(12) }))
|
||||||
|
.query(({ ctx, input }) => {
|
||||||
|
const repo = new CorridorRepository(ctx.db);
|
||||||
|
const symbol = input.symbol.toUpperCase();
|
||||||
|
const snapshot = repo.latestSnapshot(symbol);
|
||||||
|
const spy = repo.latestSnapshot('SPY');
|
||||||
|
const series = repo.snapshotsForSymbol(symbol, 90);
|
||||||
|
const corridor = (snapshot?.corridor1yLow ?? null) !== null
|
||||||
|
? {
|
||||||
|
low: snapshot!.corridor1yLow,
|
||||||
|
high: snapshot!.corridor1yHigh,
|
||||||
|
median: snapshot!.corridor1yMedian,
|
||||||
|
currentPE: snapshot!.trailingPE ?? snapshot!.forwardPE,
|
||||||
|
fairValue1y: snapshot!.fairValue1y,
|
||||||
|
impliedUpside1y: snapshot!.impliedUpside1y,
|
||||||
|
pePercentile1y: snapshot!.pePercentile1y,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
snapshot,
|
||||||
|
spy,
|
||||||
|
series,
|
||||||
|
corridor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Estuary user's corridor watchlist (tickers surfaced in the Corridor panel). */
|
||||||
|
corridorWatchlist: protectedProcedure
|
||||||
|
.query(({ ctx }) => {
|
||||||
|
const repo = new CorridorRepository(ctx.db);
|
||||||
|
return { symbols: repo.listWatchlist(ctx.userId!) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Add a ticker to the corridor watchlist. */
|
||||||
|
corridorWatchlistAdd: protectedProcedure
|
||||||
|
.input(z.object({ symbol: z.string().min(1).max(12).transform((s) => s.toUpperCase()) }))
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
const repo = new CorridorRepository(ctx.db);
|
||||||
|
repo.addToWatchlist(ctx.userId!, input.symbol);
|
||||||
|
return { symbols: repo.listWatchlist(ctx.userId!) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Remove a ticker from the corridor watchlist. */
|
||||||
|
corridorWatchlistRemove: protectedProcedure
|
||||||
|
.input(z.object({ symbol: z.string().min(1).max(12).transform((s) => s.toUpperCase()) }))
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
const repo = new CorridorRepository(ctx.db);
|
||||||
|
repo.removeFromWatchlist(ctx.userId!, input.symbol);
|
||||||
|
return { symbols: repo.listWatchlist(ctx.userId!) };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Corridor-method backtest scorecard: @alojoh's rated names vs actual forward returns. */
|
||||||
|
corridorBacktest: publicProcedure
|
||||||
|
.query(({ ctx }) => {
|
||||||
|
const repo = new CorridorRepository(ctx.db);
|
||||||
|
const rows = repo.listBacktests();
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
aggregate: aggregateBacktest(
|
||||||
|
rows.map((r) => ({
|
||||||
|
articleDate: r.articleDate,
|
||||||
|
articleId: r.articleId,
|
||||||
|
rankingType: r.rankingType as 'entry_1y' | 'entry_90d',
|
||||||
|
topSymbols: r.topSymbols,
|
||||||
|
bottomSymbols: r.bottomSymbols,
|
||||||
|
topAvgReturn: r.topAvgReturn,
|
||||||
|
bottomAvgReturn: r.bottomAvgReturn,
|
||||||
|
spread: r.spread,
|
||||||
|
isWin: r.isWin,
|
||||||
|
horizonDays: r.horizonDays,
|
||||||
|
resolvableCount: 0,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Replay the corridor-method backtest corpus against cached candles (offline; idempotent). */
|
||||||
|
corridorBacktestRun: publicProcedure
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
const { runCorridorBacktest } = await import('../analysis/corridorBacktest.ts');
|
||||||
|
const result = await runCorridorBacktest(ctx.db, async (symbol) => {
|
||||||
|
const entry = await ctx.cache.get<PriceCandle[]>(`yfinance:candles:${symbol.toUpperCase()}:1d`);
|
||||||
|
return (entry?.value ?? []) as PriceCandle[];
|
||||||
|
});
|
||||||
|
return { grades: result.grades.length, resolved: result.aggregate.resolved, aggregate: result.aggregate };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Force a confluence evaluation cycle for the current symbol now (debug/admin). */
|
||||||
|
runEvaluationNow: protectedProcedure
|
||||||
|
.input(z.object({ symbol: z.string().min(1).max(12).optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const summary = await runConfluenceEvaluationCycle(ctx.db, ctx.cache, {
|
||||||
|
symbols: input?.symbol ? [input.symbol] : undefined,
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
import { LayoutShell } from '@/components/LayoutShell';
|
import { LayoutShell } from '@/components/LayoutShell';
|
||||||
import { ConfluencePanel } from '@/components/ConfluencePanel';
|
import { ConfluencePanel } from '@/components/ConfluencePanel';
|
||||||
|
import { CorridorPanel } from '@/components/CorridorPanel';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'confluence', label: 'Slot Picture' },
|
||||||
|
{ id: 'corridor', label: 'Price Corridor' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confluence — entry/exit picture quality from the34-slot confluence rack.
|
* Confluence — entry/exit picture quality from the confluence rack, plus the
|
||||||
|
* Price Corridor view (valuation corridor + corridor-method backtest ledger).
|
||||||
* Evidence-based descriptions of a symbol's current setup (ADR-0007).
|
* Evidence-based descriptions of a symbol's current setup (ADR-0007).
|
||||||
*/
|
*/
|
||||||
export default function ConfluencePage() {
|
export default function ConfluencePage() {
|
||||||
|
const [tab, setTab] = useState<'confluence' | 'corridor'>('confluence');
|
||||||
return (
|
return (
|
||||||
<LayoutShell>
|
<LayoutShell>
|
||||||
<ConfluencePanel />
|
<div className="mb-4 flex gap-2">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`rounded border px-3 py-1.5 text-sm ${
|
||||||
|
tab === t.id
|
||||||
|
? 'border-fg-muted/40 bg-bg-secondary text-fg'
|
||||||
|
: 'border-fg-muted/15 bg-transparent text-fg-muted hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'confluence' ? <ConfluencePanel /> : <CorridorPanel />}
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,10 @@ export default function FundDetailPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedBook = [...book].sort((a, b) => b.as_of.localeCompare(a.as_of));
|
// Deduplicate to most recent entry per symbol (fixes React duplicate-key warning)
|
||||||
|
const sortedBook = [...book]
|
||||||
|
.sort((a, b) => b.as_of.localeCompare(a.as_of))
|
||||||
|
.filter((p, i, arr) => arr.findIndex((q) => q.symbol === p.symbol) === i);
|
||||||
const resetIdx = sortedBook.findIndex((p) => p.notes === 'book_reset');
|
const resetIdx = sortedBook.findIndex((p) => p.notes === 'book_reset');
|
||||||
const [tip, setTip] = useState<{ symbol: string; text: string; x: number; y: number } | null>(null);
|
const [tip, setTip] = useState<{ symbol: string; text: string; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/trpc';
|
||||||
|
import { useActiveSymbol } from '@/stores/active-symbol-store';
|
||||||
|
import { CollapsibleSection } from '@/components/CollapsibleSection';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types (subset of server types for display)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SnapshotRow {
|
||||||
|
snapshotDate: string;
|
||||||
|
currentPrice: number;
|
||||||
|
forwardPE: number | null;
|
||||||
|
trailingPE: number | null;
|
||||||
|
corridor1yHigh: number | null;
|
||||||
|
corridor1yLow: number | null;
|
||||||
|
corridor1yMedian: number | null;
|
||||||
|
fairValue1y: number | null;
|
||||||
|
impliedUpside1y: number | null;
|
||||||
|
pePercentile1y: number | null;
|
||||||
|
dataSource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CorridorData {
|
||||||
|
symbol: string;
|
||||||
|
snapshot: SnapshotRow | null;
|
||||||
|
spy: SnapshotRow | null;
|
||||||
|
series: SnapshotRow[];
|
||||||
|
corridor: {
|
||||||
|
low: number | null;
|
||||||
|
high: number | null;
|
||||||
|
median: number | null;
|
||||||
|
currentPE: number | null;
|
||||||
|
fairValue1y: number | null;
|
||||||
|
impliedUpside1y: number | null;
|
||||||
|
pePercentile1y: number | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BacktestAggregate {
|
||||||
|
entries: number;
|
||||||
|
resolved: number;
|
||||||
|
resolved1y: number;
|
||||||
|
resolved90d: number;
|
||||||
|
hitRate: number | null;
|
||||||
|
avgSpread: number | null;
|
||||||
|
bestSpread: number | null;
|
||||||
|
worstSpread: number | null;
|
||||||
|
avgTopReturn: number | null;
|
||||||
|
avgBottomReturn: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BacktestRow {
|
||||||
|
id: string;
|
||||||
|
articleDate: string;
|
||||||
|
rankingType: string;
|
||||||
|
topSymbols: string[];
|
||||||
|
bottomSymbols: string[];
|
||||||
|
topAvgReturn: number | null;
|
||||||
|
bottomAvgReturn: number | null;
|
||||||
|
spread: number | null;
|
||||||
|
isWin: boolean;
|
||||||
|
horizonDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const pct = (v: number | null | undefined, digits = 1): string =>
|
||||||
|
v === null || v === undefined || !Number.isFinite(v) ? '-' : `${(v * 100).toFixed(digits)}%`;
|
||||||
|
|
||||||
|
const num = (v: number | null | undefined, digits = 2): string =>
|
||||||
|
v === null || v === undefined || !Number.isFinite(v) ? '-' : v.toFixed(digits);
|
||||||
|
|
||||||
|
const price = (v: number | null | undefined): string =>
|
||||||
|
v === null || v === undefined || !Number.isFinite(v) ? '-' : `$${v.toFixed(2)}`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CorridorPanel() {
|
||||||
|
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||||
|
const [symbol, setSymbol] = useState(activeSymbol);
|
||||||
|
const [data, setData] = useState<CorridorData | null>(null);
|
||||||
|
const [watchlist, setWatchlist] = useState<string[]>([]);
|
||||||
|
const [addInput, setAddInput] = useState('');
|
||||||
|
const [backtest, setBacktest] = useState<{ rows: BacktestRow[]; aggregate: BacktestAggregate } | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
const loadCorridor = useCallback((sym: string) => {
|
||||||
|
if (!sym) return;
|
||||||
|
api.confluence.corridorSnapshot(sym)
|
||||||
|
.then((r) => setData(r as unknown as CorridorData))
|
||||||
|
.catch(() => setData(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadCorridor(symbol); }, [symbol, loadCorridor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.confluence.corridorWatchlist()
|
||||||
|
.then((r) => setWatchlist(r.symbols))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.confluence.corridorBacktest()
|
||||||
|
.then((r) => setBacktest(r))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToWatchlist = async () => {
|
||||||
|
const s = addInput.trim().toUpperCase();
|
||||||
|
if (!s) return;
|
||||||
|
const r = await api.confluence.corridorWatchlistAdd(s).catch(() => null);
|
||||||
|
if (r) { setWatchlist(r.symbols); setAddInput(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromWatchlist = async (s: string) => {
|
||||||
|
const r = await api.confluence.corridorWatchlistRemove(s).catch(() => null);
|
||||||
|
if (r) setWatchlist(r.symbols);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runBacktest = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
await api.confluence.corridorBacktestRun();
|
||||||
|
const r = await api.confluence.corridorBacktest();
|
||||||
|
if (r) setBacktest(r);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = data?.corridor;
|
||||||
|
const currentPE = c?.currentPE ?? null;
|
||||||
|
const pctPosition = c?.pePercentile1y ?? null;
|
||||||
|
// Position within [low, high] as a fraction (for a mini corridor bar).
|
||||||
|
const low = c?.low ?? null;
|
||||||
|
const high = c?.high ?? null;
|
||||||
|
const barPct = low !== null && high !== null && currentPE !== null && high > low
|
||||||
|
? Math.max(0, Math.min(100, ((currentPE - low) / (high - low)) * 100))
|
||||||
|
: null;
|
||||||
|
const cheap = pctPosition !== null && pctPosition < 0.33;
|
||||||
|
const stretched = pctPosition !== null && pctPosition > 0.67;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header: symbol + watchlist */}
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-fg-muted mb-1">Symbol</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={symbol}
|
||||||
|
onChange={(e) => setSymbol(e.target.value.toUpperCase().trim())}
|
||||||
|
className="w-24 rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg"
|
||||||
|
maxLength={12}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-fg-muted mb-1">Watchlist</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addInput}
|
||||||
|
onChange={(e) => setAddInput(e.target.value.toUpperCase().trim())}
|
||||||
|
placeholder="TICKER"
|
||||||
|
className="w-28 rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg"
|
||||||
|
maxLength={12}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addToWatchlist(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addToWatchlist}
|
||||||
|
className="rounded bg-bg-secondary border border-fg-muted/30 px-3 py-1.5 text-xs text-fg hover:border-fg-muted/60"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{watchlist.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{watchlist.map((s) => (
|
||||||
|
<span key={s} className="inline-flex items-center gap-1 rounded border border-fg-muted/20 bg-bg-secondary px-2 py-0.5 text-xs text-fg">
|
||||||
|
<button onClick={() => loadCorridor(s)} className="hover:text-green-400">{s}</button>
|
||||||
|
<button onClick={() => removeFromWatchlist(s)} className="text-fg-muted hover:text-red-400" aria-label={`Remove ${s}`}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Corridor snapshots */}
|
||||||
|
{data && data.snapshot && (
|
||||||
|
<CollapsibleSection title={`Valuation Corridor — ${symbol}`} defaultOpen>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Corridor band + current position */}
|
||||||
|
{barPct !== null && low !== null && high !== null && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-[11px] text-fg-muted mb-1">
|
||||||
|
<span>{num(low, 1)}x (low)</span>
|
||||||
|
<span>{num(high, 1)}x (high)</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-2.5 rounded-full bg-bg-secondary border border-fg-muted/20">
|
||||||
|
<div className="absolute inset-y-0 left-0 rounded-l-full bg-green-600/30" style={{ width: '33%' }} />
|
||||||
|
<div className="absolute inset-y-0 right-0 rounded-r-full bg-amber-600/30" style={{ width: '33%' }} />
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-0.5 h-4 ${cheap ? 'bg-green-400' : stretched ? 'bg-amber-400' : 'bg-zinc-300'}`}
|
||||||
|
style={{ left: `${barPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
<span className="text-fg">Current P/E {num(currentPE, 1)}x</span>
|
||||||
|
<span className="text-fg-muted"> — at the {pctPosition !== null ? `${(pctPosition * 100).toFixed(0)}th` : '?'} percentile of its 1-year corridor</span>
|
||||||
|
{cheap && <span className="ml-2 text-green-400">(cheap zone)</span>}
|
||||||
|
{stretched && <span className="ml-2 text-amber-400">(stretched zone)</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key numbers table */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<Stat label="1y Median" value={`${num(c?.median ?? null, 1)}x`} />
|
||||||
|
<Stat label="Implied Upside (1y)" value={pct(c?.impliedUpside1y ?? null)} tone={(c?.impliedUpside1y ?? 0) >= 0.15 ? 'green' : (c?.impliedUpside1y ?? 0) < -0.1 ? 'amber' : 'neutral'} />
|
||||||
|
<Stat label="Fair Value (1y)" value={price(c?.fairValue1y ?? null)} />
|
||||||
|
<Stat label="SPY fwd P/E" value={`${num(data.spy?.forwardPE ?? null, 1)}x ${data.spy && data.spy.forwardPE != null && data.spy.forwardPE < 20.5 ? '· cheap' : ''}`} tone={data.spy && data.spy.forwardPE != null && data.spy.forwardPE < 20.5 ? 'green' : 'neutral'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.series.length > 0 && (
|
||||||
|
<details className="text-xs text-fg-muted">
|
||||||
|
<summary className="cursor-pointer hover:text-fg">Recent snapshots ({data.series.length})</summary>
|
||||||
|
<table className="mt-2 w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-fg-muted border-b border-fg-muted/20">
|
||||||
|
<th className="text-left py-1">Date</th>
|
||||||
|
<th className="text-right py-1">Close</th>
|
||||||
|
<th className="text-right py-1">P/E pos</th>
|
||||||
|
<th className="text-right py-1">Implied upside</th>
|
||||||
|
<th className="text-right py-1">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.series.slice(0, 12).map((s) => (
|
||||||
|
<tr key={s.snapshotDate} className="border-b border-fg-muted/10">
|
||||||
|
<td className="py-1">{s.snapshotDate}</td>
|
||||||
|
<td className="text-right py-1">{price(s.currentPrice)}</td>
|
||||||
|
<td className="text-right py-1">{s.pePercentile1y != null ? `${(s.pePercentile1y * 100).toFixed(0)}%` : '-'}</td>
|
||||||
|
<td className="text-right py-1">{pct(s.impliedUpside1y ?? null)}</td>
|
||||||
|
<td className="text-right py-1">{s.dataSource}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && !data.snapshot && (
|
||||||
|
<div className="text-sm text-fg-muted">
|
||||||
|
No corridor snapshot for {symbol} yet. Snapshot it via the confluence evaluator, or pick a symbol from the watchlist.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Corridor-method backtest scorecard */}
|
||||||
|
<CollapsibleSection title="Corridor Method Backtest (weekly rankings vs actual returns)" defaultOpen>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={runBacktest}
|
||||||
|
disabled={running}
|
||||||
|
className="rounded bg-bg-secondary border border-fg-muted/30 px-3 py-1.5 text-xs text-fg hover:border-fg-muted/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running ? 'Running…' : backtest && backtest.rows.length > 0 ? 'Re-run with fresh candles' : 'Run backtest'}
|
||||||
|
</button>
|
||||||
|
{backtest && (
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{backtest.rows.length} graded weeks ({backtest.aggregate.resolved} resolved with enough forward data)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{backtest && backtest.aggregate.resolved > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<Stat label="Hit Rate (top > bottom)" value={pct(backtest.aggregate.hitRate, 0)} tone={(backtest.aggregate.hitRate ?? 0) >= 0.6 ? 'green' : 'neutral'} />
|
||||||
|
<Stat label="Avg Spread (1wk)" value={pct(backtest.aggregate.avgSpread, 2)} tone={(backtest.aggregate.avgSpread ?? 0) > 0 ? 'green' : (backtest.aggregate.avgSpread ?? 0) < 0 ? 'amber' : 'neutral'} />
|
||||||
|
<Stat label="Best Spread" value={pct(backtest.aggregate.bestSpread, 2)} tone="green" />
|
||||||
|
<Stat label="Worst Spread" value={pct(backtest.aggregate.worstSpread, 2)} tone="amber" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
Method: @alojoh's weekly "U.S. Tech Coverage" entry rankings. Each graded week holds the top-3 entry-attractive names against
|
||||||
|
the bottom-3 least-attractive names and measures 7-day forward returns from actual prices. Spread = mean(top) − mean(bottom).
|
||||||
|
This is a judgment of the methodology itself, not a participation signal.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backtest && backtest.rows.length > 0 ? (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-fg-muted border-b border-fg-muted/20">
|
||||||
|
<th className="text-left py-1">Article Date</th>
|
||||||
|
<th className="text-left py-1">Window</th>
|
||||||
|
<th className="text-left py-1">Top 3</th>
|
||||||
|
<th className="text-left py-1">Bottom 3</th>
|
||||||
|
<th className="text-right py-1">Top avg</th>
|
||||||
|
<th className="text-right py-1">Bottom avg</th>
|
||||||
|
<th className="text-right py-1">Spread</th>
|
||||||
|
<th className="text-right py-1">Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backtest.rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-fg-muted/10">
|
||||||
|
<td className="py-1">{r.articleDate}</td>
|
||||||
|
<td className="py-1">{r.rankingType === 'entry_1y' ? '1y rank' : '90d rank'}</td>
|
||||||
|
<td className="py-1 text-green-400/90">{r.topSymbols.join(', ')}</td>
|
||||||
|
<td className="py-1 text-amber-400/80">{r.bottomSymbols.join(', ')}</td>
|
||||||
|
<td className="text-right py-1">{pct(r.topAvgReturn, 2)}</td>
|
||||||
|
<td className="text-right py-1">{pct(r.bottomAvgReturn, 2)}</td>
|
||||||
|
<td className={`text-right py-1 font-medium ${(r.spread ?? 0) > 0 ? 'text-green-400' : 'text-red-400'}`}>{pct(r.spread, 2)}</td>
|
||||||
|
<td className={`text-right py-1 ${r.spread !== null && r.isWin ? 'text-green-400' : r.spread !== null ? 'text-red-400' : 'text-fg-muted'}`}>
|
||||||
|
{r.spread === null ? 'n/a' : r.isWin ? 'Top won' : 'Bottom won'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
!backtest && <p className="text-xs text-fg-muted">Backtest not yet run. Click "Run backtest" to grade the weekly rankings against actual forward returns.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small stat card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Stat({ label, value, tone = 'neutral' }: { label: string; value: string; tone?: 'green' | 'amber' | 'neutral' }) {
|
||||||
|
const color = tone === 'green' ? 'text-green-400' : tone === 'amber' ? 'text-amber-400' : 'text-fg';
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-fg-muted/20 bg-bg-secondary px-2.5 py-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-fg-muted">{label}</div>
|
||||||
|
<div className={`text-sm font-medium ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,11 @@ export function HoldingsBookView() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Edit holding state
|
||||||
|
const [editingSymbol, setEditingSymbol] = useState<string | null>(null);
|
||||||
|
const [editShares, setEditShares] = useState("");
|
||||||
|
const [editAvgCost, setEditAvgCost] = useState("");
|
||||||
|
|
||||||
const loadQuotes = useCallback(async (list: PortfolioHolding[]) => {
|
const loadQuotes = useCallback(async (list: PortfolioHolding[]) => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
setQuotes({});
|
setQuotes({});
|
||||||
@@ -158,6 +163,46 @@ export function HoldingsBookView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleStartEdit(h: PortfolioHolding) {
|
||||||
|
setEditingSymbol(h.symbol);
|
||||||
|
setEditShares(String(h.shares));
|
||||||
|
setEditAvgCost(String(h.avg_cost));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelEdit() {
|
||||||
|
setEditingSymbol(null);
|
||||||
|
setEditShares("");
|
||||||
|
setEditAvgCost("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(symbol: string) {
|
||||||
|
setError(null);
|
||||||
|
const shares = parseFloat(editShares);
|
||||||
|
const avgCost = parseFloat(editAvgCost);
|
||||||
|
if (!Number.isFinite(shares) || shares <= 0) {
|
||||||
|
setError("Shares must be a positive number.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(avgCost) || avgCost < 0) {
|
||||||
|
setError("Average cost must be >= 0.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.portfolio.updateHolding(symbol, { shares, avgCost });
|
||||||
|
setEditingSymbol(null);
|
||||||
|
setEditShares("");
|
||||||
|
setEditAvgCost("");
|
||||||
|
await load(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Could not update holding.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openResearch(symbol: string) {
|
function openResearch(symbol: string) {
|
||||||
setActive(symbol);
|
setActive(symbol);
|
||||||
// scroll=false: shell owns nested <main> scroll; push alone leaves mid-page.
|
// scroll=false: shell owns nested <main> scroll; push alone leaves mid-page.
|
||||||
@@ -317,6 +362,7 @@ export function HoldingsBookView() {
|
|||||||
const pnl = marketValue != null ? marketValue - costBasis : null;
|
const pnl = marketValue != null ? marketValue - costBasis : null;
|
||||||
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
|
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
|
||||||
const dayUp = (q?.change ?? 0) >= 0;
|
const dayUp = (q?.change ?? 0) >= 0;
|
||||||
|
const isEditing = editingSymbol === h.symbol;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -330,73 +376,132 @@ export function HoldingsBookView() {
|
|||||||
className="text-left min-h-[44px] flex flex-col justify-center"
|
className="text-left min-h-[44px] flex flex-col justify-center"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-base font-semibold text-fg">{h.symbol}</span>
|
<span className="font-mono text-base font-semibold text-fg">{h.symbol}</span>
|
||||||
<span className="text-xs text-fg-muted tnum">
|
{!isEditing && (
|
||||||
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)} sh · avg $
|
<span className="text-xs text-fg-muted tnum">
|
||||||
{fmtMoney(h.avg_cost)}
|
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)} sh · avg $
|
||||||
</span>
|
{fmtMoney(h.avg_cost)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-right shrink-0">
|
{!isEditing && (
|
||||||
<p className="font-mono text-sm font-semibold tnum text-fg">
|
<div className="text-right shrink-0">
|
||||||
{q ? `$${fmtMoney(q.price)}` : "—"}
|
<p className="font-mono text-sm font-semibold tnum text-fg">
|
||||||
</p>
|
{q ? `$${fmtMoney(q.price)}` : "—"}
|
||||||
<p
|
</p>
|
||||||
className={`text-xs tnum ${
|
<p
|
||||||
!q ? "text-fg-faint" : dayUp ? "text-up" : "text-danger"
|
className={`text-xs tnum ${
|
||||||
}`}
|
!q ? "text-fg-faint" : dayUp ? "text-up" : "text-danger"
|
||||||
>
|
}`}
|
||||||
{q ? fmtSignedPct(q.changePercent) : "—"}
|
>
|
||||||
</p>
|
{q ? fmtSignedPct(q.changePercent) : "—"}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">Market</p>
|
|
||||||
<p className="tnum font-medium text-fg">
|
|
||||||
{marketValue != null ? `$${fmtMoney(marketValue)}` : "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">P&L</p>
|
|
||||||
<p
|
|
||||||
className={`tnum font-medium ${
|
|
||||||
pnl == null ? "text-fg-faint" : pnl >= 0 ? "text-up" : "text-danger"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pnl == null
|
|
||||||
? "—"
|
|
||||||
: `${fmtSignedMoney(pnl)}${
|
|
||||||
pnlPct != null ? ` (${fmtSignedPct(pnlPct)})` : ""
|
|
||||||
}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1 border-t border-line">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openResearch(h.symbol)}
|
|
||||||
className="flex-1 min-h-[44px] rounded-md border border-line text-sm font-medium text-accent active:bg-surface-sunken"
|
|
||||||
>
|
|
||||||
Research
|
|
||||||
</button>
|
|
||||||
{showConsiderations && (
|
|
||||||
<a
|
|
||||||
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
|
|
||||||
className="flex-1 min-h-[44px] inline-flex items-center justify-center rounded-md border border-line text-sm text-fg-muted active:bg-surface-sunken"
|
|
||||||
>
|
|
||||||
Consider
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => handleRemove(h.symbol)}
|
|
||||||
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-danger disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-[11px] text-fg-muted">
|
||||||
|
Shares
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editShares}
|
||||||
|
onChange={(e) => setEditShares(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm tnum text-fg"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-[11px] text-fg-muted">
|
||||||
|
Avg cost
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editAvgCost}
|
||||||
|
onChange={(e) => setEditAvgCost(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
className="mt-1 w-full rounded-md border border-line bg-surface px-3 py-2.5 sm:py-1.5 text-base sm:text-sm tnum text-fg"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleSaveEdit(h.symbol)}
|
||||||
|
className="flex-1 min-h-[44px] rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="flex-1 min-h-[44px] rounded-md border border-line text-sm text-fg-muted"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">Market</p>
|
||||||
|
<p className="tnum font-medium text-fg">
|
||||||
|
{marketValue != null ? `$${fmtMoney(marketValue)}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">P&L</p>
|
||||||
|
<p
|
||||||
|
className={`tnum font-medium ${
|
||||||
|
pnl == null ? "text-fg-faint" : pnl >= 0 ? "text-up" : "text-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pnl == null
|
||||||
|
? "—"
|
||||||
|
: `${fmtSignedMoney(pnl)}${
|
||||||
|
pnlPct != null ? ` (${fmtSignedPct(pnlPct)})` : ""
|
||||||
|
}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1 border-t border-line">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openResearch(h.symbol)}
|
||||||
|
className="flex-1 min-h-[44px] rounded-md border border-line text-sm font-medium text-accent active:bg-surface-sunken"
|
||||||
|
>
|
||||||
|
Research
|
||||||
|
</button>
|
||||||
|
{showConsiderations && (
|
||||||
|
<a
|
||||||
|
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
|
||||||
|
className="flex-1 min-h-[44px] inline-flex items-center justify-center rounded-md border border-line text-sm text-fg-muted active:bg-surface-sunken"
|
||||||
|
>
|
||||||
|
Consider
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleStartEdit(h)}
|
||||||
|
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleRemove(h.symbol)}
|
||||||
|
className="min-h-[44px] min-w-[44px] px-3 rounded-md border border-line text-sm text-fg-muted active:text-danger disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -426,6 +531,7 @@ export function HoldingsBookView() {
|
|||||||
const pnl = marketValue != null ? marketValue - costBasis : null;
|
const pnl = marketValue != null ? marketValue - costBasis : null;
|
||||||
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
|
const pnlPct = pnl != null && costBasis > 0 ? (pnl / costBasis) * 100 : null;
|
||||||
const dayUp = (q?.change ?? 0) >= 0;
|
const dayUp = (q?.change ?? 0) >= 0;
|
||||||
|
const isEditing = editingSymbol === h.symbol;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -441,12 +547,37 @@ export function HoldingsBookView() {
|
|||||||
{h.symbol}
|
{h.symbol}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
{isEditing ? (
|
||||||
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)}
|
<td className="px-3 py-3 text-right align-middle">
|
||||||
</td>
|
<input
|
||||||
<td className="px-3 py-3 text-right tnum text-fg-muted align-middle">
|
type="number"
|
||||||
${fmtMoney(h.avg_cost)}
|
value={editShares}
|
||||||
</td>
|
onChange={(e) => setEditShares(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
className="w-20 rounded-md border border-line bg-surface px-2 py-1 text-sm tnum text-fg text-right"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
||||||
|
{fmtMoney(h.shares, h.shares % 1 === 0 ? 0 : 2)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{isEditing ? (
|
||||||
|
<td className="px-3 py-3 text-right align-middle">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editAvgCost}
|
||||||
|
onChange={(e) => setEditAvgCost(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
className="w-20 rounded-md border border-line bg-surface px-2 py-1 text-sm tnum text-fg text-right"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<td className="px-3 py-3 text-right tnum text-fg-muted align-middle">
|
||||||
|
${fmtMoney(h.avg_cost)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
<td className="px-3 py-3 text-right tnum text-fg align-middle">
|
||||||
{q ? `$${fmtMoney(q.price)}` : "—"}
|
{q ? `$${fmtMoney(q.price)}` : "—"}
|
||||||
</td>
|
</td>
|
||||||
@@ -477,39 +608,71 @@ export function HoldingsBookView() {
|
|||||||
}`}
|
}`}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right align-middle whitespace-nowrap">
|
<td className="px-4 py-3 text-right align-middle whitespace-nowrap">
|
||||||
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
|
{isEditing ? (
|
||||||
<button
|
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => openResearch(h.symbol)}
|
type="button"
|
||||||
className="text-accent hover:underline"
|
disabled={busy}
|
||||||
>
|
onClick={() => handleSaveEdit(h.symbol)}
|
||||||
Research
|
className="text-accent hover:underline disabled:opacity-50"
|
||||||
</button>
|
>
|
||||||
<span className="text-fg-faint" aria-hidden>
|
Save
|
||||||
·
|
</button>
|
||||||
</span>
|
<span className="text-fg-faint" aria-hidden>·</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
onClick={handleCancelEdit}
|
||||||
onClick={() => handleRemove(h.symbol)}
|
className="text-fg-muted hover:text-fg hover:underline"
|
||||||
className="text-fg-muted hover:text-danger hover:underline disabled:opacity-50"
|
>
|
||||||
>
|
Cancel
|
||||||
Remove
|
</button>
|
||||||
</button>
|
</div>
|
||||||
{showConsiderations && (
|
) : (
|
||||||
<>
|
<div className="inline-flex items-center justify-end gap-2 text-[11px]">
|
||||||
<span className="text-fg-faint" aria-hidden>
|
<button
|
||||||
·
|
type="button"
|
||||||
</span>
|
onClick={() => openResearch(h.symbol)}
|
||||||
<a
|
className="text-accent hover:underline"
|
||||||
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
|
>
|
||||||
className="text-fg-muted hover:text-fg hover:underline"
|
Research
|
||||||
>
|
</button>
|
||||||
Consider
|
<span className="text-fg-faint" aria-hidden>
|
||||||
</a>
|
·
|
||||||
</>
|
</span>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleStartEdit(h)}
|
||||||
|
className="text-fg-muted hover:text-accent hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<span className="text-fg-faint" aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleRemove(h.symbol)}
|
||||||
|
className="text-fg-muted hover:text-danger hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{showConsiderations && (
|
||||||
|
<>
|
||||||
|
<span className="text-fg-faint" aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/exits?symbol=${encodeURIComponent(h.symbol)}`}
|
||||||
|
className="text-fg-muted hover:text-fg hover:underline"
|
||||||
|
>
|
||||||
|
Consider
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const SECTIONS: NavSection[] = [
|
|||||||
{ label: "Screener", href: "/screener", navId: "screener" },
|
{ label: "Screener", href: "/screener", navId: "screener" },
|
||||||
{ label: "Institutional", href: "/institutional", navId: "institutional" },
|
{ label: "Institutional", href: "/institutional", navId: "institutional" },
|
||||||
{ label: "Dealer Flow", href: "/dealer-flow", navId: "dealer-flow" },
|
{ label: "Dealer Flow", href: "/dealer-flow", navId: "dealer-flow" },
|
||||||
|
{ label: "Confluence", href: "/confluence", navId: "confluence" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1474,6 +1474,61 @@ export const api = {
|
|||||||
trpcQuery<{ slots: unknown[]; note: string }>("confluence.scorecard", symbol ? { symbol } : {}),
|
trpcQuery<{ slots: unknown[]; note: string }>("confluence.scorecard", symbol ? { symbol } : {}),
|
||||||
saveRack: (input: { id?: string; name: string; description?: string; slotIds: string[] }) =>
|
saveRack: (input: { id?: string; name: string; description?: string; slotIds: string[] }) =>
|
||||||
trpcMutate<{ id: string; name: string; slotIds: string[]; isSystem: boolean; description: string | null }>("confluence.saveRack", input),
|
trpcMutate<{ id: string; name: string; slotIds: string[]; isSystem: boolean; description: string | null }>("confluence.saveRack", input),
|
||||||
|
corridorSnapshot: (symbol: string) =>
|
||||||
|
trpcQuery<{
|
||||||
|
symbol: string;
|
||||||
|
snapshot: Record<string, unknown> | null;
|
||||||
|
spy: Record<string, unknown> | null;
|
||||||
|
series: Array<Record<string, unknown>>;
|
||||||
|
corridor: {
|
||||||
|
low: number | null;
|
||||||
|
high: number | null;
|
||||||
|
median: number | null;
|
||||||
|
currentPE: number | null;
|
||||||
|
fairValue1y: number | null;
|
||||||
|
impliedUpside1y: number | null;
|
||||||
|
pePercentile1y: number | null;
|
||||||
|
} | null;
|
||||||
|
}>("confluence.corridorSnapshot", { symbol }),
|
||||||
|
corridorWatchlist: () =>
|
||||||
|
trpcQuery<{ symbols: string[] }>("confluence.corridorWatchlist"),
|
||||||
|
corridorWatchlistAdd: (symbol: string) =>
|
||||||
|
trpcMutate<{ symbols: string[] }>("confluence.corridorWatchlistAdd", { symbol }),
|
||||||
|
corridorWatchlistRemove: (symbol: string) =>
|
||||||
|
trpcMutate<{ symbols: string[] }>("confluence.corridorWatchlistRemove", { symbol }),
|
||||||
|
corridorBacktest: () =>
|
||||||
|
trpcQuery<{
|
||||||
|
rows: Array<{
|
||||||
|
id: string;
|
||||||
|
articleDate: string;
|
||||||
|
articleId: string;
|
||||||
|
rankingType: string;
|
||||||
|
topSymbols: string[];
|
||||||
|
bottomSymbols: string[];
|
||||||
|
topAvgReturn: number | null;
|
||||||
|
bottomAvgReturn: number | null;
|
||||||
|
spread: number | null;
|
||||||
|
isWin: boolean;
|
||||||
|
horizonDays: number;
|
||||||
|
gradedAt: string;
|
||||||
|
}>;
|
||||||
|
aggregate: {
|
||||||
|
entries: number;
|
||||||
|
resolved: number;
|
||||||
|
resolved1y: number;
|
||||||
|
resolved90d: number;
|
||||||
|
hitRate: number | null;
|
||||||
|
avgSpread: number | null;
|
||||||
|
bestSpread: number | null;
|
||||||
|
worstSpread: number | null;
|
||||||
|
avgTopReturn: number | null;
|
||||||
|
avgBottomReturn: number | null;
|
||||||
|
};
|
||||||
|
}>("confluence.corridorBacktest"),
|
||||||
|
corridorBacktestRun: () =>
|
||||||
|
trpcMutate<{ grades: number; resolved: number; aggregate: Record<string, unknown> }>("confluence.corridorBacktestRun", {}),
|
||||||
|
runEvaluationNow: (opts?: { symbol?: string }) =>
|
||||||
|
trpcMutate<Record<string, unknown>>("confluence.runEvaluationNow", opts ?? {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type NavItemId =
|
|||||||
| "filings"
|
| "filings"
|
||||||
| "options"
|
| "options"
|
||||||
| "dealer-flow"
|
| "dealer-flow"
|
||||||
|
| "confluence"
|
||||||
| "alerts"
|
| "alerts"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "admin"
|
| "admin"
|
||||||
@@ -79,6 +80,7 @@ export const NAV_DENSITY: Record<NavItemId, DensityGate> = {
|
|||||||
filings: "full",
|
filings: "full",
|
||||||
options: "full",
|
options: "full",
|
||||||
"dealer-flow": "focused",
|
"dealer-flow": "focused",
|
||||||
|
confluence: "standard",
|
||||||
alerts: "focused",
|
alerts: "focused",
|
||||||
settings: "focused",
|
settings: "focused",
|
||||||
admin: "focused",
|
admin: "focused",
|
||||||
|
|||||||
Reference in New Issue
Block a user