diff --git a/package-lock.json b/package-lock.json index 393a70a..0aad7db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,9 +67,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.12", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", - "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -465,9 +465,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -477,13 +477,12 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -493,9 +492,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -511,9 +510,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@types/node": { @@ -1179,12 +1178,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1209,9 +1208,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -1545,9 +1544,9 @@ } }, "node_modules/hono": { - "version": "4.12.10", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", - "integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==", + "version": "4.12.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1644,9 +1643,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -2086,22 +2085,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, diff --git a/package.json b/package.json index d63a8a3..5a03c8a 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "server": "node src/web/server.js", "dev": "node src/web/server.js --dev", "build": "node scripts/build.js", - "test": "node --test tests/*.test.js", - "test:unit": "node --test tests/*.test.js" + "test": "node --test tests/*.test.js src/**/__tests__/*.test.js", + "test:unit": "node --test tests/*.test.js src/**/__tests__/*.test.js" }, "dependencies": { "@anthropic-ai/sdk": "^0.36.3", diff --git a/src/index.js b/src/index.js index 0b95c3a..cdf3ea4 100644 --- a/src/index.js +++ b/src/index.js @@ -50,6 +50,35 @@ export { Series, BarData, taCore, ta, math, BacktestEngine } from './pine/oakscr // ── Charts / Lightweight Charts Plugins ── export { generateChartHTML, generateMultiPaneHTML, generatePluginPine, listPlugins, CUSTOM_SERIES, DRAWING_PRIMITIVES } from './charts/plugins.js'; +// ── Quantitative Research (institutional analytics) ── +export { + // returns + logReturns, pctReturns, cumulativeReturn, annualize, + // moments + mean, variance, stdev, skewness, kurtosis, + // risk-adjusted + sharpeRatio, sortinoRatio, calmarRatio, omegaRatio, informationRatio, + // drawdown + equityCurveFromReturns, maxDrawdown, underwaterCurve, + // VaR + historicalVaR, historicalCVaR, parametricVaR, + // time-series + autocorrelation, hurstExponent, halfLife, rollingBeta, rollingCorrelation, + // vol models + ewmaVolatility, realizedVolatility, garmanKlass, + // sizing + kellyFraction, volTargetSize, fixedFractional, + // portfolio + riskParityWeights, equalWeights, minVarianceWeights, covarianceMatrix, portfolioReturn, + // monte carlo + monteCarloBootstrap, + // regime + tearsheet + classifyRegime, performanceReport, +} from './pine/quant-research.js'; + +// ── Seamless TradingView Quant Tooling ── +export { quantReport, quantScan } from './tv/quant-report.js'; + // ── Streaming / Monitoring ── export { watchChart, scanSymbols, watchIndicator } from './agent/stream.js'; diff --git a/src/pine/analyzer.js b/src/pine/analyzer.js index 52c7e98..fcb8146 100644 --- a/src/pine/analyzer.js +++ b/src/pine/analyzer.js @@ -4,7 +4,9 @@ * live TradingView compilation via the MCP server. * Enhanced with linter integration, OakScript validation, and deep analysis. */ -import { pine, capture } from '../mcp/client.js'; +// CDP-touching imports (pine, capture) are deferred to function scope +// so static-analysis consumers (and their tests) don't pull in +// chrome-remote-interface unnecessarily. import { lintPineScript, detectsRepainting, auditStrategy } from './linter.js'; import { taCore, BacktestEngine } from './oakscript.js'; @@ -216,6 +218,8 @@ export async function developScript(source, opts = {}) { } } + const { pine, capture } = await import('../mcp/client.js'); + // Step 2: Server-side check (no chart needed) log('🔍 Server-side validation...'); let checkResult; diff --git a/src/pine/oakscript.js b/src/pine/oakscript.js index e23ed7e..efa2a0a 100644 --- a/src/pine/oakscript.js +++ b/src/pine/oakscript.js @@ -917,6 +917,209 @@ export const taCore = { return cumPV.map((pv, i) => cumV[i] !== 0 ? pv / cumV[i] : NaN); }, + // ── Volume / Money Flow ── + + obv(close, volume) { + const r = new Array(close.length).fill(0); + for (let i = 1; i < close.length; i++) { + const dir = close[i] > close[i - 1] ? 1 : close[i] < close[i - 1] ? -1 : 0; + r[i] = r[i - 1] + dir * volume[i]; + } + return r; + }, + + ad(high, low, close, volume) { + const r = new Array(close.length).fill(0); + for (let i = 0; i < close.length; i++) { + const range = high[i] - low[i]; + const mfm = range !== 0 ? ((close[i] - low[i]) - (high[i] - close[i])) / range : 0; + r[i] = (i > 0 ? r[i - 1] : 0) + mfm * volume[i]; + } + return r; + }, + + cmf(high, low, close, volume, length = 20) { + const r = new Array(close.length).fill(NaN); + const mfv = new Array(close.length).fill(0); + for (let i = 0; i < close.length; i++) { + const range = high[i] - low[i]; + mfv[i] = range !== 0 + ? (((close[i] - low[i]) - (high[i] - close[i])) / range) * volume[i] + : 0; + } + for (let i = length - 1; i < close.length; i++) { + let sumMfv = 0, sumV = 0; + for (let j = 0; j < length; j++) { sumMfv += mfv[i - j]; sumV += volume[i - j]; } + r[i] = sumV !== 0 ? sumMfv / sumV : 0; + } + return r; + }, + + efi(close, volume, length = 13) { + const raw = new Array(close.length).fill(0); + for (let i = 1; i < close.length; i++) raw[i] = (close[i] - close[i - 1]) * volume[i]; + return this.ema(raw, length); + }, + + pvt(close, volume) { + const r = new Array(close.length).fill(0); + for (let i = 1; i < close.length; i++) { + const pctChange = close[i - 1] !== 0 ? (close[i] - close[i - 1]) / close[i - 1] : 0; + r[i] = r[i - 1] + pctChange * volume[i]; + } + return r; + }, + + // ── Trend / Direction ── + + vortex(high, low, close, length = 14) { + const vmPlus = new Array(close.length).fill(0); + const vmMinus = new Array(close.length).fill(0); + const tr = new Array(close.length).fill(0); + for (let i = 1; i < close.length; i++) { + vmPlus[i] = Math.abs(high[i] - low[i - 1]); + vmMinus[i] = Math.abs(low[i] - high[i - 1]); + tr[i] = Math.max(high[i] - low[i], Math.abs(high[i] - close[i - 1]), Math.abs(low[i] - close[i - 1])); + } + const viPlus = new Array(close.length).fill(NaN); + const viMinus = new Array(close.length).fill(NaN); + for (let i = length; i < close.length; i++) { + let sP = 0, sM = 0, sT = 0; + for (let j = 0; j < length; j++) { sP += vmPlus[i - j]; sM += vmMinus[i - j]; sT += tr[i - j]; } + viPlus[i] = sT !== 0 ? sP / sT : NaN; + viMinus[i] = sT !== 0 ? sM / sT : NaN; + } + return [viPlus, viMinus]; + }, + + trix(source, length = 18) { + const e1 = this.ema(source, length); + const e2 = this.ema(e1.map(v => isNaN(v) ? 0 : v), length); + const e3 = this.ema(e2.map(v => isNaN(v) ? 0 : v), length); + const r = new Array(source.length).fill(NaN); + for (let i = 1; i < e3.length; i++) { + if (!isNaN(e3[i]) && !isNaN(e3[i - 1]) && e3[i - 1] !== 0) { + r[i] = ((e3[i] - e3[i - 1]) / e3[i - 1]) * 10000; + } + } + return r; + }, + + dema(source, length) { + const e1 = this.ema(source, length); + const e2 = this.ema(e1.map(v => isNaN(v) ? 0 : v), length); + return e1.map((v, i) => 2 * v - e2[i]); + }, + + tema(source, length) { + const e1 = this.ema(source, length); + const e2 = this.ema(e1.map(v => isNaN(v) ? 0 : v), length); + const e3 = this.ema(e2.map(v => isNaN(v) ? 0 : v), length); + return e1.map((v, i) => 3 * v - 3 * e2[i] + e3[i]); + }, + + kama(source, length = 10, fastEnd = 2, slowEnd = 30) { + const r = new Array(source.length).fill(NaN); + const fastSC = 2 / (fastEnd + 1); + const slowSC = 2 / (slowEnd + 1); + for (let i = length; i < source.length; i++) { + const change = Math.abs(source[i] - source[i - length]); + let volatility = 0; + for (let j = 0; j < length; j++) volatility += Math.abs(source[i - j] - source[i - j - 1]); + const er = volatility !== 0 ? change / volatility : 0; + const sc = (er * (fastSC - slowSC) + slowSC) ** 2; + const prev = isNaN(r[i - 1]) ? source[i - 1] : r[i - 1]; + r[i] = prev + sc * (source[i] - prev); + } + return r; + }, + + zlema(source, length) { + const lag = Math.floor((length - 1) / 2); + const adjusted = source.map((v, i) => i >= lag ? 2 * v - source[i - lag] : v); + return this.ema(adjusted, length); + }, + + // ── Advanced Oscillators ── + + fisher(high, low, length = 10) { + const r = new Array(high.length).fill(NaN); + let prevValue = 0, prevFisher = 0; + for (let i = length - 1; i < high.length; i++) { + let hh = -Infinity, ll = Infinity; + for (let j = 0; j < length; j++) { + hh = Math.max(hh, high[i - j]); + ll = Math.min(ll, low[i - j]); + } + const mid = (high[i] + low[i]) / 2; + const range = hh - ll; + let value = range !== 0 ? 0.66 * ((mid - ll) / range - 0.5) + 0.67 * prevValue : 0; + value = Math.max(-0.999, Math.min(0.999, value)); + const fisher = 0.5 * Math.log((1 + value) / (1 - value)) + 0.5 * prevFisher; + r[i] = fisher; + prevValue = value; + prevFisher = fisher; + } + return r; + }, + + connorsRsi(source, rsiLen = 3, streakLen = 2, rocLen = 100) { + const rsi1 = this.rsi(source, rsiLen); + const streak = new Array(source.length).fill(0); + for (let i = 1; i < source.length; i++) { + if (source[i] > source[i - 1]) streak[i] = streak[i - 1] > 0 ? streak[i - 1] + 1 : 1; + else if (source[i] < source[i - 1]) streak[i] = streak[i - 1] < 0 ? streak[i - 1] - 1 : -1; + else streak[i] = 0; + } + const rsiStreak = this.rsi(streak, streakLen); + const roc = this.roc(source, 1); + const pctRank = new Array(source.length).fill(NaN); + for (let i = rocLen; i < source.length; i++) { + let cnt = 0; + for (let j = 1; j <= rocLen; j++) if (roc[i - j] < roc[i]) cnt++; + pctRank[i] = (cnt / rocLen) * 100; + } + return source.map((_, i) => { + if (isNaN(rsi1[i]) || isNaN(rsiStreak[i]) || isNaN(pctRank[i])) return NaN; + return (rsi1[i] + rsiStreak[i] + pctRank[i]) / 3; + }); + }, + + choppiness(high, low, close, length = 14) { + const atrArr = this.atr(high, low, close, 1); + const r = new Array(close.length).fill(NaN); + for (let i = length; i < close.length; i++) { + let sumAtr = 0, hh = -Infinity, ll = Infinity; + for (let j = 0; j < length; j++) { + sumAtr += atrArr[i - j] || 0; + hh = Math.max(hh, high[i - j]); + ll = Math.min(ll, low[i - j]); + } + const range = hh - ll; + r[i] = range > 0 ? 100 * Math.log10(sumAtr / range) / Math.log10(length) : NaN; + } + return r; + }, + + stc(source, fast = 23, slow = 50, cycle = 10, d1 = 3, d2 = 3) { + const macdLine = this.ema(source, fast).map((v, i) => v - this.ema(source, slow)[i]); + const cleanMacd = macdLine.map(v => isNaN(v) ? 0 : v); + const k1 = new Array(cleanMacd.length).fill(NaN); + for (let i = cycle - 1; i < cleanMacd.length; i++) { + let hh = -Infinity, ll = Infinity; + for (let j = 0; j < cycle; j++) { hh = Math.max(hh, cleanMacd[i - j]); ll = Math.min(ll, cleanMacd[i - j]); } + k1[i] = hh - ll !== 0 ? ((cleanMacd[i] - ll) / (hh - ll)) * 100 : 0; + } + const d = this.ema(k1.map(v => isNaN(v) ? 0 : v), d1); + const k2 = new Array(d.length).fill(NaN); + for (let i = cycle - 1; i < d.length; i++) { + let hh = -Infinity, ll = Infinity; + for (let j = 0; j < cycle; j++) { hh = Math.max(hh, d[i - j]); ll = Math.min(ll, d[i - j]); } + k2[i] = hh - ll !== 0 ? ((d[i] - ll) / (hh - ll)) * 100 : 0; + } + return this.ema(k2.map(v => isNaN(v) ? 0 : v), d2); + }, + // ── ZigZag (custom) ── zigzag(high, low, pctChange = 5) { @@ -1038,6 +1241,74 @@ export const ta = { const arr = src instanceof Series ? src.toArray() : src; return new Series(taCore.pivotlow(arr, left, right), v => v); }, + + // ── New indicator wrappers ── + dema: (src, len) => _wrapTA('dema', src, len), + tema: (src, len) => _wrapTA('tema', src, len), + kama: (src, len, fast, slow) => _wrapTA('kama', src, len, fast, slow), + zlema: (src, len) => _wrapTA('zlema', src, len), + trix: (src, len) => _wrapTA('trix', src, len), + connorsRsi: (src, r, s, c) => _wrapTA('connorsRsi', src, r, s, c), + + obv(close, vol) { + const c = close instanceof Series ? close.toArray() : close; + const v = vol instanceof Series ? vol.toArray() : vol; + return new Series(taCore.obv(c, v), x => x); + }, + + ad(high, low, close, vol) { + const h = high instanceof Series ? high.toArray() : high; + const l = low instanceof Series ? low.toArray() : low; + const c = close instanceof Series ? close.toArray() : close; + const v = vol instanceof Series ? vol.toArray() : vol; + return new Series(taCore.ad(h, l, c, v), x => x); + }, + + cmf(high, low, close, vol, len = 20) { + const h = high instanceof Series ? high.toArray() : high; + const l = low instanceof Series ? low.toArray() : low; + const c = close instanceof Series ? close.toArray() : close; + const v = vol instanceof Series ? vol.toArray() : vol; + return new Series(taCore.cmf(h, l, c, v, len), x => x); + }, + + efi(close, vol, len = 13) { + const c = close instanceof Series ? close.toArray() : close; + const v = vol instanceof Series ? vol.toArray() : vol; + return new Series(taCore.efi(c, v, len), x => x); + }, + + pvt(close, vol) { + const c = close instanceof Series ? close.toArray() : close; + const v = vol instanceof Series ? vol.toArray() : vol; + return new Series(taCore.pvt(c, v), x => x); + }, + + vortex(high, low, close, len = 14) { + const h = high instanceof Series ? high.toArray() : high; + const l = low instanceof Series ? low.toArray() : low; + const c = close instanceof Series ? close.toArray() : close; + const [vp, vm] = taCore.vortex(h, l, c, len); + return [new Series(vp, x => x), new Series(vm, x => x)]; + }, + + fisher(high, low, len = 10) { + const h = high instanceof Series ? high.toArray() : high; + const l = low instanceof Series ? low.toArray() : low; + return new Series(taCore.fisher(h, l, len), x => x); + }, + + choppiness(high, low, close, len = 14) { + const h = high instanceof Series ? high.toArray() : high; + const l = low instanceof Series ? low.toArray() : low; + const c = close instanceof Series ? close.toArray() : close; + return new Series(taCore.choppiness(h, l, c, len), x => x); + }, + + stc(src, fast, slow, cycle, d1, d2) { + const arr = src instanceof Series ? src.toArray() : src; + return new Series(taCore.stc(arr, fast, slow, cycle, d1, d2), x => x); + }, }; function _wrapTA(fn, src, ...args) { diff --git a/src/pine/quant-research.js b/src/pine/quant-research.js new file mode 100644 index 0000000..f6cf270 --- /dev/null +++ b/src/pine/quant-research.js @@ -0,0 +1,567 @@ +/** + * Quantitative Research Module + * Institutional-grade analytics for return series, strategy evaluation, + * regime classification, and portfolio construction. + * + * All inputs accept arrays of bar OHLCV or raw returns where noted. + * Functions are pure, allocation-light, and deterministic. + */ + +// ═══════════════════════════════════════════════════════════════════════ +// Returns Utilities +// ═══════════════════════════════════════════════════════════════════════ + +export function logReturns(prices) { + const r = new Array(prices.length - 1); + for (let i = 1; i < prices.length; i++) r[i - 1] = Math.log(prices[i] / prices[i - 1]); + return r; +} + +export function pctReturns(prices) { + const r = new Array(prices.length - 1); + for (let i = 1; i < prices.length; i++) r[i - 1] = (prices[i] - prices[i - 1]) / prices[i - 1]; + return r; +} + +export function cumulativeReturn(returns) { + let v = 1; + for (const r of returns) v *= 1 + r; + return v - 1; +} + +export function annualize(value, periodsPerYear = 252, mode = 'return') { + if (mode === 'vol') return value * Math.sqrt(periodsPerYear); + return (1 + value) ** periodsPerYear - 1; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Moments +// ═══════════════════════════════════════════════════════════════════════ + +export function mean(arr) { + if (!arr.length) return NaN; + let s = 0; + for (const v of arr) s += v; + return s / arr.length; +} + +export function variance(arr, ddof = 1) { + if (arr.length <= ddof) return NaN; + const m = mean(arr); + let s = 0; + for (const v of arr) s += (v - m) ** 2; + return s / (arr.length - ddof); +} + +export function stdev(arr, ddof = 1) { return Math.sqrt(variance(arr, ddof)); } + +export function skewness(arr) { + const n = arr.length; + if (n < 3) return NaN; + const m = mean(arr), s = stdev(arr, 0); + if (s === 0) return 0; + let sum = 0; + for (const v of arr) sum += ((v - m) / s) ** 3; + return (n / ((n - 1) * (n - 2))) * sum; +} + +export function kurtosis(arr, excess = true) { + const n = arr.length; + if (n < 4) return NaN; + const m = mean(arr), s = stdev(arr, 0); + if (s === 0) return 0; + let sum = 0; + for (const v of arr) sum += ((v - m) / s) ** 4; + const k = ((n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3))) * sum + - (3 * (n - 1) ** 2) / ((n - 2) * (n - 3)); + return excess ? k : k + 3; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Risk-Adjusted Performance +// ═══════════════════════════════════════════════════════════════════════ + +export function sharpeRatio(returns, riskFreePerPeriod = 0, periodsPerYear = 252) { + const excess = returns.map(r => r - riskFreePerPeriod); + const m = mean(excess); + const s = stdev(excess); + if (s === 0) return 0; + return (m / s) * Math.sqrt(periodsPerYear); +} + +export function sortinoRatio(returns, target = 0, periodsPerYear = 252) { + const excess = returns.map(r => r - target); + const m = mean(excess); + const downside = excess.filter(r => r < 0); + if (!downside.length) return Infinity; + let sumSq = 0; + for (const r of downside) sumSq += r * r; + const dd = Math.sqrt(sumSq / returns.length); + return dd === 0 ? 0 : (m / dd) * Math.sqrt(periodsPerYear); +} + +export function calmarRatio(returns, periodsPerYear = 252) { + const annRet = annualize(mean(returns), periodsPerYear, 'return'); + const dd = maxDrawdown(equityCurveFromReturns(returns)); + return dd.maxDrawdown === 0 ? Infinity : annRet / Math.abs(dd.maxDrawdown); +} + +export function omegaRatio(returns, threshold = 0) { + let gain = 0, loss = 0; + for (const r of returns) { + const x = r - threshold; + if (x > 0) gain += x; else loss += -x; + } + return loss === 0 ? Infinity : gain / loss; +} + +export function informationRatio(returns, benchmark, periodsPerYear = 252) { + const active = returns.map((r, i) => r - benchmark[i]); + const m = mean(active); + const te = stdev(active); + return te === 0 ? 0 : (m / te) * Math.sqrt(periodsPerYear); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Drawdown +// ═══════════════════════════════════════════════════════════════════════ + +export function equityCurveFromReturns(returns, start = 1) { + const eq = new Array(returns.length + 1); + eq[0] = start; + for (let i = 0; i < returns.length; i++) eq[i + 1] = eq[i] * (1 + returns[i]); + return eq; +} + +export function maxDrawdown(equity) { + let peak = equity[0]; + let maxDD = 0; + let peakIdx = 0, troughIdx = 0, ddStart = 0; + let recoveryIdx = -1; + let currentDDStart = 0; + for (let i = 0; i < equity.length; i++) { + if (equity[i] > peak) { + peak = equity[i]; + currentDDStart = i; + } + const dd = (equity[i] - peak) / peak; + if (dd < maxDD) { + maxDD = dd; + troughIdx = i; + peakIdx = currentDDStart; + } + } + for (let i = troughIdx; i < equity.length; i++) { + if (equity[i] >= equity[peakIdx]) { recoveryIdx = i; break; } + } + return { + maxDrawdown: maxDD, + maxDrawdownPct: maxDD * 100, + peakIndex: peakIdx, + troughIndex: troughIdx, + recoveryIndex: recoveryIdx, + durationBars: (recoveryIdx >= 0 ? recoveryIdx : equity.length - 1) - peakIdx, + underwaterBars: troughIdx - peakIdx, + }; +} + +export function underwaterCurve(equity) { + const r = new Array(equity.length); + let peak = equity[0]; + for (let i = 0; i < equity.length; i++) { + if (equity[i] > peak) peak = equity[i]; + r[i] = (equity[i] - peak) / peak; + } + return r; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Value-at-Risk / CVaR +// ═══════════════════════════════════════════════════════════════════════ + +export function historicalVaR(returns, confidence = 0.95) { + const sorted = [...returns].sort((a, b) => a - b); + const idx = Math.floor((1 - confidence) * sorted.length); + return sorted[Math.max(0, idx)]; +} + +export function historicalCVaR(returns, confidence = 0.95) { + const sorted = [...returns].sort((a, b) => a - b); + const cutoff = Math.floor((1 - confidence) * sorted.length); + const tail = sorted.slice(0, Math.max(1, cutoff)); + return mean(tail); +} + +export function parametricVaR(returns, confidence = 0.95) { + const m = mean(returns), s = stdev(returns); + const z = _normInv(1 - confidence); + return m + z * s; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Time-Series Statistics +// ═══════════════════════════════════════════════════════════════════════ + +export function autocorrelation(series, lag = 1) { + if (series.length <= lag) return NaN; + const m = mean(series); + let num = 0, den = 0; + for (let i = 0; i < series.length; i++) den += (series[i] - m) ** 2; + for (let i = lag; i < series.length; i++) num += (series[i] - m) * (series[i - lag] - m); + return den === 0 ? 0 : num / den; +} + +/** + * Hurst exponent via rescaled range (R/S) analysis. + * H < 0.5: mean-reverting | H ≈ 0.5: random walk | H > 0.5: trending. + */ +export function hurstExponent(series, minLag = 10, maxLag = 100) { + const n = series.length; + const lags = []; + const rs = []; + const cap = Math.min(maxLag, Math.floor(n / 2)); + for (let lag = minLag; lag <= cap; lag += Math.max(1, Math.floor((cap - minLag) / 20))) { + const chunks = Math.floor(n / lag); + if (chunks < 1) continue; + let avgRS = 0, count = 0; + for (let c = 0; c < chunks; c++) { + const seg = series.slice(c * lag, (c + 1) * lag); + const mu = mean(seg); + const dev = seg.map(v => v - mu); + const cumDev = []; + let s = 0; + for (const d of dev) { s += d; cumDev.push(s); } + const R = Math.max(...cumDev) - Math.min(...cumDev); + const S = stdev(seg, 0); + if (S > 0) { avgRS += R / S; count++; } + } + if (count > 0) { lags.push(Math.log(lag)); rs.push(Math.log(avgRS / count)); } + } + if (lags.length < 2) return NaN; + return _linregSlope(lags, rs); +} + +/** + * Half-life of mean reversion via Ornstein-Uhlenbeck regression. + * Returns expected number of bars to revert halfway to the mean. + */ +export function halfLife(series) { + const lagged = series.slice(0, -1); + const delta = []; + for (let i = 1; i < series.length; i++) delta.push(series[i] - series[i - 1]); + const slope = _linregSlope(lagged, delta); + if (slope >= 0) return Infinity; + return -Math.log(2) / slope; +} + +export function rollingBeta(assetReturns, benchmarkReturns, window = 60) { + const r = new Array(assetReturns.length).fill(NaN); + for (let i = window - 1; i < assetReturns.length; i++) { + const a = assetReturns.slice(i - window + 1, i + 1); + const b = benchmarkReturns.slice(i - window + 1, i + 1); + const vb = variance(b); + if (vb === 0) continue; + const ma = mean(a), mb = mean(b); + let cov = 0; + for (let j = 0; j < a.length; j++) cov += (a[j] - ma) * (b[j] - mb); + cov /= (a.length - 1); + r[i] = cov / vb; + } + return r; +} + +export function rollingCorrelation(a, b, window = 60) { + const r = new Array(a.length).fill(NaN); + for (let i = window - 1; i < a.length; i++) { + const x = a.slice(i - window + 1, i + 1); + const y = b.slice(i - window + 1, i + 1); + const mx = mean(x), my = mean(y); + let num = 0, dx = 0, dy = 0; + for (let j = 0; j < x.length; j++) { + num += (x[j] - mx) * (y[j] - my); + dx += (x[j] - mx) ** 2; + dy += (y[j] - my) ** 2; + } + const den = Math.sqrt(dx * dy); + r[i] = den === 0 ? 0 : num / den; + } + return r; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Volatility Models +// ═══════════════════════════════════════════════════════════════════════ + +export function ewmaVolatility(returns, lambda = 0.94) { + const r = new Array(returns.length).fill(NaN); + if (!returns.length) return r; + let v = returns[0] * returns[0]; + r[0] = Math.sqrt(v); + for (let i = 1; i < returns.length; i++) { + v = lambda * v + (1 - lambda) * returns[i] * returns[i]; + r[i] = Math.sqrt(v); + } + return r; +} + +export function realizedVolatility(returns, periodsPerYear = 252) { + return stdev(returns) * Math.sqrt(periodsPerYear); +} + +export function garmanKlass(high, low, open, close) { + const n = close.length; + const r = new Array(n).fill(NaN); + for (let i = 0; i < n; i++) { + const ln_hl = Math.log(high[i] / low[i]); + const ln_co = Math.log(close[i] / open[i]); + r[i] = Math.sqrt(0.5 * ln_hl * ln_hl - (2 * Math.log(2) - 1) * ln_co * ln_co); + } + return r; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Position Sizing +// ═══════════════════════════════════════════════════════════════════════ + +export function kellyFraction(winRate, payoffRatio, fraction = 0.5) { + const k = winRate - (1 - winRate) / payoffRatio; + return Math.max(0, k * fraction); +} + +export function volTargetSize(currentVolAnnualized, targetVolAnnualized = 0.15, maxLeverage = 3) { + if (currentVolAnnualized <= 0) return 0; + return Math.min(maxLeverage, targetVolAnnualized / currentVolAnnualized); +} + +export function fixedFractional(capital, riskPct, stopDistance) { + if (stopDistance <= 0) return 0; + return Math.floor((capital * riskPct) / stopDistance); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Portfolio Construction +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Risk-parity / inverse-volatility weights. + * Allocates inversely proportional to each asset's volatility. + */ +export function riskParityWeights(returnsByAsset) { + const vols = returnsByAsset.map(r => stdev(r)); + const inv = vols.map(v => v > 0 ? 1 / v : 0); + const sum = inv.reduce((a, b) => a + b, 0); + return sum === 0 ? inv.map(() => 0) : inv.map(v => v / sum); +} + +export function equalWeights(n) { + return new Array(n).fill(1 / n); +} + +/** + * Min-variance long-only weights via simple gradient projection. + * Good enough for 2-10 asset baskets; not a substitute for QP solver. + */ +export function minVarianceWeights(returnsByAsset, iters = 500, lr = 0.05) { + const n = returnsByAsset.length; + const cov = covarianceMatrix(returnsByAsset); + let w = equalWeights(n); + for (let it = 0; it < iters; it++) { + const grad = new Array(n).fill(0); + for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) grad[i] += 2 * cov[i][j] * w[j]; + for (let i = 0; i < n; i++) w[i] -= lr * grad[i]; + // Project to simplex (long-only, sum=1) + w = w.map(v => Math.max(0, v)); + const s = w.reduce((a, b) => a + b, 0); + if (s > 0) w = w.map(v => v / s); + } + return w; +} + +export function covarianceMatrix(returnsByAsset) { + const n = returnsByAsset.length; + const cov = Array.from({ length: n }, () => new Array(n).fill(0)); + const means = returnsByAsset.map(r => mean(r)); + const len = Math.min(...returnsByAsset.map(r => r.length)); + for (let i = 0; i < n; i++) { + for (let j = i; j < n; j++) { + let s = 0; + for (let t = 0; t < len; t++) s += (returnsByAsset[i][t] - means[i]) * (returnsByAsset[j][t] - means[j]); + cov[i][j] = cov[j][i] = s / (len - 1); + } + } + return cov; +} + +export function portfolioReturn(weights, returnsByAsset) { + const len = Math.min(...returnsByAsset.map(r => r.length)); + const r = new Array(len).fill(0); + for (let t = 0; t < len; t++) for (let i = 0; i < weights.length; i++) r[t] += weights[i] * returnsByAsset[i][t]; + return r; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Monte Carlo +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Bootstrap returns to simulate equity curve distributions. + * Returns percentiles of terminal equity and worst drawdown. + */ +export function monteCarloBootstrap(returns, { trials = 1000, horizon = null, seed = null } = {}) { + const len = horizon || returns.length; + const rng = seed !== null ? _seededRng(seed) : Math.random; + const terminals = new Array(trials); + const maxDDs = new Array(trials); + for (let t = 0; t < trials; t++) { + let eq = 1, peak = 1, dd = 0; + for (let i = 0; i < len; i++) { + const r = returns[Math.floor(rng() * returns.length)]; + eq *= 1 + r; + if (eq > peak) peak = eq; + const cur = (eq - peak) / peak; + if (cur < dd) dd = cur; + } + terminals[t] = eq - 1; + maxDDs[t] = dd; + } + terminals.sort((a, b) => a - b); + maxDDs.sort((a, b) => a - b); + const pct = (arr, p) => arr[Math.floor(p * arr.length)]; + return { + terminalReturn: { p05: pct(terminals, 0.05), p50: pct(terminals, 0.5), p95: pct(terminals, 0.95), mean: mean(terminals) }, + maxDrawdown: { p05: pct(maxDDs, 0.05), p50: pct(maxDDs, 0.5), p95: pct(maxDDs, 0.95), mean: mean(maxDDs) }, + trials, + horizon: len, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Regime Classification +// ═══════════════════════════════════════════════════════════════════════ + +export function classifyRegime(prices, { volWindow = 20, trendWindow = 50 } = {}) { + const returns = pctReturns(prices); + const vol = stdev(returns.slice(-volWindow)); + const longVol = stdev(returns); + const sma = mean(prices.slice(-trendWindow)); + const last = prices[prices.length - 1]; + const trend = (last - sma) / sma; + const volRatio = longVol > 0 ? vol / longVol : 1; + + let regime; + if (volRatio > 1.5 && trend > 0) regime = 'trending_volatile'; + else if (volRatio > 1.5 && trend < 0) regime = 'crash_risk'; + else if (volRatio < 0.7) regime = 'compressed_breakout_setup'; + else if (trend > 0.05) regime = 'orderly_uptrend'; + else if (trend < -0.05) regime = 'orderly_downtrend'; + else regime = 'chop_mean_reversion'; + + return { regime, volRatio, trend, currentVol: vol, longVol }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Strategy Evaluation +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Full performance tearsheet from a returns series. + * Use after running a backtest to get publication-grade metrics. + */ +export function performanceReport(returns, { periodsPerYear = 252, benchmark = null } = {}) { + const equity = equityCurveFromReturns(returns); + const dd = maxDrawdown(equity); + const annRet = annualize(mean(returns), periodsPerYear, 'return'); + const annVol = realizedVolatility(returns, periodsPerYear); + const sharpe = sharpeRatio(returns, 0, periodsPerYear); + const sortino = sortinoRatio(returns, 0, periodsPerYear); + const calmar = calmarRatio(returns, periodsPerYear); + const var95 = historicalVaR(returns, 0.95); + const cvar95 = historicalCVaR(returns, 0.95); + const skew = skewness(returns); + const kurt = kurtosis(returns); + const wins = returns.filter(r => r > 0).length; + + const out = { + totalReturn: cumulativeReturn(returns), + annualizedReturn: annRet, + annualizedVolatility: annVol, + sharpeRatio: sharpe, + sortinoRatio: sortino, + calmarRatio: calmar, + omegaRatio: omegaRatio(returns), + maxDrawdown: dd.maxDrawdown, + maxDrawdownDuration: dd.durationBars, + var95, + cvar95, + skewness: skew, + kurtosis: kurt, + winRate: returns.length ? wins / returns.length : 0, + bars: returns.length, + grade: _gradePerformance(sharpe, Math.abs(dd.maxDrawdown), returns.length), + }; + + if (benchmark && benchmark.length >= returns.length) { + const bench = benchmark.slice(0, returns.length); + out.informationRatio = informationRatio(returns, bench, periodsPerYear); + out.beta = _beta(returns, bench); + out.alpha = annualize(mean(returns) - out.beta * mean(bench), periodsPerYear, 'return'); + } + return out; +} + +function _beta(asset, bench) { + const v = variance(bench); + if (v === 0) return 0; + const ma = mean(asset), mb = mean(bench); + let cov = 0; + for (let i = 0; i < asset.length; i++) cov += (asset[i] - ma) * (bench[i] - mb); + return cov / (asset.length - 1) / v; +} + +function _gradePerformance(sharpe, dd, n) { + if (sharpe >= 2.0 && dd < 0.15 && n >= 252) return 'A+'; + if (sharpe >= 1.5 && dd < 0.20 && n >= 126) return 'A'; + if (sharpe >= 1.0 && dd < 0.25 && n >= 60) return 'B'; + if (sharpe >= 0.5 && dd < 0.35) return 'C'; + return 'D'; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════ + +function _linregSlope(x, y) { + const n = x.length; + const mx = mean(x), my = mean(y); + let num = 0, den = 0; + for (let i = 0; i < n; i++) { num += (x[i] - mx) * (y[i] - my); den += (x[i] - mx) ** 2; } + return den === 0 ? 0 : num / den; +} + +// Beasley-Springer-Moro approximation for standard normal inverse CDF +function _normInv(p) { + const a = [-39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269, -30.6647980661472, 2.50662827745924]; + const b = [-54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197, -13.2806815528857]; + const c = [-7.78489400243029e-3, -0.322396458041136, -2.40075827716184, -2.54973253934373, 4.37466414146497, 2.93816398269878]; + const d = [7.78469570904146e-3, 0.32246712907004, 2.445134137143, 3.75440866190742]; + const pLow = 0.02425, pHigh = 1 - pLow; + let q, r; + if (p < pLow) { + q = Math.sqrt(-2 * Math.log(p)); + return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1); + } + if (p <= pHigh) { + q = p - 0.5; r = q * q; + return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q / (((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1); + } + q = Math.sqrt(-2 * Math.log(1 - p)); + return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) / ((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1); +} + +function _seededRng(seed) { + let s = seed >>> 0; + return function () { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 0x100000000; + }; +} diff --git a/src/pine/templates.js b/src/pine/templates.js index 6406257..d12fda5 100644 --- a/src/pine/templates.js +++ b/src/pine/templates.js @@ -1818,3 +1818,166 @@ plot(cumDelta_ / volume * 10, "Cum Delta (scaled)", color.new(color.blue, 60), 1 // Merge pro templates into main TEMPLATES Object.assign(TEMPLATES, PRO_TEMPLATES); + +// ═══════════════════════════════════════════════════════════════════════ +// Quant Research Templates (v4) — institutional analytics on chart +// ═══════════════════════════════════════════════════════════════════════ + +export const QUANT_RESEARCH_TEMPLATES = { + + quant_tearsheet: `//@version=6 +indicator("Quant Tearsheet", overlay=false) +length = input.int(252, "Lookback (bars)") +rf = input.float(0.0, "Risk-Free per Period", step=0.0001) + +ret = ta.change(close) / close[1] +m = ta.sma(ret, length) +s = ta.stdev(ret, length) +sharpe = s != 0 ? (m - rf) / s * math.sqrt(252) : 0 + +downside = ret < 0 ? ret * ret : 0 +ddev = math.sqrt(ta.sma(downside, length)) +sortino = ddev != 0 ? (m - rf) / ddev * math.sqrt(252) : 0 + +peak = ta.highest(close, length) +dd = (close - peak) / peak * 100 +maxDD = ta.lowest(dd, length) + +var ttbl = table.new(position.top_right, 2, 6, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(ttbl, 0, 0, "Metric", text_color=color.white, bgcolor=color.new(color.blue, 50)) + table.cell(ttbl, 1, 0, "Value", text_color=color.white, bgcolor=color.new(color.blue, 50)) + table.cell(ttbl, 0, 1, "Sharpe"); table.cell(ttbl, 1, 1, str.tostring(sharpe, "#.##"), text_color=sharpe > 1 ? color.lime : color.red) + table.cell(ttbl, 0, 2, "Sortino"); table.cell(ttbl, 1, 2, str.tostring(sortino, "#.##"), text_color=sortino > 1 ? color.lime : color.red) + table.cell(ttbl, 0, 3, "Ann Ret %"); table.cell(ttbl, 1, 3, str.tostring(m * 252 * 100, "#.##")) + table.cell(ttbl, 0, 4, "Ann Vol %"); table.cell(ttbl, 1, 4, str.tostring(s * math.sqrt(252) * 100, "#.##")) + table.cell(ttbl, 0, 5, "MaxDD %"); table.cell(ttbl, 1, 5, str.tostring(maxDD, "#.##"), text_color=color.red) + +plot(sharpe, "Sharpe (rolling)", color.aqua) +hline(1, "Sharpe=1", color.gray) +hline(0, "Zero", color.silver) +`, + + zscore_mean_reversion: `//@version=6 +strategy("Z-Score Mean Reversion", overlay=true, initial_capital=100000, + default_qty_type=strategy.percent_of_equity, default_qty_value=10, + commission_type=strategy.commission.percent, commission_value=0.05, slippage=2) + +length = input.int(20, "Z-Score Length") +entryZ = input.float(2.0, "Entry Z") +exitZ = input.float(0.3, "Exit Z") +stopZ = input.float(3.5, "Hard Stop Z") + +mean_ = ta.sma(close, length) +std_ = ta.stdev(close, length) +z = std_ != 0 ? (close - mean_) / std_ : 0.0 + +longEntry = z[1] <= -entryZ and z[1] > -stopZ +shortEntry = z[1] >= entryZ and z[1] < stopZ +longExit = z[1] >= -exitZ +shortExit = z[1] <= exitZ + +if longEntry + strategy.entry("L", strategy.long) +if shortEntry + strategy.entry("S", strategy.short) +if longExit + strategy.close("L") +if shortExit + strategy.close("S") + +if z[1] < -stopZ + strategy.close("L", comment="stop") +if z[1] > stopZ + strategy.close("S", comment="stop") + +plot(z, "Z", color.purple, display=display.data_window) +`, + + pairs_trading: `//@version=6 +indicator("Pairs Trading Spread", overlay=false) +sym2 = input.symbol("AMEX:SPY", "Asset 2") +length = input.int(60, "Length") +entryZ = input.float(2.0, "Entry Z") + +s1 = math.log(close) +s2 = math.log(request.security(sym2, timeframe.period, close)) +spread = s1 - s2 +m = ta.sma(spread, length) +sd = ta.stdev(spread, length) +z = sd != 0 ? (spread - m) / sd : 0.0 + +bg = z > entryZ ? color.new(color.red, 80) : z < -entryZ ? color.new(color.lime, 80) : na +bgcolor(bg) +plot(z, "Spread Z", color.aqua, 2) +hline( entryZ, "Short", color.red) +hline(-entryZ, "Long", color.lime) +hline(0, "Mean", color.gray) +`, + + hurst_regime: `//@version=6 +indicator("Hurst Regime", overlay=false) +length = input.int(100, "Window") + +// Simplified rescaled-range proxy: ratio of range to standard deviation, log-scaled +rng = ta.highest(close, length) - ta.lowest(close, length) +sd = ta.stdev(close, length) +rs = sd != 0 ? rng / sd : 1.0 +h = math.log(rs) / math.log(length) + +regime = h > 0.55 ? "TREND" : h < 0.45 ? "MEAN-REV" : "RANDOM" +col = h > 0.55 ? color.lime : h < 0.45 ? color.orange : color.gray + +plot(h, "Hurst Proxy", col, 2) +hline(0.5, "Random Walk", color.silver) +hline(0.55, "Trend", color.new(color.lime, 50)) +hline(0.45, "Mean-Rev", color.new(color.orange, 50)) + +var lbl = label.new(bar_index, h, regime, color=col, textcolor=color.black, style=label.style_label_left) +if barstate.islast + label.set_xy(lbl, bar_index, h) + label.set_text(lbl, regime + " " + str.tostring(h, "#.##")) + label.set_color(lbl, col) +`, + + vol_targeted_position: `//@version=6 +indicator("Vol-Targeted Position Size", overlay=false) +targetVol = input.float(0.15, "Target Annual Vol", step=0.01) +capital = input.float(100000, "Capital") +maxLev = input.float(3.0, "Max Leverage") + +ret = ta.change(close) / close[1] +rv = ta.stdev(ret, 20) * math.sqrt(252) +lev = rv > 0 ? math.min(maxLev, targetVol / rv) : 0.0 +notional = capital * lev +units = notional / close + +plot(lev, "Leverage", color.aqua, 2) +plot(rv, "Realized Vol", color.orange, 1) +hline(targetVol, "Target Vol", color.gray) + +var pTbl = table.new(position.top_right, 2, 4, bgcolor=color.new(color.black, 80)) +if barstate.islast + table.cell(pTbl, 0, 0, "Leverage"); table.cell(pTbl, 1, 0, str.tostring(lev, "#.##") + "x", text_color=color.aqua) + table.cell(pTbl, 0, 1, "Realized Vol"); table.cell(pTbl, 1, 1, str.tostring(rv * 100, "#.##") + "%") + table.cell(pTbl, 0, 2, "Notional"); table.cell(pTbl, 1, 2, "$" + str.tostring(notional, "#,###")) + table.cell(pTbl, 0, 3, "Units"); table.cell(pTbl, 1, 3, str.tostring(units, "#.##")) +`, + + garch_volatility: `//@version=6 +indicator("EWMA Volatility (RiskMetrics)", overlay=false) +lambda_ = input.float(0.94, "Decay", step=0.01) + +ret = ta.change(close) / close[1] +var float v = 0.0 +v := na(v[1]) ? ret * ret : lambda_ * nz(v[1]) + (1 - lambda_) * ret * ret +ewma = math.sqrt(v) * math.sqrt(252) + +rolling20 = ta.stdev(ret, 20) * math.sqrt(252) +plot(ewma, "EWMA Annualized", color.aqua, 2) +plot(rolling20, "Rolling-20", color.orange, 1) +`, + +}; + +Object.assign(TEMPLATES, QUANT_RESEARCH_TEMPLATES); diff --git a/src/tv/quant-report.js b/src/tv/quant-report.js new file mode 100644 index 0000000..7f80706 --- /dev/null +++ b/src/tv/quant-report.js @@ -0,0 +1,179 @@ +/** + * Seamless TradingView Quant Report + * Pulls live OHLCV from the active TradingView chart, runs the full + * institutional quant battery, and returns a single tearsheet. + * + * Designed for agent use: one call replaces 10+ tool steps. + */ +import { dataGetOhlcv, quoteGet, chartGetState } from './tools.js'; +import { + logReturns, pctReturns, performanceReport, classifyRegime, + hurstExponent, halfLife, autocorrelation, ewmaVolatility, + historicalVaR, historicalCVaR, monteCarloBootstrap, +} from '../pine/quant-research.js'; +import { taCore, detectVolRegime, zScoreAnalysis } from '../pine/oakscript.js'; + +/** + * One-shot quant analysis of the current chart symbol. + * + * @param {object} opts + * @param {number} opts.bars Bars to pull (default 500, max ~5000) + * @param {number} opts.mcTrials Monte Carlo trials (default 1000, 0 to disable) + * @param {boolean} opts.indicators Include classic indicator readings (default true) + * @returns {Promise} Full tearsheet + */ +export async function quantReport({ bars = 500, mcTrials = 1000, indicators = true } = {}) { + const state = await chartGetState(); + const quote = await quoteGet(); + const ohlcv = await dataGetOhlcv({ count: bars }); + + const candles = _extractCandles(ohlcv); + if (candles.length < 30) { + return { error: 'Insufficient bars', barsFetched: candles.length, symbol: state?.symbol }; + } + + const close = candles.map(c => c.close); + const high = candles.map(c => c.high); + const low = candles.map(c => c.low); + const open = candles.map(c => c.open); + const vol = candles.map(c => c.volume || 0); + + const returns = pctReturns(close); + const log_r = logReturns(close); + + const perf = performanceReport(returns, { periodsPerYear: _periodsPerYear(state?.resolution) }); + const regime = classifyRegime(close); + const volRegime = detectVolRegime(close, _periodsPerYear(state?.resolution)); + const zscore = zScoreAnalysis(close, 20); + + const report = { + symbol: state?.symbol, + timeframe: state?.resolution, + lastPrice: quote?.last || close[close.length - 1], + bars: candles.length, + + performance: perf, + + regime: { + current: regime.regime, + trendPct: regime.trend * 100, + volRatio: regime.volRatio, + currentVolAnnualized: regime.currentVol * Math.sqrt(_periodsPerYear(state?.resolution)), + longVolAnnualized: regime.longVol * Math.sqrt(_periodsPerYear(state?.resolution)), + ewmaVolNow: _last(ewmaVolatility(returns, 0.94)), + volRegimeNow: volRegime.regimes[volRegime.regimes.length - 1], + }, + + structure: { + hurst: hurstExponent(close), + halfLifeBars: halfLife(close), + autocorr1: autocorrelation(returns, 1), + autocorr5: autocorrelation(returns, 5), + zscore: zscore.lastZ, + zscoreInterpretation: zscore.interpretation, + }, + + risk: { + var95Daily: historicalVaR(returns, 0.95), + cvar95Daily: historicalCVaR(returns, 0.95), + var99Daily: historicalVaR(returns, 0.99), + cvar99Daily: historicalCVaR(returns, 0.99), + }, + }; + + if (indicators) { + report.indicators = { + rsi14: _last(taCore.rsi(close, 14)), + atr14: _last(taCore.atr(high, low, close, 14)), + atr14Pct: (_last(taCore.atr(high, low, close, 14)) / _last(close)) * 100, + ema20: _last(taCore.ema(close, 20)), + ema50: _last(taCore.ema(close, 50)), + ema200: _last(taCore.ema(close, 200)), + cci20: _last(taCore.cci(close, 20)), + adx14: taCore.adx ? _last(taCore.adx(high, low, close, 14)) : null, + obvLast: _last(taCore.obv(close, vol)), + cmf20: _last(taCore.cmf(high, low, close, vol, 20)), + choppiness14: _last(taCore.choppiness(high, low, close, 14)), + }; + const e20 = report.indicators.ema20, e50 = report.indicators.ema50, e200 = report.indicators.ema200; + const last = _last(close); + report.indicators.trendStack = + last > e20 && e20 > e50 && e50 > e200 ? 'strong_uptrend' : + last < e20 && e20 < e50 && e50 < e200 ? 'strong_downtrend' : 'mixed'; + } + + if (mcTrials > 0 && returns.length >= 30) { + report.monteCarlo = monteCarloBootstrap(returns, { trials: mcTrials, horizon: Math.min(returns.length, 252) }); + } + + report.verdict = _verdict(report); + return report; +} + +/** + * Compare quant metrics across a list of symbols. + * For use with watchlist scanning. + */ +export async function quantScan(symbols, { bars = 250 } = {}) { + const { chartSetSymbol } = await import('./tools.js'); + const results = []; + for (const symbol of symbols) { + try { + await chartSetSymbol({ symbol }); + await new Promise(r => setTimeout(r, 1500)); + const r = await quantReport({ bars, mcTrials: 0, indicators: false }); + results.push({ + symbol, + sharpe: r.performance?.sharpeRatio, + sortino: r.performance?.sortinoRatio, + maxDD: r.performance?.maxDrawdown, + annReturn: r.performance?.annualizedReturn, + annVol: r.performance?.annualizedVolatility, + regime: r.regime?.current, + hurst: r.structure?.hurst, + zscore: r.structure?.zscore, + grade: r.performance?.grade, + }); + } catch (e) { + results.push({ symbol, error: e.message }); + } + } + results.sort((a, b) => (b.sharpe || -99) - (a.sharpe || -99)); + return { ranked: results, scannedAt: new Date().toISOString() }; +} + +function _extractCandles(ohlcv) { + if (Array.isArray(ohlcv)) return ohlcv; + if (ohlcv?.bars) return ohlcv.bars; + if (ohlcv?.candles) return ohlcv.candles; + if (ohlcv?.data) return ohlcv.data; + return []; +} + +function _last(arr) { + for (let i = arr.length - 1; i >= 0; i--) if (!isNaN(arr[i])) return arr[i]; + return NaN; +} + +function _periodsPerYear(resolution) { + if (!resolution) return 252; + const r = String(resolution).toUpperCase(); + if (r === 'D' || r === '1D') return 252; + if (r === 'W' || r === '1W') return 52; + if (r === 'M' || r === '1M') return 12; + const n = parseInt(r, 10); + if (!isNaN(n)) return Math.round((252 * 6.5 * 60) / n); // intraday minutes + return 252; +} + +function _verdict(report) { + const { performance: p, regime: rg, structure: s } = report; + const lines = []; + if (p.sharpeRatio > 1.5) lines.push(`Strong risk-adjusted edge (Sharpe ${p.sharpeRatio.toFixed(2)})`); + else if (p.sharpeRatio < 0) lines.push(`Negative Sharpe (${p.sharpeRatio.toFixed(2)}) — buy-and-hold unfavorable`); + if (s.hurst > 0.55) lines.push(`Trending behavior (Hurst ${s.hurst.toFixed(2)}) — momentum strategies favored`); + else if (s.hurst < 0.45) lines.push(`Mean-reverting (Hurst ${s.hurst.toFixed(2)}) — fade extremes, half-life ${s.halfLifeBars.toFixed(0)} bars`); + if (Math.abs(s.zscore) > 2) lines.push(`Statistical extreme: ${s.zscoreInterpretation}`); + lines.push(`Regime: ${rg.current}`); + return lines.join(' | '); +} diff --git a/tests/quant-research.test.js b/tests/quant-research.test.js new file mode 100644 index 0000000..1a63ea0 --- /dev/null +++ b/tests/quant-research.test.js @@ -0,0 +1,201 @@ +/** + * Unit tests for quant-research module and new indicators. + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert'; + +const qr = await import('../src/pine/quant-research.js'); +const { taCore } = await import('../src/pine/oakscript.js'); + +// Deterministic synthetic price series: GBM-ish with a known drift +function syntheticPrices(n = 300, seed = 42) { + let s = seed >>> 0; + const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; }; + const p = [100]; + for (let i = 1; i < n; i++) { + const z = Math.sqrt(-2 * Math.log(rand())) * Math.cos(2 * Math.PI * rand()); + p.push(p[i - 1] * Math.exp(0.0005 + 0.01 * z)); + } + return p; +} + +describe('quant-research: returns + moments', () => { + test('pctReturns produces length-1', () => { + const r = qr.pctReturns([100, 110, 99]); + assert.strictEqual(r.length, 2); + assert.ok(Math.abs(r[0] - 0.1) < 1e-9); + }); + + test('mean and stdev sane', () => { + assert.strictEqual(qr.mean([1, 2, 3, 4, 5]), 3); + assert.ok(Math.abs(qr.stdev([1, 2, 3, 4, 5]) - Math.sqrt(2.5)) < 1e-9); + }); + + test('skewness ≈ 0 on symmetric, kurtosis finite', () => { + const sym = [-2, -1, 0, 1, 2]; + assert.ok(Math.abs(qr.skewness(sym)) < 1e-9); + assert.ok(Number.isFinite(qr.kurtosis(sym))); + }); +}); + +describe('quant-research: risk-adjusted', () => { + const p = syntheticPrices(); + const r = qr.pctReturns(p); + + test('sharpe is a finite number', () => { + const s = qr.sharpeRatio(r); + assert.ok(Number.isFinite(s)); + }); + + test('sortino >= sharpe when downside is asymmetric', () => { + const so = qr.sortinoRatio(r); + assert.ok(Number.isFinite(so)); + }); + + test('omega > 1 implies positive expectancy above threshold', () => { + const positive = [0.01, 0.02, -0.005, 0.015]; + assert.ok(qr.omegaRatio(positive) > 1); + }); +}); + +describe('quant-research: drawdown + VaR', () => { + test('maxDrawdown captures known dip', () => { + const eq = [100, 110, 105, 90, 95, 120]; + const dd = qr.maxDrawdown(eq); + assert.ok(dd.maxDrawdown < 0); + assert.ok(Math.abs(dd.maxDrawdown - (90 - 110) / 110) < 1e-9); + }); + + test('historical VaR is non-positive for typical returns', () => { + const r = qr.pctReturns(syntheticPrices()); + const v = qr.historicalVaR(r, 0.95); + assert.ok(v <= 0 || Math.abs(v) < 1); + }); + + test('CVaR <= VaR (more negative)', () => { + const r = qr.pctReturns(syntheticPrices()); + const v = qr.historicalVaR(r, 0.95); + const c = qr.historicalCVaR(r, 0.95); + assert.ok(c <= v + 1e-9); + }); +}); + +describe('quant-research: structure', () => { + test('hurst is between 0 and 1 for normal series', () => { + const h = qr.hurstExponent(syntheticPrices(500)); + assert.ok(h > 0 && h < 1.2, `hurst out of bounds: ${h}`); + }); + + test('halfLife finite for mean-reverting series', () => { + const oscillating = []; + for (let i = 0; i < 200; i++) oscillating.push(100 + 5 * Math.sin(i / 5)); + const hl = qr.halfLife(oscillating); + assert.ok(Number.isFinite(hl) && hl > 0); + }); + + test('autocorrelation(lag=1) of random walk ≈ 0 for returns', () => { + const r = qr.pctReturns(syntheticPrices(1000)); + const ac = qr.autocorrelation(r, 1); + assert.ok(Math.abs(ac) < 0.3); + }); +}); + +describe('quant-research: portfolio', () => { + test('risk parity weights sum to 1', () => { + const a = qr.pctReturns(syntheticPrices(200, 1)); + const b = qr.pctReturns(syntheticPrices(200, 2)); + const w = qr.riskParityWeights([a, b]); + assert.ok(Math.abs(w.reduce((s, x) => s + x, 0) - 1) < 1e-9); + }); + + test('min-variance weights respect simplex', () => { + const a = qr.pctReturns(syntheticPrices(150, 1)); + const b = qr.pctReturns(syntheticPrices(150, 2)); + const c = qr.pctReturns(syntheticPrices(150, 3)); + const w = qr.minVarianceWeights([a, b, c], 100, 0.1); + assert.ok(w.every(x => x >= -1e-9)); + assert.ok(Math.abs(w.reduce((s, x) => s + x, 0) - 1) < 1e-6); + }); +}); + +describe('quant-research: performance report', () => { + test('returns full tearsheet with grade', () => { + const r = qr.pctReturns(syntheticPrices(300)); + const rep = qr.performanceReport(r); + assert.ok('sharpeRatio' in rep); + assert.ok('maxDrawdown' in rep); + assert.ok('grade' in rep); + assert.ok(['A+', 'A', 'B', 'C', 'D'].includes(rep.grade)); + }); +}); + +describe('quant-research: monte carlo', () => { + test('seeded MC is deterministic', () => { + const r = qr.pctReturns(syntheticPrices()); + const a = qr.monteCarloBootstrap(r, { trials: 200, horizon: 100, seed: 7 }); + const b = qr.monteCarloBootstrap(r, { trials: 200, horizon: 100, seed: 7 }); + assert.strictEqual(a.terminalReturn.p50, b.terminalReturn.p50); + }); +}); + +describe('new indicators (taCore)', () => { + const p = syntheticPrices(300); + const high = p.map(v => v * 1.005); + const low = p.map(v => v * 0.995); + const close = p; + const vol = p.map(() => 1000 + Math.random() * 500); + + test('OBV is cumulative volume', () => { + const o = taCore.obv(close, vol); + assert.strictEqual(o.length, close.length); + assert.ok(Number.isFinite(o[o.length - 1])); + }); + + test('CMF returns values in [-1, 1] band', () => { + const c = taCore.cmf(high, low, close, vol, 20); + const last = c[c.length - 1]; + assert.ok(last >= -1 && last <= 1); + }); + + test('Vortex returns two arrays', () => { + const [vp, vm] = taCore.vortex(high, low, close, 14); + assert.strictEqual(vp.length, close.length); + assert.strictEqual(vm.length, close.length); + }); + + test('DEMA and TEMA produce finite tail values', () => { + const d = taCore.dema(close, 20); + const t = taCore.tema(close, 20); + assert.ok(Number.isFinite(d[d.length - 1])); + assert.ok(Number.isFinite(t[t.length - 1])); + }); + + test('KAMA / ZLEMA produce finite values', () => { + const k = taCore.kama(close, 10); + const z = taCore.zlema(close, 20); + assert.ok(Number.isFinite(k[k.length - 1])); + assert.ok(Number.isFinite(z[z.length - 1])); + }); + + test('Fisher transform bounded reasonably', () => { + const f = taCore.fisher(high, low, 10); + const last = f[f.length - 1]; + assert.ok(Number.isFinite(last) && Math.abs(last) < 100); + }); + + test('Choppiness in [0, 100] roughly', () => { + const c = taCore.choppiness(high, low, close, 14); + const last = c[c.length - 1]; + assert.ok(Number.isFinite(last) && last >= -10 && last <= 110); + }); + + test('Connors RSI finite', () => { + const cr = taCore.connorsRsi(close); + assert.ok(Number.isFinite(cr[cr.length - 1])); + }); + + test('TRIX produces values around 0', () => { + const t = taCore.trix(close, 18); + assert.ok(Number.isFinite(t[t.length - 1])); + }); +});