Skip to content

Commit 6cd4ade

Browse files
authored
feat(updater): add mandatory returnsBasis and performanceAsOf to metrics (#41)
* feat(updater): add mandatory returnsBasis and performanceAsOf to metrics * chore(data): backfill returnsBasis and performanceAsOf
1 parent df5f516 commit 6cd4ade

4 files changed

Lines changed: 39 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ Each fund carries a derived `metrics` object that powers the catalog columns sha
4444
- `siAnn` - since-inception annualized -> *SI Ann.*
4545
- `dividendYield` - 12-month trailing yield or indicated yield (latest distribution x frequency / price)
4646
- `secYield` - 30-day SEC yield when published; unavailable values stay empty and are never shown as 0
47+
- `returnsBasis` - mandatory non-empty text saying how the returns were computed: official WisdomTree month-end Market Price Returns (gaps filled with Yahoo estimates), or estimates derived from Yahoo adjusted closes when the product page is unavailable
48+
- `performanceAsOf` - mandatory ISO `YYYY-MM-DD` date the returns are as of: the WisdomTree month-end performance table date, or the last Yahoo close date when derived (not the NAV date); `null` only when truly unknown
4749
- NAV and the catalog figures come from WisdomTree; history and returns derived from Yahoo adjusted closes are market-price estimates, not official NAV returns
4850
- A limited `HISTORY_RANGE` shortens the published history, so long-tenor figures derived from Yahoo are unavailable for ranges shorter than the tenor
4951

‎api/wisdomtree/index.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@
140140
"dividendYieldText": "3.73%",
141141
"secYield": 3.81,
142142
"secYieldText": "3.81%",
143-
"returnsBasis": "WisdomTree product-page Market Price Returns where published; Yahoo adjusted market-price closes for missing values"
143+
"returnsBasis": "official WisdomTree product-page Market Price Returns (month-end table); gaps filled with estimates derived from Yahoo Finance adjusted closes",
144+
"performanceAsOf": "2026-08-31"
144145
},
145146
"holdings": 5,
146147
"history": 3184

‎scripts/update-data.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { describe, expect, test } from 'bun:test';
44
import { readFileSync } from 'node:fs';
55
import {
66
annualizedToTotal,
7+
deriveMetrics,
8+
performanceAsOfDate,
9+
RETURNS_BASIS_OFFICIAL,
10+
RETURNS_BASIS_DERIVED,
711
cleanHoldingTicker,
812
inferDistributionFrequency,
913
mergeDistributionRecords,
@@ -230,6 +234,24 @@ describe('Yahoo chart and derived metrics', () => {
230234
expect(annualizedToTotal(10, 3)).toBe(33.1);
231235
});
232236

237+
test('metrics end with returnsBasis then performanceAsOf', () => {
238+
const days = [
239+
{ date: '2025-01-02', close: 100, adjClose: 100, volume: 1 },
240+
{ date: '2026-01-02', close: 110, adjClose: 110, volume: 1 },
241+
];
242+
const fund = { dividendYield: null, secYield: null } as any;
243+
const derived = priceReturns(days, new Date('2026-01-03T00:00:00Z'));
244+
const estimated = deriveMetrics(derived, fund, [], { paymentsPerYear: null }, 110);
245+
expect(estimated.returnsBasis).toBe(RETURNS_BASIS_DERIVED);
246+
expect(estimated.performanceAsOf).toBe('2026-01-02');
247+
expect(Object.keys(estimated).slice(-2)).toEqual(['returnsBasis', 'performanceAsOf']);
248+
const official = deriveMetrics({ ...derived, asOfDate: '2025-12-31' }, fund, [], { paymentsPerYear: null }, 110, true);
249+
expect(official.returnsBasis).toBe(RETURNS_BASIS_OFFICIAL);
250+
expect(official.performanceAsOf).toBe('2025-12-31');
251+
expect(performanceAsOfDate({ ...derived, asOfDate: '' })).toBeNull();
252+
expect(estimated.ytd).not.toBe(0);
253+
});
254+
233255
test('frequency inference follows the shared ETF updater convention', () => {
234256
const monthly = [0, 1, 2, 3].map((month) => ({ epoch: Date.UTC(2026, month, 15) / 1000, amount: 0.1 }));
235257
expect(inferDistributionFrequency(monthly)).toEqual({ frequency: 'Monthly', paymentsPerYear: 12 });

‎scripts/update-data.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,7 +1258,16 @@ function lastCompletedQuarterEnd(now = new Date()): string {
12581258
return day.toISOString().slice(0, 10);
12591259
}
12601260

1261-
function deriveMetrics(derived: PriceReturns, fund: CatalogFund, dividends: Array<{ epoch: number; amount: number }>, frequency: { paymentsPerYear: number | null }, price: number | null): JsonRecord {
1261+
export const RETURNS_BASIS_OFFICIAL = 'official WisdomTree product-page Market Price Returns (month-end table); gaps filled with estimates derived from Yahoo Finance adjusted closes';
1262+
export const RETURNS_BASIS_DERIVED = 'estimates derived from Yahoo Finance adjusted market-price closes, not official WisdomTree NAV returns';
1263+
1264+
/** performanceAsOf: the provider's month-end performance table date, or the last Yahoo close date when derived; null when unknown. */
1265+
export function performanceAsOfDate(returns: PriceReturns): string | null {
1266+
const iso = toIsoDate(returns.asOfDate);
1267+
return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : null;
1268+
}
1269+
1270+
export function deriveMetrics(derived: PriceReturns, fund: CatalogFund, dividends: Array<{ epoch: number; amount: number }>, frequency: { paymentsPerYear: number | null }, price: number | null, official = false): JsonRecord {
12621271
const latest = dividends[dividends.length - 1];
12631272
const indicated = fund.dividendYield ?? (latest && frequency.paymentsPerYear && price ? round((latest.amount * frequency.paymentsPerYear / price) * 100, 2) : null);
12641273
return {
@@ -1275,7 +1284,8 @@ function deriveMetrics(derived: PriceReturns, fund: CatalogFund, dividends: Arra
12751284
dividendYieldText: indicated === null ? '—' : `${indicated.toFixed(2)}%`,
12761285
secYield: fund.secYield,
12771286
secYieldText: fund.secYield === null ? '—' : `${fund.secYield.toFixed(2)}%`,
1278-
returnsBasis: 'adjusted market-price closes (Yahoo chart API), not official WisdomTree NAV returns',
1287+
returnsBasis: official ? RETURNS_BASIS_OFFICIAL : RETURNS_BASIS_DERIVED,
1288+
performanceAsOf: performanceAsOfDate(derived),
12791289
};
12801290
}
12811291

@@ -1584,10 +1594,7 @@ async function processFund(fund: CatalogFund, config: UpdaterConfig, previous: J
15841594
const officialReturns = productSummary?.officialReturns.monthEnd || null;
15851595
const effectiveReturns = mergeOfficialReturns(derived, officialReturns);
15861596
const price = productSummary?.marketPrice ?? chart?.regularMarketPrice ?? (days.length ? days[days.length - 1].close : numberOrNull(previous.closePriceValue));
1587-
const metrics = deriveMetrics(effectiveReturns, fund, distributionEvents, frequency, price);
1588-
if (officialReturns) {
1589-
metrics.returnsBasis = 'WisdomTree product-page Market Price Returns where published; Yahoo adjusted market-price closes for missing values';
1590-
}
1597+
const metrics = deriveMetrics(effectiveReturns, fund, distributionEvents, frequency, price, Boolean(officialReturns));
15911598
const returnFilterReasons = matchesReturnFilters(metrics, config);
15921599
if (returnFilterReasons.length) {
15931600
return { __skipped: true, ticker: fund.ticker, __skipReasons: returnFilterReasons };

0 commit comments

Comments
 (0)