commit d08c2230a8bac4c270d38a09dad615957e9c3b9d Author: ojy Date: Wed May 13 03:22:23 2026 +0000 Initial commit — options pricing dashboard Full-stack options analytics app: IV surface, Greeks, skew metrics, vol term structure. Yahoo Finance data with Black-Scholes IV computation and historical vol fallback for after-hours data. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a901a1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +backend/data/*.db +backend/data/*.db-shm +backend/data/*.db-wal +backend/.env +dist/ +*.log +.DS_Store diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..9c441a9 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1380 @@ +{ + "name": "options-pricer-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "options-pricer-backend", + "version": "1.0.0", + "dependencies": { + "@hono/node-server": "^1.13.0", + "better-sqlite3": "^11.6.0", + "hono": "^4.6.0", + "yahoo-finance2": "^3.14.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@deno/shim-deno": { + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/@deno/shim-deno/-/shim-deno-0.18.2.tgz", + "integrity": "sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==", + "license": "MIT", + "dependencies": { + "@deno/shim-deno-test": "^0.5.0", + "which": "^4.0.0" + } + }, + "node_modules/@deno/shim-deno-test": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@deno/shim-deno-test/-/shim-deno-test-0.5.0.tgz", + "integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "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" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fetch-mock-cache": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz", + "integrity": "sha512-hDk+Nbt0Y8Aq7KTEU6ASQAcpB34UjhkpD3QjzD6yvEKP4xVElAqXrjQ7maL+LYMGafx51Zq6qUfDM57PNu/qMw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "filenamify-url": "2.1.2" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify-url": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/filenamify-url/-/filenamify-url-2.1.2.tgz", + "integrity": "sha512-3rMbAr7vDNMOGsj1aMniQFl749QjgM+lMJ/77ZRSPTIgxvolZwoQbn8dXLs7xfd+hAdli+oTnSWZNkJJLWQFEQ==", + "license": "MIT", + "dependencies": { + "filenamify": "^4.3.0", + "humanize-url": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/humanize-url": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/humanize-url/-/humanize-url-2.1.1.tgz", + "integrity": "sha512-V4nxsPGNE7mPjr1qDp471YfW8nhBiTRWrG/4usZlpvFU8I7gsV7Jvrrzv/snbLm5dWO3dr1ennu2YqnhTWFmYA==", + "license": "MIT", + "dependencies": { + "normalize-url": "^4.5.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-url": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tough-cookie-file-store": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tough-cookie-file-store/-/tough-cookie-file-store-2.0.3.tgz", + "integrity": "sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==", + "license": "MIT", + "dependencies": { + "tough-cookie": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie-file-store/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yahoo-finance2": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.0.tgz", + "integrity": "sha512-gsT/tqgeizKtMxbIIWFiFyuhM/6MZE4yEyNLmPekr88AX14JL2HWw0/QNMOR081jVtzTjihqDW0zV7IayH1Wcw==", + "license": "MIT", + "dependencies": { + "@deno/shim-deno": "~0.18.0", + "fetch-mock-cache": "npm:fetch-mock-cache@^2.1.3", + "json-schema": "^0.4.0", + "tough-cookie": "npm:tough-cookie@^5.1.1", + "tough-cookie-file-store": "npm:tough-cookie-file-store@^2.0.3" + }, + "bin": { + "yahoo-finance": "esm/bin/yahoo-finance.js" + }, + "engines": { + "node": ">=20.0.0" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d207a9a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "options-pricer-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "node --import tsx/esm --watch src/server.ts", + "start": "node --import tsx/esm src/server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.13.0", + "better-sqlite3": "^11.6.0", + "hono": "^4.6.0", + "yahoo-finance2": "^3.14.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/backend/src/db/snapshots.ts b/backend/src/db/snapshots.ts new file mode 100644 index 0000000..62f2fbb --- /dev/null +++ b/backend/src/db/snapshots.ts @@ -0,0 +1,99 @@ +import Database from "better-sqlite3"; +import { mkdirSync, existsSync } from "fs"; +import type { OptionQuote, SkewMetrics } from "../lib/analytics.js"; + +const DB_DIR = "./data"; +const DB_PATH = `${DB_DIR}/snapshots.db`; + +if (!existsSync(DB_DIR)) { + mkdirSync(DB_DIR, { recursive: true }); +} + +const db = new Database(DB_PATH); +db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); + +db.exec(` + CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + expiry TEXT NOT NULL, + timestamp TEXT NOT NULL, + spot REAL, + atm_iv REAL, + rr25 REAL, + rr10 REAL, + fly25 REAL, + chain_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_symbol_time + ON snapshots(symbol, timestamp); + CREATE INDEX IF NOT EXISTS idx_symbol_expiry_time + ON snapshots(symbol, expiry, timestamp); +`); + +export type SnapshotRow = { + id: number; + symbol: string; + expiry: string; + timestamp: string; + spot: number | null; + atm_iv: number | null; + rr25: number | null; + rr10: number | null; + fly25: number | null; + chain_json: string | null; +}; + +const stmtInsert = db.prepare(` + INSERT INTO snapshots + (symbol, expiry, timestamp, spot, atm_iv, rr25, rr10, fly25, chain_json) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?) +`); + +const stmtGetBySymbol = db.prepare(` + SELECT * FROM snapshots + WHERE symbol = ? + ORDER BY timestamp DESC + LIMIT ? +`); + +const stmtGetLatest = db.prepare(` + SELECT * FROM snapshots + WHERE symbol = ? AND expiry = ? + ORDER BY timestamp DESC + LIMIT 1 +`); + +export function saveSnapshot( + symbol: string, + expiry: string, + spot: number, + metrics: SkewMetrics, + chain: OptionQuote[] +): void { + stmtInsert.run( + symbol, + expiry, + new Date().toISOString(), + spot, + metrics.atmIv ?? null, + metrics.rr25 ?? null, + metrics.rr10 ?? null, + metrics.fly25 ?? null, + JSON.stringify(chain) + ); +} + +export function getSnapshots(symbol: string, limit = 100): SnapshotRow[] { + return stmtGetBySymbol.all(symbol, limit) as SnapshotRow[]; +} + +export function getLatestSnapshot(symbol: string, expiry: string): SnapshotRow | null { + return (stmtGetLatest.get(symbol, expiry) as SnapshotRow | undefined) ?? null; +} + +export function getDb(): Database.Database { + return db; +} diff --git a/backend/src/lib/analytics.ts b/backend/src/lib/analytics.ts new file mode 100644 index 0000000..6bb18a2 --- /dev/null +++ b/backend/src/lib/analytics.ts @@ -0,0 +1,270 @@ +/** + * Options chain analytics: ATM IV, risk reversals, butterflies, vol surface. + */ + +// --------------------------------------------------------------------------- +// Type definitions +// --------------------------------------------------------------------------- + +export type OptionQuote = { + strike: number; + expiry: string; + type: "call" | "put"; + bid: number; + ask: number; + iv: number; // implied volatility (from yahoo or calculated) + delta: number; + gamma: number; + theta: number; + vega: number; + volume: number; + openInterest: number; + bsPrice: number; // our own BS theoretical price + midPrice: number; // (bid + ask) / 2 +}; + +export type ChainSnapshot = { + symbol: string; + expiry: string; + spot: number; + spotIv: number; // stock-level 30-day IV from Yahoo (quote.impliedVolatility) + timestamp: string; + chain: OptionQuote[]; +}; + +export type VolSurface = { + expiries: string[]; + strikes: number[]; + matrix: Record>; // expiry → strike → IV +}; + +export type SkewMetrics = { + expiry: string; + atmIv: number; + rr25: number; + rr10: number; + fly25: number; +}; + +// --------------------------------------------------------------------------- +// Helper utilities +// --------------------------------------------------------------------------- + +/** + * Filter a chain to a single option type with non-zero liquidity. + */ +function filterByType( + chain: OptionQuote[], + type: "call" | "put" +): OptionQuote[] { + return chain.filter((q) => q.type === type && q.iv > 0); +} + +/** + * Find the option in the list whose strike is closest to the target. + */ +function closestByStrike( + quotes: OptionQuote[], + targetStrike: number +): OptionQuote | null { + if (quotes.length === 0) return null; + return quotes.reduce((best, q) => + Math.abs(q.strike - targetStrike) < Math.abs(best.strike - targetStrike) + ? q + : best + ); +} + +// --------------------------------------------------------------------------- +// Exported analytics functions +// --------------------------------------------------------------------------- + +/** + * Find the ATM (at-the-money) strike closest to the spot price. + * Uses calls preferentially; falls back to all strikes if no calls. + */ +export function findATMStrike(chain: OptionQuote[], spot: number): number { + const calls = filterByType(chain, "call"); + const pool = calls.length > 0 ? calls : chain; + if (pool.length === 0) return spot; + + const atm = closestByStrike(pool, spot); + return atm ? atm.strike : spot; +} + +/** + * Return the ATM implied vol for the given option type. + * Looks up the option at the ATM strike. + */ +export function getATMIV( + chain: OptionQuote[], + spot: number, + type: "call" | "put" +): number { + const filtered = filterByType(chain, type); + const atm = closestByStrike(filtered, spot); + return atm ? atm.iv : 0; +} + +/** + * Find the option in the chain closest to the target delta. + * + * For calls, delta is positive [0, 1]. + * For puts, targetDelta should be expressed as a negative value (e.g. -0.25 for 25-delta put), + * or as an absolute value — the function handles both conventions by comparing |delta|. + */ +export function findDeltaStrike( + chain: OptionQuote[], + targetDelta: number, + type: "call" | "put" +): OptionQuote | null { + const filtered = filterByType(chain, type); + if (filtered.length === 0) return null; + + const absDelta = Math.abs(targetDelta); + + return filtered.reduce((best, q) => { + const qDiff = Math.abs(Math.abs(q.delta) - absDelta); + const bestDiff = Math.abs(Math.abs(best.delta) - absDelta); + return qDiff < bestDiff ? q : best; + }); +} + +/** + * Risk Reversal = IV(call @ delta) - IV(put @ |delta|) + * + * A positive risk reversal means calls are more expensive than puts (bullish skew). + * + * @param chain - Full options chain (calls + puts) for a single expiry + * @param expiry - Expiry string (used for filtering; chain may be pre-filtered) + * @param targetDelta - Delta level, e.g. 0.25 for 25-delta RR + */ +export function calcRiskReversal( + chain: OptionQuote[], + expiry: string, + targetDelta: number +): number { + const expiryChain = chain.filter((q) => q.expiry === expiry); + + const callOpt = findDeltaStrike(expiryChain, targetDelta, "call"); + const putOpt = findDeltaStrike(expiryChain, Math.abs(targetDelta), "put"); + + if (!callOpt || !putOpt) return 0; + + return callOpt.iv - putOpt.iv; +} + +/** + * Butterfly spread metric = 0.5 * (IV_25d_call + IV_25d_put) - IV_ATM + * + * Measures the curvature (kurtosis) of the vol smile. + * A positive butterfly means wings are more expensive than the ATM (vol smile). + * + * @param chain - Full options chain for a single expiry (call + put) + * @param expiry - Target expiry string + */ +export function calcButterfly(chain: OptionQuote[], expiry: string): number { + const expiryChain = chain.filter((q) => q.expiry === expiry); + + // Get spot from the chain (use the midpoint strike as proxy if no spot available) + // We use the ATM call and put average for the ATM IV + const calls = filterByType(expiryChain, "call"); + const puts = filterByType(expiryChain, "put"); + + if (calls.length === 0 || puts.length === 0) return 0; + + // Approximate spot from the chain: use the midpoint of strike range + const allStrikes = expiryChain.map((q) => q.strike); + const spotProxy = (Math.max(...allStrikes) + Math.min(...allStrikes)) / 2; + + // ATM IV: average of ATM call and put IV + const atmCall = closestByStrike(calls, spotProxy); + const atmPut = closestByStrike(puts, spotProxy); + if (!atmCall || !atmPut) return 0; + const atmIv = (atmCall.iv + atmPut.iv) / 2; + + // 25-delta wings + const call25 = findDeltaStrike(expiryChain, 0.25, "call"); + const put25 = findDeltaStrike(expiryChain, 0.25, "put"); + if (!call25 || !put25) return 0; + + return 0.5 * (call25.iv + put25.iv) - atmIv; +} + +/** + * Build a vol surface from an array of chain snapshots. + * + * The surface is organized as: expiry → strike → IV. + * Only liquid options (non-zero IV, bid, ask) are included. + * IV values are averaged if both call and put exist at the same strike. + */ +export function buildVolSurface(snapshots: ChainSnapshot[]): VolSurface { + // Use a map to accumulate IVs per (expiry, strike) + const ivAccum: Record> = {}; + + for (const snapshot of snapshots) { + const expiry = snapshot.expiry; + if (!ivAccum[expiry]) { + ivAccum[expiry] = {}; + } + + for (const quote of snapshot.chain) { + if (quote.iv <= 0) continue; + + const { strike, iv } = quote; + if (!ivAccum[expiry][strike]) { + ivAccum[expiry][strike] = { sum: 0, count: 0 }; + } + ivAccum[expiry][strike].sum += iv; + ivAccum[expiry][strike].count += 1; + } + } + + // Sort expiries and strikes + const expiries = Object.keys(ivAccum).sort(); + + const strikeSet = new Set(); + for (const expiry of expiries) { + for (const strikeStr of Object.keys(ivAccum[expiry])) { + strikeSet.add(Number(strikeStr)); + } + } + const strikes = Array.from(strikeSet).sort((a, b) => a - b); + + // Build the matrix + const matrix: Record> = {}; + for (const expiry of expiries) { + matrix[expiry] = {}; + for (const strike of strikes) { + const entry = ivAccum[expiry][strike]; + if (entry && entry.count > 0) { + matrix[expiry][strike] = entry.sum / entry.count; + } + } + } + + return { expiries, strikes, matrix }; +} + +/** + * Compute a full set of skew metrics for a given expiry from a snapshot. + * Returns SkewMetrics with ATM IV, 25-delta and 10-delta risk reversals, and 25-delta butterfly. + */ +export function computeSkewMetrics( + snapshot: ChainSnapshot +): SkewMetrics { + const { expiry, chain, spot, spotIv } = snapshot; + + // Prefer per-option IV computed from real bid/ask; fall back to stock-level 30-day IV + const atmIv = + getATMIV(chain, spot, "call") || + getATMIV(chain, spot, "put") || + spotIv || + 0; + + const rr25 = calcRiskReversal(chain, expiry, 0.25); + const rr10 = calcRiskReversal(chain, expiry, 0.10); + const fly25 = calcButterfly(chain, expiry); + + return { expiry, atmIv, rr25, rr10, fly25 }; +} diff --git a/backend/src/lib/blackscholes.ts b/backend/src/lib/blackscholes.ts new file mode 100644 index 0000000..1fd10ac --- /dev/null +++ b/backend/src/lib/blackscholes.ts @@ -0,0 +1,305 @@ +/** + * Black-Scholes pricing, Greeks, and Implied Volatility + * Implemented from scratch — no external math libraries. + * + * Abramowitz & Stegun 26.2.17 approximation for normalCDF, + * max error 7.5e-8. + */ + +/** + * Standard normal CDF using Horner's method (A&S 26.2.17). + * Accurate to within 7.5e-8. + */ +export function normalCDF(x: number): number { + // Use symmetry: CDF(-x) = 1 - CDF(x) + const sign = x >= 0 ? 1 : -1; + const absX = Math.abs(x); + + // A&S constants for polynomial approximation + const a1 = 0.319381530; + const a2 = -0.356563782; + const a3 = 1.781477937; + const a4 = -1.821255978; + const a5 = 1.330274429; + const p = 0.2316419; + + const t = 1.0 / (1.0 + p * absX); + + // Horner's method: ((((a5*t + a4)*t + a3)*t + a2)*t + a1)*t + const poly = t * (a1 + t * (a2 + t * (a3 + t * (a4 + t * a5)))); + const pdf = normalPDF(absX); + const approx = 1.0 - pdf * poly; + + // For negative x, CDF(x) = 1 - CDF(-x) + if (sign === 1) { + return approx; + } else { + return 1.0 - approx; + } +} + +/** + * Standard normal PDF. + */ +export function normalPDF(x: number): number { + return Math.exp(-0.5 * x * x) / Math.sqrt(2.0 * Math.PI); +} + +/** + * Compute d1 and d2 for Black-Scholes. + */ +function computeD1D2( + S: number, + K: number, + T: number, + r: number, + sigma: number +): { d1: number; d2: number } { + const sqrtT = Math.sqrt(T); + const d1 = + (Math.log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * sqrtT); + const d2 = d1 - sigma * sqrtT; + return { d1, d2 }; +} + +/** + * Black-Scholes theoretical price. + * + * @param S - Current spot price + * @param K - Strike price + * @param T - Time to expiry in years + * @param r - Risk-free rate (e.g. 0.05 for 5%) + * @param sigma - Implied / historical vol (e.g. 0.20 for 20%) + * @param type - 'call' or 'put' + * @returns Theoretical option price + */ +export function bsPrice( + S: number, + K: number, + T: number, + r: number, + sigma: number, + type: "call" | "put" +): number { + // Edge case: expired option + if (T <= 0) { + if (type === "call") return Math.max(S - K, 0); + return Math.max(K - S, 0); + } + + const { d1, d2 } = computeD1D2(S, K, T, r, sigma); + const discountFactor = Math.exp(-r * T); + + if (type === "call") { + return S * normalCDF(d1) - K * discountFactor * normalCDF(d2); + } else { + return K * discountFactor * normalCDF(-d2) - S * normalCDF(-d1); + } +} + +/** + * Black-Scholes Greeks. + * + * Theta is returned per calendar day (divided by 365). + * Vega is returned per 1% move in implied vol (divided by 100). + * + * @param S - Current spot price + * @param K - Strike price + * @param T - Time to expiry in years + * @param r - Risk-free rate + * @param sigma - Implied vol + * @param type - 'call' or 'put' + * @returns Object with { delta, gamma, theta, vega, rho } + */ +export function bsGreeks( + S: number, + K: number, + T: number, + r: number, + sigma: number, + type: "call" | "put" +): { delta: number; gamma: number; theta: number; vega: number; rho: number } { + // Edge case: expired option + if (T <= 0) { + const intrinsic = type === "call" ? S - K : K - S; + const itm = intrinsic > 0; + return { + delta: type === "call" ? (itm ? 1 : 0) : (itm ? -1 : 0), + gamma: 0, + theta: 0, + vega: 0, + rho: 0, + }; + } + + const { d1, d2 } = computeD1D2(S, K, T, r, sigma); + const sqrtT = Math.sqrt(T); + const nd1 = normalPDF(d1); + const discountFactor = Math.exp(-r * T); + + // Delta + let delta: number; + if (type === "call") { + delta = normalCDF(d1); + } else { + delta = normalCDF(d1) - 1; // equivalently: -normalCDF(-d1) + } + + // Gamma (same for call and put) + const gamma = nd1 / (S * sigma * sqrtT); + + // Theta (per year) — we divide by 365 for per-day + let thetaAnnual: number; + if (type === "call") { + thetaAnnual = + -(S * nd1 * sigma) / (2 * sqrtT) - + r * K * discountFactor * normalCDF(d2); + } else { + thetaAnnual = + -(S * nd1 * sigma) / (2 * sqrtT) + + r * K * discountFactor * normalCDF(-d2); + } + const theta = thetaAnnual / 365; + + // Vega (per 1% vol move) — raw vega is per unit, divide by 100 + const vegaRaw = S * nd1 * sqrtT; + const vega = vegaRaw / 100; + + // Rho (per 1% rate move) — raw rho, divide by 100 + let rhoRaw: number; + if (type === "call") { + rhoRaw = K * T * discountFactor * normalCDF(d2); + } else { + rhoRaw = -K * T * discountFactor * normalCDF(-d2); + } + const rho = rhoRaw / 100; + + return { delta, gamma, theta, vega, rho }; +} + +/** + * Calculate implied volatility using Newton-Raphson with bisection fallback. + * + * First attempts Newton-Raphson (fast convergence near the solution). + * Falls back to bisection over [0.0001, 5.0] if vega is too small or + * Newton-Raphson diverges / oscillates. + * + * @param S - Spot price + * @param K - Strike + * @param T - Time to expiry in years + * @param r - Risk-free rate + * @param marketPrice - Observed market price (mid) + * @param type - 'call' or 'put' + * @param maxIter - Maximum iterations (default 100, NR uses 50) + * @param tol - Convergence tolerance (default 1e-7) + * @returns Implied volatility or null if no convergence + */ +export function impliedVol( + S: number, + K: number, + T: number, + r: number, + marketPrice: number, + type: "call" | "put", + maxIter = 100, + tol = 1e-7 +): number | null { + // Basic sanity check + if (T <= 0) return null; + if (marketPrice <= 0) return null; + + // Intrinsic value bounds check + const intrinsic = + type === "call" ? Math.max(S - K * Math.exp(-r * T), 0) : Math.max(K * Math.exp(-r * T) - S, 0); + if (marketPrice < intrinsic - 1e-6) return null; + + // --- Newton-Raphson phase (up to 50 iterations) --- + const nrMaxIter = 50; + // Initial guess using Brenner-Subrahmanyam approximation + let sigma = + Math.sqrt((2 * Math.PI) / T) * (marketPrice / S); + // Clamp initial guess to reasonable bounds + sigma = Math.max(0.01, Math.min(sigma, 5.0)); + + let useBisection = false; + + for (let i = 0; i < nrMaxIter; i++) { + const price = bsPrice(S, K, T, r, sigma, type); + const diff = price - marketPrice; + + if (Math.abs(diff) < tol) { + return sigma; + } + + // Vega: raw vega (not per 1%) for NR step + const { d1 } = computeD1D2(S, K, T, r, sigma); + const vegaRaw = S * normalPDF(d1) * Math.sqrt(T); + + // If vega is effectively zero, switch to bisection + if (Math.abs(vegaRaw) < 1e-10) { + useBisection = true; + break; + } + + const sigmaNext = sigma - diff / vegaRaw; + + // If the step produces an out-of-range sigma, fall back to bisection + if (sigmaNext <= 0 || sigmaNext > 10.0 || !isFinite(sigmaNext)) { + useBisection = true; + break; + } + + sigma = sigmaNext; + } + + // Check if NR converged + if (!useBisection) { + const price = bsPrice(S, K, T, r, sigma, type); + if (Math.abs(price - marketPrice) < tol) { + return sigma; + } + // NR stalled but not converged — try bisection + useBisection = true; + } + + // --- Bisection fallback --- + const bisectMaxIter = maxIter; + let lo = 0.0001; + let hi = 5.0; + + // Check that the bracket is valid + const priceLo = bsPrice(S, K, T, r, lo, type); + const priceHi = bsPrice(S, K, T, r, hi, type); + + if ( + (priceLo - marketPrice) * (priceHi - marketPrice) > 0 + ) { + // Market price out of model range — cannot bracket + return null; + } + + for (let i = 0; i < bisectMaxIter; i++) { + const mid = (lo + hi) / 2; + const priceMid = bsPrice(S, K, T, r, mid, type); + const diff = priceMid - marketPrice; + + if (Math.abs(diff) < tol || (hi - lo) / 2 < tol) { + return mid; + } + + if ((priceLo - marketPrice) * diff < 0) { + hi = mid; + } else { + lo = mid; + } + } + + // Final best estimate from bisection midpoint + const finalSigma = (lo + hi) / 2; + const finalPrice = bsPrice(S, K, T, r, finalSigma, type); + if (Math.abs(finalPrice - marketPrice) < 1e-4) { + return finalSigma; + } + + return null; +} diff --git a/backend/src/lib/datafetch.ts b/backend/src/lib/datafetch.ts new file mode 100644 index 0000000..ddfe560 --- /dev/null +++ b/backend/src/lib/datafetch.ts @@ -0,0 +1,284 @@ +import YahooFinance from "yahoo-finance2"; +import type { OptionsResult, CallOrPut } from "yahoo-finance2/modules/options"; +import { bsPrice, bsGreeks, impliedVol } from "./blackscholes.js"; +import type { OptionQuote, ChainSnapshot } from "./analytics.js"; +import { + fmpEnabled, + fmpExpirations, + fmpOptionsChain, + fmpQuote, +} from "./fmp.js"; + +const yf = new YahooFinance({ suppressNotices: ["yahooSurvey"] }); + +const RISK_FREE_RATE = 0.05; + +/** + * Compute 30-day annualized realized volatility from daily closing prices. + * Used as the ATM IV baseline when options markets are closed / bid-ask are stale. + */ +async function fetchHistoricalVol(symbol: string): Promise { + try { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 45); // fetch 45 days to ensure 30 trading days + + const rows = await yf.historical(symbol, { + period1: start, + period2: end, + interval: "1d", + }); + + const closes = rows + .map((r) => r.adjClose ?? r.close) + .filter((v): v is number => v != null && v > 0); + + if (closes.length < 5) return 0; + + const logReturns: number[] = []; + for (let i = 1; i < closes.length; i++) { + logReturns.push(Math.log(closes[i] / closes[i - 1])); + } + + const mean = logReturns.reduce((a, b) => a + b, 0) / logReturns.length; + const variance = + logReturns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / + (logReturns.length - 1); + + return Math.sqrt(variance * 252); // annualize + } catch { + return 0; + } +} + +function timeToExpiry(expiryDateStr: string): number { + const daysRemaining = (new Date(expiryDateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24); + return Math.max(daysRemaining, 0) / 365; +} + +function toExpiryString(val: Date | number | string): string { + if (typeof val === "string") return val; + const d = val instanceof Date ? val : new Date((val as number) * 1000); + return d.toISOString().split("T")[0]; +} + +function enrichOption( + raw: CallOrPut, + type: "call" | "put", + expiry: string, + spot: number +): OptionQuote | null { + const strike = raw.strike ?? 0; + const bid = raw.bid ?? 0; + const ask = raw.ask ?? 0; + const lastPrice = parseFloat(String(raw.lastPrice ?? 0)); + const volume = raw.volume ?? 0; + const openInterest = raw.openInterest ?? 0; + + if (strike <= 0) return null; + + // During market hours: use mid/ask. After hours: fall back to lastPrice (last traded price). + const marketPrice = + bid > 0 && ask > 0 ? (bid + ask) / 2 : + ask > 0 ? ask : + lastPrice; + + if (marketPrice <= 0) return null; + + const midPrice = ask > 0 ? (bid > 0 ? (bid + ask) / 2 : ask) : lastPrice; + const T = timeToExpiry(expiry); + const r = RISK_FREE_RATE; + + let iv: number; + if (marketPrice > 0 && T > 0 && spot > 0) { + const calculatedIV = impliedVol(spot, strike, T, r, marketPrice, type) ?? null; + if (calculatedIV !== null && calculatedIV > 0.01 && calculatedIV < 5 && isFinite(calculatedIV)) { + iv = calculatedIV; + } else { + // Newton-Raphson failed — try Yahoo's per-option IV as fallback, but only if plausible + const yahooIV = raw.impliedVolatility; + if (yahooIV > 0.01 && yahooIV < 5 && isFinite(yahooIV)) { + iv = yahooIV; + } else { + return null; + } + } + } else { + return null; + } + + const theoreticalPrice = T > 0 && spot > 0 ? bsPrice(spot, strike, T, r, iv, type) : 0; + const greeks = + T > 0 && spot > 0 + ? bsGreeks(spot, strike, T, r, iv, type) + : { delta: 0, gamma: 0, theta: 0, vega: 0, rho: 0 }; + + return { + strike, + expiry, + type, + bid, + ask, + iv, + delta: greeks.delta, + gamma: greeks.gamma, + theta: greeks.theta, + vega: greeks.vega, + volume, + openInterest, + bsPrice: theoreticalPrice, + midPrice, + }; +} + +// --------------------------------------------------------------------------- +// FMP path — converts FmpOption[] into ChainSnapshot +// --------------------------------------------------------------------------- + +async function fetchViaFmp(symbol: string, expiry: string): Promise { + try { + const [fmpOptions, quote] = await Promise.all([ + fmpOptionsChain(symbol, expiry), + fmpQuote(symbol), + ]); + + if (fmpOptions.length === 0) return null; + + const spot = quote?.price ?? 0; + const T = timeToExpiry(expiry); + const r = RISK_FREE_RATE; + + const chain: OptionQuote[] = fmpOptions.map((o) => { + const theoreticalPrice = + T > 0 && spot > 0 + ? bsPrice(spot, o.strike, T, r, o.impliedVolatility, o.type) + : 0; + + return { + strike: o.strike, + expiry, + type: o.type, + bid: o.bid, + ask: o.ask, + iv: o.impliedVolatility, + delta: o.delta, + gamma: o.gamma, + theta: o.theta, + vega: o.vega, + volume: o.volume, + openInterest: o.openInterest, + bsPrice: theoreticalPrice, + midPrice: o.mid, + }; + }); + + const spotIv = quote?.impliedVolatility ?? 0; + + return { + symbol, + expiry, + spot, + spotIv, + timestamp: new Date().toISOString(), + chain, + }; + } catch (err) { + console.warn(`[datafetch] FMP failed for ${symbol} ${expiry}:`, (err as Error).message); + return null; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function fetchExpirations(symbol: string): Promise { + if (fmpEnabled()) { + try { + const dates = await fmpExpirations(symbol); + if (dates.length > 0) { + console.log(`[datafetch] FMP expirations for ${symbol}: ${dates.length} dates`); + return dates; + } + } catch (err) { + console.warn(`[datafetch] FMP expirations failed, falling back to Yahoo:`, (err as Error).message); + } + } + + const result: OptionsResult = await yf.options(symbol); + const dates = result.expirationDates ?? []; + return dates.map((d) => toExpiryString(d)).sort(); +} + +export async function fetchOptionsChain( + symbol: string, + expiry?: string +): Promise { + // --- Determine which expiries to fetch --- + let expiriesToFetch: string[]; + + if (expiry) { + expiriesToFetch = [expiry]; + } else { + const all = await fetchExpirations(symbol); + const now = Date.now(); + expiriesToFetch = all.filter((e) => new Date(e).getTime() > now).slice(0, 3); + } + + if (expiriesToFetch.length === 0) { + throw new Error(`No valid expiration dates found for ${symbol}`); + } + + // --- FMP path --- + if (fmpEnabled()) { + const snapshots: ChainSnapshot[] = []; + for (const exp of expiriesToFetch) { + const snap = await fetchViaFmp(symbol, exp); + if (snap) { + snapshots.push(snap); + } else { + console.warn(`[datafetch] FMP returned no data for ${symbol} ${exp}`); + } + } + if (snapshots.length > 0) { + console.log(`[datafetch] FMP: fetched ${snapshots.length} snapshots for ${symbol}`); + return snapshots; + } + console.warn(`[datafetch] FMP returned nothing for ${symbol}, falling back to Yahoo`); + } + + // --- Yahoo Finance fallback --- + const historicalVol = await fetchHistoricalVol(symbol); + const snapshots: ChainSnapshot[] = []; + + for (const expiryDate of expiriesToFetch) { + try { + const result: OptionsResult = await yf.options(symbol, { + date: new Date(expiryDate), + }); + + const spot: number = result.quote?.regularMarketPrice ?? 0; + + const optionExpiry = result.options?.[0]; + const rawCalls: CallOrPut[] = optionExpiry?.calls ?? []; + const rawPuts: CallOrPut[] = optionExpiry?.puts ?? []; + + const chain: OptionQuote[] = [ + ...rawCalls.map((r) => enrichOption(r, "call", expiryDate, spot)), + ...rawPuts.map((r) => enrichOption(r, "put", expiryDate, spot)), + ] + .filter((q): q is OptionQuote => q !== null) + .sort((a, b) => a.strike - b.strike); + + snapshots.push({ symbol, expiry: expiryDate, spot, spotIv: historicalVol, timestamp: new Date().toISOString(), chain }); + } catch (err) { + console.error(`[datafetch] Failed for ${symbol} expiry ${expiryDate}: ${err instanceof Error ? err.message : err}`); + } + } + + if (snapshots.length === 0) { + throw new Error(`Failed to fetch any options data for ${symbol}`); + } + + return snapshots; +} diff --git a/backend/src/lib/fmp.ts b/backend/src/lib/fmp.ts new file mode 100644 index 0000000..53f4171 --- /dev/null +++ b/backend/src/lib/fmp.ts @@ -0,0 +1,119 @@ +/** + * Financial Modeling Prep (FMP) options data client. + * Free tier: 250 calls/day. https://financialmodelingprep.com + * + * Provides real options chains with IV and Greeks already computed. + */ + +const BASE = "https://financialmodelingprep.com/api"; + +function key(): string { + return process.env.FMP_API_KEY ?? ""; +} + +export function fmpEnabled(): boolean { + const k = key(); + return k.length > 0 && k !== "demo"; +} + +async function fmpGet(path: string, params: Record = {}): Promise { + const qs = new URLSearchParams({ ...params, apikey: key() }).toString(); + const res = await fetch(`${BASE}${path}?${qs}`); + if (!res.ok) throw new Error(`FMP ${path} → ${res.status} ${res.statusText}`); + const data = await res.json(); + if (data?.["Error Message"]) throw new Error(`FMP: ${data["Error Message"]}`); + return data; +} + +export type FmpOption = { + symbol: string; + expiration: string; + type: "call" | "put"; + strike: number; + bid: number; + ask: number; + mid: number; + volume: number; + openInterest: number; + impliedVolatility: number; + delta: number; + gamma: number; + theta: number; + vega: number; + lastPrice: number; +}; + +export type FmpQuote = { + symbol: string; + price: number; + impliedVolatility?: number; +}; + +/** Get current stock quote (price + 30d IV if available). */ +export async function fmpQuote(symbol: string): Promise { + try { + const data = await fmpGet(`/v3/quote/${symbol}`); + const q = Array.isArray(data) ? data[0] : null; + if (!q) return null; + return { + symbol, + price: q.price ?? 0, + impliedVolatility: q.impliedVolatility ?? undefined, + }; + } catch (err) { + console.warn(`[fmp] quote ${symbol}:`, (err as Error).message); + return null; + } +} + +/** Get all available option expiration dates for a symbol. */ +export async function fmpExpirations(symbol: string): Promise { + const data = await fmpGet(`/v4/options/${symbol}`); + if (!Array.isArray(data) || data.length === 0) return []; + const dates = [...new Set(data.map((o: any) => o.date ?? o.expiration).filter(Boolean))]; + return (dates as string[]).sort(); +} + +/** Get full options chain for a symbol and specific expiry. */ +export async function fmpOptionsChain( + symbol: string, + expiry: string +): Promise { + const data = await fmpGet(`/v4/options/${symbol}`, { expiration: expiry }); + if (!Array.isArray(data)) return []; + + return data + .map((o: any): FmpOption | null => { + const strike = parseFloat(o.strike ?? o.strikePrice ?? "0"); + if (!strike || strike <= 0) return null; + + const type = (o.type ?? o.putCall ?? "").toLowerCase(); + if (type !== "call" && type !== "put") return null; + + const bid = parseFloat(o.bid ?? "0"); + const ask = parseFloat(o.ask ?? "0"); + const lastPrice = parseFloat(o.lastPrice ?? o.last ?? "0"); + const mid = bid > 0 && ask > 0 ? (bid + ask) / 2 : ask > 0 ? ask : lastPrice; + + const iv = parseFloat(o.impliedVolatility ?? o.iv ?? "0"); + + return { + symbol, + expiration: expiry, + type: type as "call" | "put", + strike, + bid, + ask, + mid, + volume: parseInt(o.volume ?? "0", 10), + openInterest: parseInt(o.openInterest ?? o.open_interest ?? "0", 10), + impliedVolatility: iv, + delta: parseFloat(o.delta ?? "0"), + gamma: parseFloat(o.gamma ?? "0"), + theta: parseFloat(o.theta ?? "0"), + vega: parseFloat(o.vega ?? "0"), + lastPrice, + }; + }) + .filter((o): o is FmpOption => o !== null && o.impliedVolatility > 0.001); +} diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts new file mode 100644 index 0000000..28c5c16 --- /dev/null +++ b/backend/src/routes/options.ts @@ -0,0 +1,427 @@ +/** + * Hono route handlers for the options pricing API. + * + * All routes return: + * { ok: true, data: ..., timestamp: "..." } on success + * { ok: false, error: "...", timestamp: "..." } on failure + */ + +import { Hono } from "hono"; +import { fetchOptionsChain, fetchExpirations } from "../lib/datafetch.js"; + +/** How old a snapshot can be and still count for the term structure (more lenient than primary TTL). */ +const TERM_STRUCTURE_TTL_MS = 30 * 60 * 1000; // 30 minutes +import { + buildVolSurface, + computeSkewMetrics, + getATMIV, +} from "../lib/analytics.js"; +import type { ChainSnapshot } from "../lib/analytics.js"; +import { + saveSnapshot, + getSnapshots, + getLatestSnapshot, +} from "../db/snapshots.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** How old a cached snapshot can be before we re-fetch (milliseconds). */ +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +function now(): string { + return new Date().toISOString(); +} + +function ok(data: T): { ok: true; data: T; timestamp: string } { + return { ok: true, data, timestamp: now() }; +} + +function fail(error: string): { ok: false; error: string; timestamp: string } { + return { ok: false, error, timestamp: now() }; +} + +/** + * Check if a snapshot row is fresh enough (within CACHE_TTL_MS of now). + */ +function isFresh(timestampIso: string): boolean { + const snapshotTime = new Date(timestampIso).getTime(); + return Date.now() - snapshotTime < CACHE_TTL_MS; +} + +/** + * Parse the `chain_json` field from a DB row and return a ChainSnapshot. + * Returns null if parsing fails. + */ +function rowToSnapshot( + row: { + symbol: string; + expiry: string; + spot: number | null; + timestamp: string; + chain_json: string | null; + atm_iv: number | null; + rr25: number | null; + rr10: number | null; + fly25: number | null; + } +): ChainSnapshot | null { + try { + const chain = row.chain_json ? JSON.parse(row.chain_json) : []; + return { + symbol: row.symbol, + expiry: row.expiry, + spot: row.spot ?? 0, + spotIv: row.atm_iv ?? 0, + timestamp: row.timestamp, + chain, + }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +export const optionsRouter = new Hono(); + +// --------------------------------------------------------------------------- +// GET /api/chain?symbol=AAPL&expiry=2025-01-17 +// --------------------------------------------------------------------------- +optionsRouter.get("/chain", async (c) => { + const symbol = c.req.query("symbol")?.toUpperCase(); + const expiry = c.req.query("expiry"); + + if (!symbol) { + return c.json(fail("Missing required query parameter: symbol"), 400); + } + + try { + // If an expiry was specified, check the cache first + if (expiry) { + const cached = getLatestSnapshot(symbol, expiry); + if (cached && isFresh(cached.timestamp)) { + const snapshot = rowToSnapshot(cached); + if (snapshot) { + return c.json(ok({ cached: true, snapshots: [snapshot] })); + } + } + } + + // Cache miss — fetch fresh data + const snapshots = await fetchOptionsChain(symbol, expiry); + + // Persist each fetched snapshot + for (const snapshot of snapshots) { + const metrics = computeSkewMetrics(snapshot); + saveSnapshot( + snapshot.symbol, + snapshot.expiry, + snapshot.spot, + metrics, + snapshot.chain + ); + } + + return c.json(ok({ cached: false, snapshots })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[GET /api/chain] ${msg}`); + return c.json(fail(msg), 502); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/expirations?symbol=AAPL +// --------------------------------------------------------------------------- +optionsRouter.get("/expirations", async (c) => { + const symbol = c.req.query("symbol")?.toUpperCase(); + + if (!symbol) { + return c.json(fail("Missing required query parameter: symbol"), 400); + } + + try { + const expirations = await fetchExpirations(symbol); + return c.json(ok({ symbol, expirations })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[GET /api/expirations] ${msg}`); + return c.json(fail(msg), 502); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/analytics?symbol=AAPL&expiry=2025-01-17 +// --------------------------------------------------------------------------- +optionsRouter.get("/analytics", async (c) => { + const symbol = c.req.query("symbol")?.toUpperCase(); + const expiry = c.req.query("expiry"); + + if (!symbol) { + return c.json(fail("Missing required query parameter: symbol"), 400); + } + + try { + // ----------------------------------------------------------------------- + // Step 1: Fetch the primary expiry (fresh data, used for Greeks + skew) + // ----------------------------------------------------------------------- + let primarySnapshots: ChainSnapshot[]; + + if (expiry) { + const cached = getLatestSnapshot(symbol, expiry); + if (cached && isFresh(cached.timestamp)) { + const snap = rowToSnapshot(cached); + primarySnapshots = snap ? [snap] : []; + } else { + primarySnapshots = await fetchOptionsChain(symbol, expiry); + for (const snap of primarySnapshots) { + saveSnapshot(snap.symbol, snap.expiry, snap.spot, computeSkewMetrics(snap), snap.chain); + } + } + } else { + // No expiry specified — fetch nearest 3 as primary + primarySnapshots = await fetchOptionsChain(symbol); + for (const snap of primarySnapshots) { + saveSnapshot(snap.symbol, snap.expiry, snap.spot, computeSkewMetrics(snap), snap.chain); + } + } + + if (primarySnapshots.length === 0) { + return c.json(fail("No options data available for the specified parameters"), 404); + } + + // ----------------------------------------------------------------------- + // Step 2: Build term structure — fetch nearest 5 expiries, using cache + // aggressively (30-min TTL) to avoid extra Yahoo calls + // ----------------------------------------------------------------------- + const allExpiries = await fetchExpirations(symbol); + const now = Date.now(); + const nearestFive = allExpiries + .filter((e) => new Date(e).getTime() > now) + .slice(0, 5); + + // Ensure the primary expiry is included even if far out + if (expiry && !nearestFive.includes(expiry)) nearestFive.unshift(expiry); + + const seenExpiries = new Set(primarySnapshots.map((s) => s.expiry)); + const termSnapshots: ChainSnapshot[] = [...primarySnapshots]; + + for (const exp of nearestFive) { + if (seenExpiries.has(exp)) continue; + const cached = getLatestSnapshot(symbol, exp); + if (cached && Date.now() - new Date(cached.timestamp).getTime() < TERM_STRUCTURE_TTL_MS) { + const snap = rowToSnapshot(cached); + if (snap) { termSnapshots.push(snap); seenExpiries.add(exp); } + } else { + // Not cached — fetch fresh and save + try { + const fetched = await fetchOptionsChain(symbol, exp); + for (const snap of fetched) { + saveSnapshot(snap.symbol, snap.expiry, snap.spot, computeSkewMetrics(snap), snap.chain); + termSnapshots.push(snap); + seenExpiries.add(snap.expiry); + } + } catch { + // Non-fatal — term structure just has fewer bars + } + } + } + + // Sort by expiry date + termSnapshots.sort((a, b) => a.expiry.localeCompare(b.expiry)); + + // ----------------------------------------------------------------------- + // Step 3: Build metrics from all term-structure snapshots + // ----------------------------------------------------------------------- + const skewMetrics: Record> = {}; + for (const s of termSnapshots) { + skewMetrics[s.expiry] = computeSkewMetrics(s); + } + + // Build vol surface from all snapshots + const volSurface = buildVolSurface(termSnapshots); + + // Primary snapshot (the requested expiry, or the first) + const primarySnapshot = expiry + ? primarySnapshots.find((s) => s.expiry === expiry) ?? primarySnapshots[0] + : primarySnapshots[0]; + + const atmIv = primarySnapshot + ? getATMIV(primarySnapshot.chain, primarySnapshot.spot, "call") || + getATMIV(primarySnapshot.chain, primarySnapshot.spot, "put") + : 0; + + // Build per-strike IV arrays for the skew chart (primary expiry only) + const allStrikes = primarySnapshot + ? [...new Set(primarySnapshot.chain.map((q) => q.strike))].sort((a, b) => a - b) + : []; + + const callIVs = allStrikes.map((k) => { + const q = primarySnapshot?.chain.find( + (o) => o.type === "call" && o.strike === k && o.iv > 0 + ); + return q ? q.iv : null; + }); + + const putIVs = allStrikes.map((k) => { + const q = primarySnapshot?.chain.find( + (o) => o.type === "put" && o.strike === k && o.iv > 0 + ); + return q ? q.iv : null; + }); + + // Greeks for ATM and nearest ITM options (primary expiry) + const greeks = (() => { + if (!primarySnapshot || primarySnapshot.chain.length === 0) return null; + const { chain, spot } = primarySnapshot; + + const calls = chain.filter((o) => o.type === "call" && o.iv > 0); + const puts = chain.filter((o) => o.type === "put" && o.iv > 0); + + const closest = (arr: typeof chain, target: number) => + arr.length === 0 ? null : + arr.reduce((b, o) => Math.abs(o.strike - target) < Math.abs(b.strike - target) ? o : b); + + // ATM: closest strike to spot for each type + const atmCall = closest(calls, spot); + const atmPut = closest(puts, spot); + + // Nearest ITM: one strike deeper in-the-money than ATM (distinct from ATM row) + const atmCallStrike = atmCall?.strike ?? spot; + const atmPutStrike = atmPut?.strike ?? spot; + const itmCallCandidates = calls.filter((o) => o.strike < atmCallStrike); + const itmPutCandidates = puts.filter((o) => o.strike > atmPutStrike); + const itmCall = itmCallCandidates.length > 0 + ? itmCallCandidates.reduce((b, o) => o.strike > b.strike ? o : b) + : null; + const itmPut = itmPutCandidates.length > 0 + ? itmPutCandidates.reduce((b, o) => o.strike < b.strike ? o : b) + : null; + + const pick = (o: typeof chain[number] | null) => o ? { + strike: o.strike, iv: o.iv, + delta: o.delta, gamma: o.gamma, theta: o.theta, vega: o.vega, + bid: o.bid, ask: o.ask, midPrice: o.midPrice, + } : null; + + return { + atmCall: pick(atmCall), + atmPut: pick(atmPut), + itmCall: pick(itmCall), + itmPut: pick(itmPut), + }; + })(); + + return c.json( + ok({ + symbol, + expiry: expiry ?? primarySnapshots.map((s) => s.expiry), + spot: primarySnapshot?.spot ?? null, + atmIv, + skewMetrics, + volSurface, + strikes: allStrikes, + callIVs, + putIVs, + greeks, + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[GET /api/analytics] ${msg}`); + return c.json(fail(msg), 502); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/snapshots?symbol=AAPL&limit=50 +// --------------------------------------------------------------------------- +optionsRouter.get("/snapshots", (c) => { + const symbol = c.req.query("symbol")?.toUpperCase(); + const limitStr = c.req.query("limit") ?? "50"; + + if (!symbol) { + return c.json(fail("Missing required query parameter: symbol"), 400); + } + + const limit = Math.min(parseInt(limitStr, 10) || 50, 500); + + try { + const rows = getSnapshots(symbol, limit); + + // Shape the rows for the chart consumer — omit chain_json to keep payload small + const data = rows.map((row) => ({ + id: row.id, + symbol: row.symbol, + expiry: row.expiry, + timestamp: row.timestamp, + spot: row.spot, + atmIv: row.atm_iv, + rr25: row.rr25, + rr10: row.rr10, + fly25: row.fly25, + })); + + return c.json(ok({ symbol, count: data.length, snapshots: data })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[GET /api/snapshots] ${msg}`); + return c.json(fail(msg), 500); + } +}); + +// --------------------------------------------------------------------------- +// POST /api/refresh?symbol=AAPL +// --------------------------------------------------------------------------- +optionsRouter.post("/refresh", async (c) => { + const symbol = c.req.query("symbol")?.toUpperCase(); + + if (!symbol) { + return c.json(fail("Missing required query parameter: symbol"), 400); + } + + try { + // Force fetch fresh data (no cache check) + const snapshots = await fetchOptionsChain(symbol); + + const savedMetrics = []; + for (const snapshot of snapshots) { + const metrics = computeSkewMetrics(snapshot); + saveSnapshot( + snapshot.symbol, + snapshot.expiry, + snapshot.spot, + metrics, + snapshot.chain + ); + savedMetrics.push(metrics); + } + + const volSurface = buildVolSurface(snapshots); + const primarySnapshot = snapshots[0]; + const atmIv = primarySnapshot + ? getATMIV(primarySnapshot.chain, primarySnapshot.spot, "call") || + getATMIV(primarySnapshot.chain, primarySnapshot.spot, "put") + : 0; + + return c.json( + ok({ + symbol, + refreshed: snapshots.map((s) => s.expiry), + spot: primarySnapshot?.spot ?? null, + atmIv, + skewMetrics: savedMetrics, + volSurface, + snapshots, + }) + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[POST /api/refresh] ${msg}`); + return c.json(fail(msg), 502); + } +}); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..13e66ed --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,88 @@ +import { readFileSync, existsSync } from "fs"; +import { resolve } from "path"; + +// Load .env manually (no dotenv dependency needed) +const envPath = resolve(process.cwd(), ".env"); +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf-8").split("\n")) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim(); + } +} + +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { fileURLToPath } from "url"; + +// Initialize DB (creates tables on first run) +import "./db/snapshots.js"; + +import { optionsRouter } from "./routes/options.js"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const FRONTEND_DIST = resolve(__dirname, "../../frontend"); + +const app = new Hono(); + +app.use( + "*", + cors({ + origin: "*", + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization", "Accept"], + maxAge: 86400, + }) +); + +app.use("*", logger()); + +app.get("/health", (c) => + c.json({ ok: true, service: "options-pricer-backend", timestamp: new Date().toISOString() }) +); + +app.route("/api", optionsRouter); + +if (existsSync(FRONTEND_DIST)) { + console.log(`[server] Serving frontend SPA from ${FRONTEND_DIST}`); + app.use("/*", serveStatic({ root: FRONTEND_DIST })); + app.get("*", (c) => { + const indexPath = resolve(FRONTEND_DIST, "index.html"); + if (existsSync(indexPath)) { + return c.html(readFileSync(indexPath, "utf-8")); + } + return c.text("Frontend not found", 404); + }); +} else { + app.get("/", (c) => + c.json({ + service: "options-pricer-backend", + version: "1.0.0", + docs: "API at /api/*", + routes: [ + "GET /api/chain?symbol=SPY&expiry=YYYY-MM-DD", + "GET /api/expirations?symbol=SPY", + "GET /api/analytics?symbol=SPY&expiry=YYYY-MM-DD", + "GET /api/snapshots?symbol=SPY&limit=50", + "POST /api/refresh?symbol=SPY", + "GET /health", + ], + }) + ); +} + +app.onError((err, c) => + c.json({ ok: false, error: err.message ?? "Internal server error", timestamp: new Date().toISOString() }, 500) +); + +app.notFound((c) => + c.json({ ok: false, error: `Not found: ${c.req.method} ${c.req.path}`, timestamp: new Date().toISOString() }, 404) +); + +const PORT = parseInt(process.env.PORT ?? "3001", 10); + +serve({ fetch: app.fetch, port: PORT }, () => { + console.log(`Options Pricer backend running on http://localhost:${PORT}`); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..71ba332 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "paths": {} + }, + "include": ["src/**/*.ts"] +} diff --git a/frontend/assets/alpine.min.js b/frontend/assets/alpine.min.js new file mode 100644 index 0000000..ab371ef --- /dev/null +++ b/frontend/assets/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0931821 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,892 @@ + + + + + + + Options Pricer — Dashboard + + + + + + + + + + +
+ + + + + +
+ + +
+
+ + + + + + + + +
+ + +
+
+ +
+
+
+
+
+
ATM IV
+ LIVE +
+
+
+
+
At-the-money implied vol
+
+
+
+ + +
+
+
+
+
+
+
+
25d Risk Reversal
+ +
+
+
+
+
Call IV − Put IV at 25Δ
+
+
+
+ + +
+
+
+
+
+
+
+
25d Butterfly
+ +
+
+
+
+
Wing IV − ATM IV
+
+
+
+ + +
+
+
+
+
+
+
+
Underlying Price
+ +
+
+
+
+
+
+
+
+ +
+ + + +
+ + +
+
+
+
+
+
+

+ + Risk Reversal Trend (25Δ) +

+
+ Last 30 snapshots +
+
+
+ +
+
+
+ + +
+
+
+
+
+
+

+ + Butterfly Trend (25Δ Fly) +

+
+ Last 30 snapshots +
+
+
+ +
+
+
+ +
+ + +
+ +
+ + + +
+
+
+
+
    +
  • + Options Pricer © +
  • +
  • + Powered by Tabler & ApexCharts +
  • +
+
+
+
+
+ +
+ + +
+ + + + + diff --git a/frontend/surface.html b/frontend/surface.html new file mode 100644 index 0000000..42ebcfa --- /dev/null +++ b/frontend/surface.html @@ -0,0 +1,894 @@ + + + + + + Vol Surface — Options Pricer + + + + + + + + +
+ + + + + +
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+ +
+ ATM IV + +
+ +
+ RR25 + +
+ +
+ Fly25 + +
+ +
+
+ + +
+
+

Greeks

+
+ ATM & nearest ITM · +
+
+
+
+ + + + + + + + + + + + + + + + + + + +
TypeStrikeIVMidDeltaGammaThetaVega
+
+
+
+ + +
+ + +
+
+
+

+ IV Skew — + + +

+
+
+ +
+
+
+ + +
+
+
+

ATM IV Term Structure

+
+
+ +
+
+
+ +
+ + +
+
+

Per-Expiry Skew Metrics

+
+ +
+
+
+ + + + + + + + + + + + + + +
ExpiryATM IVRR25RR10Fly25Skew Direction
+
+
+ + +
+
+ + + + +

No surface loaded

+

Enter a symbol and click Lookup, then select an expiry and click Load Surface.

+
+
+ + +
+
+
+
+
+
+ Loading skew chart… +
+
+
+
+
+
+
+
+ Loading term structure chart… +
+
+
+
+
+
+ +
+
+
+
+ + + + diff --git a/frontend/tracker.html b/frontend/tracker.html new file mode 100644 index 0000000..f5098e1 --- /dev/null +++ b/frontend/tracker.html @@ -0,0 +1,714 @@ + + + + + + + Options Metrics Tracker + + + + + + + + + + + +
+ + + + + +
+ + +
+
+
+ + +
+ Symbol + +
+ + +
+ Expiry + +
+ + + + + + + + + + +
+
+
+ + +
+
+ + +
+ + + + + +

No snapshot data. Enter a symbol and click Load History.

+
+ + +
+
+ + +
+
+
+ + ATM Implied Volatility History +
+
+
+
+ + +
+
+
+ + 25Δ Risk Reversal History +
+
+
+
+ +
+
+
+ + 25Δ Butterfly History +
+
+
+
+ +
+ + +
+
+

+ Snapshot History + + (most recent first, showing up to 20) + +

+
+
+ + + + + + + + + + + + + + + + + + + +
TimestampExpirySpotATM IVRR25RR10Fly25Butterfly Signal
No data available
+
+
+
+ +
+
+
+
+ + + + +