Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b04193e615 | ||
|
|
07d95ba601 |
@@ -0,0 +1,141 @@
|
||||
// Investor Flow — candleProvider.test.ts (M22 slice 8)
|
||||
// Tests for the candle-resolution seam: pure fold-in logic + todayIso + the
|
||||
// cache-backed provider against a fake CacheRepository.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CacheRepository, CacheEntry, PriceCandle, Quote } from '../../cache/CacheRepository.ts';
|
||||
import {
|
||||
CacheCandleProvider,
|
||||
type CandleProvider,
|
||||
foldRealtimeBar,
|
||||
todayIso,
|
||||
} from '../candleProvider.ts';
|
||||
|
||||
const bar = (ts: string, c: number, o = c, h = Math.max(o, c), l = Math.min(o, c), v = 1000): PriceCandle =>
|
||||
({ ts, o, h, l, c, v, adjClose: c });
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe('foldRealtimeBar', () => {
|
||||
const candles = [bar('2026-08-07', 100), bar('2026-08-10', 105)];
|
||||
|
||||
it('returns unchanged when there is no quote or no candles', () => {
|
||||
const r1 = foldRealtimeBar(candles, null, '2026-08-11');
|
||||
assert.equal(r1.folded, false);
|
||||
assert.equal(r1.candles, candles);
|
||||
const r2 = foldRealtimeBar([], { symbol: 'X', price: 100 }, '2026-08-11');
|
||||
assert.equal(r2.folded, false);
|
||||
assert.deepEqual(r2.candles, []);
|
||||
});
|
||||
|
||||
it('appends a realtime bar when the quote is on a later day', () => {
|
||||
const { candles: out, folded } = foldRealtimeBar(
|
||||
candles,
|
||||
{ symbol: 'X', price: 108 },
|
||||
'2026-08-11',
|
||||
);
|
||||
assert.equal(folded, true);
|
||||
assert.equal(out.length, 3);
|
||||
const last = out[out.length - 1];
|
||||
assert.equal(last.ts, '2026-08-11');
|
||||
assert.equal(last.c, 108);
|
||||
assert.equal(last.o, 105); // prior close
|
||||
assert.ok(last.h >= 108 && last.h >= 105);
|
||||
assert.ok(last.l <= 108 && last.l <= 105);
|
||||
assert.equal(last.v, 0);
|
||||
});
|
||||
|
||||
it('replaces today’s bar close with the live price when the series already has today', () => {
|
||||
const withToday = [bar('2026-08-07', 100), bar('2026-08-11', 110, 108, 112, 107)];
|
||||
const { candles: out, folded } = foldRealtimeBar(withToday, { symbol: 'X', price: 113 }, '2026-08-11');
|
||||
assert.equal(folded, true);
|
||||
assert.equal(out.length, 2);
|
||||
const last = out[out.length - 1];
|
||||
assert.equal(last.ts, '2026-08-11');
|
||||
assert.equal(last.c, 113); // live price wins
|
||||
assert.equal(last.h, 113); // expanded to contain the print
|
||||
assert.equal(last.o, 108);
|
||||
});
|
||||
|
||||
it('ignores a quote not newer than the last bar (no duplicate bar)', () => {
|
||||
const { candles: out, folded } = foldRealtimeBar(candles, { symbol: 'X', price: 90 }, '2026-08-09');
|
||||
assert.equal(folded, false);
|
||||
assert.equal(out.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todayIso', () => {
|
||||
it('formats YYYY-MM-DD', () => {
|
||||
const s = todayIso(new Date('2026-08-10T12:00:00Z'));
|
||||
assert.match(s, /^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CacheCandleProvider', () => {
|
||||
it('resolves stored candles with asOf = last bar ts and yfinance provenance', async () => {
|
||||
const cache = new FakeCache()
|
||||
.setValue('yfinance:candles:SPY:1d', [bar('2026-08-07', 100), bar('2026-08-10', 105)], false);
|
||||
const provider: CandleProvider = new CacheCandleProvider(cache);
|
||||
const r = await provider.resolve('spy', '1d');
|
||||
assert.equal(r.symbol, 'SPY');
|
||||
assert.equal(r.granularity, '1d');
|
||||
assert.equal(r.candles.length, 2);
|
||||
assert.equal(r.asOf, '2026-08-10');
|
||||
assert.equal(r.lastBar, 'yfinance');
|
||||
assert.equal(r.realtimeFolded, false);
|
||||
assert.equal(r.isStale, false);
|
||||
});
|
||||
|
||||
it('folds a newer live quote into a 1d series', async () => {
|
||||
const quote: Quote = { symbol: 'SPY', price: 110 };
|
||||
const cache = new FakeCache()
|
||||
.setValue('yfinance:candles:SPY:1d', [bar('2026-08-07', 100), bar('2026-08-10', 105)], false)
|
||||
.setValue('yfinance:quote:SPY', quote, false);
|
||||
const r = await new CacheCandleProvider(cache).resolve('SPY', '1d');
|
||||
assert.equal(r.realtimeFolded, true);
|
||||
assert.equal(r.lastBar, 'realtime');
|
||||
assert.equal(r.asOf, todayIso());
|
||||
assert.equal(r.candles[r.candles.length - 1].c, 110);
|
||||
});
|
||||
|
||||
it('does not fold a quote for weekly granularity', async () => {
|
||||
const cache = new FakeCache()
|
||||
.setValue('yfinance:candles:SPY:1wk', [bar('2026-08-07', 500)], false)
|
||||
.setValue('yfinance:quote:SPY', { symbol: 'SPY', price: 520 }, false);
|
||||
const r = await new CacheCandleProvider(cache).resolve('SPY', '1wk');
|
||||
assert.equal(r.realtimeFolded, false);
|
||||
assert.equal(r.lastBar, 'yfinance');
|
||||
assert.equal(r.candles.length, 1);
|
||||
});
|
||||
|
||||
it('flags staleness when candles are absent', async () => {
|
||||
const cache = new FakeCache(); // nothing stored
|
||||
const r = await new CacheCandleProvider(cache).resolve('NVDA', '1d');
|
||||
assert.equal(r.isStale, true);
|
||||
assert.deepEqual(r.candles, []);
|
||||
assert.match(r.asOf, /^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// Investor Flow — Candle Resolution Seam for confluence (M22, slice 8)
|
||||
//
|
||||
// CANDLE_PROVIDER: the single way confluence slot evaluators obtain a symbol's
|
||||
// price history. It resolves daily or weekly candles from the shared cache
|
||||
// (`yfinance:candles:<symbol>:<granularity>`) and optionally folds the freshest
|
||||
// live quote into the series so a mid-session evaluation sees the current price
|
||||
// instead of only the last EOD close.
|
||||
//
|
||||
// Why a seam instead of calling `cache.get` inline:
|
||||
// • evaluators stay testable against fake candle streams,
|
||||
// • one place owns "what does confluence mean by candles" (sorted ascending,
|
||||
// quote fold-in rules, staleness), so a future realtime/replay source can
|
||||
// slot in without touching any slot logic.
|
||||
//
|
||||
// ADR-0007: this is a data seam. It resolves price history; it never emits a
|
||||
// directive. `asOf` on the resolution is the effective evaluation date.
|
||||
//
|
||||
// Pure where possible: `foldRealtimeBar` is a pure function; the cache-backed
|
||||
// provider is a thin shim over CacheRepository.
|
||||
|
||||
import type { CacheRepository, PriceCandle, Quote } from '../cache/CacheRepository.ts';
|
||||
import type { SlotGranularity } from './confluenceSlots.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Where the last bar of the resolved series came from. */
|
||||
export type BarProvenance = 'yfinance' | 'realtime';
|
||||
|
||||
/** A symbol's resolved candle series for one granularity. */
|
||||
export interface CandleResolution {
|
||||
symbol: string;
|
||||
granularity: SlotGranularity;
|
||||
/** Candles sorted ascending by ts. May include a folded-in realtime bar. */
|
||||
candles: PriceCandle[];
|
||||
/** Effective evaluation date (YYYY-MM-DD) = last bar ts, or quote date when folded. */
|
||||
asOf: string;
|
||||
/** Last-bar provenance: folded live quote vs stored EOD bar. */
|
||||
lastBar: BarProvenance;
|
||||
/** True when the folded realtime bar was appended/updated (not a stored bar). */
|
||||
realtimeFolded: boolean;
|
||||
/** True when the underlying cached series is absent or past its freshness window. */
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candle-resolution seam for confluence evaluators and the slot engine.
|
||||
*
|
||||
* `resolve` must return candles sorted ascending by ts. Implementations may be
|
||||
* cache-backed (CacheCandleProvider), precomputed fixtures (tests), or a future
|
||||
* realtime source — evaluators must not care which.
|
||||
*/
|
||||
export interface CandleProvider {
|
||||
resolve(symbol: string, granularity: SlotGranularity): Promise<CandleResolution>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fold the freshest live quote into a daily series.
|
||||
*
|
||||
* Rules:
|
||||
* • no quote / non-finite price ⇒ unchanged
|
||||
* • last bar is already `today` ⇒ its close is replaced with the live price
|
||||
* (O/L/H expanded to contain the print); keeps bar count stable
|
||||
* • last bar is before `today` ⇒ a new bar for `today` is appended with the
|
||||
* quote price (O = last close, L/H bracketing it, V = 0)
|
||||
*
|
||||
* Returns the new array plus whether anything was folded. Pure.
|
||||
*/
|
||||
export function foldRealtimeBar(
|
||||
candles: PriceCandle[],
|
||||
quote: Quote | null | undefined,
|
||||
todayIso: string,
|
||||
): { candles: PriceCandle[]; folded: boolean } {
|
||||
if (!quote?.price || !Number.isFinite(quote.price) || candles.length === 0) {
|
||||
return { candles, folded: false };
|
||||
}
|
||||
|
||||
const last = candles[candles.length - 1];
|
||||
const lastTs = (last.ts ?? '').slice(0, 10);
|
||||
const price = quote.price;
|
||||
|
||||
if (lastTs === todayIso) {
|
||||
const updated: PriceCandle = {
|
||||
ts: last.ts,
|
||||
o: last.o,
|
||||
h: Math.max(last.h, price),
|
||||
l: Math.min(last.l, price),
|
||||
c: price,
|
||||
v: last.v,
|
||||
adjClose: last.adjClose,
|
||||
};
|
||||
return { candles: [...candles.slice(0, -1), updated], folded: true };
|
||||
}
|
||||
|
||||
if (lastTs < todayIso) {
|
||||
const o = last.c;
|
||||
return {
|
||||
candles: [
|
||||
...candles,
|
||||
{ ts: todayIso, o, h: Math.max(o, price), l: Math.min(o, price), c: price, v: 0 },
|
||||
],
|
||||
folded: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { candles, folded: false };
|
||||
}
|
||||
|
||||
/** Current date as YYYY-MM-DD in US/Eastern (the market session's clock). */
|
||||
export function todayIso(now: Date = new Date()): string {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache-backed provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cache-backed CandleProvider. Reads `yfinance:candles:<symbol>:<granularity>`
|
||||
* from the shared cache; for daily granularity it folds the freshest quote in
|
||||
* when the quote is newer than the last stored bar.
|
||||
*/
|
||||
export class CacheCandleProvider implements CandleProvider {
|
||||
private readonly _cache: CacheRepository;
|
||||
|
||||
constructor(cache: CacheRepository) {
|
||||
this._cache = cache;
|
||||
}
|
||||
|
||||
async resolve(symbol: string, granularity: SlotGranularity): Promise<CandleResolution> {
|
||||
const sym = symbol.toUpperCase();
|
||||
const entry = await this._cache.get<PriceCandle[]>(`yfinance:candles:${sym}:${granularity}`);
|
||||
const stored = (entry?.value ?? []).slice();
|
||||
const isStale = entry?.isStale ?? true;
|
||||
|
||||
let candles = stored;
|
||||
let realtimeFolded = false;
|
||||
let lastBar: BarProvenance = 'yfinance';
|
||||
|
||||
if (granularity === '1d') {
|
||||
const quoteEntry = await this._cache.get<Quote>(`yfinance:quote:${sym}`);
|
||||
const quote = quoteEntry?.value;
|
||||
const fold = foldRealtimeBar(candles, quote, todayIso());
|
||||
if (fold.folded && fold.candles.length > 0) {
|
||||
candles = fold.candles;
|
||||
realtimeFolded = true;
|
||||
lastBar = 'realtime';
|
||||
}
|
||||
}
|
||||
|
||||
const lastTs = candles.length > 0 ? (candles[candles.length - 1].ts ?? '').slice(0, 10) : '';
|
||||
const asOf = lastTs || todayIso();
|
||||
|
||||
return {
|
||||
symbol: sym,
|
||||
granularity,
|
||||
candles,
|
||||
asOf,
|
||||
lastBar,
|
||||
realtimeFolded,
|
||||
isStale,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<number> = 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;
|
||||
}
|
||||
@@ -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 = <T,>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user