diff --git a/.env.example b/.env.example index 7a9d87b7..9253264f 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,12 @@ MYSQL_PASSWORD=appuser123 # Frontend / Vite ######################################## # 프록시 대상은 compose.dev.yaml의 백엔드 서비스명(backtest-be-fast)이다. -VITE_API_BASE_URL=/api +# +# VITE_API_BASE_URL은 비워 두어야 한다. +# 서비스 레이어(backtestService.ts, backtestApi.ts)가 axios에 전체 경로 +# `/api/v1/...`를 그대로 넘기므로, 여기에 `/api`를 넣으면 baseURL과 겹쳐 +# `/api/api/v1/backtest`가 되어 vite 프록시에 매칭되지 않고 404가 난다. +# (운영 빌드는 이 값을 주입하지 않아 자연히 빈 값이 된다) +VITE_API_BASE_URL= API_PROXY_TARGET=http://backtest-be-fast:8000 FASTAPI_PROXY_TARGET=http://backtest-be-fast:8000 diff --git a/CLAUDE.md b/CLAUDE.md index 6f294c50..e7fda29c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,32 +4,22 @@ **라고할때살걸** — Korean trading strategy backtesting platform (SMA, RSI, MACD, Bollinger, EMA, Buy&Hold). Supports portfolios, DCA, rebalancing. -**Stack:** Python 3.11 / FastAPI | TypeScript / React / Vite | MySQL 8.0 (prod) / SQLite (tests) | Docker Compose | Jenkins CI/CD - ## Commands ```bash -# BE tests (no DB) -pytest backtest_be_fast/tests/unit -v - -# FE -cd backtest_fe && npm run dev | npm run build | npm test - # Docker (full stack) docker compose -f compose.dev.yaml up -d --build docker compose -f compose.dev.yaml exec backtest-be-fast pytest tests/unit -v docker compose -f compose.dev.yaml exec backtest-fe npm test ``` -**URLs:** FE http://localhost:5173 | BE Docs http://localhost:8000/docs - ## Architecture **BE flow:** `FastAPI Endpoint → PortfolioManagerService → BacktestEngine / StrategyService / StockRepository → UnifiedDataService` **FE (Feature-Sliced Design):** `shared` ← `features` ← `pages` (no reverse imports). State: Zustand (global) + `useReducer` (forms). UI: shadcn/ui + Tailwind + Recharts. -**DB:** `stocks`, `daily_prices`, `stock_news` (see `database/schema.sql`) +**DB schema:** see `database/schema.sql` **API:** POST `/api/v1/backtest` — main endpoint. Errors: `@handle_portfolio_errors` decorator. @@ -49,13 +39,9 @@ docker compose -f compose.dev.yaml exec backtest-fe npm test ## Sub-Agent Usage -When working on this codebase, **actively use specialized sub-agents** for better quality: -- **`frontend-developer`** for React/TypeScript component changes -- **`python-pro` / `fastapi-pro`** for BE service/API changes -- **`test-automator`** for writing tests -- **`code-reviewer` / `architect-review`** for reviewing changes before finalizing -- **`debugger`** when encountering errors -- **`Explore`** agent for broad codebase research +- **`Explore`** for broad codebase research (where does X live, how is Y wired) +- **`Plan`** for designing multi-step changes before writing code +- **`general-purpose`** for multi-step research/edit tasks that need their own context Always verify changes in Docker containers (`docker compose exec`) before declaring work complete. @@ -63,7 +49,6 @@ Always verify changes in Docker containers (`docker compose exec`) before declar - **BE markers:** `@pytest.mark.unit` (no DB), `@pytest.mark.integration` (DB), `@pytest.mark.external` (real API) - **FE:** Vitest + React Testing Library; Playwright for E2E -- 141 BE unit tests, 94+ FE tests ## Commit Convention diff --git a/backtest_fe/components.json b/backtest_fe/components.json index b321c760..5f270b54 100644 --- a/backtest_fe/components.json +++ b/backtest_fe/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.js", + "config": "", "css": "src/index.css", "baseColor": "slate", "cssVariables": true, diff --git a/backtest_fe/package-lock.json b/backtest_fe/package-lock.json index 4c8fcef3..e4e624b1 100644 --- a/backtest_fe/package-lock.json +++ b/backtest_fe/package-lock.json @@ -26,26 +26,26 @@ "date-fns": "^4.1.0", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", "react-router-dom": "^7.18.2", - "recharts": "^2.9.0", + "recharts": "^3.10.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1" }, "devDependencies": { "@playwright/test": "^1.56.1", + "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@vitejs/plugin-react": "^4.0.0", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", - "autoprefixer": "^10.4.21", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", @@ -55,7 +55,7 @@ "patch-package": "^8.0.0", "postcss": "^8.5.6", "shadcn": "^3.2.1", - "tailwindcss": "^3.3.0", + "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "vite": "^7.2.2", "vitest": "^4.1.10" @@ -519,6 +519,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1456,25 +1457,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1482,7 +1489,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@hono/node-server": { @@ -1880,6 +1889,8 @@ }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.3" @@ -1901,6 +1912,8 @@ }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -1998,6 +2011,8 @@ }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -2063,6 +2078,8 @@ }, "node_modules/@radix-ui/react-focus-scope": { "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -2123,6 +2140,8 @@ }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -2161,6 +2180,8 @@ }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", @@ -2191,6 +2212,8 @@ }, "node_modules/@radix-ui/react-portal": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.3", @@ -2213,6 +2236,8 @@ }, "node_modules/@radix-ui/react-presence": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", @@ -2235,6 +2260,8 @@ }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -2256,6 +2283,8 @@ }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -2498,6 +2527,8 @@ }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "license": "MIT", "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" @@ -2540,6 +2571,8 @@ }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "license": "MIT", "dependencies": { "@radix-ui/rect": "1.1.1" @@ -2556,6 +2589,8 @@ }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" @@ -2572,6 +2607,8 @@ }, "node_modules/@radix-ui/react-visually-hidden": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.3" @@ -2593,8 +2630,36 @@ }, "node_modules/@radix-ui/rect": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "dev": true, @@ -2970,9 +3035,285 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3136,18 +3477,26 @@ }, "node_modules/@types/d3-array": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -3155,17 +3504,23 @@ }, "node_modules/@types/d3-path": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", "dependencies": { "@types/d3-time": "*" } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -3173,10 +3528,14 @@ }, "node_modules/@types/d3-time": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, "node_modules/@types/deep-eql": { @@ -3214,26 +3573,24 @@ "undici-types": "~7.12.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "devOptional": true, - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.24", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/statuses": { @@ -3241,6 +3598,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -3853,28 +4216,6 @@ "node": ">=14" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -3946,42 +4287,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/axios": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", @@ -4037,17 +4342,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4246,14 +4540,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001743", "dev": true, @@ -4298,51 +4584,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "3.6.0", + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { "node": ">=8" @@ -4604,17 +4856,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/cssstyle": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", @@ -4631,11 +4872,16 @@ } }, "node_modules/csstype": { - "version": "3.1.3", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -4646,6 +4892,8 @@ }, "node_modules/d3-color": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "license": "ISC", "engines": { "node": ">=12" @@ -4653,13 +4901,17 @@ }, "node_modules/d3-ease": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "license": "BSD-3-Clause", "engines": { "node": ">=12" } }, "node_modules/d3-format": { - "version": "3.1.0", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -4667,6 +4919,8 @@ }, "node_modules/d3-interpolate": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -4677,6 +4931,8 @@ }, "node_modules/d3-path": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "license": "ISC", "engines": { "node": ">=12" @@ -4684,6 +4940,8 @@ }, "node_modules/d3-scale": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -4698,6 +4956,8 @@ }, "node_modules/d3-shape": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -4708,6 +4968,8 @@ }, "node_modules/d3-time": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -4718,6 +4980,8 @@ }, "node_modules/d3-time-format": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -4728,6 +4992,8 @@ }, "node_modules/d3-timer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "license": "ISC", "engines": { "node": ">=12" @@ -4787,6 +5053,8 @@ }, "node_modules/decimal.js-light": { "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, "node_modules/dedent": { @@ -4862,15 +5130,20 @@ "node": ">=6" } }, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -4881,11 +5154,6 @@ "node": ">=0.3.1" } }, - "node_modules/dlv": { - "version": "1.1.3", - "dev": true, - "license": "MIT" - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4894,14 +5162,6 @@ "license": "MIT", "peer": true }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dotenv": { "version": "17.2.2", "dev": true, @@ -4968,6 +5228,20 @@ "node": ">= 0.8" } }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -5043,6 +5317,17 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -5353,7 +5638,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/eventsource": { @@ -5506,13 +5793,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-equals": { - "version": "5.2.2", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -5768,18 +6048,6 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -6186,6 +6454,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "dev": true, @@ -6226,6 +6504,8 @@ }, "node_modules/internmap": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", "engines": { "node": ">=12" @@ -6256,31 +6536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -6479,11 +6734,13 @@ } }, "node_modules/jiti": { - "version": "1.21.7", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/jose": { @@ -6498,6 +6755,7 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -6697,109 +6955,343 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/locate-path": { - "version": "6.0.0", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "6.0.0", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.6.2", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "loose-envify": "cli.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lucide-react": { - "version": "0.544.0", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.544.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", @@ -7064,16 +7556,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/mz": { - "version": "2.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -7156,22 +7638,6 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-run-path": { "version": "6.0.0", "dev": true, @@ -7200,17 +7666,10 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, "node_modules/object-inspect": { @@ -7560,11 +8019,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "dev": true, - "license": "MIT" - }, "node_modules/path-to-regexp": { "version": "6.3.0", "dev": true, @@ -7595,22 +8049,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/pkce-challenge": { "version": "5.0.0", "dev": true, @@ -7692,121 +8130,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -7879,19 +8202,6 @@ "node": ">=6" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8007,34 +8317,56 @@ } }, "node_modules/react": { - "version": "18.3.1", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.8" } }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "dev": true, @@ -8137,19 +8469,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "license": "MIT", @@ -8170,39 +8489,6 @@ } } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/recast": { "version": "0.23.11", "dev": true, @@ -8219,37 +8505,35 @@ } }, "node_modules/recharts": { - "version": "2.15.4", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", "license": "MIT", + "workspaces": [ + "www" + ], "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -8264,6 +8548,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "dev": true, @@ -8282,24 +8581,11 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.10", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" }, "node_modules/resolve-from": { "version": "4.0.0", @@ -8459,11 +8745,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "7.8.5", @@ -8916,37 +9201,6 @@ "node": ">=8" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -8958,17 +9212,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -8985,58 +9228,24 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.17", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/thenify": { - "version": "3.3.1", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } + "license": "MIT" }, - "node_modules/thenify-all": { - "version": "1.6.0", + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, "engines": { - "node": ">=0.8" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tiny-invariant": { @@ -9214,11 +9423,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/ts-morph": { "version": "26.0.0", "dev": true, @@ -9449,10 +9653,14 @@ } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "dev": true, - "license": "MIT" + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } }, "node_modules/vary": { "version": "1.1.2", @@ -9463,7 +9671,9 @@ } }, "node_modules/victory-vendor": { - "version": "36.9.2", + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/backtest_fe/package.json b/backtest_fe/package.json index 0ccd1527..f409f48c 100644 --- a/backtest_fe/package.json +++ b/backtest_fe/package.json @@ -47,26 +47,26 @@ "date-fns": "^4.1.0", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", "react-router-dom": "^7.18.2", - "recharts": "^2.9.0", + "recharts": "^3.10.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1" }, "devDependencies": { "@playwright/test": "^1.56.1", + "@tailwindcss/postcss": "^4.3.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@vitejs/plugin-react": "^4.0.0", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", - "autoprefixer": "^10.4.21", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", @@ -76,7 +76,7 @@ "patch-package": "^8.0.0", "postcss": "^8.5.6", "shadcn": "^3.2.1", - "tailwindcss": "^3.3.0", + "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "vite": "^7.2.2", "vitest": "^4.1.10" diff --git a/backtest_fe/postcss.config.js b/backtest_fe/postcss.config.js index 2e7af2b7..a7f73a2d 100644 --- a/backtest_fe/postcss.config.js +++ b/backtest_fe/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + '@tailwindcss/postcss': {}, }, } diff --git a/backtest_fe/src/features/backtest/api/backtestApi.ts b/backtest_fe/src/features/backtest/api/backtestApi.ts deleted file mode 100644 index 7ac42f6a..00000000 --- a/backtest_fe/src/features/backtest/api/backtestApi.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { apiClient, extractErrorMessage } from '@/shared/api/client'; - -export interface ApiError { - message: string; - status: number; - errorId?: string; - type?: 'network' | 'validation' | 'server' | 'data_not_found' | 'rate_limit'; -} - -const extractErrorId = (detail: unknown): string | undefined => { - if (typeof detail !== 'string') { - return undefined; - } - const match = detail.match(/오류 ID:\s*([a-zA-Z0-9]+)/); - return match?.[1]; -}; - -const mapStatusToType = (status: number): ApiError['type'] => { - switch (status) { - case 400: - case 422: - return 'validation'; - case 404: - return 'data_not_found'; - case 429: - return 'rate_limit'; - default: - return 'server'; - } -}; - -const toApiError = (error: unknown): ApiError => { - if (error && typeof error === 'object' && 'isAxiosError' in error) { - const axiosErr = error as unknown as { response?: { status: number; data?: { detail?: string; message?: string } }; message: string }; - const status = axiosErr.response?.status ?? 0; - const data = axiosErr.response?.data; - - if (status === 0) { - return { - message: '네트워크 연결에 문제가 발생했습니다. 인터넷 연결을 확인해주세요.', - status: 0, - type: 'network', - }; - } - - const detail = data?.detail; - const message = (typeof detail === 'string' ? detail : undefined) - ?? data?.message - ?? extractErrorMessage(error); - - return { - message, - status, - errorId: extractErrorId(detail), - type: mapStatusToType(status), - }; - } - - if (error instanceof Error) { - return { message: error.message, status: 500, type: 'server' }; - } - - return { message: '알 수 없는 오류가 발생했습니다.', status: 500, type: 'server' }; -}; - -export const getStockData = async (ticker: string, startDate: string, endDate: string, signal?: AbortSignal) => { - try { - const { data } = await apiClient.get( - `/api/v1/backtest/stock-data/${ticker}`, - { params: { start_date: startDate, end_date: endDate }, signal }, - ); - return data; - } catch (error) { - throw toApiError(error); - } -}; - -export const getExchangeRate = async (startDate: string, endDate: string, signal?: AbortSignal) => { - try { - const { data } = await apiClient.get( - `/api/v1/backtest/exchange-rate`, - { params: { start_date: startDate, end_date: endDate }, signal }, - ); - return data; - } catch (error) { - throw toApiError(error); - } -}; - -export const getStockVolatilityNews = async ( - ticker: string, - startDate: string, - endDate: string, - threshold = 5.0, - signal?: AbortSignal, -) => { - try { - const { data } = await apiClient.get( - `/api/v1/backtest/stock-volatility-news/${ticker}`, - { params: { start_date: startDate, end_date: endDate, threshold }, signal }, - ); - return data; - } catch (error) { - throw toApiError(error); - } -}; - -export const getNaverNews = async (ticker: string, date: string, display = 10, signal?: AbortSignal) => { - try { - const { data } = await apiClient.get( - `/api/v1/naver-news/ticker/${ticker}/date`, - { params: { start_date: date, end_date: date, display }, signal }, - ); - return data; - } catch (error) { - throw toApiError(error); - } -}; - -export const getLatestTickerNews = async (ticker: string, display = 10, signal?: AbortSignal) => { - try { - const { data } = await apiClient.get( - `/api/v1/naver-news/ticker/${ticker}`, - { params: { display }, signal }, - ); - return data; - } catch (error) { - throw toApiError(error); - } -}; - -export const searchNews = async (query: string, display = 15, signal?: AbortSignal) => { - try { - const { data } = await apiClient.get( - `/api/v1/naver-news/search`, - { params: { query, display }, signal }, - ); - return data; - } catch (error) { - throw toApiError(error); - } -}; diff --git a/backtest_fe/src/features/backtest/components/BacktestResults.tsx b/backtest_fe/src/features/backtest/components/BacktestResults.tsx index 72b82ae2..1120a1e9 100644 --- a/backtest_fe/src/features/backtest/components/BacktestResults.tsx +++ b/backtest_fe/src/features/backtest/components/BacktestResults.tsx @@ -48,7 +48,7 @@ const BacktestResults: React.FC = ({ data, isPortfolio }) if (!data) { return ( -
+
@@ -60,7 +60,7 @@ const BacktestResults: React.FC = ({ data, isPortfolio }) if (isPortfolio && (!('portfolio_composition' in data) || !data.portfolio_composition)) { return ( -
+
diff --git a/backtest_fe/src/features/backtest/components/EquityChart.tsx b/backtest_fe/src/features/backtest/components/EquityChart.tsx index 3f12fcd5..1d81ee40 100644 --- a/backtest_fe/src/features/backtest/components/EquityChart.tsx +++ b/backtest_fe/src/features/backtest/components/EquityChart.tsx @@ -1,17 +1,12 @@ import React, { memo, useMemo } from 'react'; import { ComposedChart, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, Line, Area, ReferenceLine } from 'recharts'; +import type { TextAnchor } from 'recharts'; import { CustomTooltip } from './shared'; import { useRenderPerformance } from '@/shared/components'; - -interface EquityChartData { - date: string; - return_pct: number; - drawdown_pct: number; - [key: string]: string | number; -} +import type { EquityPoint } from '../model/types'; interface EquityChartProps { - data: EquityChartData[]; + data: EquityPoint[]; } // 차트 설정 상수 (컴포넌트 외부로 이동하여 재생성 방지) @@ -49,10 +44,11 @@ const EquityChart: React.FC = memo(({ data }) => { // 반응형 X축 설정 const xAxisProps = useMemo(() => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 640; + const textAnchor: TextAnchor = isMobile ? 'end' : 'middle'; return { interval: getXAxisInterval(processedData.length, typeof window !== 'undefined' ? window.innerWidth : 1024), angle: isMobile ? -45 : 0, - textAnchor: isMobile ? 'end' : 'middle', + textAnchor, height: isMobile ? 60 : 30, }; }, [processedData.length]); @@ -60,13 +56,14 @@ const EquityChart: React.FC = memo(({ data }) => { return ( - + {/* recharts v3: CartesianGrid의 yAxisId가 YAxis와 일치해야 가로 그리드가 눈금에 맞춰 그려진다. */} + diff --git a/backtest_fe/src/features/backtest/components/ExchangeRateChart.tsx b/backtest_fe/src/features/backtest/components/ExchangeRateChart.tsx deleted file mode 100644 index ef2f6d8b..00000000 --- a/backtest_fe/src/features/backtest/components/ExchangeRateChart.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from "react"; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; -import { useExchangeRate } from "../hooks/useExchangeRate"; -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; - -interface ExchangeRateChartProps { - startDate: string; - endDate: string; - className?: string; - showCard?: boolean; -} - -const ExchangeRateChart: React.FC = ({ - startDate, - endDate, - className = "", - showCard = true -}) => { - const { exchangeData, loading, error } = useExchangeRate({ startDate, endDate }); - - // 환율 포맷팅 함수 - const formatRate = (value: number) => { - return `₩${value.toFixed(0)}`; - }; - - if (loading) { - const loadingContent = ( -
-
-
- 환율 데이터 로딩 중... -
-
- ); - return showCard ? ( - - - 원달러 환율 변동 - - - {loadingContent} - - - ) : loadingContent; - } - - if (error) { - const errorContent = ( -
-

{error}

-
- ); - return showCard ? ( - - - 원달러 환율 변동 - - - {errorContent} - - - ) : errorContent; - } - - if (!exchangeData || exchangeData.length === 0) { - const emptyContent = ( -
-

표시할 환율 데이터가 없습니다.

-
- ); - return showCard ? ( - - - 원달러 환율 변동 - - - {emptyContent} - - - ) : emptyContent; - } - - const minRate = Math.min(...exchangeData.map((d) => d.rate)); - const maxRate = Math.max(...exchangeData.map((d) => d.rate)); - const firstRate = exchangeData[0]?.rate ?? 0; - const lastRate = exchangeData[exchangeData.length - 1]?.rate ?? 0; - const rateChange = firstRate !== 0 ? ((lastRate - firstRate) / firstRate * 100) : 0; - - const chartContent = ( - <> - - - - { - const date = new Date(value); - return `${date.getMonth() + 1}/${date.getDate()}`; - }} - /> - `₩${value.toFixed(0)}`} - /> - [`₩${value.toFixed(2)}`, '환율']} - labelFormatter={(label: any) => `날짜: ${label}`} - /> - - - - - {/* 환율 요약 정보 */} -
-
-
시작 환율
-
{formatRate(firstRate)}
-
-
-
종료 환율
-
{formatRate(lastRate)}
-
-
-
최고점
-
{formatRate(maxRate)}
-
-
-
최저점
-
{formatRate(minRate)}
-
-
- - ); - - if (showCard) { - return ( - - -
-
- 원/달러 환율 -

백테스트 기간 동안의 원달러 환율 변화

-
-
-
= 0 ? 'bg-destructive/10 text-destructive' : 'bg-green-100 text-green-800'}`}> - {rateChange >= 0 ? '▲' : '▼'} {Math.abs(rateChange).toFixed(2)}% -
-
기간 변동률
-
-
-
- - {chartContent} - -
- ); - } - - return
{chartContent}
; -}; - -export default ExchangeRateChart; diff --git a/backtest_fe/src/features/backtest/components/OHLCChart.tsx b/backtest_fe/src/features/backtest/components/OHLCChart.tsx index 32a98756..36816f1c 100644 --- a/backtest_fe/src/features/backtest/components/OHLCChart.tsx +++ b/backtest_fe/src/features/backtest/components/OHLCChart.tsx @@ -12,33 +12,12 @@ import { ReferenceLine } from 'recharts'; import { CustomTooltip } from './shared'; - -interface OHLCData { - date: string; - open: number; - high: number; - low: number; - close: number; - volume: number; - [key: string]: string | number; -} - -interface Indicator { - name: string; - color: string; - data?: Array<{ date: string; value: number }>; -} - -interface Trade { - date: string; - type: 'entry' | 'exit'; - price?: number; -} +import type { IndicatorData, OhlcPoint, TradeMarker } from '../model/types'; interface OHLCChartProps { - data: OHLCData[]; - indicators: Indicator[]; - trades: Trade[]; + data: OhlcPoint[]; + indicators: IndicatorData[]; + trades: TradeMarker[]; } const OHLCChart: React.FC = memo(({ data, indicators, trades }) => { @@ -50,7 +29,7 @@ const OHLCChart: React.FC = memo(({ data, indicators, trades }) // 데이터 병합 로직 메모이제이션 const mergedData = useMemo(() => { return safeData.map(ohlc => { - const point: Record = { + const point: Record = { ...ohlc, open: Number(ohlc.open) || 0, high: Number(ohlc.high) || 0, @@ -119,7 +98,8 @@ const OHLCChart: React.FC = memo(({ data, indicators, trades })
- + {/* recharts v3: CartesianGrid의 yAxisId가 YAxis와 일치해야 가로 그리드가 눈금에 맞춰 그려진다. */} + diff --git a/backtest_fe/src/features/backtest/components/PortfolioBacktestForm.tsx b/backtest_fe/src/features/backtest/components/PortfolioBacktestForm.tsx index 794a5160..1fab1fd4 100644 --- a/backtest_fe/src/features/backtest/components/PortfolioBacktestForm.tsx +++ b/backtest_fe/src/features/backtest/components/PortfolioBacktestForm.tsx @@ -80,9 +80,10 @@ const PortfolioBacktestForm: React.FC = ({ onSubmit, if (responseData?.detail) { if (Array.isArray(responseData.detail)) { // Pydantic validation errors - errorMessages = responseData.detail.map((err: any) => { - if (err.msg) { - return err.msg; + // FastAPI/Pydantic 422 응답: [{ loc, msg, type }, ...] + errorMessages = responseData.detail.map((err: unknown) => { + if (typeof err === 'object' && err !== null && 'msg' in err && err.msg) { + return String(err.msg); } return JSON.stringify(err); }); diff --git a/backtest_fe/src/features/backtest/components/StockPriceChart.tsx b/backtest_fe/src/features/backtest/components/StockPriceChart.tsx index 218b2563..2cfc92e1 100644 --- a/backtest_fe/src/features/backtest/components/StockPriceChart.tsx +++ b/backtest_fe/src/features/backtest/components/StockPriceChart.tsx @@ -25,6 +25,16 @@ interface TradeLog { ReturnPct?: number; } +// 주가 데이터에 매매 신호를 merge한 차트 데이터 포인트 +// buySignal/sellSignal은 해당 날짜에 신호가 없으면 undefined (recharts가 점을 건너뜀) +interface StockChartPoint { + date: string; + price: number; + volume?: number; + buySignal?: number; + sellSignal?: number; +} + interface StockPriceChartProps { stocksData: StockData[]; tickerInfo?: { [symbol: string]: TickerInfo }; @@ -102,7 +112,7 @@ const StockPriceChart: React.FC = memo(({ stocksData, tick const selectedStockData = stocksData.find(stock => stock.symbol === selectedSymbol); // 선택된 종목의 매매 신호를 주가 데이터에 merge - const chartDataWithSignals = useMemo(() => { + const chartDataWithSignals = useMemo(() => { if (!selectedStockData) return []; const logs = tradeLogs[selectedSymbol]; @@ -116,7 +126,7 @@ const StockPriceChart: React.FC = memo(({ stocksData, tick logs.forEach((trade) => { // 실제 거래 날짜 추출 - let tradeDates: string[] = []; + const tradeDates: string[] = []; if (trade.Type) { // Type 필드가 있으면 (리밸런싱, DCA 등) @@ -162,7 +172,7 @@ const StockPriceChart: React.FC = memo(({ stocksData, tick // Y축 도메인 계산 (메모이제이션) const yAxisDomain = useMemo<[number, number]>(() => { const prices = chartDataWithSignals - .map((d: any) => d.price) + .map((d) => d.price) .filter((price): price is number => typeof price === 'number' && !isNaN(price)); if (prices.length === 0) return [0, 100]; @@ -251,13 +261,15 @@ const StockPriceChart: React.FC = memo(({ stocksData, tick domain={yAxisDomain} /> `날짜: ${label}`} - formatter={(value: any, name: string) => { + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} + formatter={(value: unknown, name: unknown) => { if (!value) return null; - if (name === 'price') return [formatPrice(value), '주가']; - if (name === 'buySignal') return [formatPrice(value), '매수']; - if (name === 'sellSignal') return [formatPrice(value), '매도']; - return [value, name]; + const key = String(name); + const price = Number(value); + if (key === 'price') return [formatPrice(price), '주가']; + if (key === 'buySignal') return [formatPrice(price), '매수']; + if (key === 'sellSignal') return [formatPrice(price), '매도']; + return [String(value), key]; }} /> {/* 주가 라인 */} diff --git a/backtest_fe/src/features/backtest/components/StockVolatilityNews.tsx b/backtest_fe/src/features/backtest/components/StockVolatilityNews.tsx deleted file mode 100644 index 70900c6c..00000000 --- a/backtest_fe/src/features/backtest/components/StockVolatilityNews.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from 'react'; -import VolatilityTable from './volatility/VolatilityTable'; -import NewsModal from './volatility/NewsModal'; -import { useVolatilityNews } from '../hooks/useVolatilityNews'; -import { StockVolatilityNewsProps } from '../model/types/volatility-news-types'; -import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'; - -const StockVolatilityNews: React.FC = ({ - symbols, - startDate, - endDate, - className = "" -}) => { - const { - volatilityData, - selectedStock, - newsData, - showNewsModal, - currentNewsEvent, - loading, - newsLoading, - error, - actions: { - setSelectedStock, - openNewsModal, - closeNewsModal - } - } = useVolatilityNews({ symbols, startDate, endDate, canViewNews: true }); - - // 현금이 아닌 유효한 심볼만 필터링 - const validSymbols = symbols.filter(symbol => - symbol.toUpperCase() !== 'CASH' && symbol !== '현금' - ); - - // 로딩 중일 때 - if (loading) { - return ( - - - 📰 주가 급등/급락 뉴스 - - -
-
- 변동성 데이터를 불러오는 중... -
-
-
- ); - } - - // 에러가 있을 때 - if (error) { - return ( - - - 📰 주가 급등/급락 뉴스 - - -
{error}
-
-
- ); - } - - const selectedEvents = volatilityData[selectedStock] || []; - const hasSignificantEvents = Object.values(volatilityData).some(events => events.length > 0); - - return ( - <> - - - 📰 주가 급등/급락 뉴스 (5% 이상 변동일) - - - {!hasSignificantEvents ? ( -
-

해당 기간 중 5% 이상 급등/급락한 날이 없습니다.

-
- ) : ( - <> - {/* 종목 선택 버튼들 */} -
-
- {validSymbols.map((symbol, idx) => { - const eventCount = volatilityData[symbol]?.length || 0; - return ( - - ); - })} -
-
- - {/* 변동성 테이블 */} - - - )} -
-
- - {/* 뉴스 모달 */} - - - ); -}; - -export default StockVolatilityNews; diff --git a/backtest_fe/src/features/backtest/components/StrategyHelpModal.tsx b/backtest_fe/src/features/backtest/components/StrategyHelpModal.tsx index 1a1a8b2d..f7f04e5e 100644 --- a/backtest_fe/src/features/backtest/components/StrategyHelpModal.tsx +++ b/backtest_fe/src/features/backtest/components/StrategyHelpModal.tsx @@ -64,7 +64,7 @@ const StrategyHelpModal: React.FC = ({ {strategy.description}
-
+
{strategy.category} diff --git a/backtest_fe/src/features/backtest/components/TradesChart.tsx b/backtest_fe/src/features/backtest/components/TradesChart.tsx index 4cf8a221..02f415ca 100644 --- a/backtest_fe/src/features/backtest/components/TradesChart.tsx +++ b/backtest_fe/src/features/backtest/components/TradesChart.tsx @@ -2,17 +2,10 @@ import React, { memo, useMemo, useCallback } from 'react'; import { ScatterChart, CartesianGrid, XAxis, YAxis, ResponsiveContainer, Scatter, Cell, ReferenceLine } from 'recharts'; import { TrendingUp } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'; - -interface Trade { - date: string; - type: 'entry' | 'exit'; - pnl_pct?: number; - price?: number; - [key: string]: string | number | undefined; -} +import type { TradeMarker } from '../model/types'; interface TradesChartProps { - trades: Trade[]; + trades: TradeMarker[]; showCard?: boolean; } diff --git a/backtest_fe/src/features/backtest/components/__tests__/BacktestForm.test.tsx b/backtest_fe/src/features/backtest/components/__tests__/BacktestForm.test.tsx index a11c848c..d467e54a 100644 --- a/backtest_fe/src/features/backtest/components/__tests__/BacktestForm.test.tsx +++ b/backtest_fe/src/features/backtest/components/__tests__/BacktestForm.test.tsx @@ -11,8 +11,16 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, waitFor } from '@/test/utils' import userEvent from '@testing-library/user-event' +// FormData.get()이 반환하는 원본 값 그대로 전달된다 (string | File | null) +interface MockBacktestFormData { + symbol: FormDataEntryValue | null + startDate: FormDataEntryValue | null + endDate: FormDataEntryValue | null + strategy: FormDataEntryValue | null +} + // Mock BacktestForm 컴포넌트 (실제 구현은 복잡하므로 간단한 버전으로 테스트) -const MockBacktestForm = ({ onSubmit }: { onSubmit: (data: any) => void }) => { +const MockBacktestForm = ({ onSubmit }: { onSubmit: (data: MockBacktestFormData) => void }) => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault() const formData = new FormData(e.currentTarget) diff --git a/backtest_fe/src/features/backtest/components/__tests__/ChartRendering.test.tsx b/backtest_fe/src/features/backtest/components/__tests__/ChartRendering.test.tsx new file mode 100644 index 00000000..bdcc878a --- /dev/null +++ b/backtest_fe/src/features/backtest/components/__tests__/ChartRendering.test.tsx @@ -0,0 +1,155 @@ +/** + * 차트 SVG 렌더링 회귀 테스트 (recharts v3) + * + * **테스트 범위**: + * - 커스텀 yAxisId를 쓰는 차트(EquityChart, OHLCChart)에서 가로 그리드가 + * Y축 눈금에 맞춰 그려지는지 검증한다. + * + * **배경 (recharts v2 → v3 breaking change)**: + * - v3부터 CartesianGrid는 자체 xAxisId/yAxisId(기본값 0)를 가지며, + * 이 값이 실제 YAxis의 yAxisId와 일치해야 해당 축의 눈금 기준으로 + * 가로 그리드 라인을 그린다. + * - 불일치 시 그리드는 사라지지 않고 "경계선 2줄"로만 축소되어 그려지므로 + * 타입 에러도 런타임 에러도 없이 조용히 눈금선만 사라진다. + * → 그래서 라인 개수를 직접 세는 방식으로 검증한다. + * + * **한계**: + * - ResponsiveContainer는 happy-dom에서 크기가 0이므로 우회하기 위해 + * 차트 내부 구조를 직접 렌더링하지 않고 실제 컴포넌트를 렌더링하되, + * 컨테이너 크기를 강제로 지정한다. + * - 색상/여백/툴팁 서식 등 시각적 요소는 검증하지 않는다. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { render } from '@testing-library/react'; +import EquityChart from '../EquityChart'; +import OHLCChart from '../OHLCChart'; + +// ResponsiveContainer가 실제 크기를 갖도록 강제한다. +// happy-dom은 레이아웃 엔진이 없어 모든 요소의 크기가 0이며, +// 전역 ResizeObserver 목(src/test/setup.ts)은 콜백을 호출하지 않는다. +// 이 파일에서만 크기를 통지하는 ResizeObserver로 교체한다. +const CHART_WIDTH = 800; +const CHART_HEIGHT = 400; + +const ORIGINAL_RO = global.ResizeObserver; +const ORIGINAL_RECT = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getBoundingClientRect' +); +const nativeRect = HTMLElement.prototype.getBoundingClientRect; + +const sizedRect = () => + ({ + width: CHART_WIDTH, + height: CHART_HEIGHT, + top: 0, + left: 0, + bottom: CHART_HEIGHT, + right: CHART_WIDTH, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + +class SizedResizeObserver { + constructor(private callback: ResizeObserverCallback) {} + + observe(target: Element) { + this.callback( + [{ target, contentRect: sizedRect() } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver + ); + } + + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + global.ResizeObserver = SizedResizeObserver as unknown as typeof ResizeObserver; + + // ResponsiveContainer가 읽는 컨테이너 요소에만 크기를 부여한다. + // 모든 요소에 크기를 주면 Legend 높이가 400px로 잡혀 플롯 영역이 + // 음수 높이가 되고 축/그리드/곡선이 전부 사라진다. + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value(this: HTMLElement) { + if (this.classList?.contains('recharts-responsive-container')) { + return sizedRect(); + } + return nativeRect.call(this); + }, + }); +}); + +afterAll(() => { + global.ResizeObserver = ORIGINAL_RO; + if (ORIGINAL_RECT) { + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', ORIGINAL_RECT); + } +}); + +const equityData = Array.from({ length: 10 }, (_, i) => ({ + date: `2023-01-${String(i + 1).padStart(2, '0')}`, + return_pct: i * 1.5, + drawdown_pct: -i * 0.4, +})); + +const ohlcData = Array.from({ length: 10 }, (_, i) => ({ + date: `2023-01-${String(i + 1).padStart(2, '0')}`, + open: 100 + i, + high: 105 + i, + low: 95 + i, + close: 102 + i, + volume: 1000 + i * 10, +})); + +describe('차트 SVG 렌더링 (recharts v3)', () => { + describe('EquityChart', () => { + it('SVG와 데이터 곡선을 렌더링한다', () => { + const { container } = render(); + + expect(container.querySelector('svg.recharts-surface')).toBeTruthy(); + // 수익률 Line + 드로우다운 Area + expect(container.querySelectorAll('.recharts-curve').length).toBeGreaterThan(0); + }); + + it('가로 그리드가 Y축 눈금에 맞춰 그려진다 (경계선 2줄이 아님)', () => { + const { container } = render(); + + const horizontal = container.querySelectorAll( + '.recharts-cartesian-grid-horizontal line' + ); + // yAxisId 불일치 시 2줄(위/아래 경계)로 축소된다. + expect(horizontal.length).toBeGreaterThan(2); + }); + + it('좌/우 Y축을 모두 렌더링한다', () => { + const { container } = render(); + + expect(container.querySelectorAll('.recharts-yAxis').length).toBe(2); + }); + }); + + describe('OHLCChart', () => { + it('가로 그리드가 price 축 눈금에 맞춰 그려진다', () => { + const { container } = render( + + ); + + const horizontal = container.querySelectorAll( + '.recharts-cartesian-grid-horizontal line' + ); + expect(horizontal.length).toBeGreaterThan(2); + }); + + it('거래량 Bar와 종가 Line을 렌더링한다', () => { + const { container } = render( + + ); + + expect(container.querySelectorAll('.recharts-bar-rectangle').length).toBeGreaterThan(0); + expect(container.querySelectorAll('.recharts-line-curve').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/backtest_fe/src/features/backtest/components/lazy/LazyChartComponents.tsx b/backtest_fe/src/features/backtest/components/lazy/LazyChartComponents.tsx index 071cfc36..f2d88dd4 100644 --- a/backtest_fe/src/features/backtest/components/lazy/LazyChartComponents.tsx +++ b/backtest_fe/src/features/backtest/components/lazy/LazyChartComponents.tsx @@ -6,11 +6,5 @@ export const LazyOHLCChart = lazy(() => import('../OHLCChart')); export const LazyTradesChart = lazy(() => import('../TradesChart')); export const LazyStockPriceChart = lazy(() => import('../StockPriceChart')); -// 뉴스 관련 컴포넌트 지연 로딩 -export const LazyStockVolatilityNews = lazy(() => import('../StockVolatilityNews')); - // 통계 컴포넌트 지연 로딩 export const LazyStatsSummary = lazy(() => import('../StatsSummary')); - -// 환율 차트 지연 로딩 -export const LazyExchangeRateChart = lazy(() => import('../ExchangeRateChart')); diff --git a/backtest_fe/src/features/backtest/components/results/BenchmarkIndexChart.tsx b/backtest_fe/src/features/backtest/components/results/BenchmarkIndexChart.tsx index bf42cb04..155329c1 100644 --- a/backtest_fe/src/features/backtest/components/results/BenchmarkIndexChart.tsx +++ b/backtest_fe/src/features/backtest/components/results/BenchmarkIndexChart.tsx @@ -8,12 +8,28 @@ */ import React, { useState, useMemo, memo, useCallback } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import type { LegendPayload } from 'recharts'; import { useRenderPerformance } from '@/shared/components'; +import type { BenchmarkSeriesPoint, EquityPoint } from '../../model/types'; interface BenchmarkIndexChartProps { - sp500Data: any[]; - nasdaqData: any[]; - portfolioEquityData: any[]; // 포트폴리오 equity curve 데이터 + sp500Data: BenchmarkSeriesPoint[]; + nasdaqData: BenchmarkSeriesPoint[]; + portfolioEquityData: EquityPoint[]; // 포트폴리오 equity curve 데이터 +} + +/** 시작점을 100으로 맞춘 정규화 시계열 포인트 */ +interface NormalizedPoint { + date: string; + normalized: number; +} + +/** 포트폴리오/지수 세 계열을 날짜로 병합한 차트 행 */ +interface MergedBenchmarkRow { + date: string; + portfolio?: number; + sp500?: number; + nasdaq?: number; } const BenchmarkIndexChart: React.FC = memo(({ @@ -32,14 +48,17 @@ const BenchmarkIndexChart: React.FC = memo(({ }); // 데이터를 시작점 100으로 normalize하는 함수 (샘플링은 useChartData에서 처리됨) - const normalizeData = (data: any[], valueKey: string) => { + const normalizeData = ( + data: T[], + valueKey: keyof T & string + ): NormalizedPoint[] => { if (!data || data.length === 0) return []; - const startValue = data[0][valueKey]; + const startValue = Number(data[0]?.[valueKey]); if (!startValue || startValue === 0) return []; - return data.map((point: any) => ({ + return data.map((point) => ({ date: point.date, - normalized: (point[valueKey] / startValue) * 100, + normalized: (Number(point[valueKey]) / startValue) * 100, })); }; @@ -105,7 +124,7 @@ const BenchmarkIndexChart: React.FC = memo(({ // 세 데이터를 날짜별로 병합 const mergedData = useMemo(() => { - const dataMap = new Map(); + const dataMap = new Map(); // 포트폴리오 데이터 추가 normalizedPortfolio.forEach(p => { @@ -139,7 +158,7 @@ const BenchmarkIndexChart: React.FC = memo(({ const yAxisDomain = useMemo(() => { if (mergedData.length === 0) return ['auto', 'auto']; - let allValues: number[] = []; + const allValues: number[] = []; mergedData.forEach(item => { if (visibleLines.portfolio && item.portfolio !== undefined) { @@ -168,7 +187,7 @@ const BenchmarkIndexChart: React.FC = memo(({ return `${date.getMonth() + 1}/${date.getDate()}`; }; - const handleLegendClick = useCallback((data: any) => { + const handleLegendClick = useCallback((data: LegendPayload) => { const dataKey = data.dataKey; if (dataKey && typeof dataKey === 'string') { setVisibleLines(prev => ({ @@ -202,15 +221,16 @@ const BenchmarkIndexChart: React.FC = memo(({ tick={{ fontSize: 13 }} /> { + formatter={(value: unknown, name: unknown) => { const labels: Record = { portfolio: '내 포트폴리오', sp500: 'S&P 500', nasdaq: 'NASDAQ', }; - return [value.toFixed(2), labels[name] || name]; + const key = String(name); + return [Number(value).toFixed(2), labels[key] || key]; }} - labelFormatter={(label: string) => `날짜: ${label}`} + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} contentStyle={{ backgroundColor: 'rgba(255, 255, 255, 0.95)', border: '1px solid #ccc', diff --git a/backtest_fe/src/features/backtest/components/results/BenchmarkReturnsChart.tsx b/backtest_fe/src/features/backtest/components/results/BenchmarkReturnsChart.tsx index ed89eb08..ea7130e2 100644 --- a/backtest_fe/src/features/backtest/components/results/BenchmarkReturnsChart.tsx +++ b/backtest_fe/src/features/backtest/components/results/BenchmarkReturnsChart.tsx @@ -9,15 +9,24 @@ */ import React, { useMemo, useState, memo, useCallback } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import type { LegendPayload } from 'recharts'; import { useRenderPerformance } from '@/shared/components'; -import { EquityPoint } from '../../model/types'; +import { BenchmarkSeriesPoint, EquityPoint } from '../../model/types'; interface BenchmarkReturnsChartProps { - sp500Data: any[]; - nasdaqData: any[]; + sp500Data: BenchmarkSeriesPoint[]; + nasdaqData: BenchmarkSeriesPoint[]; portfolioEquityData: EquityPoint[]; } +/** 포트폴리오/지수 세 계열의 기간 수익률을 날짜로 병합한 차트 행 */ +interface MergedReturnsRow { + date: string; + portfolio?: number; + sp500?: number; + nasdaq?: number; +} + export const BenchmarkReturnsChart: React.FC = memo(({ sp500Data, nasdaqData, @@ -35,7 +44,7 @@ export const BenchmarkReturnsChart: React.FC = memo( // 모든 데이터를 날짜 기준으로 병합 const mergedData = useMemo(() => { - const dataMap = new Map(); + const dataMap = new Map(); // 포트폴리오 수익률 추가 (배열 형태로 받음) if (portfolioEquityData && portfolioEquityData.length > 0) { @@ -103,7 +112,7 @@ export const BenchmarkReturnsChart: React.FC = memo( return `${date.getMonth() + 1}/${date.getDate()}`; }; - const handleLegendClick = useCallback((data: any) => { + const handleLegendClick = useCallback((data: LegendPayload) => { const dataKey = data.dataKey; if (dataKey && typeof dataKey === 'string') { setVisibleLines(prev => ({ @@ -130,15 +139,16 @@ export const BenchmarkReturnsChart: React.FC = memo( tick={{ fontSize: 13 }} /> { + formatter={(value: unknown, name: unknown) => { const labels: Record = { portfolio: '내 포트폴리오', sp500: 'S&P 500', nasdaq: 'NASDAQ', }; - return [`${value.toFixed(2)}%`, labels[name] || name]; + const key = String(name); + return [`${Number(value).toFixed(2)}%`, labels[key] || key]; }} - labelFormatter={(label: string) => `날짜: ${label}`} + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} contentStyle={{ backgroundColor: 'rgba(255, 255, 255, 0.95)', border: '1px solid #ccc', diff --git a/backtest_fe/src/features/backtest/components/results/ChartsSection/BenchmarkSection.tsx b/backtest_fe/src/features/backtest/components/results/ChartsSection/BenchmarkSection.tsx index a038ddc5..ee423a77 100644 --- a/backtest_fe/src/features/backtest/components/results/ChartsSection/BenchmarkSection.tsx +++ b/backtest_fe/src/features/backtest/components/results/ChartsSection/BenchmarkSection.tsx @@ -7,13 +7,13 @@ import React, { memo } from 'react'; import BenchmarkIndexChart from '../BenchmarkIndexChart'; import BenchmarkReturnsChart from '../BenchmarkReturnsChart'; import { ResultBlock } from '../../shared'; -import { EquityPoint } from '../../../model/types'; +import { BenchmarkSeriesPoint, EquityPoint } from '../../../model/types'; interface BenchmarkSectionProps { - sp500Benchmark: any[]; - nasdaqBenchmark: any[]; - sp500BenchmarkWithReturn: any[]; - nasdaqBenchmarkWithReturn: any[]; + sp500Benchmark: BenchmarkSeriesPoint[]; + nasdaqBenchmark: BenchmarkSeriesPoint[]; + sp500BenchmarkWithReturn: BenchmarkSeriesPoint[]; + nasdaqBenchmarkWithReturn: BenchmarkSeriesPoint[]; equityDataForBenchmark: EquityPoint[]; aggregationType?: 'daily' | 'weekly' | 'monthly'; } diff --git a/backtest_fe/src/features/backtest/components/results/ChartsSection/PortfolioCharts.tsx b/backtest_fe/src/features/backtest/components/results/ChartsSection/PortfolioCharts.tsx index 1d5a8259..4aeaa1fa 100644 --- a/backtest_fe/src/features/backtest/components/results/ChartsSection/PortfolioCharts.tsx +++ b/backtest_fe/src/features/backtest/components/results/ChartsSection/PortfolioCharts.tsx @@ -20,15 +20,15 @@ import { Loader2 } from 'lucide-react'; import { LazyStockPriceChart } from '../../lazy/LazyChartComponents'; import { ChartLoading } from '@/shared/components'; import { ResultBlock } from '../../shared'; -import { EquityPoint, PortfolioData } from '../../../model/types'; +import { EquityPoint, PortfolioData, StockDataItem, TickerInfo, TradeLog } from '../../../model/types'; import { formatCurrency, formatDateShort } from '../../../utils'; interface PortfolioChartsProps { portfolioData: PortfolioData; portfolioEquityData: EquityPoint[]; - stocksData: Array<{ symbol: string; data: any[] }>; - tickerInfo: Record; - tradeLogs: Record; + stocksData: StockDataItem[]; + tickerInfo: Record; + tradeLogs: Record; loadingStockData?: boolean; aggregationType?: 'daily' | 'weekly' | 'monthly'; } @@ -117,11 +117,11 @@ export const PortfolioCharts: React.FC = memo(({ formatCurrency(value)} /> [ - formatCurrency(value), + formatter={(value: unknown) => [ + formatCurrency(Number(value)), isMultipleStocks ? '포트폴리오 가치' : '자산 가치', ]} - labelFormatter={(label: string) => `날짜: ${label}`} + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} /> {/* 리밸런싱 마커 - 차트 데이터에 포함된 날짜 기준 */} {rebalance_history?.map((event, idx) => ( @@ -162,8 +162,8 @@ export const PortfolioCharts: React.FC = memo(({ tickFormatter={(value: number) => `${value.toFixed(1)}%`} /> [`${value.toFixed(2)}%`, `${periodLabel} 수익률`]} - labelFormatter={(label: string) => `날짜: ${label}`} + formatter={(value: unknown) => [`${Number(value).toFixed(2)}%`, `${periodLabel} 수익률`]} + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} /> ; - tickerInfo: Record; - tradeLogs: Record; + stocksData: StockDataItem[]; + tickerInfo: Record; + tradeLogs: Record; loadingStockData?: boolean; aggregationType?: 'daily' | 'weekly' | 'monthly'; } @@ -47,9 +47,9 @@ export const SingleStockCharts: React.FC = memo(({ }> @@ -57,7 +57,7 @@ export const SingleStockCharts: React.FC = memo(({ {/* 수익률 & 드로우다운 차트 */} }> - + @@ -82,7 +82,7 @@ export const SingleStockCharts: React.FC = memo(({ <> }> - + diff --git a/backtest_fe/src/features/backtest/components/results/ChartsSection/SupplementaryCharts.tsx b/backtest_fe/src/features/backtest/components/results/ChartsSection/SupplementaryCharts.tsx index 00e827e7..fcd8c1c0 100644 --- a/backtest_fe/src/features/backtest/components/results/ChartsSection/SupplementaryCharts.tsx +++ b/backtest_fe/src/features/backtest/components/results/ChartsSection/SupplementaryCharts.tsx @@ -1,183 +1,195 @@ -/** - * 부가 정보 차트 컴포넌트 - * 환율, 급등락 이벤트, 최신 뉴스, 리밸런싱 히스토리, 포트폴리오 비중 변화 - */ - -import React, { memo, useMemo } from 'react'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from 'recharts'; -import { ResultBlock } from '../../shared'; -import VolatilityEventsSection from '../VolatilityEventsSection'; -import LatestNewsSection from '../LatestNewsSection'; -import RebalanceHistoryTable from '../RebalanceHistoryTable'; -import WeightHistoryChart from '../WeightHistoryChart'; -import { formatDateShort, formatKRW } from '../../../utils'; - -interface SupplementaryChartsProps { - // 환율 데이터 - exchangeRates: any[]; - exchangeStats: any; - - // 급등락/뉴스 데이터 - volatilityEvents: Record; - latestNews: Record; - hasVolatilityEvents: boolean; - hasNews: boolean; - tickerInfo: Record; - - // 리밸런싱 데이터 (포트폴리오만) - rebalanceHistory: any[]; - weightHistory: any[]; - portfolioComposition?: any[]; -} - -export const SupplementaryCharts: React.FC = memo(({ - exchangeRates, - exchangeStats, - volatilityEvents, - latestNews, - hasVolatilityEvents, - hasNews, - tickerInfo, - rebalanceHistory, - weightHistory, - portfolioComposition, -}) => { - const hasExchangeRates = exchangeRates && exchangeRates.length > 0; - const hasRebalanceHistory = rebalanceHistory && rebalanceHistory.length > 0; - const hasWeightHistory = - weightHistory && - weightHistory.length > 0 && - portfolioComposition && - portfolioComposition.length > 1; - - // 환율 Y축 domain 계산 메모이제이션 (매 렌더링마다 재계산 방지) - const exchangeRateDomain = useMemo(() => { - if (!hasExchangeRates) return [0, 100]; - const rates = exchangeRates.map((d: any) => d.rate); - return [Math.min(...rates), Math.max(...rates)]; - }, [exchangeRates, hasExchangeRates]); - - return ( - <> - {/* 환율 차트 */} - {hasExchangeRates && ( - -
- - - - - formatKRW(value)} - /> - [formatKRW(value, 2), '환율']} - labelFormatter={(label: string) => `날짜: ${label}`} - /> - - - - - {/* 환율 주요 지점 정보 */} - {exchangeStats && ( -
-
-
시작점
-
- {formatKRW(exchangeStats.start_point?.rate, 2)} -
-
- {exchangeStats.start_point?.date - ? new Date(exchangeStats.start_point.date).toLocaleDateString('ko-KR') - : ''} -
-
-
-
종료점
-
- {formatKRW(exchangeStats.end_point?.rate, 2)} -
-
- {exchangeStats.end_point?.date - ? new Date(exchangeStats.end_point.date).toLocaleDateString('ko-KR') - : ''} -
-
-
-
최고점
-
- {formatKRW(exchangeStats.high_point?.rate, 2)} -
-
- {exchangeStats.high_point?.date - ? new Date(exchangeStats.high_point.date).toLocaleDateString('ko-KR') - : ''} -
-
-
-
최저점
-
- {formatKRW(exchangeStats.low_point?.rate, 2)} -
-
- {exchangeStats.low_point?.date - ? new Date(exchangeStats.low_point.date).toLocaleDateString('ko-KR') - : ''} -
-
-
- )} -
-
- )} - - {/* 급등락 이벤트 */} - {hasVolatilityEvents && ( - - )} - - {/* 최신 뉴스 */} - {hasNews && } - - {/* 리밸런싱 히스토리 테이블 */} - {hasRebalanceHistory && ( - - - - )} - - {/* 포트폴리오 비중 변화 차트 */} - {hasWeightHistory && ( - - - - )} - - ); -}); - -SupplementaryCharts.displayName = 'SupplementaryCharts'; +/** + * 부가 정보 차트 컴포넌트 + * 환율, 급등락 이벤트, 최신 뉴스, 리밸런싱 히스토리, 포트폴리오 비중 변화 + */ + +import React, { memo, useMemo } from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { ResultBlock } from '../../shared'; +import VolatilityEventsSection from '../VolatilityEventsSection'; +import LatestNewsSection from '../LatestNewsSection'; +import RebalanceHistoryTable from '../RebalanceHistoryTable'; +import WeightHistoryChart from '../WeightHistoryChart'; +import { formatDateShort, formatKRW } from '../../../utils'; +import type { + ExchangeRatePoint, + ExchangeRateStats, + NewsItem, + RebalanceEvent, + TickerInfo, + VolatilityEvent, + WeightHistoryPoint, +} from '../../../model/types'; +// 포트폴리오 구성 종목({ symbol, weight })은 결과 타입 쪽 Stock을 사용한다. +// (폼 쪽 Stock과 이름이 겹치므로 모듈 경로로 직접 import) +import type { Stock } from '../../../model/types/backtest-result-types'; + +interface SupplementaryChartsProps { + // 환율 데이터 + exchangeRates: ExchangeRatePoint[]; + exchangeStats: ExchangeRateStats | undefined; + + // 급등락/뉴스 데이터 + volatilityEvents: Record; + latestNews: Record; + hasVolatilityEvents: boolean; + hasNews: boolean; + tickerInfo: Record; + + // 리밸런싱 데이터 (포트폴리오만) + rebalanceHistory: RebalanceEvent[]; + weightHistory: WeightHistoryPoint[]; + portfolioComposition?: Stock[]; +} + +export const SupplementaryCharts: React.FC = memo(({ + exchangeRates, + exchangeStats, + volatilityEvents, + latestNews, + hasVolatilityEvents, + hasNews, + tickerInfo, + rebalanceHistory, + weightHistory, + portfolioComposition, +}) => { + const hasExchangeRates = exchangeRates && exchangeRates.length > 0; + const hasRebalanceHistory = rebalanceHistory && rebalanceHistory.length > 0; + const hasWeightHistory = + weightHistory && + weightHistory.length > 0 && + portfolioComposition && + portfolioComposition.length > 1; + + // 환율 Y축 domain 계산 메모이제이션 (매 렌더링마다 재계산 방지) + const exchangeRateDomain = useMemo(() => { + if (!hasExchangeRates) return [0, 100]; + const rates = exchangeRates.map((d) => d.rate); + return [Math.min(...rates), Math.max(...rates)]; + }, [exchangeRates, hasExchangeRates]); + + return ( + <> + {/* 환율 차트 */} + {hasExchangeRates && ( + +
+ + + + + formatKRW(value)} + /> + [formatKRW(Number(value), 2), '환율']} + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} + /> + + + + + {/* 환율 주요 지점 정보 */} + {exchangeStats && ( +
+
+
시작점
+
+ {exchangeStats.start_point ? formatKRW(exchangeStats.start_point.rate, 2) : '-'} +
+
+ {exchangeStats.start_point?.date + ? new Date(exchangeStats.start_point.date).toLocaleDateString('ko-KR') + : ''} +
+
+
+
종료점
+
+ {exchangeStats.end_point ? formatKRW(exchangeStats.end_point.rate, 2) : '-'} +
+
+ {exchangeStats.end_point?.date + ? new Date(exchangeStats.end_point.date).toLocaleDateString('ko-KR') + : ''} +
+
+
+
최고점
+
+ {exchangeStats.high_point ? formatKRW(exchangeStats.high_point.rate, 2) : '-'} +
+
+ {exchangeStats.high_point?.date + ? new Date(exchangeStats.high_point.date).toLocaleDateString('ko-KR') + : ''} +
+
+
+
최저점
+
+ {exchangeStats.low_point ? formatKRW(exchangeStats.low_point.rate, 2) : '-'} +
+
+ {exchangeStats.low_point?.date + ? new Date(exchangeStats.low_point.date).toLocaleDateString('ko-KR') + : ''} +
+
+
+ )} +
+
+ )} + + {/* 급등락 이벤트 */} + {hasVolatilityEvents && ( + + )} + + {/* 최신 뉴스 */} + {hasNews && } + + {/* 리밸런싱 히스토리 테이블 */} + {hasRebalanceHistory && ( + + + + )} + + {/* 포트폴리오 비중 변화 차트 */} + {hasWeightHistory && ( + + + + )} + + ); +}); + +SupplementaryCharts.displayName = 'SupplementaryCharts'; diff --git a/backtest_fe/src/features/backtest/components/results/WarningBanner.tsx b/backtest_fe/src/features/backtest/components/results/WarningBanner.tsx index 784ff80b..6f25acc0 100644 --- a/backtest_fe/src/features/backtest/components/results/WarningBanner.tsx +++ b/backtest_fe/src/features/backtest/components/results/WarningBanner.tsx @@ -21,7 +21,7 @@ const WarningBanner: React.FC = ({ warnings }) => { return (
-
+
diff --git a/backtest_fe/src/features/backtest/components/results/WeightHistoryChart.tsx b/backtest_fe/src/features/backtest/components/results/WeightHistoryChart.tsx index b3e256aa..7bbc48ca 100644 --- a/backtest_fe/src/features/backtest/components/results/WeightHistoryChart.tsx +++ b/backtest_fe/src/features/backtest/components/results/WeightHistoryChart.tsx @@ -1,15 +1,17 @@ import React, { useMemo, memo } from 'react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine } from 'recharts'; -import { WeightHistoryPoint } from '../../model/types/backtest-result-types'; +import { RebalanceEvent, WeightHistoryPoint } from '../../model/types/backtest-result-types'; import { getStockDisplayName } from '../../model/strategyConfig'; import { TEXT_STYLES } from '@/shared/styles/design-tokens'; import { useRenderPerformance } from '@/shared/components'; -interface RebalanceEvent { +/** + * 스택 영역 차트의 한 행: date + 심볼별 비중(%). + * 리밸런싱 날짜만 추가된 행은 모든 심볼이 null이다 (connectNulls로 선이 이어짐). + */ +interface WeightChartRow { date: string; - trades: Array; - weights_before: Record; - weights_after: Record; + [symbol: string]: string | number | null; } interface WeightHistoryChartProps { @@ -57,7 +59,7 @@ const WeightHistoryChart: React.FC = memo(({ // 차트 데이터 (샘플링은 useChartData에서 처리됨) const chartData = useMemo(() => { return weightHistory.map(point => { - const dataPoint: Record = { + const dataPoint: WeightChartRow = { date: point.date, }; symbols.forEach(symbol => { @@ -80,7 +82,7 @@ const WeightHistoryChart: React.FC = memo(({ const rebalanceDatesToAdd = rebalanceHistory .filter(event => !existingDates.has(event.date)) .map(event => { - const nullPoint: Record = { + const nullPoint: WeightChartRow = { date: event.date, }; // 모든 심볼에 대해 null 값 설정 @@ -130,8 +132,11 @@ const WeightHistoryChart: React.FC = memo(({ domain={[0, 100]} /> [`${value.toFixed(2)}%`, symbolDisplayNames[name] || name]} - labelFormatter={(label: string) => `날짜: ${label}`} + formatter={(value: unknown, name: unknown) => { + const key = String(name); + return [`${Number(value).toFixed(2)}%`, symbolDisplayNames[key] || key]; + }} + labelFormatter={(label: unknown) => `날짜: ${String(label)}`} contentStyle={{ backgroundColor: 'rgba(255, 255, 255, 0.95)', border: '1px solid #ccc', diff --git a/backtest_fe/src/features/backtest/components/volatility/VolatilityTable.tsx b/backtest_fe/src/features/backtest/components/volatility/VolatilityTable.tsx index 8ce6da95..e39aea40 100644 --- a/backtest_fe/src/features/backtest/components/volatility/VolatilityTable.tsx +++ b/backtest_fe/src/features/backtest/components/volatility/VolatilityTable.tsx @@ -62,7 +62,7 @@ const VolatilityTable: React.FC = ({ selectedStock, events