slice 4a (omlx/ornith-35): parseAdjustments (chart events -> price_adjustments) + 12 tests
First dispatch to the LOCAL ornith-35 (omlx) — ~3min vs ~8min for remote. Pure fn parses yf2 chart events (splits/dividends, array or map) into PriceAdjustment[]; 12 tests incl. NVDA 5y fixture (2 splits + 26 dividends). Reviewed + approved.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
// Investor Flow — parseAdjustments tests.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseAdjustments } from "../yfinance-adjustments.ts";
|
||||
import fixture from "./fixtures/nvda.chart5y.json" with { type: "json" };
|
||||
|
||||
test("NVDA 5y fixture: 2 splits + 26 dividends = 28, sorted asc", () => {
|
||||
const out = parseAdjustments("NVDA", fixture as unknown as Record<string, unknown>);
|
||||
assert.equal(out.length, 28);
|
||||
|
||||
// Verify sorted ascending by exDate.
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
assert.ok(
|
||||
out[i - 1].exDate <= out[i].exDate,
|
||||
`out of order at index ${i}: ${out[i - 1].exDate} > ${out[i].exDate}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Exactly 2 splits, both with ratio 4.0 and 10.0.
|
||||
const splits = out.filter((a) => a.type === "split");
|
||||
assert.equal(splits.length, 2);
|
||||
assert.ok(splits.some((s) => s.ratio === 4.0), "should contain 4:1 split");
|
||||
assert.ok(splits.some((s) => s.ratio === 10.0), "should contain 10:1 split");
|
||||
|
||||
// 26 dividends, each ratio is a number.
|
||||
const divs = out.filter((a) => a.type === "dividend");
|
||||
assert.equal(divs.length, 26);
|
||||
for (const d of divs) {
|
||||
assert.equal(typeof d.ratio, "number", "dividend ratio must be a number");
|
||||
assert.ok(d.ratio > 0, "dividend ratio must be positive");
|
||||
}
|
||||
|
||||
// All rows carry the symbol.
|
||||
assert.ok(out.every((a) => a.symbol === "NVDA"), "all rows should carry symbol NVDA");
|
||||
});
|
||||
|
||||
test("one split has ratio 4.0", () => {
|
||||
const out = parseAdjustments("NVDA", fixture as unknown as Record<string, unknown>);
|
||||
const s = out.find((a) => a.type === "split" && a.ratio === 4.0);
|
||||
assert.ok(s, "expected a split with ratio 4.0");
|
||||
assert.equal(s!.exDate, "2021-07-20");
|
||||
});
|
||||
|
||||
test("a dividend ratio is a number", () => {
|
||||
const out = parseAdjustments("NVDA", fixture as unknown as Record<string, unknown>);
|
||||
const d = out.find((a) => a.type === "dividend");
|
||||
assert.ok(d, "expected at least one dividend");
|
||||
assert.equal(typeof d!.ratio, "number");
|
||||
});
|
||||
|
||||
test("empty chartJson -> []", () => {
|
||||
const out = parseAdjustments("X", {});
|
||||
assert.deepEqual(out, []);
|
||||
});
|
||||
|
||||
test("missing events -> []", () => {
|
||||
const out = parseAdjustments("X", { quotes: [] });
|
||||
assert.deepEqual(out, []);
|
||||
});
|
||||
|
||||
test("single split from map-style events", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: {
|
||||
"1": { date: "2024-06-10T13:30:00Z", numerator: 10, denominator: 1 },
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("X", chart);
|
||||
assert.equal(out.length, 1);
|
||||
assert.deepEqual(out[0], {
|
||||
symbol: "X",
|
||||
exDate: "2024-06-10",
|
||||
type: "split",
|
||||
ratio: 10.0,
|
||||
});
|
||||
});
|
||||
|
||||
test("array-style splits + dividends mixed and sorted", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: [
|
||||
{ date: "2024-06-10T13:30:00Z", numerator: 2, denominator: 1 },
|
||||
],
|
||||
dividends: [
|
||||
{ date: "2024-03-01T00:00:00Z", amount: 0.5 },
|
||||
{ date: "2024-09-15T00:00:00Z", amount: 0.25 },
|
||||
],
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("TSLA", chart);
|
||||
assert.equal(out.length, 3);
|
||||
assert.equal(out[0].exDate, "2024-03-01");
|
||||
assert.equal(out[0].type, "dividend");
|
||||
assert.equal(out[1].exDate, "2024-06-10");
|
||||
assert.equal(out[1].type, "split");
|
||||
assert.equal(out[2].exDate, "2024-09-15");
|
||||
assert.equal(out[2].type, "dividend");
|
||||
});
|
||||
|
||||
test("defensive: null/undefined entries inside splits array are skipped", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: [
|
||||
null,
|
||||
undefined,
|
||||
{ date: "2024-01-01T00:00:00Z", numerator: 3, denominator: 1 },
|
||||
],
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("A", chart);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].ratio, 3.0);
|
||||
});
|
||||
|
||||
test("defensive: entries missing date are skipped", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: [{ numerator: 5, denominator: 1 }], // no date
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("B", chart);
|
||||
assert.equal(out.length, 0);
|
||||
});
|
||||
|
||||
test("defensive: events is a map (not array) for splits/dividends", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: {
|
||||
"a": { date: "2024-01-01T00:00:00Z", numerator: 2, denominator: 1 },
|
||||
"b": { date: "2024-06-01T00:00:00Z", numerator: 3, denominator: 1 },
|
||||
},
|
||||
dividends: {
|
||||
"x": { date: "2024-03-15T00:00:00Z", amount: 0.1 },
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("C", chart);
|
||||
assert.equal(out.length, 3);
|
||||
// Should be sorted by date.
|
||||
assert.equal(out[0].exDate, "2024-01-01");
|
||||
assert.equal(out[1].exDate, "2024-03-15");
|
||||
assert.equal(out[2].exDate, "2024-06-01");
|
||||
});
|
||||
|
||||
test("defensive: zero denominator defaults to 1", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: [{ date: "2024-01-01T00:00:00Z", numerator: 4, denominator: 0 }],
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("Z", chart);
|
||||
assert.equal(out[0].ratio, 4.0); // 4 / 1 (denominator clamped to 1)
|
||||
});
|
||||
|
||||
test("defensive: non-number numerator/amount handled gracefully", () => {
|
||||
const chart = {
|
||||
events: {
|
||||
splits: [{ date: "2024-01-01T00:00:00Z", numerator: "bad" as unknown as number, denominator: 2 }],
|
||||
dividends: [{ date: "2024-02-01T00:00:00Z", amount: "bad" as unknown as number }],
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
const out = parseAdjustments("W", chart);
|
||||
assert.equal(out.length, 2);
|
||||
// numerator "bad" -> defaults to 1, so ratio = 1/2 = 0.5
|
||||
assert.equal(out[0].ratio, 0.5);
|
||||
// amount "bad" -> defaults to 0
|
||||
assert.equal(out[1].ratio, 0);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Investor Flow — yfinance-adjustments
|
||||
// Pure parser for yahoo-finance2 v3 chart `events` payloads into PriceAdjustment rows.
|
||||
// Zero runtime dependencies: this module only reads JSON-shaped input and emits objects.
|
||||
|
||||
/**
|
||||
* A single corporate-action adjustment row produced by parseAdjustments().
|
||||
*
|
||||
* - `symbol` is the ticker the chart was fetched for.
|
||||
* - `exDate` is the event date in YYYY-MM-DD (UTC, derived from ISO input).
|
||||
* - `type` is either "split" or "dividend".
|
||||
* - `ratio`:
|
||||
* split → numerator / denominator (e.g. 4.0 for a 4:1 split).
|
||||
* dividend → amount per share in USD (e.g. 0.01).
|
||||
*/
|
||||
export interface PriceAdjustment {
|
||||
symbol: string;
|
||||
exDate: string;
|
||||
type: "split" | "dividend";
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
type SplitEvent = { date: string; numerator: number; denominator: number; splitRatio?: string };
|
||||
type DividendEvent = { date: string; amount: number };
|
||||
|
||||
/**
|
||||
* Parse a yahoo-finance2 v3 chart response's `events` object into a flat list of
|
||||
* PriceAdjustment rows, sorted by exDate ascending.
|
||||
*
|
||||
* The function is defensive:
|
||||
* - `chartJson.events` may be absent or null/undefined → return [].
|
||||
* - `splits` / `dividends` may be absent, null, an array, or a map of keyed objects.
|
||||
* Both shapes are handled via `Object.values()`.
|
||||
* - Entries missing a `date` are skipped.
|
||||
*/
|
||||
export function parseAdjustments(
|
||||
symbol: string,
|
||||
chartJson: Record<string, unknown>,
|
||||
): PriceAdjustment[] {
|
||||
const result: PriceAdjustment[] = [];
|
||||
|
||||
const events = (chartJson.events as Record<string, unknown>) ?? {};
|
||||
emitFrom(events.splits, symbol, "split", result);
|
||||
emitFrom(events.dividends, symbol, "dividend", result);
|
||||
|
||||
result.sort((a, b) => (a.exDate < b.exDate ? -1 : a.exDate > b.exDate ? 1 : 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
function emitFrom(
|
||||
source: unknown,
|
||||
symbol: string,
|
||||
type: "split" | "dividend",
|
||||
out: PriceAdjustment[],
|
||||
): void {
|
||||
if (!source) return;
|
||||
|
||||
const entries: Record<string, unknown>[] = Array.isArray(source)
|
||||
? (source as Record<string, unknown>[])
|
||||
: Object.values(source as Record<string, unknown>);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
|
||||
if (type === "split") {
|
||||
const e = entry as SplitEvent;
|
||||
if (!e.date) continue;
|
||||
const numerator = typeof e.numerator === "number" ? e.numerator : 1;
|
||||
const denominator = typeof e.denominator === "number" && e.denominator !== 0 ? e.denominator : 1;
|
||||
out.push({
|
||||
symbol,
|
||||
exDate: e.date.slice(0, 10),
|
||||
type: "split",
|
||||
ratio: numerator / denominator,
|
||||
});
|
||||
} else {
|
||||
const e = entry as DividendEvent;
|
||||
if (!e.date) continue;
|
||||
const amount = typeof e.amount === "number" ? e.amount : 0;
|
||||
out.push({
|
||||
symbol,
|
||||
exDate: e.date.slice(0, 10),
|
||||
type: "dividend",
|
||||
ratio: amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user