diff --git a/README.md b/README.md index 9d0b4bc..a2f43b2 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,319 @@ -# React + TypeScript + Vite +# QuantAMM Web App -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Frontend for QuantAMM product discovery, documentation/fact sheets, and simulation workflows. -Currently, two official plugins are available: +The app is built with React + TypeScript + Vite and uses both: +- GraphQL (Apollo) for Balancer data +- REST (RTK Query) for app/backend operations such as simulations, docs, prices, and audit logging -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +## Prerequisites -## Expanding the ESLint configuration +- Node.js `20.x` (matches CI) +- npm `10+` (or compatible with your Node install) -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +## Quick Start -- Configure the top-level `parserOptions` property like this: +1. Install dependencies -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -}; +```bash +npm ci ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +2. Create local env file + +```bash +cp env.local.template .env.local +``` + +3. Fill in required values in `.env.local` (see variables below) +4. Run the app + +```bash +npm run dev +``` + +Default local URL: `http://localhost:5173` + +## Environment Variables + +Create `.env.local` from `env.local.template`. + +| Variable | Required | Purpose | +| --- | --- | --- | +| `VITE_BASE_URL` | Yes | Base URL for REST endpoints used by RTK Query services | +| `VITE_GRAPH_TARGET` | Yes | GraphQL endpoint used by Apollo + GraphQL codegen | +| `VITE_USE_STUBS_DATA` | Optional | Enables/disables stub product list flow in hooks | +| `VITE_AG_GRID_LICENSE_KEY` | Required for enterprise features | AG Grid/AG Charts enterprise license key | + +Notes: +- Never commit `.env.local`. +- `npm run codegen` reads `.env.local` via `dotenv_config_path=.env.local`. +- `VITE_AG_GRID_LICENSE_KEY` must be set at build time for AG Grid/AG Charts enterprise features. + +## Scripts + +- `npm run dev`: start Vite dev server +- `npm run start`: same as `dev` +- `npm run build`: type-check (`tsc`) + production Vite build +- `npm run preview`: preview production build locally +- `npm run lint`: ESLint with strict warning policy (`--max-warnings 0`) +- `npm run format`: Prettier write on `src/` +- `npm run test`: run Vitest once +- `npm run test:watch`: run Vitest in watch mode +- `npm run test:coverage`: run tests with V8 coverage output +- `npm run codegen`: regenerate GraphQL types/hooks from `src/queries/*.graphql` + +## Project Structure + +Top-level frontend source is in `src/`, and the repo follows a mostly feature-first structure. + +### High-level layout + +| Path | Responsibility | +| --- | --- | +| `src/index.tsx` | Runtime entry point; wires Apollo provider, Redux provider, and router | +| `src/App.tsx` | Main app shell; layout/menu, global bootstrapping, route outlet host | +| `src/routes.tsx` | Route graph (`createBrowserRouter`) and page-level composition | +| `src/routeComponents.ts` | Lazy imports for route-level code splitting | +| `src/app/` | Store, typed hooks, app-level Redux plumbing | +| `src/features/` | Domain features (UI, slice logic, feature-local utils/tests) | +| `src/services/` | Shared RTK Query services and service helpers | +| `src/queries/` | Apollo client + GraphQL operation documents | +| `src/__generated__/` | GraphQL codegen output (generated types/hooks) | +| `src/hooks/` | Cross-feature hooks for data fetch/transform orchestration | +| `src/utils/` | Generic utilities not owned by a single feature | +| `src/test/setup.ts` | Global test setup for Vitest | +| `public/prerun_sims/` | Static MessagePack files used for pre-run simulation data | + +### Product/domain structure under `src/features` + +| Path | What it owns | +| --- | --- | +| `src/features/simulationRunner/` | Simulation wizard orchestration, run controls, import/export, run-status flow | +| `src/features/simulationRunConfiguration/` | Pool/token selection, update-rule parameters, pre-run configuration state | +| `src/features/simulationResults/` | Result summaries, visualisations, breakdown tables, compare/save flows | +| `src/features/productExplorer/` | Product listing, filtering, sorting, pagination, explorer state | +| `src/features/productDetail/` | Product detail page, sidebar/content panels, product modal actions | +| `src/features/coinData/` | Coin data page, current price polling, coin-level state | +| `src/features/documentation/` | Documentation pages, fact sheets, landing/TOS/ineligible flows | +| `src/features/shared/` | Shared graphs, tables, and explanatory content components | +| `src/features/themes/` | Theme slice and AG Grid theme integration | + +### Typical module pattern inside a feature + +Common pattern in this repo: +- `FeaturePage.tsx` style containers for major screens +- `featureSlice.ts` for feature state and reducers +- `*Service.ts` for feature-specific RTK Query APIs +- `*.ts` utilities for transforms/view-model logic +- `*.test.ts` / `*.test.tsx` colocated near logic being tested + +This keeps UI, state, and logic near their domain ownership while still allowing shared hooks/services across features. + +## App Architecture Overview + +### Runtime composition + +Runtime boot sequence: +1. `src/index.tsx` mounts React root +2. Wraps app with `ApolloProvider` +3. Wraps app with Redux `Provider` +4. Mounts `RouterProvider` with route config from `src/routes.tsx` + +`src/App.tsx` then provides: +- App-level layout (`antd` `Layout`, header/menu/content) +- App bootstrap side effects (for example, price history load and simulation initialization when entering simulation routes) +- Route outlet rendering via `` + +### Routing architecture + +- Routes are centralized in `src/routes.tsx`. +- Route components are lazy-loaded through `src/routeComponents.ts` to keep initial bundle size smaller. +- Route groups include: + - landing/company/research/contact/docs pages + - product explorer and product detail routes + - simulation runner and simulation result comparison routes + - fact-sheet and simulator-example routes +- `src/routeErrorBoundary.tsx` handles router-level errors. + +### State architecture and ownership + +Redux Toolkit store is in `src/app/store.ts`. + +Core state slices: +- `simConfig`: simulation pool/time/rule configuration state +- `simRunner`: simulation wizard step/run status and active run breakdowns +- `simResults`: result-focused state (comparison, chart/breakdown selections) +- `docs`: documentation-related state +- `productExplorer`: explorer filters/search/sort/tab/page-level state +- `currentPrices`: current token pricing state +- `theme`: UI theme state + +In the same store, RTK Query API slices are registered for: +- simulation execution +- coin price retrieval +- documentation retrieval +- product and filter retrieval +- financial analysis +- audit logging + +### Data access and integration boundaries + +There are three primary data paths: + +1. GraphQL via Apollo +- Client: `src/queries/apolloClient.ts` +- Operations: `src/queries/*.graphql` +- Generated types/hooks: `src/__generated__/graphql-types.ts` + +2. REST via RTK Query +- Feature-local example: `src/features/simulationRunner/simulationRunnerService.ts` +- Shared service examples: `src/services/productRetrievalService.ts`, `src/services/financialAnalysisService.ts`, `src/services/auditLogService.ts` +- Most requests include CSRF token propagation from cookie helpers + +3. Local static runtime assets +- Precomputed simulation data loaded from `public/prerun_sims/*.msgpack` + +### Simulation workflow architecture + +The simulation product flow is split across: +- `simulationRunConfiguration` for building pool config +- `simulationRunner` for run orchestration and step transitions +- `simulationResults` for post-run analysis and visual output + +Runner step model (managed by `simRunner.simulationRunnerCurrentStepIndex`): +- `0`: options +- `1`: pool constituent selection +- `2`: time range +- `3`: hooks +- `4`: final review +- `5`: run progress/state view +- `6`: results summary +- `7`: save-to-compare view + +Execution path: +1. UI triggers `createRunSimulationsThunk` (`simulationRunButtonLogic.ts`) +2. Thunk initializes ranges and pools in runner state +3. Calls `runSimulation` RTK Query mutation per pool/time range +4. Converts API response into snapshots + analysis payload +5. Dispatches success/failure reducers (`addSimRunResults`, `completeRun`, `failRun`) +6. Completes run and advances to results step + +### UI and styling architecture + +- UI components primarily use Ant Design primitives +- Data-heavy tables/charts use AG Grid and AG Charts enterprise modules +- Styling uses a mix of CSS modules (`*.module.css`) and Sass modules (`*.module.scss`) +- Feature folders generally keep style modules next to owning components + +## Testing + +Current test stack: +- Vitest +- Testing Library (`@testing-library/*`) +- Coverage via `@vitest/coverage-v8` + +Configuration: `vite.config.ts` + +Important current convention: +- Vitest environment is `node` by default. +- Most current tests focus on pure logic (reducers, utilities, view-model logic). +- If you add DOM-heavy component tests, prefer file-level jsdom override: + +```ts +// @vitest-environment jsdom +``` + +Run specific test file: + +```bash +npx vitest run src/features/simulationRunner/simulationRunButton.test.ts +``` + +## Linting, Formatting, and Type Safety + +- TypeScript is strict (`strict: true` and additional unused/fallthrough checks). +- ESLint uses type-aware rules (`@typescript-eslint/*-type-checked`). +- Prettier config is in `.prettierrc`. + +Recommended pre-PR local check: + +```bash +npm run lint +npm run test +npm run build +``` + +## GraphQL Codegen Workflow + +1. Update queries in `src/queries/*.graphql` +2. Ensure `.env.local` has correct `VITE_GRAPH_TARGET` +3. Run: + +```bash +npm run codegen +``` + +Generated output: +- `src/__generated__/graphql-types.ts` + +## CI and Branch Quality Gates + +GitHub Actions workflow: `.github/workflows/tests.yml` + +Current CI jobs on PRs to `main` and pushes to `main`: +- Lint +- Test +- Build + +To enforce merge blocking on CI: +1. Go to GitHub repo settings +2. `Branches` -> branch protection rule for `main` +3. Enable `Require status checks to pass before merging` +4. Select required checks from this workflow (e.g. `Lint`, `Test`, `Build`) + +## Common Developer Tasks + +### Add a new route/page + +1. Create feature/page component under `src/features/...` +2. Add lazy export in `src/routeComponents.ts` +3. Register route in `src/routes.tsx` + +### Add a new REST endpoint + +1. Add/extend an RTK Query service in `src/services` or feature-local service +2. Register reducer + middleware in `src/app/store.ts` (if new API slice) +3. Use generated hook in feature component/hook + +### Add a new Redux slice + +1. Create slice in relevant `src/features/...` +2. Add reducer to `src/app/store.ts` +3. Add selectors + tests near the slice + +## Troubleshooting + +### Build fails with missing module path + +If files were moved during refactors, check relative imports first (especially CSS module imports). + +### Tests pass locally but fail in CI + +- Re-run `npm ci` to align dependency tree with lockfile +- Run `npm run lint && npm run test && npm run build` locally +- Confirm no generated or local-only files are accidentally required + +### GraphQL codegen issues + +- Validate `VITE_GRAPH_TARGET` in `.env.local` +- Ensure query files are valid under `src/queries/*.graphql` + +--- + +If you are new to this codebase, start with: +1. `src/routes.tsx` +2. `src/app/store.ts` +3. `src/features/simulationRunner/` and `src/features/simulationRunConfiguration/` + +Those files give the fastest understanding of routing, global state, and the main workflow engine in this app. diff --git a/env.local.template b/env.local.template index ab51022..cbbada9 100644 --- a/env.local.template +++ b/env.local.template @@ -12,7 +12,10 @@ VITE_USE_STUBS_DATA=false VITE_GRAPH_TARGET=https://api-v3.balancer.fi/ # if backend is running locally - VITE_BASE_URL=http://localhost:5001/api +VITE_BASE_URL=http://localhost:5001/api # if backend is running on production +# VITE_BASE_URL=https://your-api-host/api +# AG Grid / AG Charts enterprise license key +VITE_AG_GRID_LICENSE_KEY= diff --git a/src/App.tsx b/src/App.tsx index 09f5208..c55f5df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ const { Content, Header } = Layout; const isRoute = (value: string): value is ROUTES => (Object.values(ROUTES) as string[]).includes(value); -const AG_GRID_LICENSE_KEY = import.meta.env.AG_GRID_LICENCE_KEY ?? ''; +const AG_GRID_LICENSE_KEY = import.meta.env.VITE_AG_GRID_LICENSE_KEY ?? ''; const initialiseSimsToRun = (): AppThunk => (dispatch, getState) => { if (getState().simRunner.simulationsToRun.length === 0) { @@ -46,17 +46,14 @@ function App() { return; } - if (page === ROUTES.SIMULATION_RUNNER) { + if (page !== ROUTES.SIMULATION_RUNNER) { + dispatch(loadPriceHistoryAsync()); dispatch(initialiseSimsToRun()); } }, [dispatch] ); - useEffect(() => { - dispatch(loadPriceHistoryAsync()); - }, [dispatch]); - useEffect(() => { const segment = location.pathname.split('/')[1] ?? ''; const page = isRoute(segment) ? segment : ROUTES.HOME; diff --git a/src/Menu.tsx b/src/Menu.tsx index 681311c..b4bb0d4 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -73,8 +73,31 @@ export const MenuComponent: FC = ({ initialise }) => { [initialise, navigate, dispatch] ); - function getMobileItems(): MenuItem[] { - return [ + const liveProductChildren = useMemo( + () => + liveProducts.factsheets + .filter((x) => x.status == 'LIVE') + .map((product) => ({ + key: + ROUTES.PRODUCT_EXPLORER + + '/' + + product.poolChain + + '/' + + product.poolId, + label: `View ${product.iconTitle}`, + icon: ( + {product.iconTitle} + ), + })), + [liveProducts.factsheets] + ); + + const mobileItems = useMemo( + () => [ { key: 'home', label: '', @@ -90,25 +113,8 @@ export const MenuComponent: FC = ({ initialise }) => { key: 'view-products', label: 'View Products', type: 'submenu', - style: { marginLeft: 'auto' }, // Align to the right - children: liveProducts.factsheets - .filter((x) => x.status == 'LIVE') - .map((product) => ({ - key: - ROUTES.PRODUCT_EXPLORER + - '/' + - product.poolChain + - '/' + - product.poolId, - label: `View ${product.iconTitle}`, - icon: ( - {product.iconTitle} - ), - })), + style: { marginLeft: 'auto' }, + children: liveProductChildren, }, { key: 'Education', @@ -126,11 +132,12 @@ export const MenuComponent: FC = ({ initialise }) => { }, ], }, - ]; - } + ], + [liveProductChildren] + ); - function getItems(): MenuItem[] { - return [ + const desktopItems = useMemo( + () => [ { key: 'home', label: '', @@ -146,25 +153,8 @@ export const MenuComponent: FC = ({ initialise }) => { key: 'view-products', label: 'View Products', type: 'submenu', - style: { marginLeft: 'auto' }, // Align to the right - children: liveProducts.factsheets - .filter((x) => x.status == 'LIVE') - .map((product) => ({ - key: - ROUTES.PRODUCT_EXPLORER + - '/' + - product.poolChain + - '/' + - product.poolId, - label: `View ${product.iconTitle}`, - icon: ( - {product.iconTitle} - ), - })), + style: { marginLeft: 'auto' }, + children: liveProductChildren, }, { key: 'About', @@ -219,8 +209,9 @@ export const MenuComponent: FC = ({ initialise }) => { key: 'tos', label: 'Terms of Service', }, - ]; - } + ], + [liveProductChildren] + ); return (
= ({ initialise }) => { onClick={handleClick} selectedKeys={[current]} mode="horizontal" - items={isMobile ? getMobileItems() : getItems()} + items={isMobile ? mobileItems : desktopItems} overflowedIndicator={} style={{ width: '100%', diff --git a/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx b/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx index c7b5411..a40d42f 100644 --- a/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx +++ b/src/features/documentation/factSheets/arbitrumMacro/arbitrumMacroSimView.tsx @@ -1,5 +1,5 @@ import { Col, Row, Tabs, Spin } from 'antd'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { SimulationResultsSummaryStep } from '../../../simulationResults/simulationResultsSummaryStep'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; @@ -11,6 +11,21 @@ export default function ArbitrumMacroSimulatorExample() { const [key, setKey] = useState('2'); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(false); + const seriesName = useMemo( + () => ({ + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + HODL: '#52ad80', + }), + [] + ); + const seriesStrokeColor = useMemo( + () => ({ + 'Power Channel': 'ARBITRUM MACRO BTF', + 'Balancer Weighted': 'Traditional DEX', + }), + [] + ); // Function to load breakdowns based on the selected tab const loadBreakdowns = async (poolNames: Pool[]) => { @@ -56,15 +71,6 @@ export default function ArbitrumMacroSimulatorExample() { }); // Trigger loading of breakdowns }, [key]); // Dependency array ensures that effect runs when `key` changes - const seriesName = { - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - HODL: '#52ad80', - }; - const seriesStrokeColor = { - 'Power Channel': 'ARBITRUM MACRO BTF', - 'Balancer Weighted': 'Traditional DEX', - }; return (
diff --git a/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx b/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx index 5dd05f8..5a71e75 100644 --- a/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx +++ b/src/features/documentation/factSheets/baseMacro/baseMacroSimView.tsx @@ -1,5 +1,5 @@ import { Col, Row, Tabs, Spin } from 'antd'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { SimulationResultsSummaryStep } from '../../../simulationResults/simulationResultsSummaryStep'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; @@ -11,6 +11,21 @@ export default function BaseMacroSimulatorExample() { const [key, setKey] = useState('2'); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(false); + const seriesName = useMemo( + () => ({ + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + HODL: '#52ad80', + }), + [] + ); + const seriesStrokeColor = useMemo( + () => ({ + 'Power Channel': 'BASE MACRO BTF', + 'Balancer Weighted': 'Traditional DEX', + }), + [] + ); // Function to load breakdowns based on the selected tab const loadBreakdowns = async (poolNames: Pool[]) => { @@ -56,15 +71,6 @@ export default function BaseMacroSimulatorExample() { }); // Trigger loading of breakdowns }, [key]); // Dependency array ensures that effect runs when `key` changes - const seriesName = { - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - HODL: '#52ad80', - }; - const seriesStrokeColor = { - 'Power Channel': 'BASE MACRO BTF', - 'Balancer Weighted': 'Traditional DEX', - }; return (
diff --git a/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx b/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx index 595210e..65d1073 100644 --- a/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx +++ b/src/features/documentation/factSheets/desktop/factsheetDesktop.tsx @@ -159,6 +159,26 @@ export function FactSheetDesktop(props: FactsheetDesktopProps) { return props.model.xAxisIntervals.get(period); }, [period, props.model.xAxisIntervals]); + const testMarketValueBreakdowns = useMemo( + () => + [breakdowns[btf], breakdowns[cfmm], breakdowns[hodl]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btf, cfmm, hodl] + ); + + const trainMarketValueBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[cfmmTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, cfmmTrain, hodlTrain] + ); + return (
@@ -297,15 +317,11 @@ export function FactSheetDesktop(props: FactsheetDesktopProps) { } className={styles.cardMarginSmall} > - + )} @@ -558,7 +574,8 @@ export function FactSheetDesktop(props: FactsheetDesktopProps) { > - + + )} diff --git a/src/features/documentation/factSheets/mobile/factsheetMobile.tsx b/src/features/documentation/factSheets/mobile/factsheetMobile.tsx index ee57fda..e66ac5c 100644 --- a/src/features/documentation/factSheets/mobile/factsheetMobile.tsx +++ b/src/features/documentation/factSheets/mobile/factsheetMobile.tsx @@ -123,6 +123,21 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { [period, props.model.poolPrefix] ); + const btfTrain = useMemo( + () => props.model.poolPrefix + `BTF${props.model.trainPeriod}`, + [props.model.poolPrefix, props.model.trainPeriod] + ); + + const cfmmTrain = useMemo( + () => props.model.poolPrefix + `CFMM${props.model.trainPeriod}`, + [props.model.poolPrefix, props.model.trainPeriod] + ); + + const hodlTrain = useMemo( + () => props.model.poolPrefix + `Hodl${props.model.trainPeriod}`, + [props.model.poolPrefix, props.model.trainPeriod] + ); + const trainXAxisMonthInterval = useMemo(() => { return props.model.xAxisIntervals.get(props.model.trainPeriod); }, [props.model.trainPeriod, props.model.xAxisIntervals]); @@ -185,6 +200,26 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { return props.model.xAxisIntervals.get(period); }, [period, props.model.xAxisIntervals]); + const testMarketValueBreakdowns = useMemo( + () => + [breakdowns[btf], breakdowns[cfmm], breakdowns[hodl]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btf, cfmm, hodl] + ); + + const trainMarketValueBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[cfmmTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, cfmmTrain, hodlTrain] + ); + return (
@@ -317,15 +352,11 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { className={styles.cardMarginSmall} > {periodSelector} - + )} @@ -633,14 +664,11 @@ export function FactSheetMobile(props: FactsheetDesktopProps) { > - + + )} diff --git a/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx b/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx index 18439ff..b1cbf10 100644 --- a/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx +++ b/src/features/documentation/factSheets/safeHaven/safeHavenSimView.tsx @@ -1,5 +1,5 @@ import { Col, Row, Tabs, Spin } from 'antd'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { SimulationResultsSummaryStep } from '../../../simulationResults/simulationResultsSummaryStep'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; @@ -11,6 +11,21 @@ export default function SafeHavenSimulatorExample() { const [key, setKey] = useState('2'); const [breakdowns, setBreakdowns] = useState([]); const [loading, setLoading] = useState(false); + const seriesName = useMemo( + () => ({ + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + HODL: '#52ad80', + }), + [] + ); + const seriesStrokeColor = useMemo( + () => ({ + 'Power Channel': 'SAFE HAVEN BTF', + 'Balancer Weighted': 'Traditional DEX', + }), + [] + ); // Function to load breakdowns based on the selected tab const loadBreakdowns = async (poolNames: Pool[]) => { @@ -56,15 +71,6 @@ export default function SafeHavenSimulatorExample() { }); // Trigger loading of breakdowns }, [key]); // Dependency array ensures that effect runs when `key` changes - const seriesName = { - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - HODL: '#52ad80', - }; - const seriesStrokeColor = { - 'Power Channel': 'SAFE HAVEN BTF', - 'Balancer Weighted': 'Traditional DEX', - }; return (
diff --git a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx index 51f4407..cb415bf 100644 --- a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx +++ b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetDesktop.tsx @@ -191,6 +191,26 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { const xAxisMonthInterval = useMemo(() => { return props.model.xAxisIntervals.get(testPeriod); }, [testPeriod, props.model.xAxisIntervals]); + + const testComparisonBreakdowns = useMemo( + () => + [breakdowns[btf], breakdowns[hodl]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btf, hodl] + ); + + const trainingComparisonBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, hodlTrain] + ); console.log(breakdowns[btf]); return (
@@ -282,9 +302,7 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { @@ -322,11 +340,11 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { } className={styles.cardMarginSmall} > - + )} @@ -393,59 +411,53 @@ export function TruflationFactSheetDesktop(props: FactsheetDesktopProps) { - + {trainingView === 'weightChange' && ( + <> +
Constituent weights over time
+ + + )} + + )}
diff --git a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx index 725c327..ded8908 100644 --- a/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx +++ b/src/features/documentation/factSheets/truflationBitcoin/customTruflationFactsheetMobile.tsx @@ -176,6 +176,26 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { [poolKeyFor, metricsPeriod] ); + const testComparisonBreakdowns = useMemo( + () => + [breakdowns[btfTest], breakdowns[hodlTest]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTest, hodlTest] + ); + + const trainingComparisonBreakdowns = useMemo( + () => + [breakdowns[btfTrain], breakdowns[hodlTrain]].filter( + ( + breakdown + ): breakdown is SimulationRunBreakdown => breakdown !== undefined + ), + [breakdowns, btfTrain, hodlTrain] + ); + const renderPeriodSelector = ( includeTrainPeriod: boolean, value: string, @@ -337,9 +357,7 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { @@ -388,13 +406,11 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { className={styles.cardMarginSmall} > {renderPeriodSelector(false, testPeriod, setTestPeriod)} - + )} @@ -634,73 +650,67 @@ export function TruflationFactSheetMobile(props: FactsheetDesktopProps) { - + {trainingView === 'weightChange' && ( + <> +
Constituent weights over time
+ + + )} + + )}
diff --git a/src/features/documentation/landing/desktop/strategySummary.tsx b/src/features/documentation/landing/desktop/strategySummary.tsx index a653587..17ee141 100644 --- a/src/features/documentation/landing/desktop/strategySummary.tsx +++ b/src/features/documentation/landing/desktop/strategySummary.tsx @@ -1,6 +1,6 @@ import { Button, Col, Row, Tooltip, Typography } from 'antd'; import { ProductItemBackground } from '../../../productExplorer/productItem/productItemBackground'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { SimulationRunBreakdown } from '../../../simulationResults/simulationResultSummaryModels'; import { getBreakdown, Pool } from '../../../../services/breakdownService'; import { WeightChangeOverTimeGraph } from '../../../shared'; @@ -10,78 +10,6 @@ import styles from './landingDesktop.module.css'; const { Title } = Typography; export function StrategySummary() { - const [breakdowns, setBreakdowns] = useState([]); - const [loading, setLoading] = useState(true); - const [strategy, setStrategy] = useState('Momentum'); - - const [autoCycle, setAutoCycle] = useState(true); - - useEffect(() => { - let interval: NodeJS.Timeout | null = null; - - if (autoCycle) { - const allStrageies = [ - 'Momentum', - 'AntiMomentum', - 'Channel Following', - 'Power Channel', - ]; - - interval = setInterval(() => { - setStrategy((prevStrategy) => { - const currentIndex = allStrageies.indexOf(prevStrategy); - const nextIndex = (currentIndex + 1) % allStrageies.length; - return allStrageies[nextIndex]; - }); - }, 5000); // Change strategy every 5 seconds - } - - return () => { - if (interval) { - clearInterval(interval); - } - }; - }, [autoCycle]); - - // Effect to load breakdowns whenever the tab key changes - useEffect(() => { - // Function to load breakdowns based on the selected tab - const loadBreakdowns = async ( - poolNames: Pool[] - ): Promise => { - setLoading(true); // Start loading - const fetchedBreakdowns = await Promise.all( - poolNames.map((poolName) => getBreakdown(poolName)) - ); - setBreakdowns(fetchedBreakdowns); - return fetchedBreakdowns; - }; - - const loadData = async (): Promise => { - // Load breakdowns for the selected tab - return await loadBreakdowns([ - 'solExampleWeighted', - 'solExampleMomentum', - 'solExampleAntimomentum', - 'solExamplePowerChannel', - 'solExampleChannelFollowing', - 'solExampleHodl', - ] as Pool[]); // Awaiting the asynchronous function here - }; - - if (loading) { - loadData() - .then((fetchedBreakdowns) => { - setBreakdowns(fetchedBreakdowns); - }) - .catch((error) => { - console.error('Failed to load breakdowns:', error); - }) - .finally(() => { - setLoading(false); - }); - } - }, [setBreakdowns, setLoading, breakdowns, loading]); const strategies = [ { title: 'Momentum', @@ -117,14 +45,122 @@ export function StrategySummary() { }, ]; - const traditionalDexBreakdown = breakdowns.find( - (x) => x.simulationRun.updateRule.updateRuleName === 'Balancer Weighted' + const [breakdowns, setBreakdowns] = useState([]); + const [loading, setLoading] = useState(true); + const [strategy, setStrategy] = useState('Momentum'); + + const [autoCycle, setAutoCycle] = useState(true); + const overrideSeriesStrokeColor = useMemo( + () => ({ + Momentum: '#c7b283', + AntiMomentum: '#c7b283', + 'Channel Following': '#c7b283', + 'Power Channel': '#c7b283', + 'Balancer Weighted': '#528aae', + }), + [] + ); + + const overrideSeriesName = useMemo( + () => ({ + Momentum: 'QuantAMM', + AntiMomentum: 'QuantAMM', + 'Channel Following': 'QuantAMM', + 'Power Channel': 'QuantAMM', + 'Balancer Weighted': 'Traditional DEX', + }), + [] ); - const selectedStrategyBreakdown = breakdowns.find( - (x) => x.simulationRun.updateRule.updateRuleName === strategy + + useEffect(() => { + let interval: NodeJS.Timeout | null = null; + const autoCycleStrategies = [ + 'Momentum', + 'AntiMomentum', + 'Channel Following', + 'Power Channel', + ]; + + if (autoCycle) { + interval = setInterval(() => { + setStrategy((prevStrategy) => { + const currentIndex = autoCycleStrategies.indexOf(prevStrategy); + const nextIndex = (currentIndex + 1) % autoCycleStrategies.length; + return autoCycleStrategies[nextIndex]; + }); + }, 5000); // Change strategy every 5 seconds + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [autoCycle]); + + useEffect(() => { + let isMounted = true; + + const loadData = async () => { + setLoading(true); + + try { + const fetchedBreakdowns = await Promise.all( + ( + [ + 'solExampleWeighted', + 'solExampleMomentum', + 'solExampleAntimomentum', + 'solExamplePowerChannel', + 'solExampleChannelFollowing', + 'solExampleHodl', + ] as Pool[] + ).map((poolName) => getBreakdown(poolName)) + ); + if (isMounted) { + setBreakdowns(fetchedBreakdowns); + } + } catch (error) { + console.error('Failed to load breakdowns:', error); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void loadData(); + + return () => { + isMounted = false; + }; + }, []); + + const traditionalDexBreakdown = useMemo( + () => + breakdowns.find( + (x) => x.simulationRun.updateRule.updateRuleName === 'Balancer Weighted' + ), + [breakdowns] + ); + + const selectedStrategyBreakdown = useMemo( + () => + breakdowns.find((x) => x.simulationRun.updateRule.updateRuleName === strategy), + [breakdowns, strategy] + ); + + const marketValueBreakdowns = useMemo( + () => + breakdowns.filter( + (x) => + x.simulationRun.updateRule.updateRuleName === strategy || + x.simulationRun.updateRule.updateRuleName === 'Balancer Weighted' + ), + [breakdowns, strategy] ); - const StrategySelectorPanel = () => ( + const strategySelectorPanel = (

ADAPTIVE STRATEGIES

@@ -176,7 +212,7 @@ export function StrategySummary() {

); - const HoldingsChartsSection = () => ( + const holdingsChartsSection = (

Traditional DEX Pool Holdings

@@ -241,36 +277,19 @@ export function StrategySummary() {
- + {strategySelectorPanel} - + {holdingsChartsSection}
- x.simulationRun.updateRule.updateRuleName === strategy || - x.simulationRun.updateRule.updateRuleName === - 'Balancer Weighted' - )} + breakdowns={marketValueBreakdowns} forceViewResults={true} overrideXAxisInterval={24} - overrideSeriesStrokeColor={{ - Momentum: '#c7b283', - AntiMomentum: '#c7b283', - 'Channel Following': '#c7b283', - 'Power Channel': '#c7b283', - 'Balancer Weighted': '#528aae', - }} - overrideSeriesName={{ - Momentum: 'QuantAMM', - AntiMomentum: 'QuantAMM', - 'Channel Following': 'QuantAMM', - 'Power Channel': 'QuantAMM', - 'Balancer Weighted': 'Traditional DEX', - }} + overrideSeriesStrokeColor={overrideSeriesStrokeColor} + overrideSeriesName={overrideSeriesName} />
diff --git a/src/features/documentation/landing/mobile/strategySummaryMobile.tsx b/src/features/documentation/landing/mobile/strategySummaryMobile.tsx index efacf6e..8df2cf2 100644 --- a/src/features/documentation/landing/mobile/strategySummaryMobile.tsx +++ b/src/features/documentation/landing/mobile/strategySummaryMobile.tsx @@ -12,23 +12,34 @@ export function StrategySummaryMobile() { const [loading, setLoading] = useState(true); useEffect(() => { - const loadBreakdowns = async ( - poolNames: Pool[] - ): Promise => { + let isMounted = true; + + const loadBreakdowns = async () => { setLoading(true); - const fetchedBreakdowns = await Promise.all( - poolNames.map((poolName) => getBreakdown(poolName)) - ); - setBreakdowns(fetchedBreakdowns); - return fetchedBreakdowns; + try { + const fetchedBreakdowns = await Promise.all( + (['balancerWeighted', 'quantAMMAntiMomentum'] as Pool[]).map( + (poolName) => getBreakdown(poolName) + ) + ); + if (isMounted) { + setBreakdowns(fetchedBreakdowns); + } + } catch (error) { + console.error(error); + } finally { + if (isMounted) { + setLoading(false); + } + } }; - if (loading) { - loadBreakdowns(['balancerWeighted', 'quantAMMAntiMomentum'] as Pool[]) - .catch(console.error) - .finally(() => setLoading(false)); - } - }, [loading]); + void loadBreakdowns(); + + return () => { + isMounted = false; + }; + }, []); return (
diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss b/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss index eaba09c..256c9f7 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.module.scss @@ -1,20 +1,16 @@ .eventsRow { - margin-top: 20px; + margin-top: 0; } -.headerCol { - display: flex; - flex-direction: row; - justify-content: flex-start; - padding-left: 8px; - border-bottom: 1px solid var(--primary-lighter); +.eventsCollapse { + width: 100%; } .contentCol { display: flex; justify-content: center; align-items: center; - margin-top: 20px; + margin-top: 0; padding-left: 12px; padding-right: 12px; width: 100%; diff --git a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx index eff8672..f14a49c 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEvents.tsx @@ -1,5 +1,13 @@ -import { FC, memo, useMemo, useRef } from 'react'; -import { Col, Empty, Row, Spin } from 'antd'; +import { + FC, + memo, + MouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Button, Col, Collapse, Empty, Row, Spin } from 'antd'; import { AgGridReact } from 'ag-grid-react'; import { GqlChain, @@ -14,7 +22,6 @@ import { import { selectProductById } from '../../../productExplorer/productExplorerSlice'; import { CURRENT_LIVE_FACTSHEETS } from '../../../documentation/factSheets/liveFactsheets'; -import { ProductDetailEventsHeader } from './productDetailEventsHeader'; import { ProductDetailEventsGrid } from './productDetailEventsGrid'; import { ProductDetailEventsHeatmap } from './productDetailEventsHeatmap'; import { useExplorerBase } from '../productDetailUseExplorerBase'; @@ -27,6 +34,8 @@ export interface ProductDetailEventsProps { chain: GqlChain; isMobile?: boolean; } +const EVENTS_PANEL_KEY = 'events-panel'; +const { Panel } = Collapse; export const ProductDetailEvents: FC = memo( function ProductDetailEventsImpl({ @@ -34,6 +43,8 @@ export const ProductDetailEvents: FC = memo( chain, isMobile, }: ProductDetailEventsProps) { + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [hasRequestedData, setHasRequestedData] = useState(false); const darkThemeAg = useAppSelector(selectAgGridTheme); const chartTheme = useAppSelector(selectAgChartTheme); @@ -46,72 +57,140 @@ export const ProductDetailEvents: FC = memo( skip: undefined, poolId: productId, chain, + enabled: hasRequestedData, }); const explorerBase = useExplorerBase(chain); const thresholds = useBadgeThresholds(productAddress, livePools); const gridRef = useRef>(null); - const rowData = useMemo( - () => - (poolEvents ?? []).map( - ({ blockNumber, id, sender, timestamp, tx, type, valueUSD }) => ({ - id, - blockNumber, - type, - sender, - tx, - timestamp, - valueUSD, - }) - ), - [poolEvents] - ); + const rowData = poolEvents; + const isDesktop = !isMobile; const showSpinner = loading && !error && rowData.length === 0; - const heatmap = useHeatmapData(product, poolEvents); + const heatmap = useHeatmapData( + isMobile ? product : undefined, + isMobile ? poolEvents : undefined + ); + const handleCollapseChange = useCallback((activeKey: string | string[]) => { + const isOpen = Array.isArray(activeKey) + ? activeKey.includes(EVENTS_PANEL_KEY) + : activeKey === EVENTS_PANEL_KEY; + setIsPanelOpen(isOpen); + if (isOpen) { + setHasRequestedData(true); + } + }, []); + const handleCsvExport = useCallback((event?: MouseEvent) => { + event?.stopPropagation(); + gridRef.current?.api?.exportDataAsCsv(); + }, []); - return ( - - - gridRef.current?.api?.exportDataAsCsv()} - /> - + useEffect(() => { + const maybeOpenForTarget = (target?: string) => { + if (target === '#events') { + setIsPanelOpen(true); + setHasRequestedData(true); + } + }; + + maybeOpenForTarget(window.location.hash); - - {showSpinner ? ( - - ) : error && rowData.length === 0 ? ( -
Failed to load events.
- ) : isMobile ? ( -
- {!heatmap.heatmapData.length ? ( - - ) : ( - - )} -
+ const onHashChange = () => maybeOpenForTarget(window.location.hash); + const onNavSelect = (event: Event) => { + const detail = (event as CustomEvent<{ href?: string }>).detail; + maybeOpenForTarget(detail?.href); + }; + + window.addEventListener('hashchange', onHashChange); + window.addEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + + return () => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + }; + }, []); + + const content = + !hasRequestedData && isPanelOpen ? ( + + ) : showSpinner ? ( + + ) : error && rowData.length === 0 ? ( +
Failed to load events.
+ ) : !isDesktop ? ( +
+ {!heatmap.heatmapData.length ? ( + ) : ( -
-
- -
-
+ )} +
+ ) : ( +
+
+ +
+
+ ); + + return ( + + + + { + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + +
+ ) : null + } + > + + + {content} + + + +
); diff --git a/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx b/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx index 9a6f6ca..255798f 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEventsGrid.tsx @@ -39,6 +39,17 @@ export const ProductDetailEventsGrid = forwardRef< AgGridReact, EventsGridProps >(function EventsGrid({ rowData, explorerBase, thresholds }, ref) { + const usdFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [] + ); + const columnDefs = useMemo( () => [ { @@ -107,21 +118,14 @@ export const ProductDetailEventsGrid = forwardRef< enableRowGroup: true, type: 'number', valueFormatter: (p: ValueFormatterParams) => - p.value - ? new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(Number(p.value)) - : '', + p.value ? usdFormatter.format(Number(p.value)) : '', cellStyle: { textAlign: 'right' as const }, }, { colId: 'sender', field: 'sender', headerName: 'Sender', - width: 160, + width: 145, enableRowGroup: true, cellRenderer: (p: ICellRendererParams) => { const url = `${explorerBase}/address/${p.value}`; @@ -143,7 +147,7 @@ export const ProductDetailEventsGrid = forwardRef< colId: 'tx', field: 'tx', headerName: 'Tx', - width: 160, + width: 145, enableRowGroup: true, cellRenderer: (p: ICellRendererParams) => { const url = `${explorerBase}/tx/${p.value}`; @@ -176,6 +180,7 @@ export const ProductDetailEventsGrid = forwardRef< thresholds.goldThreshold, thresholds.silverThreshold, thresholds.srcPrefix, + usdFormatter, ] ); @@ -183,6 +188,7 @@ export const ProductDetailEventsGrid = forwardRef< () => ({ columnDefs, rowHeight: 26, + getRowId: (params) => String(params.data?.id ?? ''), defaultColDef: { filter: 'agTextColumnFilter', sortable: true, @@ -197,6 +203,35 @@ export const ProductDetailEventsGrid = forwardRef< }), [columnDefs] ); + const sideBar = useMemo( + () => ({ + toolPanels: [ + { + id: 'columns', + labelDefault: 'Columns', + labelKey: 'columns', + iconKey: 'columns', + toolPanel: 'agColumnsToolPanel', + minWidth: 100, + maxWidth: 300, + width: 200, + }, + { + id: 'filters', + labelDefault: 'Filters', + labelKey: 'filters', + iconKey: 'filter', + toolPanel: 'agFiltersToolPanel', + minWidth: 100, + maxWidth: 300, + width: 200, + }, + ], + position: 'right' as const, + defaultToolPanel: 'none', + }), + [] + ); return ( ); }); diff --git a/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx b/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx index 9393ceb..e0c03b2 100644 --- a/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx +++ b/src/features/productDetail/productDetailContent/events/productDetailEventsHeader.tsx @@ -7,22 +7,26 @@ const { Title } = Typography; interface EventsHeaderProps { isMobile: boolean; onCsv: () => void; + showTitle?: boolean; } export const ProductDetailEventsHeader: FC = memo( - function EventsHeader({ isMobile, onCsv }) { + function EventsHeader({ isMobile, onCsv, showTitle = true }) { return ( <Row> <Col span={20}> - <h4 hidden={isMobile}>Events</h4> + <h4 hidden={isMobile || !showTitle}>Events</h4> </Col> {!isMobile && ( <Col span={4} className={styles.actionsCol}> <Button type="primary" size="small" - onClick={onCsv} + onClick={(e) => { + e.stopPropagation(); + onCsv(); + }} className={styles.csvButton} > Download CSV diff --git a/src/features/productDetail/productDetailContent/productDetailContent.tsx b/src/features/productDetail/productDetailContent/productDetailContent.tsx index be286c8..dbadd6a 100644 --- a/src/features/productDetail/productDetailContent/productDetailContent.tsx +++ b/src/features/productDetail/productDetailContent/productDetailContent.tsx @@ -5,13 +5,10 @@ import { selectProductById } from '../../productExplorer/productExplorerSlice'; import { ProductDetailPoolGraph } from './productDetailPoolGraph'; import { ProductDetailStats } from './productDetailStats'; import { ProductDetailNav } from './productDetailNav'; -import { ProductDetailEvents } from './events/productDetailEvents'; import sharedStyles from '../../../shared.module.scss'; import { ProductDetailInfo } from '../productDetailSidebar/productDetailInfo'; -import { GqlChain } from '../../../__generated__/graphql-types'; import { ProductDetailSidebarSocials } from '../productDetailSidebar/productDetailSidebarSocials'; -import { ProductDetailSidebarStrategySummary } from '../productDetailSidebar/productDetailSidebarStrategySummary'; import { ProductDetailTable } from './components/productDetailTable'; import styles from './productDetailContent.module.scss'; @@ -33,8 +30,14 @@ export const ProductDetailContent: FC<ProductDetailContentProps> = ({ <Content> {product?.id && product.id !== '' && ( <> - <ProductDetailNav productId={product.id} chain={product.chain} /> - <div className={sharedStyles.scrollable}> + <ProductDetailNav + productId={product.id} + chain={product.chain} + /> + <div + id="product-detail-scroll-container" + className={sharedStyles.scrollable} + > <ProductDetailPoolGraph productId={product.id} /> {isMobile ? ( <ProductDetailInfo product={product} isMobile={isMobile} /> @@ -49,25 +52,11 @@ export const ProductDetailContent: FC<ProductDetailContentProps> = ({ ) : ( <></> )} - <Row> - <Col span={24} className={styles.strategyCol}> - <ProductDetailSidebarStrategySummary product={product} /> - </Col> - </Row> <Row> <Col span={24}> <ProductDetailStats productId={product.id} /> </Col> </Row> - <Row> - <Col span={24}> - <ProductDetailEvents - productId={product.id} - chain={product.chain as GqlChain} - isMobile={isMobile} - /> - </Col> - </Row> {isMobile ? ( <Row id="details" className={styles.detailsRow}> <Col span={24} className={styles.detailsCol}> diff --git a/src/features/productDetail/productDetailContent/productDetailNav.tsx b/src/features/productDetail/productDetailContent/productDetailNav.tsx index 6995e45..04c5f6e 100644 --- a/src/features/productDetail/productDetailContent/productDetailNav.tsx +++ b/src/features/productDetail/productDetailContent/productDetailNav.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; import { Affix, Anchor, Button } from 'antd'; import { getBalancerPoolUrl } from '../../../utils'; import { ProductModal } from '../modal/productModal'; @@ -7,6 +8,8 @@ import styles from './productDetailNav.module.scss'; import { useAppSelector } from '../../../app/hooks'; import { selectAcceptedTermsAndConditions } from '../../productExplorer/productExplorerSlice'; +const PRODUCT_DETAIL_SCROLL_CONTAINER_ID = 'product-detail-scroll-container'; + interface ProductDetailNavProps { productId: string; chain: string; @@ -16,8 +19,9 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const acceptedTermsAndConditions = useAppSelector( selectAcceptedTermsAndConditions ); - const [targetOffset, setTargetOffset] = useState<number | undefined>( - undefined + const [targetOffset, setTargetOffset] = useState<number | undefined>(120); + const [anchorContainer, setAnchorContainer] = useState<HTMLElement | null>( + null ); const [productModalUrl, setProductModalUrl] = useState<string | undefined>( undefined @@ -25,8 +29,12 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const [isWithdraw, setIsWithdraw] = useState(false); useEffect(() => { - setTargetOffset(window.innerHeight / 2); - }, []); + // Track sections relative to the product detail scroll container. + setAnchorContainer( + document.getElementById(PRODUCT_DETAIL_SCROLL_CONTAINER_ID) + ); + setTargetOffset(120); + }, [productId]); const showProductModal = (url: string) => { setProductModalUrl(url); @@ -39,40 +47,66 @@ function ProductDetailNavInternal({ productId, chain }: ProductDetailNavProps) { const baseBalancerUrl = getBalancerPoolUrl(chain, productId); const addLiquidityBalancerPoolUrl = `${baseBalancerUrl}/add-liquidity`; const removeLiquidityBalancerPoolUrl = `${baseBalancerUrl}/remove-liquidity`; + const getAnchorContainer = useCallback(() => { + return anchorContainer ?? document.documentElement; + }, [anchorContainer]); + const anchorItems = useMemo( + () => [ + { + key: 'graph', + href: '#graph', + title: 'Graph', + }, + { + key: 'summary', + href: '#summary', + title: 'Summary', + }, + { + key: 'metrics', + href: '#metrics', + title: 'Metrics', + }, + { + key: 'events', + href: '#events', + title: 'Events', + }, + { + key: 'distribution', + href: '#distribution', + title: 'Distribution', + }, + ], + [] + ); + const handleAnchorClick = useCallback( + (_event: ReactMouseEvent<HTMLElement>, link: any) => { + window.dispatchEvent( + new CustomEvent('product-detail-nav-select', { + detail: { href: link?.href }, + }) + ); + }, + [] + ); return ( <> <Affix> <div className={styles['product-detail-nav__container']}> <div className={styles['product-detail-nav__anchor-container']}> - <Anchor - targetOffset={targetOffset} - direction="horizontal" - affix - className={styles['product-detail-nav__anchor']} - items={[ - { - key: 'graph', - href: '#graph', - title: 'Graph', - }, - { - key: 'summary', - href: '#summary', - title: 'Summary', - }, - { - key: 'details', - href: '#details', - title: 'Details', - }, - { - key: 'events', - href: '#events', - title: 'Events', - }, - ]} - /> + {anchorContainer && ( + <Anchor + targetOffset={targetOffset} + direction="horizontal" + affix={false} + className={styles['product-detail-nav__anchor']} + getContainer={getAnchorContainer} + items={anchorItems} + onClick={handleAnchorClick} + /> + )} </div> <div className={styles['product-detail-nav__buttons-container']}> diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss b/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss index e51bf05..71dcfef 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummary.module.scss @@ -1,9 +1,7 @@ .product-detail-summary__title { - display: flex; - flex-direction: row; - justify-content: start; - padding-left: 8px; - border-bottom: 1px solid var(--primary-lighter); + display: inline-flex; + align-items: center; + gap: 8px; } .product-detail-summary__container { diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx index 7c704db..c129d72 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryDesktop.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Card, Collapse, @@ -21,6 +21,7 @@ import { TableOutlined, BoxPlotOutlined, } from '@ant-design/icons'; +import { GqlChain } from '../../../../__generated__/graphql-types'; import { FinancialMetricThresholds, Product } from '../../../../models'; import { @@ -29,17 +30,22 @@ import { } from '../../../shared/graphs'; import { SimulationRunMetric } from '../../../simulationResults/simulationResultSummaryModels'; import { ProductDetailDropdown } from '../components/productDetailDropdown'; +import { ProductDetailEvents } from '../events/productDetailEvents'; +import { ProductDetailSidebarStrategySummary } from '../../productDetailSidebar/productDetailSidebarStrategySummary'; import { CURRENT_LIVE_FACTSHEETS } from '../../../documentation/factSheets/liveFactsheets'; import styles from './productDetailSummary.module.scss'; import { getMax, getMin } from './utils'; -import Title from 'antd/es/typography/Title'; import { StrategyWorkflowCard } from './strategyWorkflowCard'; import { AnalysisSimplifiedBreakdownTable } from '../../../simulationResults/breakdowns/simulationRunPerformanceSimpleTable'; const { Text } = Typography; const { Panel } = Collapse; +const METRICS_PANEL_KEY = 'metrics'; +const WEIGHT_CHART_Y_AXIS_OVERRIDE = { label: { enabled: false } }; +const RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE = { title: { enabled: false } }; +const SHOW_STRATEGY_WORKFLOW_SECTION = false; interface ProductDetailSummaryDesktopProps { product: Product; @@ -147,6 +153,7 @@ export const ProductDetailSummaryDesktop: FC< ); const [metricsView, setMetricsView] = useState<'gauge' | 'table'>('gauge'); + const [isMetricsPanelOpen, setIsMetricsPanelOpen] = useState(true); const [selectedReturnMetricName, setSelectedReturnMetricName] = useState< string | undefined >(initialSelectedReturnAnalysis?.metricName); @@ -308,7 +315,7 @@ export const ProductDetailSummaryDesktop: FC< ]; }, [simulationRunBreakdown]); - const MetricsToggle = () => ( + const metricsToggle = ( <div onClick={(e) => { e.stopPropagation(); @@ -318,7 +325,7 @@ export const ProductDetailSummaryDesktop: FC< }} > <Segmented - size="large" + size="small" value={metricsView} onChange={(v) => setMetricsView(v as 'gauge' | 'table')} options={[ @@ -343,7 +350,47 @@ export const ProductDetailSummaryDesktop: FC< </div> ); - const PoolWeightsCard = () => ( + const handleMetricsCollapseChange = useCallback( + (activeKey: string | string[]) => { + const isOpen = Array.isArray(activeKey) + ? activeKey.includes(METRICS_PANEL_KEY) + : activeKey === METRICS_PANEL_KEY; + setIsMetricsPanelOpen(isOpen); + }, + [] + ); + + useEffect(() => { + const maybeOpenForTarget = (target?: string) => { + if (target === '#metrics') { + setIsMetricsPanelOpen(true); + } + }; + + maybeOpenForTarget(window.location.hash); + + const onHashChange = () => maybeOpenForTarget(window.location.hash); + const onNavSelect = (event: Event) => { + const detail = (event as CustomEvent<{ href?: string }>).detail; + maybeOpenForTarget(detail?.href); + }; + + window.addEventListener('hashchange', onHashChange); + window.addEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + + return () => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + }; + }, []); + + const poolWeightsCard = ( <Card className={styles['product-detail-summary__cardDesktop']} title="Pool weights" @@ -362,268 +409,263 @@ export const ProductDetailSummaryDesktop: FC< <ProductTokenWeightChangeOverTimeGraph product={product} isBenchmark={weightsView === 'benchmark'} - yAxisOverride={{ label: { enabled: false } }} + yAxisOverride={WEIGHT_CHART_Y_AXIS_OVERRIDE} /> </div> </Card> ); - const ReturnDistributionCard = () => ( - <Card - className={styles['product-detail-summary__cardDesktop']} - title="Return distribution" - extra={ - <Segmented - value={returnsView} - onChange={(v) => setReturnsView(v as 'product' | 'benchmark')} - options={[ - { label: 'Product', value: 'product' }, - { label: 'Benchmark', value: 'benchmark' }, - ]} - /> - } - > - <div className={styles['product-detail-summary__chart']}> - {ts.length > 0 ? ( - <ReturnDistributionGraph - yAxisOverride={{ title: { enabled: false } }} - marketValues={ - returnsView === 'benchmark' - ? marketValuesBenchmark - : marketValuesProduct - } + const returnDistributionCard = ( + <div id="distribution"> + <Card + className={styles['product-detail-summary__cardDesktop']} + title="Return distribution" + extra={ + <Segmented + value={returnsView} + onChange={(v) => setReturnsView(v as 'product' | 'benchmark')} + options={[ + { label: 'Product', value: 'product' }, + { label: 'Benchmark', value: 'benchmark' }, + ]} /> - ) : ( - <Text type="secondary">No Data</Text> - )} - </div> - </Card> + } + > + <div className={styles['product-detail-summary__chart']}> + {ts.length > 0 ? ( + <ReturnDistributionGraph + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} + marketValues={ + returnsView === 'benchmark' + ? marketValuesBenchmark + : marketValuesProduct + } + /> + ) : ( + <Text type="secondary">No Data</Text> + )} + </div> + </Card> + </div> ); return ( <div className={styles['product-detail-summary__desktop']}> - <PoolWeightsCard /> - - { - /* hidden as the subgraph has still not been pushed to prod*/ - <div hidden> - <Collapse - defaultActiveKey={[]} - className={styles['product-detail-summary__collapse']} - bordered={false} + {poolWeightsCard} + <ProductDetailSidebarStrategySummary product={product} /> + + {SHOW_STRATEGY_WORKFLOW_SECTION && ( + <Collapse + defaultActiveKey={[]} + className={styles['product-detail-summary__collapse']} + bordered={false} + > + <Panel + key="strategy-workflow" + header={ + <div className={styles['product-detail-summary__collapseHeader']}> + <span className={styles['product-detail-summary__stepNumber']}> + 2 + </span> + <Text strong>Strategy workflow</Text> + <Text + type="secondary" + className={styles['product-detail-summary__collapseHint']} + ></Text> + </div> + } > - <Panel - key="strategy-workflow" - header={ - <div - className={styles['product-detail-summary__collapseHeader']} - > - <span - className={styles['product-detail-summary__stepNumber']} - > - 2 - </span> - <Text strong>Strategy workflow</Text> - <Text - type="secondary" - className={styles['product-detail-summary__collapseHint']} - ></Text> - </div> - } - > - <StrategyWorkflowCard product={product} factsheet={factsheet} /> - </Panel> - </Collapse> - </div> - } + <StrategyWorkflowCard product={product} factsheet={factsheet} /> + </Panel> + </Collapse> + )} {/* Collapsible metrics section with icon toggle */} - <Collapse - defaultActiveKey={['metrics']} - className={styles['product-detail-summary__collapse']} - bordered={false} - > - <Panel - key="metrics" - header={ - <div className={styles['product-detail-summary__title']}> - <Tooltip title="This pool is new and does not have enough data for live financial metrics. This is a simulated performance metric analysis based on the test period (see factsheet). Once the pool has been running for a while it will become live metrics"> - <Title level={4} style={{ margin: 0 }}> - Simulated HODL Performance Metric Analysis {' '} - <WarningOutlined - type="warning" - style={{ color: 'orange' }} - />{' '} - - -
- } - extra={} +
+ - {metricsView === 'table' ? ( - isLoading ? ( - - ) : simulationRunBreakdown ? ( - + + Simulated HODL Performance Metric Analysis + + + + + } + extra={metricsToggle} + > + {metricsView === 'table' ? ( + isLoading ? ( + + ) : simulationRunBreakdown ? ( + + ) : ( + No Data + ) ) : ( - No Data - ) - ) : ( - <> - - Metric:{' '} - -
- } - > - {isLoading ? ( - - ) : ( - - - - + + Metric:{' '} + - - - - - - - - -
- - Delta (Product − Benchmark) - +
+ } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + + +
- {deltaTag} + + Delta (Product − Benchmark) + +
+ {deltaTag} +
+ + + + Product: {format2(productReturnValue)} + + + Benchmark:{' '} + {format2(benchmarkReturnValue)} + +
- +
+ )} +
+ + {/* Benchmark-relative metric (product only) */} + + Benchmark metric: + +
+ } + > + {isLoading ? ( + + ) : ( + + + - - - Product: {format2(productReturnValue)} - + + + + +
- Benchmark:{' '} - {format2(benchmarkReturnValue)} + {selectedBenchmarkRelThreshold?.tooltipDescription} - -
- -
- )} - - - {/* Benchmark-relative metric (product only) */} - - Benchmark metric: - -
- } - > - {isLoading ? ( - - ) : ( - - - - - - - -
- - {selectedBenchmarkRelThreshold?.tooltipDescription} - -
- -
- )} - - - )} - - +
+ +
+ )} + + + )} + + +
+ + - + {returnDistributionCard}
); }; diff --git a/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx b/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx index f716565..473d530 100644 --- a/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx +++ b/src/features/productDetail/productDetailContent/summary/productDetailSummaryMobile.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { Card, Collapse, Divider, Tooltip, Typography } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; +import { GqlChain } from '../../../../__generated__/graphql-types'; import { FinancialMetricThresholds, Product } from '../../../../models'; import { SimulationRunMetric } from '../../../simulationResults/simulationResultSummaryModels'; import { @@ -9,9 +10,15 @@ import { ReturnDistributionGraph, } from '../../../shared/graphs'; import { ComparableProductSelector } from '../comparableProduct/comparableProductSelector'; +import { ProductDetailEvents } from '../events/productDetailEvents'; +import { ProductDetailSidebarStrategySummary } from '../../productDetailSidebar/productDetailSidebarStrategySummary'; import { getThresholdColor, getThresholdPostscript } from './utils'; const { Text } = Typography; +const WEIGHT_CHART_Y_AXIS_OVERRIDE = { label: { enabled: false } }; +const LEGEND_DISABLED_OVERRIDE = { enabled: false }; +const RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE = { title: { enabled: false } }; +const SHOW_COMPARE_PRODUCT_PANEL = false; interface ProductDetailSummaryMobileProps { product: Product; @@ -181,6 +188,18 @@ export const ProductDetailSummaryMobile = ({ 'Annualized Jensen’s Alpha (%)', [selectedBenchmarkReturnAnalysis] ); + const productMarketValues = useMemo( + () => product.timeSeries?.map((x) => x.sharePrice) ?? [], + [product.timeSeries] + ); + const benchmarkMarketValues = useMemo( + () => product.timeSeries?.map((x) => x.hodlSharePrice) ?? [], + [product.timeSeries] + ); + const comparingProductMarketValues = useMemo( + () => comparingProduct?.timeSeries?.map((x) => x.sharePrice) ?? [], + [comparingProduct?.timeSeries] + ); // Colors + grade text for the three entities per card const repr = ( @@ -192,8 +211,8 @@ export const ProductDetailSummaryMobile = ({ grade: getThresholdPostscript(thresholds, metricName, v), // "(VERY GOOD)" etc. }); - const CompareProductPanel = () => ( - ); - const ReturnDistributionCard = () => ( -
+ const returnDistributionCard = ( +
{product.name}
{(product.timeSeries?.length ?? 0) > 0 && ( x.sharePrice) ?? [] - } - yAxisOverride={{ title: { enabled: false } }} + marketValues={productMarketValues} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} /> )}
@@ -275,10 +292,8 @@ export const ProductDetailSummaryMobile = ({
{(product.timeSeries?.length ?? 0) > 0 && ( x.hodlSharePrice) ?? [] - } - yAxisOverride={{ title: { enabled: false } }} + marketValues={benchmarkMarketValues} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} /> )}
@@ -289,11 +304,8 @@ export const ProductDetailSummaryMobile = ({
{(comparingProduct?.timeSeries?.length ?? 0) > 0 && ( x.sharePrice) ?? - [] - } - yAxisOverride={{ title: { enabled: false } }} + marketValues={comparingProductMarketValues} + yAxisOverride={RETURN_DISTRIBUTION_Y_AXIS_OVERRIDE} /> )}
@@ -307,90 +319,95 @@ export const ProductDetailSummaryMobile = ({ return (
{/* Hidden because currently live analytics is turned off, comparing factsheet with live is not great */} - - - } - > -
- {/* Product */} - - - {/* HODL */} - - - {/* Comparing product (optional) */} - {comparingProduct && ( + {compareProductPanel} + {poolWeightCard} +
+ +
+
+ } + > +
+ {/* Product */} x.metricName == selectedReturnAnalysis?.metricName - )?.metricValue ?? 0 + label={product.name} + value={selectedReturnAnalysis?.metricValue ?? 0} + gradeColor={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + selectedBenchmarkReturnAnalysis?.metricValue ?? 0 + ).color } + gradeText={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + selectedBenchmarkReturnAnalysis?.metricValue ?? 0 + ).grade + } + /> + + {/* HODL */} + x.metricName == selectedReturnAnalysis?.metricName - )?.metricValue ?? 0 + selectedReturnAnalysis?.metricValue ?? 0 ).color } gradeText={ repr( returnAnalysisThresholds, currentReturnAnalysisLabel, - comparingProductReturnAnalysis?.find( - (x) => x.metricName == selectedReturnAnalysis?.metricName - )?.metricValue ?? 0 + selectedReturnAnalysis?.metricValue ?? 0 ).grade } /> - )} -
-
+ + {/* Comparing product (optional) */} + {comparingProduct && ( + x.metricName == selectedReturnAnalysis?.metricName + )?.metricValue ?? 0 + } + gradeColor={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + comparingProductReturnAnalysis?.find( + (x) => x.metricName == selectedReturnAnalysis?.metricName + )?.metricValue ?? 0 + ).color + } + gradeText={ + repr( + returnAnalysisThresholds, + currentReturnAnalysisLabel, + comparingProductReturnAnalysis?.find( + (x) => x.metricName == selectedReturnAnalysis?.metricName + )?.metricValue ?? 0 + ).grade + } + /> + )} +
+ +
- +
+ +
+ + {returnDistributionCard}
); }; diff --git a/src/features/productDetail/productDetailSidebar/productDetailSidebarStrategySummary.tsx b/src/features/productDetail/productDetailSidebar/productDetailSidebarStrategySummary.tsx index bd61221..95aeb9e 100644 --- a/src/features/productDetail/productDetailSidebar/productDetailSidebarStrategySummary.tsx +++ b/src/features/productDetail/productDetailSidebar/productDetailSidebarStrategySummary.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import { Product } from '../../../models'; import { Eli5 } from '../../shared'; import { ProductDetailSidebarElement } from './productDetailSidebarElement'; @@ -8,10 +8,12 @@ import { Collapse } from 'antd'; interface ProductDetailSidebarStrategySummaryProps { product: Product; } +const STRATEGY_PANEL_KEY = 'strategy'; export const ProductDetailSidebarStrategySummary: FC< ProductDetailSidebarStrategySummaryProps > = ({ product }) => { + const [isPanelOpen, setIsPanelOpen] = useState(false); const livePools = CURRENT_LIVE_FACTSHEETS; const liveProduct = livePools.factsheets.find( (p) => p.poolId.toLowerCase() == product.address.toLowerCase() @@ -19,12 +21,52 @@ export const ProductDetailSidebarStrategySummary: FC< const strategyName = liveProduct?.fixedSettings.find( (x) => x[0] == 'Strategy' )?.[1]; + + const handleCollapseChange = useCallback((activeKey: string | string[]) => { + const isOpen = Array.isArray(activeKey) + ? activeKey.includes(STRATEGY_PANEL_KEY) + : activeKey === STRATEGY_PANEL_KEY; + setIsPanelOpen(isOpen); + }, []); + + useEffect(() => { + const maybeOpenForTarget = (target?: string) => { + if (target === '#summary') { + setIsPanelOpen(true); + } + }; + + maybeOpenForTarget(window.location.hash); + + const onHashChange = () => maybeOpenForTarget(window.location.hash); + const onNavSelect = (event: Event) => { + const detail = (event as CustomEvent<{ href?: string }>).detail; + maybeOpenForTarget(detail?.href); + }; + + window.addEventListener('hashchange', onHashChange); + window.addEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + + return () => { + window.removeEventListener('hashchange', onHashChange); + window.removeEventListener( + 'product-detail-nav-select', + onNavSelect as EventListener + ); + }; + }, []); + return ( <> diff --git a/src/features/productExplorer/productExplorer.tsx b/src/features/productExplorer/productExplorer.tsx index 492461e..a7dd4a4 100644 --- a/src/features/productExplorer/productExplorer.tsx +++ b/src/features/productExplorer/productExplorer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { startTransition, useCallback, useEffect, useState } from 'react'; import { Layout } from 'antd'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { useRetrieveFiltersQuery } from '../../services/productRetrievalService'; @@ -20,6 +20,11 @@ import TermsOfServiceGateModal from '../documentation/landing/termsOfServiceModa export default function ProductExplorer() { const dispatch = useAppDispatch(); const [horizontalView, setHorizontalView] = useState(true); + const handleHorizontalViewChange = useCallback((checked: boolean) => { + startTransition(() => { + setHorizontalView(checked); + }); + }, []); const handleGateClose = () => dispatch(setAcceptedTermsAndConditions(true)); @@ -74,7 +79,7 @@ export default function ProductExplorer() { <> diff --git a/src/features/productExplorer/productExplorerSlice.ts b/src/features/productExplorer/productExplorerSlice.ts index 63fead9..27266cd 100644 --- a/src/features/productExplorer/productExplorerSlice.ts +++ b/src/features/productExplorer/productExplorerSlice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { RootState } from '../../app/store'; import { FilterList, @@ -188,70 +188,102 @@ export const selectLoadingSimulationRunBreakdown = ( export const selectLoadingJsonBreakdown = (state: RootState) => state.productExplorer.loadingJsonProductSimulations; -export const selectReturnAnalysisByProductId = ( - state: RootState, - id: string -) => { - const targetProduct = state.productExplorer.productMap[id]; - - if (targetProduct) { - return ( - targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.return_analysis - // filter out absolute return metric - .filter((element) => element.metricName !== 'Absolute Return (%)') - ); - } +const getProductById = (state: RootState, id: string) => + state.productExplorer.productMap[id]; - return null; -}; +const filterReturnAnalysis = (targetProduct?: Product) => + targetProduct?.simulationRunBreakdown?.simulationRunResultAnalysis?.return_analysis + // filter out absolute return metric + .filter((element) => element.metricName !== 'Absolute Return (%)') ?? null; -export const selectBenchmarkAnalysisByProductId = ( - state: RootState, - id: string, +const filterBenchmarkAnalysis = ( + targetProduct: Product | undefined, benchmarkName: string | null ) => { - const targetProduct = state.productExplorer.productMap[id]; - if (targetProduct && benchmarkName) { return targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.benchmark_analysis.filter( (element) => element.benchmarkName === benchmarkName ); - } else if (targetProduct && !benchmarkName) { - //the return analysis metrics of the benchmark itself + } + if (targetProduct && !benchmarkName) { + // the return analysis metrics of the benchmark itself return targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.benchmark_analysis.filter( (element) => element.benchmarkName === undefined || element.benchmarkName === '' ); } - return null; }; +const mapTimeseriesAnalysis = (targetProduct?: Product) => + targetProduct?.simulationRunBreakdown?.simulationRunResultAnalysis?.return_timeseries_analysis.map( + (element) => ({ + ...element, + metricKey: getMetricKey(element.metricName), + }) + ) ?? null; + +const returnAnalysisSelectorCache = new Map< + string, + (state: RootState) => ReturnType +>(); + +export const selectReturnAnalysisByProductId = (state: RootState, id: string) => { + let selector = returnAnalysisSelectorCache.get(id); + if (!selector) { + selector = createSelector( + [(rootState: RootState) => getProductById(rootState, id)], + (targetProduct) => filterReturnAnalysis(targetProduct) + ); + returnAnalysisSelectorCache.set(id, selector); + } + return selector(state); +}; + +const benchmarkAnalysisSelectorCache = new Map< + string, + (state: RootState) => ReturnType +>(); + +export const selectBenchmarkAnalysisByProductId = ( + state: RootState, + id: string, + benchmarkName: string | null +) => { + const cacheKey = `${id}::${benchmarkName ?? '__default__'}`; + let selector = benchmarkAnalysisSelectorCache.get(cacheKey); + if (!selector) { + selector = createSelector( + [(rootState: RootState) => getProductById(rootState, id)], + (targetProduct) => filterBenchmarkAnalysis(targetProduct, benchmarkName) + ); + benchmarkAnalysisSelectorCache.set(cacheKey, selector); + } + return selector(state); +}; + const getMetricKey = (metricName: string) => { return metricName.toLowerCase().replace(/ /g, '_'); }; +const timeseriesAnalysisSelectorCache = new Map< + string, + (state: RootState) => ReturnType +>(); + export const selectTimeseriesAnalysisByProductId = ( state: RootState, id: string ) => { - const targetProduct = state.productExplorer.productMap[id]; - - if (targetProduct) { - const result = - targetProduct.simulationRunBreakdown?.simulationRunResultAnalysis?.return_timeseries_analysis.map( - (element) => { - return { - ...element, - metricKey: getMetricKey(element.metricName), - }; - } - ); - - return result; + let selector = timeseriesAnalysisSelectorCache.get(id); + if (!selector) { + selector = createSelector( + [(rootState: RootState) => getProductById(rootState, id)], + (targetProduct) => mapTimeseriesAnalysis(targetProduct) + ); + timeseriesAnalysisSelectorCache.set(id, selector); } - - return null; + return selector(state); }; export const selectPageSize = (state: RootState) => diff --git a/src/features/productExplorer/productItem/card/productItem.tsx b/src/features/productExplorer/productItem/card/productItem.tsx index 114384f..2343025 100644 --- a/src/features/productExplorer/productItem/card/productItem.tsx +++ b/src/features/productExplorer/productItem/card/productItem.tsx @@ -15,6 +15,7 @@ import { ProductItemPerformanceAreaGraph } from './productItemPerformanceAreaGra import { ProductItemBottom } from './productItemBottom'; import { ProductItemBackground } from '../productItemBackground'; import { getTvl } from '../productItemHelpers'; +import { ProductItemWide } from '../wide/productItemWide'; import styles from './productItem.module.scss'; const DEFAULT_ACTIVE_KEY = '1'; @@ -23,9 +24,10 @@ const { Text } = Typography; interface ProductItemProps { product: Product; + wide?: boolean; } -export const ProductItem: FC = ({ product }) => { +const ProductItemCard: FC> = ({ product }) => { const dispatch = useAppDispatch(); const overrideTab = useAppSelector(selectOverrideTab); const isDarkTheme = useAppSelector(selectTheme); @@ -134,7 +136,17 @@ export const ProductItem: FC = ({ product }) => {
- {product.name} + + {product.name} +
@@ -176,3 +188,11 @@ export const ProductItem: FC = ({ product }) => {
); }; + +export const ProductItem: FC = ({ product, wide }) => { + if (wide) { + return ; + } + + return ; +}; diff --git a/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx b/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx index ce62ec8..8776db5 100644 --- a/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx +++ b/src/features/productExplorer/productItem/card/productItemPerformanceAreaGraph.tsx @@ -14,6 +14,7 @@ import { currencyFormatter, percentageFormatter, } from '../../../../utils/formatters'; +import { useHasEnteredViewport } from '../../../../hooks/useHasEnteredViewport'; type PerformanceGraphData = MonthlyPerformance & { trendUp: boolean; @@ -57,9 +58,13 @@ const SCALE_FACTOR = 1.3; export const ProductItemPerformanceAreaGraph: FC< ProductItemPerformanceGraphProps > = ({ data, wide }) => { - const mappedData = mapPerformanceData(data); + const { containerRef, hasEnteredViewport } = + useHasEnteredViewport(); + const mappedData = useMemo(() => mapPerformanceData(data), [data]); const chartTheme = useAppSelector(selectAgChartTheme); + const chartWidth = wide ? 100 : 288; + const chartHeight = wide ? 100 : 240; const performanceList = useMemo( () => mappedData.map((item) => item.absReturn), @@ -155,33 +160,42 @@ export const ProductItemPerformanceAreaGraph: FC< ]; }, [mappedData, interval, min, max, wide]); - return ( - ({ + width: chartWidth, + height: chartHeight, + data: mappedData, + series, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data', }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', }, }, }, - }} - /> + }, + }), + [axes, chartHeight, chartTheme, chartWidth, mappedData, series] + ); + + return ( +
+ {hasEnteredViewport ? ( + + ) : ( +
+ )} +
); }; diff --git a/src/features/productExplorer/productItem/productItemGrid.tsx b/src/features/productExplorer/productItem/productItemGrid.tsx index 73b1341..0c39279 100644 --- a/src/features/productExplorer/productItem/productItemGrid.tsx +++ b/src/features/productExplorer/productItem/productItemGrid.tsx @@ -12,7 +12,6 @@ import { ProductExplorerSort } from '../productExplorerSort/productExplorerSort' import { ProductExplorerTabOverride } from '../productExplorerTabOverride/productExplorerTabOverride'; import { ProductExplorerPagination } from '../ProductExplorerPagination'; import { useSort } from './useSort'; -import { ProductItemWide } from './wide/productItemWide'; import { ProductItemGridHeader } from './productItemGridHeader'; import { ProductItemLoading } from './card/productItemLoading'; import { ProductItemWideLoading } from './wide/productItemWideLoading'; @@ -38,6 +37,11 @@ export const ProductItemGrid: FC = ({ wide }) => { () => Object.keys(products).length > 0, [products] ); + const sortedProducts = useMemo( + () => sort(Object.values(products)), + [products, sort] + ); + const showLoadingProducts = loading && !areProductsLoaded; useEffect(() => { if (parent.current) { @@ -95,25 +99,20 @@ export const ProductItemGrid: FC = ({ wide }) => { }} > {wide && } - {(loading || !areProductsLoaded) && + {showLoadingProducts && loadingProducts.map((loadingProduct) => ( {wide ? : } ))} - {!loading && - areProductsLoaded && - sort(Object.values(products)).map((product) => ( + {areProductsLoaded && + sortedProducts.map((product) => ( - {wide ? ( - - ) : ( - - )} + ))} - {!loading && areProductsLoaded && ( + {areProductsLoaded && ( diff --git a/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx b/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx index de07b7a..7a18e93 100644 --- a/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx +++ b/src/features/productExplorer/productItem/wide/productItemPerformanceLineGraph.tsx @@ -12,6 +12,7 @@ import { CURRENT_PERFORMANCE_PERIOD, Product } from '../../../../models'; import { selectAgChartTheme } from '../../../themes/themeSlice'; import { filterByTimeRange } from '../../../productDetail/productDetailContent/helpers'; import { getCurrentPerformanceComponent } from '../shared/CurrentPerformance'; +import { useHasEnteredViewport } from '../../../../hooks/useHasEnteredViewport'; import styles from './productItemPerformanceLineGraph.module.scss'; @@ -45,9 +46,13 @@ const mapPerformanceData = (product: Product) => { export const ProductItemPerformanceLineGraph: FC< ProductItemPerformanceGraphProps > = ({ product, wide }) => { - const mappedData = mapPerformanceData(product); + const { containerRef, hasEnteredViewport } = + useHasEnteredViewport(); + const mappedData = useMemo(() => mapPerformanceData(product), [product]); const chartTheme = useAppSelector(selectAgChartTheme); + const chartWidth = wide ? 100 : 288; + const chartHeight = wide ? 100 : 240; const series: AgCartesianSeriesOptions[] = useMemo(() => { return [ @@ -105,43 +110,50 @@ export const ProductItemPerformanceLineGraph: FC< ]; }, []); + const chartOptions = useMemo( + () => ({ + width: chartWidth, + height: chartHeight, + data: mappedData, + series, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + animation: { + enabled: false, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + }, + }, + }), + [axes, chartHeight, chartTheme, chartWidth, mappedData, series] + ); + return ( -
+
{getCurrentPerformanceComponent(product)}
- + {hasEnteredViewport ? ( + + ) : ( +
+ )}
); }; diff --git a/src/features/productExplorer/productItem/wide/productItemWide.tsx b/src/features/productExplorer/productItem/wide/productItemWide.tsx index e52a762..593779b 100644 --- a/src/features/productExplorer/productItem/wide/productItemWide.tsx +++ b/src/features/productExplorer/productItem/wide/productItemWide.tsx @@ -56,100 +56,12 @@ export const ProductItemWide: FC = ({ product }) => { return !!product.timeSeries && product.timeSeries.length > 0; }, [product.timeSeries]); - const LoadingGraph = () => ( + const renderLoadingGraph = () => (
); - const OverviewScoresColumn = () => ( - 0 - ? styles['product-item__card-column'] - : undefined - } - > - {product.overview.length > 0 ? ( - ( - - - {String(item[1].metric)} - - - {String(item[1].value)} / {MAX_SCORE} - - - )} - /> - ) : ( -
- -
- )} - - ); - - const ChartsAndTokensColumns = () => ( - <> - - {product.overview.length > 0 ? ( -
- -
- ) : ( - - )} - - - {shouldShow ? ( -
- -
- ) : ( - - )} - - - {shouldShow ? ( - - ) : ( - - )} - - - ); - return (
= ({ product }) => { >
{product.name} @@ -213,8 +131,88 @@ export const ProductItemWide: FC = ({ product }) => { )} - - + 0 + ? styles['product-item__card-column'] + : undefined + } + > + {product.overview.length > 0 ? ( + ( + + + {String(item[1].metric)} + + + {String(item[1].value)} / {MAX_SCORE} + + + )} + /> + ) : ( +
+ {renderLoadingGraph()} +
+ )} + + + {product.overview.length > 0 ? ( +
+ +
+ ) : ( + renderLoadingGraph() + )} + + + {shouldShow ? ( +
+ +
+ ) : ( + renderLoadingGraph() + )} + + + {shouldShow ? ( + + ) : ( + renderLoadingGraph() + )} +
diff --git a/src/features/shared/graphs/productItemOverviewGraph.tsx b/src/features/shared/graphs/productItemOverviewGraph.tsx index 626737d..411d60d 100644 --- a/src/features/shared/graphs/productItemOverviewGraph.tsx +++ b/src/features/shared/graphs/productItemOverviewGraph.tsx @@ -10,6 +10,7 @@ import { import 'ag-charts-enterprise'; import { Typography } from 'antd'; import { useAppSelector } from '../../../app/hooks'; +import { useHasEnteredViewport } from '../../../hooks/useHasEnteredViewport'; import { selectAgChartTheme } from '../../themes/themeSlice'; import { productExplorerTranslation } from '../../productExplorer/translations'; import { @@ -51,8 +52,12 @@ export const ProductItemOverviewGraph: FC = ({ widthOverride, }) => { const chartTheme = useAppSelector(selectAgChartTheme); + const { containerRef, hasEnteredViewport } = + useHasEnteredViewport(); const totalScore = getTotalScore(data.map((item) => item.value ?? 0)); + const chartWidth = widthOverride ?? (wide ? 100 : 288); + const chartHeight = heightOverride ?? (wide ? 100 : 240); const radarColor = useMemo(() => { return getTotalScoreColor(totalScore); @@ -118,8 +123,58 @@ export const ProductItemOverviewGraph: FC = ({ ]; }, [intervalStepOverride, fontSizeOverride, orientationOverride, wide]); + const chartOptions = useMemo( + () => ({ + width: chartWidth, + height: chartHeight, + data, + series, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + animation: { + enabled: !wide, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + 'radar-area': { + axes: { + 'angle-category': { + label: { + color: isDarkTheme ? '#FFFFEF' : '#0B1827', + }, + }, + }, + }, + }, + }, + }), + [ + axes, + chartHeight, + chartTheme, + chartWidth, + data, + isDarkTheme, + series, + wide, + ] + ); + return ( -
+
{showScoreOverall && (
@@ -130,45 +185,11 @@ export const ProductItemOverviewGraph: FC = ({
)} - + {hasEnteredViewport ? ( + + ) : ( +
+ )}
); }; diff --git a/src/features/shared/graphs/returnDistributionGraph.tsx b/src/features/shared/graphs/returnDistributionGraph.tsx index 27bd95c..0f8a125 100644 --- a/src/features/shared/graphs/returnDistributionGraph.tsx +++ b/src/features/shared/graphs/returnDistributionGraph.tsx @@ -1,5 +1,7 @@ -import { FC, useMemo } from 'react'; +import { FC, memo, useMemo } from 'react'; import { + AgCartesianAxisOptions, + AgChartOptions, AgHistogramSeriesOptions, AgCategoryAxisOptions, AgNumberAxisOptions, @@ -29,7 +31,7 @@ const distributionBarSeries: AgHistogramSeriesOptions[] = [ }, ]; -export const ReturnDistributionGraph: FC = ({ +const ReturnDistributionGraphComponent: FC = ({ marketValues = [], xAxisOverride, yAxisOverride, @@ -42,74 +44,82 @@ export const ReturnDistributionGraph: FC = ({ ); }, [marketValues]); - return ( - ( + () => [ + { + type: 'number', + position: 'left', + title: { + text: 'Daily Return Count', }, - data, - series: distributionBarSeries, - axes: [ - { - type: 'number', - position: 'left', - title: { - text: 'Daily Return Count', - }, - ...yAxisOverride, - }, - { - type: 'number', - position: 'bottom', - title: { - text: 'Daily Return (%)', - }, - ...xAxisOverride, - }, - ], - legend: { - enabled: false, + ...yAxisOverride, + }, + { + type: 'number', + position: 'bottom', + title: { + text: 'Daily Return (%)', }, - overlays: { - noData: { - text: 'No data to display', - }, + ...xAxisOverride, + }, + ], + [xAxisOverride, yAxisOverride] + ); + + const chartOptions = useMemo( + () => ({ + height: 230, + padding: { + left: 20, + right: 20, + top: 20, + bottom: 20, + }, + data, + series: distributionBarSeries, + axes, + legend: { + enabled: false, + }, + overlays: { + noData: { + text: 'No data to display', }, - theme: { - baseTheme: chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', }, - line: { - series: { - stroke: '#DAAB43', - cursor: 'crosshair', - marker: { - fill: '#DAAB43', - enabled: false, - }, + }, + line: { + series: { + stroke: '#DAAB43', + cursor: 'crosshair', + marker: { + fill: '#DAAB43', + enabled: false, }, }, - histogram: { - axes: { - number: { - gridLine: { - enabled: false, - }, + }, + histogram: { + axes: { + number: { + gridLine: { + enabled: false, }, }, }, }, }, - }} - /> + }, + }), + [axes, chartTheme, data] ); + + return ; }; + +export const ReturnDistributionGraph = memo(ReturnDistributionGraphComponent); diff --git a/src/features/shared/graphs/weightChangeOverTime.tsx b/src/features/shared/graphs/weightChangeOverTime.tsx index 95c8de7..42e2572 100644 --- a/src/features/shared/graphs/weightChangeOverTime.tsx +++ b/src/features/shared/graphs/weightChangeOverTime.tsx @@ -1,13 +1,16 @@ -import { FC, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { AgCharts } from 'ag-charts-react'; import { AgAxisLabelFormatterParams, AgAreaSeriesOptions, + AgCartesianAxisOptions, + AgCartesianSeriesOptions, AgChartLegendOptions, + AgChartOptions, + AgChartThemeName, AgNumberAxisOptions, AgTimeAxisOptions, time, - AgChartThemeName, } from 'ag-charts-community'; import 'ag-charts-enterprise'; import { useAppSelector } from '../../../app/hooks'; @@ -28,14 +31,14 @@ interface WeightChangeOverTimeGraphProps { const normalisedTokenName = (token: string) => token.replace(/\./g, '-'); -export const WeightChangeOverTimeGraph: FC = ({ +function WeightChangeOverTimeGraphComponent({ simulationRunBreakdown, yAxisOverride, legendOverride, tickIntervalInMonths = 6, overrideChartTheme, overrideXAxisInterval, -}) => { +}: WeightChangeOverTimeGraphProps) { const chartTheme = useAppSelector(selectAgChartTheme); const normalisedAreaData = useMemo(() => { @@ -46,7 +49,7 @@ export const WeightChangeOverTimeGraph: FC = ({ let data = getChartTimeSteps(simulationRunBreakdown); if (data.length > 200) { - //stacked chart is more cpu intensive, ~500 points is a good balance + // stacked chart is cpu intensive; reduce points to keep UI responsive const proportion = Math.ceil(data.length / 200); data = data.filter((_, index) => index % proportion === 0); } @@ -92,8 +95,8 @@ export const WeightChangeOverTimeGraph: FC = ({ return series; }, [simulationRunBreakdown]); - const timeAxisOption: AgTimeAxisOptions = useMemo(() => { - return { + const timeAxisOption = useMemo( + () => ({ type: 'time', interval: { step: time.month.every(overrideXAxisInterval ?? tickIntervalInMonths), @@ -101,53 +104,68 @@ export const WeightChangeOverTimeGraph: FC = ({ label: { format: '%Y-%m', }, - }; - }, [tickIntervalInMonths, overrideXAxisInterval]); - - return ( - { - return params.value.toFixed(2) + '%'; - }, - }, - ...yAxisOverride, - }, - ], - series: normalisedAreaSeries, - legend: { - ...legendOverride, + }), + [tickIntervalInMonths, overrideXAxisInterval] + ); + + const axes = useMemo( + () => [ + { ...timeAxisOption }, + { + type: 'number', + position: 'left', + label: { + formatter: (params: AgAxisLabelFormatterParams) => + params.value.toFixed(2) + '%', }, - overlays: { - noData: { - text: 'No data', - }, + ...yAxisOverride, + }, + ], + [timeAxisOption, yAxisOverride] + ); + + const chartOptions = useMemo( + () => ({ + height: 230, + padding: { + right: 40, + top: 20, + bottom: 20, + left: 0, + }, + data: normalisedAreaData, + axes, + series: normalisedAreaSeries as AgCartesianSeriesOptions[], + legend: { + ...legendOverride, + }, + overlays: { + noData: { + text: 'No data', }, - theme: { - baseTheme: overrideChartTheme ? overrideChartTheme : chartTheme, - overrides: { - common: { - background: { - fill: 'transparent', - }, + }, + theme: { + baseTheme: overrideChartTheme ? overrideChartTheme : chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', }, }, }, - }} - /> + }, + }), + [ + axes, + chartTheme, + legendOverride, + normalisedAreaData, + normalisedAreaSeries, + overrideChartTheme, + ] ); -}; + + return ; +} + +export const WeightChangeOverTimeGraph = memo(WeightChangeOverTimeGraphComponent); diff --git a/src/features/simulationResults/simulationResultsSummaryStep.tsx b/src/features/simulationResults/simulationResultsSummaryStep.tsx index 527de6f..37426d5 100644 --- a/src/features/simulationResults/simulationResultsSummaryStep.tsx +++ b/src/features/simulationResults/simulationResultsSummaryStep.tsx @@ -10,7 +10,7 @@ import { } from '../simulationRunner/simulationRunnerSlice'; import { Col, Menu, Row, Tabs } from 'antd'; -import { useEffect } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { LineChartOutlined, @@ -100,7 +100,10 @@ function getBreakdownMenuItems(): ItemType[] { ]; } -export function SimulationResultsSummaryStep(props: BreakdownProps) { +function SimulationResultsSummaryStepComponent(props: BreakdownProps) { + const graphMenuItems = getGraphMenuItems(); + const breakdownMenuItems = getBreakdownMenuItems(); + const runStatusIndex = useAppSelector(selectSimulationRunStatusStepIndex); const resultChartSelection = useAppSelector( selectSimulationResultChartSelection @@ -111,7 +114,6 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { const timeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection ); - const chartSelection = useAppSelector(selectSimulationResultChartSelection); const dispatch = useAppDispatch(); @@ -127,7 +129,7 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { } }, [dispatch, props.breakdowns, timeRangeSelected]); - function getChart(): JSX.Element { + const getChart = (): JSX.Element => { if (resultChartSelection === 'MarketValueOverTime') { return ( No charts selected
; - } + }; - function getBreakdown(): JSX.Element { + const getBreakdown = (): JSX.Element => { if (resultBreakdownSelection === 'MvSummary') { return ( ; } return
No Breakdown
; - } + }; + + const handleChartSelection = useCallback( + (menuItem: { key: string }) => { + dispatch(changeChartSelected(menuItem.key)); + }, + [dispatch] + ); + + const handleBreakdownSelection = useCallback( + (menuItem: { key: string }) => { + dispatch(changeBreakdownSelected(menuItem.key)); + }, + [dispatch] + ); - const VisualisationTab = () => ( + const visualisationTab = ( @@ -193,12 +209,9 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { width: 200, fontSize: 10, }} - defaultSelectedKeys={[chartSelection]} - items={getGraphMenuItems()} - activeKey={chartSelection} - onClick={(x) => { - dispatch(changeChartSelected(x.key)); - }} + items={graphMenuItems} + activeKey={resultChartSelection} + onClick={handleChartSelection} /> @@ -213,7 +226,7 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { ); - const BreakdownTab = () => ( + const breakdownTab = ( @@ -223,11 +236,8 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { width: 200, fontSize: 10, }} - defaultSelectedKeys={[resultBreakdownSelection]} - items={getBreakdownMenuItems()} - onClick={(x) => { - dispatch(changeBreakdownSelected(x.key)); - }} + items={breakdownMenuItems} + onClick={handleBreakdownSelection} activeKey={resultBreakdownSelection} /> @@ -242,13 +252,17 @@ export function SimulationResultsSummaryStep(props: BreakdownProps) { - + {visualisationTab} - + {breakdownTab} ); } + +export const SimulationResultsSummaryStep = memo( + SimulationResultsSummaryStepComponent +); diff --git a/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx b/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx index eeee5b6..4cd8df8 100644 --- a/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultAmountChart.tsx @@ -1,7 +1,7 @@ import { AgCharts } from 'ag-charts-react'; import * as agCharts from 'ag-charts-community'; -import { AgTimeAxisOptions } from 'ag-charts-community'; -import { useMemo, useState } from 'react'; +import { AgChartOptions, AgTimeAxisOptions } from 'ag-charts-community'; +import { memo, useMemo, useState } from 'react'; import styles from '../simulationResultSummary.module.css'; import { useAppSelector } from '../../../app/hooks'; @@ -30,125 +30,240 @@ export interface FlatAmountTimeStep { type VolumeType = 'tradingVolume' | 'reserveQuantity'; -export function SimulationResultAmountChart(props: BreakdownProps) { - const simulationBreakdownResults = props.breakdowns; - const simulationTimeRangeSelected = useAppSelector( - selectSimulationResultTimeRangeSelection - ); - const chartTheme = useAppSelector(selectAgChartTheme); +const normalisedTokenName = (token: string) => token.replace(/\./g, '-'); - const [volumeType, setVolumeType] = useState('tradingVolume'); +function getAmountDeltaData(breakdown: SimulationRunBreakdown): FlatAmountTimeStep[] { + const result: FlatAmountTimeStep[] = []; + const data = getChartTimeSteps(breakdown); - // function amountFormatter(amount: number) { - // var sansDec = amount.toFixed(0); - // var formatted = sansDec.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - // return `${formatted}`; - // } + for (const [index, current] of data.entries()) { + const prev = data[index - 1]; - const normalisedTokenName = (token: string) => { - return token.replace(/\./g, '-'); - }; + const timeStep: FlatAmountTimeStep = { + date: current.date, + unix: current.unix, + }; - function getAmountDeltaData( - breakdown: SimulationRunBreakdown - ): FlatAmountTimeStep[] { - const result: FlatAmountTimeStep[] = []; - const data = getChartTimeSteps(breakdown); + current.coinsHeld.forEach((coinHeld) => { + const lowerCode = coinHeld.coin.coinCode.toLowerCase(); + if (coinHeld.amount !== undefined && coinHeld.amount !== null) { + timeStep[normalisedTokenName(lowerCode)] = Math.abs( + coinHeld.amount - + (prev?.coinsHeld.find( + (z) => z.coin.coinCode === coinHeld.coin.coinCode + )?.amount ?? 0) + ); + } + }); - for (const [index, current] of data.entries()) { - const prev = data[index - 1]; + result.push(timeStep); + } - const timeStep: FlatAmountTimeStep = { - date: current.date, - unix: current.unix, - }; + return result; +} - current.coinsHeld.forEach((coinHeld) => { - const lowerCode = coinHeld.coin.coinCode.toLowerCase(); - if (coinHeld.amount !== undefined && coinHeld.amount !== null) { - timeStep[normalisedTokenName(lowerCode)] = Math.abs( - coinHeld.amount - - (prev?.coinsHeld.find( - (z) => z.coin.coinCode === coinHeld.coin.coinCode - )?.amount ?? 0) - ); - } - }); +function getAmountData(breakdown: SimulationRunBreakdown): FlatAmountTimeStep[] { + const result: FlatAmountTimeStep[] = []; + let data = getChartTimeSteps(breakdown); - result.push(timeStep); - } + if (data.length > 500) { + // stacked chart is cpu intensive; reduce points to keep UI responsive + const proportion = Math.ceil(data.length / 500); + data = data.filter((_, index) => index % proportion === 0); + } + for (const current of data) { + const timeStep: FlatAmountTimeStep = { + date: current.date, + unix: current.unix, + }; + + current.coinsHeld.forEach((coinHeld) => { + const lowerCode = coinHeld.coin.coinCode.toLowerCase(); + timeStep[normalisedTokenName(lowerCode)] = coinHeld.amount; + }); - return result; + result.push(timeStep); } + return result; +} - function getAmountData(breakdown: SimulationRunBreakdown) { - const result: FlatAmountTimeStep[] = []; - let data = getChartTimeSteps(breakdown); +function getAmountSeries( + breakdown: SimulationRunBreakdown +): agCharts.AgCartesianSeriesOptions[] { + return breakdown.simulationRun.poolConstituents.map((x) => ({ + type: 'line', + xKey: 'unix', + yKey: normalisedTokenName(x.coin.coinCode.toLowerCase()), + yName: x.coin.coinCode.toLowerCase(), + marker: { enabled: false }, + })); +} - if (data.length > 500) { - //stacked chart is more cpu intensive, ~500 points is a good balance - const proportion = Math.ceil(data.length / 500); - data = data.filter((_, index) => index % proportion === 0); - } - for (const current of data) { - const timeStep: FlatAmountTimeStep = { - date: current.date, - unix: current.unix, - }; +function getTimeAxisOption(dataLength: number): AgTimeAxisOptions { + return { + type: 'time', + interval: { + step: + dataLength > 350 + ? agCharts.time.month.every(6) + : dataLength > 150 + ? agCharts.time.month.every(3) + : agCharts.time.month.every(1), + }, + label: { + format: '%m/%y', + }, + }; +} - current.coinsHeld.forEach((coinHeld) => { - const lowerCode = coinHeld.coin.coinCode.toLowerCase(); - timeStep[normalisedTokenName(lowerCode)] = coinHeld.amount; - }); +const AmountBreakdownRow = memo(function AmountBreakdownRow({ + breakdown, + volumeType, +}: { + breakdown: SimulationRunBreakdown; + volumeType: VolumeType; +}) { + const chartTheme = useAppSelector(selectAgChartTheme); - result.push(timeStep); - } - return result; - } + const amountDeltaData = useMemo( + () => getAmountDeltaData(breakdown), + [breakdown] + ); + const amountData = useMemo(() => getAmountData(breakdown), [breakdown]); + const amountSeries = useMemo(() => getAmountSeries(breakdown), [breakdown]); - function getAmountSeries( - breakdown: SimulationRunBreakdown - ): agCharts.AgCartesianSeriesOptions[] { - const series: agCharts.AgCartesianSeriesOptions[] = []; - breakdown.simulationRun.poolConstituents.forEach((x) => { - series.push({ - type: 'line', - xKey: 'unix', - yKey: normalisedTokenName(x.coin.coinCode.toLowerCase()), - yName: x.coin.coinCode.toLowerCase(), - marker: { enabled: false }, - }); - }); - return series; - } + const tradingVolumeOptions = useMemo( + () => ({ + height: 350, + data: amountDeltaData, + navigator: { + enabled: true, + height: 5, + spacing: 6, + }, + axes: [ + getTimeAxisOption(amountDeltaData.length), + { + type: 'log', + position: 'left', + base: 2, + label: { + format: '~s', + }, + }, + ], + series: amountSeries, + legend: { + position: 'bottom', + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + }, + }, + }), + [amountDeltaData, amountSeries, chartTheme] + ); - function getTimeAxisOption(dataLength: number): AgTimeAxisOptions { - return { - type: 'time', - interval: { - step: - dataLength > 350 - ? agCharts.time.month.every(6) - : dataLength > 150 - ? agCharts.time.month.every(3) - : agCharts.time.month.every(1), + const reserveQuantityOptions = useMemo( + () => ({ + height: 350, + data: amountData, + navigator: { + enabled: true, + height: 5, + spacing: 6, }, - label: { - format: '%m/%y', + axes: [ + getTimeAxisOption(amountData.length), + { + type: 'log', + position: 'left', + base: 2, + label: { + format: '~s', + }, + }, + ], + series: amountSeries, + legend: { + position: 'bottom', }, - }; - } + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + }, + }, + }), + [amountData, amountSeries, chartTheme] + ); + + return ( + + +
+

{breakdown.simulationRun.updateRule.updateRuleName}

+

For time period: {breakdown.timeRange.name}

+

start date: {breakdown.timeRange.startDate}

+

end date: {breakdown.timeRange.endDate}

+
+ + + + + + + +
+ ); +}); + +function SimulationResultAmountChartComponent(props: BreakdownProps) { + const simulationTimeRangeSelected = useAppSelector( + selectSimulationResultTimeRangeSelection + ); + const [volumeType, setVolumeType] = useState('tradingVolume'); const visibleBreakdowns = useMemo( () => - simulationBreakdownResults + props.breakdowns .filter((x) => x.simulationRun.updateRule.updateRuleName !== 'HODL') .filter((x) => x.simulationRunStatus === 'Complete') .filter((x) => x.timeRange.name === simulationTimeRangeSelected), - [simulationBreakdownResults, simulationTimeRangeSelected] + [props.breakdowns, simulationTimeRangeSelected] ); - const ChartDataTypeSelector = () => ( + const chartDataTypeSelector = ( Amount Changes @@ -169,131 +284,22 @@ export function SimulationResultAmountChart(props: BreakdownProps) { ); - const BreakdownAmountRows = () => ( - - - {visibleBreakdowns.map((x, index) => { - const amountDeltaData = getAmountDeltaData(x); - const amountData = getAmountData(x); - const amountSeries = getAmountSeries(x); - - return ( - - -
-

{x.simulationRun.updateRule.updateRuleName}

-

For time period: {x.timeRange.name}

-

start date: {x.timeRange.startDate}

-

end date: {x.timeRange.endDate}

-
- - - - - - - -
- ); - })} - -
- ); - return (
- - + {chartDataTypeSelector} + + + {visibleBreakdowns.map((breakdown) => ( + + ))} + +
); } + +export const SimulationResultAmountChart = memo(SimulationResultAmountChartComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx b/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx index c184280..010390f 100644 --- a/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx +++ b/src/features/simulationResults/visualisations/simulationResultDrawdownGraph.tsx @@ -8,32 +8,11 @@ import { DownOutlined } from '@ant-design/icons'; import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner/simulationRunnerSlice'; import { selectAgChartTheme } from '../../themes/themeSlice'; import { BreakdownProps } from '../simulationResultsSummaryStep'; -import { useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { SimulationResultTimestepDto } from '../../simulationRunner/simulationRunnerDtos'; -export function SimulationResultDrawdownChart(props: BreakdownProps) { - const simulationTimeRangeSelected = useAppSelector( - selectSimulationResultTimeRangeSelection - ); - const chartTheme = useAppSelector(selectAgChartTheme); - - const [drawdownType, setDrawdownType] = useState( - 'Avg Daily Drawdown per week' - ); - - const onClick = ({ key }: { key: string }) => { - setDrawdownType(key); - }; - - const visibleBreakdowns = useMemo( - () => - props.breakdowns.filter( - (x) => x.timeRange.name === simulationTimeRangeSelected - ), - [props.breakdowns, simulationTimeRangeSelected] - ); - - const items = [ +function SimulationResultDrawdownChartComponent(props: BreakdownProps) { + const drawdownItems = [ { label: 'Avg Daily Drawdown per week', key: 'Avg Daily Drawdown per week', @@ -58,6 +37,27 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { { label: 'Monthly CDaR', key: 'Monthly CDaR' }, ]; + const simulationTimeRangeSelected = useAppSelector( + selectSimulationResultTimeRangeSelection + ); + const chartTheme = useAppSelector(selectAgChartTheme); + + const [drawdownType, setDrawdownType] = useState( + 'Avg Daily Drawdown per week' + ); + + const onClick = useCallback(({ key }: { key: string }) => { + setDrawdownType(key); + }, []); + + const visibleBreakdowns = useMemo( + () => + props.breakdowns.filter( + (x) => x.timeRange.name === simulationTimeRangeSelected + ), + [props.breakdowns, simulationTimeRangeSelected] + ); + const series = useMemo((): agCharts.AgCartesianSeriesOptions[] => { const seriesArray: agCharts.AgCartesianSeriesOptions[] = []; visibleBreakdowns.forEach((x) => { @@ -113,6 +113,52 @@ export function SimulationResultDrawdownChart(props: BreakdownProps) { }; }, [visibleBreakdowns]); + const chartOptions = useMemo( + () => ({ + height: 400, + navigator: { + enabled: true, + height: 5, + spacing: 6, + }, + axes: [ + timeAxisOption, + { + type: 'number' as const, + position: 'left' as const, + }, + ], + series, + legend: { + position: 'bottom' as const, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [chartTheme, series, timeAxisOption] + ); + return (
- +
); } + +export const SimulationResultDrawdownChart = memo( + SimulationResultDrawdownChartComponent +); diff --git a/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx b/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx index 958d1fa..56c440d 100644 --- a/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultMarketValueChart.tsx @@ -1,7 +1,7 @@ import { AgCharts } from 'ag-charts-react'; import * as agCharts from 'ag-charts-community'; import { AgTimeAxisOptions } from 'ag-charts-community'; -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import styles from '../simulationResultSummary.module.css'; import { useAppSelector } from '../../../app/hooks'; @@ -24,7 +24,7 @@ export interface SeriesConfig { stroke: string | undefined; } -export function SimulationResultMarketValueChart(props: BreakdownProps) { +function SimulationResultMarketValueChartComponent(props: BreakdownProps) { const simulationBreakdownResults = props.breakdowns; const simulationTimeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection @@ -99,27 +99,96 @@ export function SimulationResultMarketValueChart(props: BreakdownProps) { visibleBreakdowns, ]); - function getTimeAxisOption(dataLength: number): AgTimeAxisOptions { - return { + const visibleBreakdownStepsLength = + visibleBreakdowns[0]?.timeSteps.length ?? 1; + + const timeAxisOption = useMemo( + () => ({ type: 'time', interval: { step: props.overrideXAxisInterval !== undefined ? agCharts.time.month.every(props.overrideXAxisInterval) - : dataLength > 350 + : visibleBreakdownStepsLength > 350 ? agCharts.time.month.every(6) - : dataLength > 150 + : visibleBreakdownStepsLength > 150 ? agCharts.time.month.every(3) : agCharts.time.month.every(1), }, label: { format: '%Y-%m', }, - }; - } + }), + [props.overrideXAxisInterval, visibleBreakdownStepsLength] + ); - const visibleBreakdownStepsLength = - visibleBreakdowns[0]?.timeSteps.length ?? 1; + const chartOptions = useMemo( + () => ({ + height: props.overrideHeight ?? 500, + navigator: { + enabled: props.overrideNagivagtion ?? true, + height: 5, + spacing: 6, + }, + padding: { + right: 40, + }, + axes: [ + timeAxisOption, + { + type: 'number' as const, + position: 'left' as const, + label: { + format: '$~s', + }, + max: props.overrideYAxisMax ?? undefined, + min: props.overrideYAxisMin ?? undefined, + interval: props.overrideYAxisInterval + ? { + values: props.overrideYAxisInterval, + } + : undefined, + }, + ], + series, + legend: { + position: 'top' as const, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [ + chartTheme, + props.overrideHeight, + props.overrideNagivagtion, + props.overrideYAxisInterval, + props.overrideYAxisMax, + props.overrideYAxisMin, + series, + timeAxisOption, + ] + ); return (
@@ -132,63 +201,7 @@ export function SimulationResultMarketValueChart(props: BreakdownProps) { - + @@ -196,3 +209,7 @@ export function SimulationResultMarketValueChart(props: BreakdownProps) {
); } + +export const SimulationResultMarketValueChart = memo( + SimulationResultMarketValueChartComponent +); diff --git a/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx b/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx index 2376c24..6a993eb 100644 --- a/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultReturnChart.tsx @@ -1,38 +1,119 @@ import { useAppSelector } from '../../../app/hooks'; import { Row, Col, Divider } from 'antd'; -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { ReturnDistributionGraph } from '../../shared/graphs'; import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner/simulationRunnerSlice'; import { BreakdownProps } from '../simulationResultsSummaryStep'; import styles from '../simulationResultSummary.module.css'; -import { SimulationRunMetric } from '../simulationResultSummaryModels'; +import { + SimulationRunBreakdown, + SimulationRunMetric, +} from '../simulationResultSummaryModels'; export interface Marker { enabled: boolean; } -export function SimulationResultReturnChart(props: BreakdownProps) { +function getMetricName( + metric: SimulationRunMetric[], + metricName: string | string[], + percentage = false +) { + const metricNames = Array.isArray(metricName) ? metricName : [metricName]; + const selected = metric.find((x) => metricNames.includes(x.metricName)); + if (selected?.metricValue == null) { + return '-'; + } + + return percentage + ? (selected.metricValue * 100).toFixed(4) + : selected.metricValue.toFixed(4); +} + +const ReturnBreakdownRow = memo(function ReturnBreakdownRow({ + result, +}: { + result: SimulationRunBreakdown; +}) { + const marketValues = useMemo( + () => result.timeSteps.map((x) => x.totalPoolMarketValue), + [result.timeSteps] + ); + + return ( + + +
+

{result.simulationRun.updateRule.updateRuleName}

+

For time period: {result.timeRange.name}

+

start date: {result.timeRange.startDate}

+

end date: {result.timeRange.endDate}

+
+ + + + + + + +

+ mean:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'mean', + true + )} +

+ + +

+ std:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'std', + true + )} +

+ + +

+ skewness:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'skewness' + )} +

+ + +

+ Kurtosis:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + 'kurtosis' + )} +

+ + +

+ Jaque Bera:{' '} + {getMetricName( + result.simulationRunResultAnalysis?.return_analysis ?? [], + ['jarqueBera', 'jarque_bera', 'jaqueBera'] + )} +

+ +
+ +
+ ); +}); + +function SimulationResultReturnChartComponent(props: BreakdownProps) { const simulationBreakdownResults = props.breakdowns; const simulationTimeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection ); - function getMetricName( - metric: SimulationRunMetric[], - metricName: string | string[], - percentage = false - ) { - const metricNames = Array.isArray(metricName) ? metricName : [metricName]; - const selected = metric.find((x) => metricNames.includes(x.metricName)); - if (selected?.metricValue == null) { - return '-'; - } - - return percentage - ? (selected.metricValue * 100).toFixed(4) - : selected.metricValue.toFixed(4); - } - const visibleBreakdowns = useMemo( () => simulationBreakdownResults @@ -50,83 +131,16 @@ export function SimulationResultReturnChart(props: BreakdownProps) { - {visibleBreakdowns.map((result, index) => ( - - -
-

{result.simulationRun.updateRule.updateRuleName}

-

For time period: {result.timeRange.name}

-

start date: {result.timeRange.startDate}

-

end date: {result.timeRange.endDate}

-
- - - x.totalPoolMarketValue - )} - /> - - - - -

- mean:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'mean', - true - )} -

- - -

- std:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'std', - true - )} -

- - -

- skewness:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'skewness' - )} -

- - -

- Kurtosis:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - 'kurtosis' - )} -

- - -

- Jaque Bera:{' '} - {getMetricName( - result.simulationRunResultAnalysis?.return_analysis ?? - [], - ['jarqueBera', 'jarque_bera', 'jaqueBera'] - )} -

- -
- -
+ {visibleBreakdowns.map((result) => ( + ))}
); } + +export const SimulationResultReturnChart = memo(SimulationResultReturnChartComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx b/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx index eda9162..bed3863 100644 --- a/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx +++ b/src/features/simulationResults/visualisations/simulationResultVaRGraph.tsx @@ -9,7 +9,7 @@ import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner import { VaRTimestep } from '../simulationResultSummaryModels'; import { selectAgChartTheme } from '../../themes/themeSlice'; import { BreakdownProps } from '../simulationResultsSummaryStep'; -import { useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { DownOutlined } from '@ant-design/icons'; import { SimulationResultTimestepDto } from '../../simulationRunner/simulationRunnerDtos'; @@ -26,7 +26,12 @@ export interface VaRSeriesConfig { marker: Marker; } -export function SimulationResultVaRChart(props: BreakdownProps) { +function SimulationResultVaRChartComponent(props: BreakdownProps) { + const varItems = [ + { label: 'Weekly CDaR', key: 'Weekly CDaR' }, + { label: 'Monthly CDaR', key: 'Monthly CDaR' }, + ]; + const simulationTimeRangeSelected = useAppSelector( selectSimulationResultTimeRangeSelection ); @@ -34,14 +39,9 @@ export function SimulationResultVaRChart(props: BreakdownProps) { const [varType, setVarType] = useState('Weekly CDaR'); - const onClick = ({ key }: { key: string }) => { + const onClick = useCallback(({ key }: { key: string }) => { setVarType(key); - }; - - const items = [ - { label: 'Weekly CDaR', key: 'Weekly CDaR' }, - { label: 'Monthly CDaR', key: 'Monthly CDaR' }, - ]; + }, []); const visibleBreakdowns = useMemo( () => @@ -106,6 +106,52 @@ export function SimulationResultVaRChart(props: BreakdownProps) { }; }, [visibleBreakdowns]); + const chartOptions = useMemo( + () => ({ + height: 400, + navigator: { + enabled: true, + height: 5, + spacing: 6, + }, + axes: [ + timeAxisOption, + { + type: 'number' as const, + position: 'left' as const, + }, + ], + series, + legend: { + position: 'bottom' as const, + }, + overlays: { + noData: { + text: 'No data', + }, + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [chartTheme, series, timeAxisOption] + ); + return (
@@ -115,7 +161,7 @@ export function SimulationResultVaRChart(props: BreakdownProps) { @@ -130,52 +176,11 @@ export function SimulationResultVaRChart(props: BreakdownProps) { - +
); } + +export const SimulationResultVaRChart = memo(SimulationResultVaRChartComponent); diff --git a/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx b/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx index 1eb4a64..9788de7 100644 --- a/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx +++ b/src/features/simulationResults/visualisations/simulationResultWeightChart.tsx @@ -1,7 +1,7 @@ import { Row, Col, Divider } from 'antd'; import { AgCharts } from 'ag-charts-react'; -import { AgDonutSeriesOptions } from 'ag-charts-community'; -import { useMemo } from 'react'; +import { AgChartOptions, AgDonutSeriesOptions } from 'ag-charts-community'; +import { memo, useMemo } from 'react'; import { useAppSelector } from '../../../app/hooks'; import { selectSimulationResultTimeRangeSelection } from '../../simulationRunner/simulationRunnerSlice'; import { WeightChangeOverTimeGraph } from '../../shared/graphs/weightChangeOverTime'; @@ -25,66 +25,125 @@ export interface FlatWeightTimeStep { [key: string]: any; } -export function SimulationResultWeightChart({ breakdowns }: BreakdownProps) { - const simulationTimeRangeSelected = useAppSelector( - selectSimulationResultTimeRangeSelection - ); - const chartTheme = useAppSelector(selectAgChartTheme); +function getPieWeightData(breakdown: SimulationRunBreakdown): FlatWeightData[] { + const data: FlatWeightData[] = []; + const final = breakdown.timeSteps[breakdown.timeSteps.length - 1]; + breakdown.simulationRun.poolConstituents.forEach((x) => { + if (x.weight !== undefined) { + data.push({ + coinCode: x.coin.coinCode, + initialWeight: x.weight, + finalWeight: + final.coinsHeld.find((y) => y.coin.coinCode === x.coin.coinCode) + ?.weight ?? x.weight, + }); + } + }); + + return data; +} + +function getPieSimulationResultSeries( + breakdown: SimulationRunBreakdown +): AgDonutSeriesOptions[] { + const seriesArray: AgDonutSeriesOptions[] = []; - function getPieWeightData( - breakdown: SimulationRunBreakdown - ): FlatWeightData[] { - const data: FlatWeightData[] = []; - const final = breakdown.timeSteps[breakdown.timeSteps.length - 1]; - breakdown.simulationRun.poolConstituents.forEach((x) => { - if (x.weight !== undefined) { - data.push({ - coinCode: x.coin.coinCode, - initialWeight: x.weight, - finalWeight: - final.coinsHeld.find((y) => y.coin.coinCode === x.coin.coinCode) - ?.weight ?? x.weight, - }); - } + if (breakdown.timeSteps.length !== 0) { + seriesArray.push({ + type: 'donut', + sectorLabelKey: 'coinCode', + angleKey: 'finalWeight', + outerRadiusOffset: -5, + innerRadiusOffset: -15, + outerRadiusRatio: 1, + innerRadiusRatio: 0.6, + shadow: { + enabled: true, + }, }); - return data; + seriesArray.push({ + type: 'donut', + angleKey: 'initialWeight', + outerRadiusOffset: -15, + innerRadiusOffset: -25, + outerRadiusRatio: 0.6, + innerRadiusRatio: 0.1, + shadow: { + enabled: true, + }, + }); } - function getPieSimulationResultSeries( - breakdown: SimulationRunBreakdown - ): AgDonutSeriesOptions[] { - const seriesArray: AgDonutSeriesOptions[] = []; - - if (breakdown.timeSteps.length !== 0) { - seriesArray.push({ - type: 'donut', - sectorLabelKey: 'coinCode', - angleKey: 'finalWeight', - outerRadiusOffset: -5, - innerRadiusOffset: -15, - outerRadiusRatio: 1, - innerRadiusRatio: 0.6, - shadow: { - enabled: true, - }, - }); + return seriesArray; +} + +const WeightBreakdownRow = memo(function WeightBreakdownRow({ + result, +}: { + result: SimulationRunBreakdown; +}) { + const chartTheme = useAppSelector(selectAgChartTheme); + + const pieWeightData = useMemo(() => getPieWeightData(result), [result]); + const pieSeries = useMemo(() => getPieSimulationResultSeries(result), [result]); - seriesArray.push({ - type: 'donut', - angleKey: 'initialWeight', - outerRadiusOffset: -15, - innerRadiusOffset: -25, - outerRadiusRatio: 0.6, - innerRadiusRatio: 0.1, - shadow: { - enabled: true, + const chartOptions = useMemo( + () => ({ + height: 220, + width: 300, + data: pieWeightData, + series: pieSeries, + overlays: { + noData: { + text: 'No data', }, - }); - } + }, + theme: { + baseTheme: chartTheme, + overrides: { + common: { + background: { + fill: 'transparent', + }, + }, + line: { + series: { + cursor: 'crosshair', + marker: { + enabled: false, + }, + }, + }, + }, + }, + }), + [chartTheme, pieSeries, pieWeightData] + ); - return seriesArray; - } + return ( + + +
+

{result.simulationRun.updateRule.updateRuleName}

+

start date: {result.timeRange.startDate}

+

end date: {result.timeRange.endDate}

+
+ + + + + + + +
+ ); +}); + +function SimulationResultWeightChartComponent({ breakdowns }: BreakdownProps) { + const simulationTimeRangeSelected = useAppSelector( + selectSimulationResultTimeRangeSelection + ); const visibleBreakdowns = useMemo( () => @@ -117,57 +176,16 @@ export function SimulationResultWeightChart({ breakdowns }: BreakdownProps) { - {visibleBreakdowns.map( - (result: SimulationRunBreakdown, index: number) => ( - - -
-

{result.simulationRun.updateRule.updateRuleName}

-

start date: {result.timeRange.startDate}

-

end date: {result.timeRange.endDate}

-
- - - - - - - -
- ) - )} + {visibleBreakdowns.map((result: SimulationRunBreakdown) => ( + + ))}
); } + +export const SimulationResultWeightChart = memo(SimulationResultWeightChartComponent); diff --git a/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx b/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx index 75813d3..c4f139f 100644 --- a/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx +++ b/src/features/simulationRunner/sections/simulationRunnerImportResultsModal.tsx @@ -1,29 +1,153 @@ -import { Button, Col, Modal } from 'antd'; -import { ChangeEvent, RefObject } from 'react'; +import { + Button, + Col, + InputNumber, + Modal, + Select, + Spin, + Typography, +} from 'antd'; +import { + ChangeEvent, + RefObject, + memo, + useEffect, + useMemo, + useState, +} from 'react'; +import { + GetPoolsSummaryQueryVariables, + GqlChain, + GqlPoolType, +} from '../../../__generated__/graphql-types'; +import { useFetchPoolsSummaryByParams } from '../../../hooks/useFetchPoolsSummaryByParams'; +import { poolTypes } from '../../productDetail/productDetailContent/comparableProduct/comparableProductFormHelper'; import styles from '../../simulationResults/simulationResultSummary.module.css'; import runnerStyles from '../simulationRunnerCommon.module.css'; +const { Text } = Typography; +const DEFAULT_MIN_TVL = 10000; +const MIN_ALLOWED_TVL = 1000; + +export interface LivePoolSelection { + id: string; + chain: GqlChain; + name: string; +} + +interface LivePoolSelectOption extends LivePoolSelection { + value: string; + label: string; +} + interface ImportResultsModalProps { isOpen: boolean; + runStatusIndex: number; + importingLivePool: boolean; onClose: () => void; paramsFileInputRef: RefObject; resultsFileInputRef: RefObject; onParamsImportClick: () => void; onResultsImportClick: () => void; + onLivePoolImport: (pool: LivePoolSelection) => Promise | void; onParamsFileChange: (event: ChangeEvent) => void; onResultsFileChange: (event: ChangeEvent) => void; } -export function SimulationRunnerImportResultsModal({ +function SimulationRunnerImportResultsModalComponent({ isOpen, + runStatusIndex, + importingLivePool, onClose, paramsFileInputRef, resultsFileInputRef, onParamsImportClick, onResultsImportClick, + onLivePoolImport, onParamsFileChange, onResultsFileChange, }: ImportResultsModalProps) { + const [selectedPoolType, setSelectedPoolType] = useState< + GqlPoolType | undefined + >(undefined); + const [selectedPoolValue, setSelectedPoolValue] = useState< + string | undefined + >(undefined); + const [minTvl, setMinTvl] = useState(DEFAULT_MIN_TVL); + + const poolParams = useMemo( + () => ({ + first: 100, + where: { + minTvl, + tagNotIn: ['BLACK_LISTED'], + ...(selectedPoolType ? { poolTypeIn: [selectedPoolType] } : {}), + }, + }), + [minTvl, selectedPoolType] + ); + + const { data, loading: loadingPools } = useFetchPoolsSummaryByParams( + poolParams, + { + skip: !isOpen, + } + ); + + const poolOptions = useMemo( + () => + data?.poolGetPools.map((pool) => ({ + value: `${pool.chain}:${pool.id}`, + label: `${pool.name} (${pool.chain})`, + id: pool.id, + chain: pool.chain, + name: pool.name, + })) ?? [], + [data?.poolGetPools] + ); + + const selectedLivePool = useMemo( + () => poolOptions.find((option) => option.value === selectedPoolValue), + [poolOptions, selectedPoolValue] + ); + + useEffect(() => { + if (selectedPoolValue && !selectedLivePool) { + setSelectedPoolValue(undefined); + } + }, [selectedPoolValue, selectedLivePool]); + + useEffect(() => { + if (!isOpen) { + setSelectedPoolValue(undefined); + } + }, [isOpen]); + + const canImportLivePool = runStatusIndex === 2 && !!selectedLivePool; + + const handleMinTvlChange = (value: number | null) => { + const nextMinTvl = Math.max(value ?? MIN_ALLOWED_TVL, MIN_ALLOWED_TVL); + setMinTvl(nextMinTvl); + setSelectedPoolValue(undefined); + }; + + const handlePoolTypeChange = (value: GqlPoolType | undefined) => { + setSelectedPoolType(value); + setSelectedPoolValue(undefined); + }; + + const handleImportLivePoolClick = () => { + if (!selectedLivePool) { + return; + } + + void onLivePoolImport({ + id: selectedLivePool.id, + chain: selectedLivePool.chain, + name: selectedLivePool.name, + }); + }; + return ( + +
OR
+ +

Import Live Pool:

+ Pool Type + + allowClear + showSearch + optionFilterProp="label" + options={poolTypes} + placeholder="Any pool type" + value={selectedPoolType} + onChange={handlePoolTypeChange} + /> + + Min TVL + + `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + } + /> + + Pool Name + {loadingPools ? ( + + ) : ( +