Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
# Get your free API key at https://www.metals.dev
METALS_DEV_API_KEY=your_api_key_here

# Cache: how long (in hours) before re-fetching the latest spot price.
# Defaults to 24 hours if unset or invalid. Set to 0 to disable caching.
# CACHE_MAX_AGE_HOURS=24
13 changes: 13 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Glossary

## Cache
Locally stored API response that avoids redundant network requests. Stored as JSON files under `~/.cache/goldcli/`. Each file contains the response data plus an ISO timestamp (`cachedAt`) for staleness checks. Not a database — just flat JSON files.

## Cache TTL
Time-to-live for cached data. Configurable via `CACHE_MAX_AGE_HOURS` env var (default 24). A cached response is "fresh" if its age is less than the TTL. Set to 0 to disable caching. Applies to the `/latest` endpoint only — timeseries data is cached indefinitely (past prices never change).

## Refresh
CLI flag (`--refresh` / `-f`) that bypasses the cache and fetches fresh data from the API. After the API call completes, the response is written to cache so subsequent non-refresh runs benefit. Not the same as clearing the cache.

## Timeseries
Historical daily metal prices from the metals.dev `/timeseries` endpoint. Because past prices are immutable, this data is fetched once and cached forever. On subsequent runs, the cache is checked for gaps (dates not yet fetched) and only the missing dates are fetched incrementally. Results are pruned to ~366 days to prevent unbounded growth.
53 changes: 53 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { resolve } from 'path';

const CACHE_DIR = resolve(homedir(), '.cache', 'goldcli');

interface CacheEntry<T> {
cachedAt: string;
data: T;
}

function getCacheDir(): string {
return CACHE_DIR;
}

function cachePath(key: string): string {
return resolve(getCacheDir(), `${key}.json`);
}

export function getCacheMaxAgeHours(): number {
const raw = process.env.CACHE_MAX_AGE_HOURS;
if (!raw) return 24;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : 24;
}

export function isCacheFresh(cachedAt: string, maxAgeHours: number): boolean {
const age = Date.now() - new Date(cachedAt).getTime();
return age < maxAgeHours * 3_600_000;
}

export function readCache<T>(key: string): CacheEntry<T> | null {
const path = cachePath(key);
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as CacheEntry<T>;
} catch {
return null;
}
}

export function writeCache<T>(key: string, data: T): void {
mkdirSync(getCacheDir(), { recursive: true });
const entry: CacheEntry<T> = { cachedAt: new Date().toISOString(), data };
writeFileSync(cachePath(key), JSON.stringify(entry));
}

export function deleteCache(key: string): void {
const path = cachePath(key);
if (existsSync(path)) {
writeFileSync(path, '');
}
}
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ program
.option('-a, --asset <name>', 'Asset to check (gold, silver, XAU, XAG). Shows all if omitted.')
.option('-m, --metal <name>', 'Alias for --asset')
.option('-c, --currency <code>', 'Currency for prices (e.g. USD, EUR, GBP)', 'USD')
.option('-f, --refresh', 'Ignore cache and fetch fresh data from the API')
.parse(process.argv);

const opts = program.opts<{ asset?: string; metal?: string; currency?: string }>();
const opts = program.opts<{ asset?: string; metal?: string; currency?: string; refresh?: boolean }>();

getPriceReports(opts.asset ?? opts.metal, opts.currency)
getPriceReports(opts.asset ?? opts.metal, opts.currency, opts.refresh ?? false)
.then(displayReports)
.catch((err: Error) => {
console.error(`${chalk.red('Error:')} ${err.message}`);
Expand Down
148 changes: 112 additions & 36 deletions src/metals-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@ import dotenv from 'dotenv';
import { homedir } from 'os';
import { resolve } from 'path';
import { LatestResponse, TimeseriesDay, TimeseriesResponse } from './types.js';
import { getCacheMaxAgeHours, readCache, writeCache } from './cache.js';

dotenv.config();
dotenv.config({ path: resolve(homedir(), '.config', 'goldcli', '.env'), override: false });

const BASE_URL = 'https://api.metals.dev/v1';

/**
* The metals.dev free-tier timeseries endpoint has a 30-day maximum range.
* Set higher if your plan supports a longer window.
*/
const MAX_TIMESERIES_RANGE_DAYS = 30;

function getApiKey(): string {
Expand All @@ -35,7 +31,17 @@ function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10);
}

export async function fetchLatest(currency = 'USD'): Promise<LatestResponse> {
export async function fetchLatest(currency = 'USD', refresh = false): Promise<LatestResponse> {
const maxAgeHours = getCacheMaxAgeHours();
const cacheKey = `latest-${currency}`;

if (!refresh && maxAgeHours > 0) {
const cached = readCache<LatestResponse>(cacheKey);
if (cached && Date.now() - new Date(cached.cachedAt).getTime() < maxAgeHours * 3_600_000) {
return cached.data;
}
}

const apiKey = getApiKey();
const url = `${BASE_URL}/latest?api_key=${apiKey}&currency=${currency}&unit=toz`;

Expand All @@ -45,52 +51,122 @@ export async function fetchLatest(currency = 'USD'): Promise<LatestResponse> {
throw new Error(`Latest endpoint error (${res.status}): ${await res.text()}`);
}

return res.json() as Promise<LatestResponse>;
const data = (await res.json()) as LatestResponse;

if (maxAgeHours > 0) {
writeCache(cacheKey, data);
}

return data;
}

/**
* Fetch timeseries data covering the last year.
*
* Because the free tier limits each request to 30 days, this method
* automatically chops the 1-year window into 30‑day chunks and merges
* the results into a single rates object.
*/
export async function fetchAllTimeseries(currency = 'USD'): Promise<Record<string, TimeseriesDay>> {
const apiKey = getApiKey();
const end = new Date();
const start = new Date(end);
start.setFullYear(start.getFullYear() - 1);
function findMissingWindows(
existing: Record<string, unknown>,
start: Date,
end: Date,
): Array<{ start: Date; end: Date }> {
const windows: Array<{ start: Date; end: Date }> = [];
let windowStart: Date | null = null;
const cur = new Date(start);

while (cur <= end) {
const key = fmtDate(cur);
if (!(key in existing)) {
if (!windowStart) windowStart = new Date(cur);
} else if (windowStart) {
windows.push({ start: windowStart, end: new Date(cur.getTime() - 86_400_000) });
windowStart = null;
}
cur.setDate(cur.getDate() + 1);
}

const windows: { s: Date; e: Date }[] = [];
let cur = new Date(start);
if (windowStart) {
windows.push({ start: windowStart, end });
}

while (cur < end) {
const winEnd = new Date(cur);
winEnd.setDate(winEnd.getDate() + MAX_TIMESERIES_RANGE_DAYS - 1);
return windows;
}

if (winEnd >= end) {
windows.push({ s: cur, e: end });
function chunkWindow(start: Date, end: Date): Array<{ start: Date; end: Date }> {
const chunks: Array<{ start: Date; end: Date }> = [];
let cur = new Date(start);
while (cur < end) {
const chunkEnd = new Date(cur);
chunkEnd.setDate(chunkEnd.getDate() + MAX_TIMESERIES_RANGE_DAYS - 1);
if (chunkEnd >= end) {
chunks.push({ start: cur, end });
break;
}

windows.push({ s: cur, e: winEnd });
cur = new Date(winEnd);
chunks.push({ start: cur, end: chunkEnd });
cur = new Date(chunkEnd);
cur.setDate(cur.getDate() + 1);
}
return chunks;
}

const allRates: Record<string, TimeseriesDay> = {};

async function fetchWindows(
apiKey: string,
currency: string,
windows: Array<{ start: Date; end: Date }>,
): Promise<Record<string, TimeseriesDay>> {
const rates: Record<string, TimeseriesDay> = {};
for (const w of windows) {
const url = `${BASE_URL}/timeseries?api_key=${apiKey}&currency=${currency}&start_date=${fmtDate(w.s)}&end_date=${fmtDate(w.e)}`;
const url = `${BASE_URL}/timeseries?api_key=${apiKey}&currency=${currency}&start_date=${fmtDate(w.start)}&end_date=${fmtDate(w.end)}`;
const res = await fetch(url);

if (!res.ok) {
throw new Error(`Timeseries endpoint error (${res.status}): ${await res.text()}`);
}

const data = (await res.json()) as TimeseriesResponse;
Object.assign(allRates, data.rates);
Object.assign(rates, data.rates);
}
return rates;
}

export async function fetchAllTimeseries(currency = 'USD', refresh = false): Promise<Record<string, TimeseriesDay>> {
const maxAgeHours = getCacheMaxAgeHours();
const cacheKey = `timeseries-${currency}`;

const end = new Date();
const start = new Date(end);
start.setFullYear(start.getFullYear() - 1);

let existing: Record<string, TimeseriesDay> = {};

if (!refresh && maxAgeHours > 0) {
const cached = readCache<Record<string, TimeseriesDay>>(cacheKey);
if (cached) {
existing = cached.data;
}
}

const missingWindows = findMissingWindows(existing, start, end);

if (missingWindows.length === 0) {
return existing;
}

const apiKey = getApiKey();
const allRates: Record<string, TimeseriesDay> = {};

for (const mw of missingWindows) {
const chunks = chunkWindow(mw.start, mw.end);
const chunkRates = await fetchWindows(apiKey, currency, chunks);
Object.assign(allRates, chunkRates);
}

const merged = { ...existing, ...allRates };

const cutoff = fmtDate(new Date(start.getTime() - 86_400_000));
const pruned: Record<string, TimeseriesDay> = {};
for (const [date, val] of Object.entries(merged)) {
if (date >= cutoff) {
pruned[date] = val;
}
}

if (maxAgeHours > 0) {
writeCache(cacheKey, pruned);
}

return allRates;
return pruned;
}
11 changes: 8 additions & 3 deletions src/prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,20 @@ export function findPrice(
* The timeseries endpoint always returns metals in USD.
* Convert to the target currency using that day's exchange rate.
*/
export function convertFromUsd(usdPrice: number, date: string, rates: Record<string, TimeseriesDay>, currency: string): number {
export function convertFromUsd(
usdPrice: number,
date: string,
rates: Record<string, TimeseriesDay>,
currency: string,
): number {
const day = rates[date];
const rate = day?.currencies?.[currency];
if (!rate) return usdPrice;
return usdPrice / rate;
}

export async function getPriceReports(assetFilter?: string, currency = 'USD'): Promise<PriceReport[]> {
const [latest, rates] = await Promise.all([fetchLatest(currency), fetchAllTimeseries(currency)]);
export async function getPriceReports(assetFilter?: string, currency = 'USD', refresh = false): Promise<PriceReport[]> {
const [latest, rates] = await Promise.all([fetchLatest(currency, refresh), fetchAllTimeseries(currency, refresh)]);

const assets = resolveAssets(assetFilter);
const today = new Date();
Expand Down
Loading