From b04193e615af000f4f6c28bdd1b05b410b118b6a Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 10 Aug 2026 21:51:46 -0400 Subject: [PATCH] feat(confluence): add seasonal + technical slot evaluators (M22 slice 9) Pure, tested evaluator modules for the seasonal family (5 slots) and the candle-computable technical family (15 slots). Uses the CandleProvider seam (slice 8) and the indicator functions from slice 1. ADR-0007 evidence-only notes on every assessment. --- .../__tests__/seasonalEvaluator.test.ts | 83 ++++++ .../__tests__/technicalEvaluator.test.ts | 180 ++++++++++++ .../src/confluence/seasonalEvaluator.ts | 101 +++++++ .../src/confluence/technicalEvaluator.ts | 272 ++++++++++++++++++ 4 files changed, 636 insertions(+) create mode 100644 app/server/src/confluence/__tests__/seasonalEvaluator.test.ts create mode 100644 app/server/src/confluence/__tests__/technicalEvaluator.test.ts create mode 100644 app/server/src/confluence/seasonalEvaluator.ts create mode 100644 app/server/src/confluence/technicalEvaluator.ts diff --git a/app/server/src/confluence/__tests__/seasonalEvaluator.test.ts b/app/server/src/confluence/__tests__/seasonalEvaluator.test.ts new file mode 100644 index 0000000..aadc551 --- /dev/null +++ b/app/server/src/confluence/__tests__/seasonalEvaluator.test.ts @@ -0,0 +1,83 @@ +// Investor Flow — seasonalEvaluator.test.ts (M22 slice 9) +// Seasonal slots are calendar-driven: build a SeasonalitySnapshot with fixed +// "now" and assert fired/not-fired per slot. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildSeasonalitySnapshot } from '../../analysis/seasonality.ts'; +import { evaluateSeasonalSlots } from '../seasonalEvaluator.ts'; + +function mkYear(year: number, closes: number[]) { + // Two candles per month: day 2 (first) and day 28 (last) so return > 0. + const out: Array<{ ts: string; c: number }> = []; + for (let i = 0; i < closes.length; i++) { + const base = closes[i]; + out.push({ ts: new Date(Date.UTC(year, i, 2)).toISOString(), c: base }); + out.push({ ts: new Date(Date.UTC(year, i, 28)).toISOString(), c: base * 1.03 }); // +3% per month + } + return out; +} + +// 6 years of monotonic closes so every month has avg>1 and win rate 1. +const candles = [ + ...[2018, 2019, 2020, 2021, 2022, 2023].map((y) => + mkYear(y, Array.from({ length: 12 }, (_, i) => 100 + (y - 2018) * 30 + i * 2)), + ).flat(), +]; + +const byId = (assessments: Array<{ id: string; state: string; note?: string }>, id: string) => + assessments.find((a) => a.id === id)!; + +test('evaluateSeasonalSlots fires seasonalFavorableMonth for a strong month', () => { + const snap = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 5, 15))); // June, historically up every year + const out = evaluateSeasonalSlots(snap, '2026-06-15'); + const a = byId(out, 'seasonalFavorableMonth'); + assert.equal(a.state, 'fired'); + assert.match(a.note!, /historically 6/i); +}); + +test('evaluateSeasonalSlots fires winterHalfOn in the November-April window', () => { + const winter = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 0, 15))); + const winterOut = evaluateSeasonalSlots(winter, '2026-01-15'); + assert.equal(byId(winterOut, 'winterHalfOn').state, 'fired'); + + const summer = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 6, 15))); + const summerOut = evaluateSeasonalSlots(summer, '2026-07-15'); + assert.equal(byId(summerOut, 'winterHalfOn').state, 'not-fired'); +}); + +test('evaluateSeasonalSlots fires electionCycleFavorableYear only in pre-election year', () => { + const preElection = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2027, 0, 15))); // year 3 + assert.equal(byId(evaluateSeasonalSlots(preElection, '2027-01-15'), 'electionCycleFavorableYear').state, 'fired'); + + const midterm = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 0, 15))); // year 2 + assert.equal(byId(evaluateSeasonalSlots(midterm, '2026-01-15'), 'electionCycleFavorableYear').state, 'not-fired'); +}); + +test('evaluateSeasonalSlots nearTurnOfMonth and nearQuarterEnd fire at month edges', () => { + const nearEnd = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 2, 29))); // March 29: quarter end + month end + const out = evaluateSeasonalSlots(nearEnd, '2026-03-29'); + assert.equal(byId(out, 'nearTurnOfMonth').state, 'fired'); + assert.equal(byId(out, 'nearQuarterEnd').state, 'fired'); + + const mid = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 2, 15))); + const midOut = evaluateSeasonalSlots(mid, '2026-03-15'); + assert.equal(byId(midOut, 'nearTurnOfMonth').state, 'not-fired'); +}); + +test('evaluateSeasonalSlots returns not-fired with a note when month data is thin', () => { + // Only one year of data → current month sampleYears < 3. + const thin = buildSeasonalitySnapshot('SPY', mkYear(2025, Array.from({ length: 12 }, (_, i) => 100 + i)), new Date(Date.UTC(2026, 5, 15))); + const out = evaluateSeasonalSlots(thin, '2026-06-15'); + const a = byId(out, 'seasonalFavorableMonth'); + assert.equal(a.state, 'not-fired'); + assert.match(a.note!, /not enough historical data/i); +}); + +test('every seasonal slot assessment carries an evidence note', () => { + const snap = buildSeasonalitySnapshot('SPY', candles, new Date(Date.UTC(2026, 2, 29))); + for (const a of evaluateSeasonalSlots(snap, '2026-03-29')) { + assert.ok(a.note && a.note.length > 10, `${a.id} missing note`); + } +}); \ No newline at end of file diff --git a/app/server/src/confluence/__tests__/technicalEvaluator.test.ts b/app/server/src/confluence/__tests__/technicalEvaluator.test.ts new file mode 100644 index 0000000..fb52426 --- /dev/null +++ b/app/server/src/confluence/__tests__/technicalEvaluator.test.ts @@ -0,0 +1,180 @@ +// Investor Flow — technicalEvaluator.test.ts (M22 slice 9) +// Candle-driven slot assessments. Fixtures build synthetic series so fired +// states are deterministic. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import type { PriceCandle } from '../../cache/CacheRepository.ts'; +import { evaluateTechnicalSlots } from '../technicalEvaluator.ts'; + +const byId = (assessments: Array<{ id: string; state: string; note?: string }>, id: string) => + assessments.find((a) => a.id === id)!; + +// ---- deterministic fixtures ------------------------------------------------- + +/** Monotonic ramp up every day: classic strong uptrend (geometric, clean MACD). */ +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; +} + +/** Monotonic decline every day: classic strong downtrend (geometric, stays > 0). */ +function rampDown(days: number, start = 300, 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); + const drift = start * dailyPct; + 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, + }); + void drift; + } + return candles; +} + +/** Flat benchmark (symbol outperforms it). */ +function flatBench(days: number, price = 100): PriceCandle[] { + const candles: PriceCandle[] = []; + const base = Date.UTC(2020, 0, 1); + for (let i = 0; i < days; i++) { + candles.push({ + ts: new Date(base + i * 86400000).toISOString().slice(0, 10), + o: price, + h: price + 1, + l: price - 1, + c: price, + v: 2_000_000, + adjClose: price, + }); + } + return candles; +} + +/** Noisy range, then a calm low-volatility drift (for volatilityRegimeLow). */ +function calmTail(days = 560): PriceCandle[] { + const candles: PriceCandle[] = []; + const base = Date.UTC(2019, 0, 1); + let price = 100; + for (let i = 0; i < days; i++) { + const calm = i >= days - 60; + const move = calm ? 0.05 : (Math.sin(i * 3.7) * 1.4 + 0.2); + price = Math.max(10, price + move); + candles.push({ + ts: new Date(base + i * 86400000).toISOString().slice(0, 10), + o: price - Math.abs(move), + h: price + 0.8, + l: price - 0.8, + c: price, + v: 2_000_000, + adjClose: price, + }); + } + return candles; +} + +function weeklyFrom(daily: PriceCandle[]): PriceCandle[] { + const out: PriceCandle[] = []; + let cur: (PriceCandle & { wk?: string }) | null = null; + for (const d of daily) { + const dh = new Date(d.ts + 'T00:00:00Z'); + const day = dh.getUTCDay() || 7; + dh.setUTCDate(dh.getUTCDate() + 4 - day); + const ys = new Date(Date.UTC(dh.getUTCFullYear(), 0, 1)); + const w = Math.ceil((((dh.getTime() - ys.getTime()) / 86400000) + 1) / 7); + const key = d.ts.slice(0, 4) + '-W' + String(w).padStart(2, '0'); + if (!cur || cur.wk !== key) { + cur = { ...d, wk: key }; + out.push(cur); + } else { + cur.h = Math.max(cur.h, d.h); + cur.l = Math.min(cur.l, d.l); + cur.c = d.c; + cur.v += d.v; + } + } + return out; +} + +// ---- tests ------------------------------------------------------------------ + +test('uptrend fires trend, momentum and strength slots', () => { + const daily = rampUp(1500); // ≥ 214 weekly bars → weekly slots assessed + const bench = flatBench(1500, 100); + const out = evaluateTechnicalSlots('T', daily, { weekly: weeklyFrom(daily), benchmarkDaily: bench }); + assert.equal(byId(out, 'trendAlignment').state, 'fired'); + assert.equal(byId(out, 'timeSeriesMomentum12_1').state, 'fired'); + assert.equal(byId(out, 'momentumForward').state, 'fired'); + assert.equal(byId(out, 'relativeStrengthVsSpy').state, 'fired'); + assert.equal(byId(out, 'macdBullish').state, 'fired'); + assert.equal(byId(out, 'macdBearish').state, 'not-fired'); + assert.equal(byId(out, 'rsiOverbought').state, 'fired'); + assert.equal(byId(out, 'rsiOversold').state, 'not-fired'); +}); + +test('downtrend kills momentum and is not trend-aligned, oversold', () => { + const daily = rampDown(1500); + const out = evaluateTechnicalSlots('T', daily, { weekly: weeklyFrom(daily) }); + assert.equal(byId(out, 'trendAlignment').state, 'not-fired'); + assert.equal(byId(out, 'timeSeriesMomentum12_1').state, 'not-fired'); + assert.equal(byId(out, 'momentumForward').state, 'not-fired'); + assert.equal(byId(out, 'rsiOversold').state, 'fired'); + assert.equal(byId(out, 'rsiOverbought').state, 'not-fired'); + // MACD halves are complementary (exactly one fires) on a monotonic decline. + const bull = byId(out, 'macdBullish').state; + const bear = byId(out, 'macdBearish').state; + assert.notEqual(bull, bear); +}); + +test('a calm low-vol tail fires volatilityRegimeLow', () => { + const daily = calmTail(); + const out = evaluateTechnicalSlots('T', daily, { weekly: weeklyFrom(daily) }); + assert.equal(byId(out, 'volatilityRegimeLow').state, 'fired'); +}); + +test('reports not-fired with notes when history is thin', () => { + const short = rampUp(30); + const out = evaluateTechnicalSlots('T', short, {}); + const a = byId(out, 'timeSeriesMomentum12_1'); + assert.equal(a.state, 'not-fired'); + assert.match(a.note!, /not enough/i); + assert.equal(byId(out, 'trendAlignment').state, 'not-fired'); +}); + +test('benchmark slot is not-fired without SPY series', () => { + const daily = rampUp(1500); + const out = evaluateTechnicalSlots('T', daily, { weekly: weeklyFrom(daily) }); + const a = byId(out, 'relativeStrengthVsSpy'); + assert.equal(a.state, 'not-fired'); + assert.match(a.note!, /benchmark series unavailable/i); +}); + +test('all fifteen technical slots are assessed with an evidence note', () => { + const daily = rampUp(1500); + const bench = flatBench(1500, 100); + const out = evaluateTechnicalSlots('T', daily, { weekly: weeklyFrom(daily), benchmarkDaily: bench }); + assert.equal(out.length, 15); + for (const a of out) { + assert.ok(a.note && a.note.length > 8, `${a.id} missing note`); + } +}); \ No newline at end of file diff --git a/app/server/src/confluence/seasonalEvaluator.ts b/app/server/src/confluence/seasonalEvaluator.ts new file mode 100644 index 0000000..cbf582f --- /dev/null +++ b/app/server/src/confluence/seasonalEvaluator.ts @@ -0,0 +1,101 @@ +// Investor Flow — Seasonal slot evaluators (M22, slice 9) +// +// Pure, calendar-based assessments for the five `seasonal` confluence slots: +// seasonalFavorableMonth, winterHalfOn, electionCycleFavorableYear, +// nearTurnOfMonth, nearQuarterEnd. +// +// Each returns a SlotAssessment (fired/not-fired + evidence note) from a +// `SeasonalitySnapshot`. ADR-0007: these describe a mild historical tailwind in +// evidence terms — never a directive. + +import type { SeasonalitySnapshot } from '../analysis/seasonality.ts'; +import type { SlotAssessment } from './confluenceRack.ts'; + +/** Minimum sample years before a month's average is trusted. */ +export const MIN_SAMPLE_YEARS = 3; +/** Monthly-average threshold for a "favorable" month. */ +export const FAVORABLE_MONTH_AVG_PCT = 1.0; +/** Monthly win-rate threshold for a "favorable" month. */ +export const FAVORABLE_MONTH_WIN_RATE = 0.55; +/** Pre-election year (3rd) of the US cycle — historically the most favorable. */ +export const FAVORABLE_CYCLE_YEARS: ReadonlySet = new Set([3]); + +function assessment(id: string, state: 'fired' | 'not-fired', note: string): SlotAssessment { + return { id, state, note }; +} + +/** + * Evaluate all five seasonal slots from a seasonality snapshot. Pure. + * @param asOf evaluation date (YYYY-MM-DD) — currently informational. + */ +export function evaluateSeasonalSlots( + snapshot: SeasonalitySnapshot, + asOf: string, +): SlotAssessment[] { + const out: SlotAssessment[] = []; + + // seasonalFavorableMonth + { + const cur = snapshot.currentMonth; + const avg = snapshot.currentMonthAvgPct; + const win = snapshot.currentMonthWinRate; + const years = snapshot.currentMonthSampleYears; + if (avg == null || win == null || years < MIN_SAMPLE_YEARS) { + out.push(assessment('seasonalFavorableMonth', 'not-fired', 'Not enough historical data for the current calendar month.')); + } else { + const favorable = avg >= FAVORABLE_MONTH_AVG_PCT && win >= FAVORABLE_MONTH_WIN_RATE; + out.push(assessment( + 'seasonalFavorableMonth', + favorable ? 'fired' : 'not-fired', + favorable + ? `Historically ${snapshot.currentMonth} averages +${avg.toFixed(1)}% with a ${(win * 100).toFixed(0)}% win rate over ${years} years (a mild tailwind, not a forecast).` + : `Historically ${snapshot.currentMonth} averages ${avg.toFixed(1)}% with a ${(win * 100).toFixed(0)}% win rate over ${years} years.`, + )); + } + } + + // winterHalfOn + { + const whichHalf = snapshot.halfYear.whichHalf; + const winterAvg = snapshot.halfYear.winterAvgPct; + const inWinter = whichHalf === 'winter'; + out.push(assessment( + 'winterHalfOn', + inWinter ? 'fired' : 'not-fired', + inWinter + ? `We are in the November-April half${winterAvg != null ? ` (historical average ${winterAvg > 0 ? '+' : ''}${winterAvg.toFixed(1)}%)` : ''}.` + : 'We are in the May-October half of the classic seasonal window.', + )); + } + + // electionCycleFavorableYear + { + const cyc = snapshot.electionCycle; + const favorable = FAVORABLE_CYCLE_YEARS.has(cyc.yearInCycle); + out.push(assessment( + 'electionCycleFavorableYear', + favorable ? 'fired' : 'not-fired', + `${cyc.year} is a ${cyc.label.toLowerCase()} (year ${cyc.yearInCycle} of 4).`, + )); + } + + // nearTurnOfMonth + out.push(assessment( + 'nearTurnOfMonth', + snapshot.calendar.nearTurnOfMonth ? 'fired' : 'not-fired', + snapshot.calendar.nearTurnOfMonth + ? `Day ${snapshot.calendar.dayOfMonth} is near the turn of the month, a historically benign liquidity pocket.` + : `Day ${snapshot.calendar.dayOfMonth} is mid-month.`, + )); + + // nearQuarterEnd + out.push(assessment( + 'nearQuarterEnd', + snapshot.calendar.nearQuarterEnd ? 'fired' : 'not-fired', + snapshot.calendar.nearQuarterEnd + ? `Quarter ${snapshot.calendar.quarter} ends soon; window-dressing flows can add short-term noise.` + : `Day ${snapshot.calendar.dayOfMonth} is not near a quarter end.`, + )); + + return out; +} \ No newline at end of file diff --git a/app/server/src/confluence/technicalEvaluator.ts b/app/server/src/confluence/technicalEvaluator.ts new file mode 100644 index 0000000..74c7292 --- /dev/null +++ b/app/server/src/confluence/technicalEvaluator.ts @@ -0,0 +1,272 @@ +// Investor Flow — Technical slot evaluators (M22, slice 9) +// +// Pure, candle-based assessments for the fifteen `technical` confluence slots. +// Consumes the same price series the CandleProvider seam resolves (daily first, +// weekly for the 50/200-window trend slots). Every function is a pure +// (candles, benchmark?) → SlotAssessment[] mapping with ADR-0007 evidence notes. + +import type { PriceCandle } from '../cache/CacheRepository.ts'; +import { + crossover, + ema, + fibLevels, + macd, + relativeStrength, + relativeVolume, + roc, + rsi, + sma, + timeSeriesMomentum, + volatilityPercentile, +} from '../analysis/indicators.ts'; +import { buildVolumeProfile } from '../analysis/volumeByPrice.ts'; +import type { SlotAssessment } from './confluenceRack.ts'; + +// --- tuning constants (pure; documented here so thresholds are reviewable) --- + +/** RSI boundary for oversold (bounce-prone) vs overbought (stretched). */ +export const RSI_OVERSOLD = 30; +export const RSI_OVERBOUGHT = 70; +/** Relative volume multiple above which volume "notably above average". */ +export const REL_VOLUME_THRESHOLD = 1.5; +/** Realized-vol percentile below which the regime is "low volatility". */ +export const VOL_LOW_PERCENTILE = 30; +/** POC proximity (fraction) counted as "price near the volume shelf". */ +export const POC_PROXIMITY_PCT = 0.03; +/** Fib level tolerance (fraction) counted as "value aligns with a fib level". */ +export const FIB_TOLERANCE_PCT = 0.02; +/** EMA-21 pullback proximity (fraction) counted as "pulled back toward it". */ +export const PULLBACK_PROXIMITY_PCT = 0.03; +/** Swing-length for the volume shelf window (trading days). */ +export const SHELF_WINDOW_DAYS = 120; +/** Forward-momentum rate-of-change window (trading days). */ +export const FORWARD_MOMENTUM_BARS = 5; + +const closes = (c: PriceCandle[]): number[] => c.map((x) => x.c); +const volumes = (c: PriceCandle[]): number[] => c.map((x) => x.v); +const last = (arr: T[]): T | undefined => arr[arr.length - 1]; + +function assessment(id: string, state: 'fired' | 'not-fired', note: string): SlotAssessment { + return { id, state, note }; +} + +function pct(a: number, b: number): number { + return ((a - b) / b) * 100; +} + +// --------------------------------------------------------------------------- +// Technical evaluator +// --------------------------------------------------------------------------- + +/** + * Evaluate all fifteen technical slots from daily candles (and, for the + * 50/200-window trend slots, weekly candles). `benchmarkDaily` is required for + * `relativeStrengthVsSpy`; when absent that slot reports not-fired with a note. + * Pure. + */ +export function evaluateTechnicalSlots( + symbol: string, + daily: PriceCandle[], + opts: { weekly?: PriceCandle[]; benchmarkDaily?: PriceCandle[] } = {}, +): SlotAssessment[] { + const out: SlotAssessment[] = []; + const weekly = opts.weekly ?? []; + + // ----- 50/200-window trend slots use weekly bars (granularity '1wk') ----- + const weekCloses = closes(weekly); + const priceNow = last(daily)?.c ?? NaN; + if (weekCloses.length < 201) { + out.push(assessment('goldenCross', 'not-fired', 'Not enough weekly history for the 50/200 cross.')); + out.push(assessment('deathCross', 'not-fired', 'Not enough weekly history for the 50/200 cross.')); + out.push(assessment('trendAlignment', 'not-fired', 'Not enough weekly history to grade trend alignment.')); + } else { + const wSma50 = sma(weekCloses, 50); + const wSma200 = sma(weekCloses, 200); + const cross = crossover(wSma50, wSma200); + const lastWeek = last(weekly)!; + const lastCross = last(cross); + if (lastCross === 'golden') { + out.push(assessment('goldenCross', 'fired', `The 50-week average has just crossed above the 200-week average.`)); + out.push(assessment('deathCross', 'not-fired', 'No 50/200 death cross in the latest weekly bar.')); + } else if (lastCross === 'death') { + out.push(assessment('goldenCross', 'not-fired', 'No 50/200 golden cross in the latest weekly bar.')); + out.push(assessment('deathCross', 'fired', `The 50-week average has just crossed below the 200-week average.`)); + } else { + const above = (wSma50.at(-1) ?? 0) > (wSma200.at(-1) ?? 0); + out.push(assessment('goldenCross', 'not-fired', above ? '50-week average remains above 200-week (no fresh cross).' : 'No 50/200 golden cross.')); + out.push(assessment('deathCross', 'not-fired', above ? 'No 50/200 death cross.' : '50-week average remains below 200-week (no fresh cross).')); + } + const s50 = wSma50.at(-1); + const s200 = wSma200.at(-1); + const aligned = s50 !== undefined && s200 !== undefined && lastWeek.c > s50 && lastWeek.c > s200; + out.push(assessment( + 'trendAlignment', + aligned ? 'fired' : 'not-fired', + aligned + ? `Price (${lastWeek.c.toFixed(2)}) sits above the 50-week (${s50!.toFixed(2)}) and 200-week (${s200!.toFixed(2)}) averages.` + : 'Price does not sit above both the 50- and 200-week averages.', + )); + } + + // ----- momentum / mean-reversion slots on daily bars ----- + const dailyCloses = closes(daily); + + // timeSeriesMomentum12_1 + { + if (dailyCloses.length < 253) { + out.push(assessment('timeSeriesMomentum12_1', 'not-fired', 'Not enough history for the 12-1 momentum window.')); + } else { + const tsm = last(timeSeriesMomentum(dailyCloses)); + const positive = tsm !== undefined && tsm > 0; + out.push(assessment( + 'timeSeriesMomentum12_1', + positive ? 'fired' : 'not-fired', + positive ? `12-1 momentum is positive (${tsm!.toFixed(1)}%).` : `12-1 momentum is ${tsm !== undefined ? `negative (${tsm.toFixed(1)}%)` : 'unavailable'}.`, + )); + } + } + + // relativeStrengthVsSpy + { + const bench = opts.benchmarkDaily; + if (!bench || bench.length !== daily.length || dailyCloses.length < 253) { + out.push(assessment('relativeStrengthVsSpy', 'not-fired', 'Benchmark series unavailable or misaligned.')); + } else { + const rs = last(relativeStrength(dailyCloses, closes(bench))); + const outperforming = rs !== undefined && rs > 0; + out.push(assessment( + 'relativeStrengthVsSpy', + outperforming ? 'fired' : 'not-fired', + outperforming ? `Outperforming the S&P 500 on 12-1 momentum by ${rs!.toFixed(1)} pts.` : `Trailing/even with the S&P 500 on 12-1 momentum${rs !== undefined ? ` (${rs!.toFixed(1)} pts)` : ''}.`, + )); + } + } + + // rsiOversold / rsiOverbought + { + const rsi14 = last(rsi(dailyCloses, 14)); + if (rsi14 === undefined) { + out.push(assessment('rsiOversold', 'not-fired', 'RSI unavailable.')); + out.push(assessment('rsiOverbought', 'not-fired', 'RSI unavailable.')); + } else { + out.push(assessment('rsiOversold', rsi14 <= RSI_OVERSOLD ? 'fired' : 'not-fired', `RSI-14 is ${rsi14.toFixed(1)}${rsi14 <= RSI_OVERSOLD ? ' — oversold territory.' : '.'}`)); + out.push(assessment('rsiOverbought', rsi14 >= RSI_OVERBOUGHT ? 'fired' : 'not-fired', `RSI-14 is ${rsi14.toFixed(1)}${rsi14 >= RSI_OVERBOUGHT ? ' — overbought territory.' : '.'}`)); + } + } + + // macdBullish / macdBearish + { + if (dailyCloses.length < 35) { + out.push(assessment('macdBullish', 'not-fired', 'Not enough history for MACD.')); + out.push(assessment('macdBearish', 'not-fired', 'Not enough history for MACD.')); + } else { + const { macdLine, signalLine } = macd(dailyCloses); + const m = last(macdLine); + const s = last(signalLine); + const bullish = m !== undefined && s !== undefined && m > s; + out.push(assessment('macdBullish', bullish ? 'fired' : 'not-fired', bullish ? 'MACD line is above its signal line.' : 'MACD line is at or below its signal line.')); + out.push(assessment('macdBearish', bullish === false ? 'fired' : 'not-fired', bullish === false ? 'MACD line is below its signal line.' : 'MACD line is at or above its signal line.')); + } + } + + // relVolume + { + const rv = last(relativeVolume(volumes(daily), 20)); + const elevated = rv !== undefined && rv >= REL_VOLUME_THRESHOLD; + out.push(assessment( + 'relVolume', + elevated ? 'fired' : 'not-fired', + elevated ? `Volume is ${rv!.toFixed(2)}x the trailing 20-day average.` : `Volume is${rv !== undefined ? ` ${rv!.toFixed(2)}x` : ''} its trailing 20-day average.`, + )); + } + + // volumeProfileShelf + { + const window = daily.slice(-SHELF_WINDOW_DAYS); + const profile = buildVolumeProfile(window, { bins: 16 }); + let nearPoc = false; + if (profile.pocMid != null && Number.isFinite(profile.pocMid) && Number.isFinite(priceNow)) { + nearPoc = Math.abs(pct(priceNow, profile.pocMid)) <= POC_PROXIMITY_PCT * 100; + } + out.push(assessment( + 'volumeProfileShelf', + nearPoc ? 'fired' : 'not-fired', + nearPoc + ? `Price is within ${(POC_PROXIMITY_PCT * 100).toFixed(1)}% of the 120-day volume profile's point of control (${profile.pocMid!.toFixed(2)}).` + : `Price is not near the 120-day volume shelf (POC ${profile.pocMid != null ? profile.pocMid.toFixed(2) : 'n/a'}).`, + )); + } + + // fibLevelCluster + { + const win = daily.slice(-SHELF_WINDOW_DAYS); + let swingHigh = -Infinity; + let swingLow = Infinity; + for (const c of win) { if (c.h > swingHigh) swingHigh = c.h; if (c.l < swingLow) swingLow = c.l; } + // Confluence reference level: the 50-day SMA, plus price itself. + const sma50 = last(sma(dailyCloses, 50)); + const refs = [priceNow, ...(sma50 !== undefined ? [sma50] : [])].filter((v) => Number.isFinite(v) && v > 0); + let near = false; + if (Number.isFinite(swingHigh) && Number.isFinite(swingLow) && swingHigh > swingLow) { + const fib = fibLevels(swingHigh, swingLow); + for (const level of Object.values(fib.retracements)) { + for (const ref of refs) { + if (Math.abs(pct(ref, level)) <= FIB_TOLERANCE_PCT * 100) { near = true; break; } + } + if (near) break; + } + } + out.push(assessment( + 'fibLevelCluster', + near ? 'fired' : 'not-fired', + near + ? 'Price sits near a Fibonacci retracement level that confluences with the 50-day average.' + : 'Price is not near a fib level confluence.', + )); + } + + // volatilityRegimeLow + { + const vp = last(volatilityPercentile(dailyCloses, 20, 504)); + const low = vp !== undefined && vp <= VOL_LOW_PERCENTILE; + out.push(assessment( + 'volatilityRegimeLow', + low ? 'fired' : 'not-fired', + low + ? `Realized volatility is in the ${vp!.toFixed(0)}th percentile of its 2-year history (low-vol regime).` + : `Realized volatility is in the ${vp !== undefined ? vp.toFixed(0) + 'th' : '? '}percentile of its history.`, + )); + } + + // pullbackToEMA21 + { + const ema21 = last(ema(dailyCloses, 21)); + const ema21Prev = dailyCloses.length >= 22 ? ema(dailyCloses, 21)[dailyCloses.length - 2] : undefined; + const rising = ema21 !== undefined && ema21Prev !== undefined && ema21 > ema21Prev; + const sma50d = last(sma(dailyCloses, 50)); + const inUptrend = sma50d !== undefined && priceNow > sma50d; + const nearEma = ema21 !== undefined && Number.isFinite(priceNow) && Math.abs(pct(priceNow, ema21)) <= PULLBACK_PROXIMITY_PCT * 100 && priceNow <= ema21; + const fired = rising && inUptrend && nearEma; + out.push(assessment( + 'pullbackToEMA21', + fired ? 'fired' : 'not-fired', + fired + ? `Price pulled back to a rising 21-day EMA (${ema21!.toFixed(2)}) within a broader uptrend.` + : `Price is not in a pullback-to-rising-EMA-21 setup (${rising ? '' : 'EMA flat/falling; '}${inUptrend ? '' : 'not above the 50-day average; '}${nearEma ? '' : 'off the EMA-21.'})`, + )); + } + + // momentumForward + { + const mom = last(roc(dailyCloses, FORWARD_MOMENTUM_BARS)); + const positive = mom !== undefined && mom > 0; + out.push(assessment( + 'momentumForward', + positive ? 'fired' : 'not-fired', + positive ? `5-day rate of change is positive (${mom!.toFixed(1)}%).` : `5-day rate of change is ${mom !== undefined ? `${mom!.toFixed(1)}%` : 'unavailable'}.`, + )); + } + + void symbol; + return out; +} \ No newline at end of file