|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Compare TypeScript openseries-ts metrics vs Python metrics (from data.json). |
| 4 | + * |
| 5 | + * Loads iris.json into OpenTimeSeries, computes metrics with the TS implementation, |
| 6 | + * and displays them side-by-side with Python-calculated metrics from data.json. |
| 7 | + * |
| 8 | + * Run: npx tsx scripts/compare-metrics.ts [--decimals=N] |
| 9 | + * npm run compare-metrics -- --decimals=6 |
| 10 | + * |
| 11 | + * Options: |
| 12 | + * --decimals=N Number of decimal places for comparison (default: 4) |
| 13 | + * --decimals N Same, space-separated |
| 14 | + * COMPARE_DECIMALS Env var (avoids npm -- when using npm run) |
| 15 | + * |
| 16 | + * Requires iris.json and data.json in ~/Documents (from the Python fetch snippet). |
| 17 | + */ |
| 18 | + |
| 19 | +import { existsSync, readFileSync } from "node:fs"; |
| 20 | +import { homedir } from "node:os"; |
| 21 | +import { join } from "node:path"; |
| 22 | + |
| 23 | +import { OpenTimeSeries } from "../src/series"; |
| 24 | + |
| 25 | +const DOCUMENTS = join(homedir(), "Documents"); |
| 26 | +const IRIS_PATH = join(DOCUMENTS, "iris.json"); |
| 27 | +const DATA_PATH = join(DOCUMENTS, "data.json"); |
| 28 | + |
| 29 | +interface IrisJsonItem { |
| 30 | + dates: string[]; |
| 31 | + values: number[]; |
| 32 | + name?: string; |
| 33 | + timeseries_id?: string; |
| 34 | +} |
| 35 | + |
| 36 | +function loadSeriesFromIrisJson(path: string): OpenTimeSeries { |
| 37 | + const raw = JSON.parse(readFileSync(path, "utf-8")); |
| 38 | + const item: IrisJsonItem = Array.isArray(raw) ? raw[0] : raw; |
| 39 | + return OpenTimeSeries.fromArrays( |
| 40 | + item.name ?? "Series", |
| 41 | + item.dates, |
| 42 | + item.values, |
| 43 | + { timeseriesId: item.timeseries_id ?? "", countries: "SE" }, |
| 44 | + ); |
| 45 | +} |
| 46 | + |
| 47 | +function loadPythonMetrics(path: string): Record<string, unknown> { |
| 48 | + const raw = JSON.parse(readFileSync(path, "utf-8")); |
| 49 | + // data.json: {"Captor Iris Bond": {"value_ret": 0.025, "geo_ret": ..., ...}} |
| 50 | + const col = typeof raw === "object" && raw !== null ? Object.values(raw)[0] : raw; |
| 51 | + return (col as Record<string, unknown>) ?? {}; |
| 52 | +} |
| 53 | + |
| 54 | +function tsDateStr(v: unknown): string { |
| 55 | + if (typeof v === "string") return v.slice(0, 10); |
| 56 | + return String(v); |
| 57 | +} |
| 58 | + |
| 59 | +function roundToStr(n: number, decimals: number): string { |
| 60 | + if (Number.isNaN(n)) return "NaN"; |
| 61 | + if (!Number.isFinite(n)) return String(n); |
| 62 | + if (decimals <= 0) return String(Math.round(n)); |
| 63 | + const rounded = Number(n.toFixed(decimals)); |
| 64 | + return Number.isInteger(rounded) ? String(rounded) : n.toFixed(decimals); |
| 65 | +} |
| 66 | + |
| 67 | +function formatForDisplay(v: unknown, decimals: number): string { |
| 68 | + if (v === undefined) return "—"; |
| 69 | + if (typeof v === "number") return roundToStr(v, decimals); |
| 70 | + return tsDateStr(v); |
| 71 | +} |
| 72 | + |
| 73 | +function parseDecimals(args: string[]): number { |
| 74 | + const def = 4; |
| 75 | + // --decimals=N |
| 76 | + const eqArg = args.find((a) => a.startsWith("--decimals=")); |
| 77 | + if (eqArg) { |
| 78 | + const n = parseInt(eqArg.slice(11), 10); |
| 79 | + if (Number.isInteger(n) && n >= 0) return n; |
| 80 | + } |
| 81 | + // --decimals N |
| 82 | + const idx = args.indexOf("--decimals"); |
| 83 | + if (idx >= 0 && args[idx + 1] != null) { |
| 84 | + const n = parseInt(args[idx + 1]!, 10); |
| 85 | + if (Number.isInteger(n) && n >= 0) return n; |
| 86 | + } |
| 87 | + // env COMPARE_DECIMALS (works with npm run even without --) |
| 88 | + const env = process.env.COMPARE_DECIMALS; |
| 89 | + if (env) { |
| 90 | + const n = parseInt(env, 10); |
| 91 | + if (Number.isInteger(n) && n >= 0) return n; |
| 92 | + } |
| 93 | + return def; |
| 94 | +} |
| 95 | + |
| 96 | +function main(): void { |
| 97 | + const decimals = parseDecimals(process.argv.slice(2)); |
| 98 | + |
| 99 | + if (!existsSync(IRIS_PATH)) { |
| 100 | + console.error(`iris.json not found at ${IRIS_PATH}`); |
| 101 | + process.exit(1); |
| 102 | + } |
| 103 | + if (!existsSync(DATA_PATH)) { |
| 104 | + console.error(`data.json not found at ${DATA_PATH}`); |
| 105 | + process.exit(1); |
| 106 | + } |
| 107 | + |
| 108 | + console.log("Loading timeseries from iris.json..."); |
| 109 | + const series = loadSeriesFromIrisJson(IRIS_PATH); |
| 110 | + |
| 111 | + console.log("Loading Python metrics from data.json..."); |
| 112 | + const pythonMetrics = loadPythonMetrics(DATA_PATH); |
| 113 | + |
| 114 | + // Compute TS metrics; map Python property names to TS getters/methods |
| 115 | + const tsMetrics: Record<string, string | number> = {}; |
| 116 | + |
| 117 | + const metrics: { pyKey: string; getTs: () => number | string }[] = [ |
| 118 | + { pyKey: "value_ret", getTs: () => series.valueRet() }, |
| 119 | + { pyKey: "geo_ret", getTs: () => series.geoRet() }, |
| 120 | + { pyKey: "arithmetic_ret", getTs: () => series.arithmeticRet() }, |
| 121 | + { pyKey: "vol", getTs: () => series.vol() }, |
| 122 | + { pyKey: "downside_deviation", getTs: () => series.downsideDeviation() }, |
| 123 | + { pyKey: "ret_vol_ratio", getTs: () => series.retVolRatio(0) }, |
| 124 | + { pyKey: "sortino_ratio", getTs: () => series.sortinoRatio(0, 0) }, |
| 125 | + { pyKey: "z_score", getTs: () => series.zScore() }, |
| 126 | + { pyKey: "skew", getTs: () => series.skew() }, |
| 127 | + { pyKey: "kurtosis", getTs: () => series.kurtosis() }, |
| 128 | + { pyKey: "positive_share", getTs: () => series.positiveShare() }, |
| 129 | + { pyKey: "var_down", getTs: () => series.varDown(0.95) }, |
| 130 | + { pyKey: "cvar_down", getTs: () => series.cvarDown(0.95) }, |
| 131 | + { pyKey: "vol_from_var", getTs: () => series.volFromVar(0.95) }, |
| 132 | + { pyKey: "worst", getTs: () => series.worst(1) }, |
| 133 | + { pyKey: "worst_month", getTs: () => series.worstMonth() }, |
| 134 | + { pyKey: "max_drawdown", getTs: () => series.maxDrawdown() }, |
| 135 | + { pyKey: "first_idx", getTs: () => series.firstIdx }, |
| 136 | + { pyKey: "last_idx", getTs: () => series.lastIdx }, |
| 137 | + { pyKey: "length", getTs: () => series.length }, |
| 138 | + { pyKey: "span_of_days", getTs: () => series.spanOfDays }, |
| 139 | + { pyKey: "yearfrac", getTs: () => series.yearfrac }, |
| 140 | + { pyKey: "periods_in_a_year", getTs: () => series.periodsInAYear }, |
| 141 | + ]; |
| 142 | + |
| 143 | + for (const { pyKey, getTs } of metrics) { |
| 144 | + try { |
| 145 | + const v = getTs(); |
| 146 | + tsMetrics[pyKey] = typeof v === "number" ? v : v; |
| 147 | + } catch { |
| 148 | + tsMetrics[pyKey] = NaN; |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + // Build comparison table |
| 153 | + const allKeys = new Set([ |
| 154 | + ...Object.keys(pythonMetrics), |
| 155 | + ...Object.keys(tsMetrics), |
| 156 | + ]); |
| 157 | + const sortedKeys = [...allKeys].sort(); |
| 158 | + |
| 159 | + const rows: string[] = []; |
| 160 | + let matches = 0; |
| 161 | + |
| 162 | + const col1 = 35; |
| 163 | + const col2 = 28; |
| 164 | + const col3 = 28; |
| 165 | + const col4 = 6; |
| 166 | + |
| 167 | + const header = |
| 168 | + "Metric".padEnd(col1) + |
| 169 | + "Python (data.json)".padEnd(col2) + |
| 170 | + "TypeScript (openseries-ts)".padEnd(col3) + |
| 171 | + "Match".padEnd(col4); |
| 172 | + rows.push(header); |
| 173 | + rows.push("=".repeat(col1 + col2 + col3 + col4)); |
| 174 | + |
| 175 | + for (const key of sortedKeys) { |
| 176 | + const pyVal = pythonMetrics[key]; |
| 177 | + const tsVal = tsMetrics[key]; |
| 178 | + |
| 179 | + const pyStr = formatForDisplay(pyVal, decimals); |
| 180 | + const tsStr = formatForDisplay(tsVal, decimals); |
| 181 | + |
| 182 | + let match = ""; |
| 183 | + if (pyVal !== undefined && tsVal !== undefined) { |
| 184 | + let matchStr = false; |
| 185 | + if (typeof pyVal === "number" && typeof tsVal === "number") { |
| 186 | + matchStr = roundToStr(pyVal, decimals) === roundToStr(tsVal, decimals); |
| 187 | + } else { |
| 188 | + matchStr = tsDateStr(pyVal) === String(tsVal).slice(0, 10); |
| 189 | + } |
| 190 | + if (matchStr) { |
| 191 | + match = "✓"; |
| 192 | + matches++; |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + rows.push( |
| 197 | + key.padEnd(col1) + |
| 198 | + pyStr.padEnd(col2) + |
| 199 | + tsStr.padEnd(col3) + |
| 200 | + match.padEnd(col4), |
| 201 | + ); |
| 202 | + } |
| 203 | + |
| 204 | + console.log("\n" + rows.join("\n")); |
| 205 | + console.log("=".repeat(col1 + col2 + col3 + col4)); |
| 206 | + |
| 207 | + const compared = sortedKeys.filter((k) => pythonMetrics[k] !== undefined && tsMetrics[k] !== undefined); |
| 208 | + console.log(`\nMatched: ${matches}/${compared.length} (rounded to ${decimals} decimals or same date)`); |
| 209 | +} |
| 210 | + |
| 211 | +main(); |
0 commit comments