diff --git a/apps/visualizer/diagram.html b/apps/visualizer/diagram.html new file mode 100644 index 0000000..f810b49 --- /dev/null +++ b/apps/visualizer/diagram.html @@ -0,0 +1,12 @@ + + + + + + Trade system diagram + + +
+ + + diff --git a/apps/visualizer/package-lock.json b/apps/visualizer/package-lock.json index 38368dd..c15303f 100644 --- a/apps/visualizer/package-lock.json +++ b/apps/visualizer/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "trade-visualizer", "dependencies": { + "@xyflow/react": "^12.8.5", "chart.js": "^4.4.1", "chartjs-adapter-luxon": "^1.3.1", "lightweight-charts": "^5.0.8", @@ -1110,6 +1111,49 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1121,14 +1165,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1166,6 +1210,36 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -1253,6 +1327,11 @@ "luxon": ">=1.0.0" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1264,9 +1343,105 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1664,6 +1839,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1730,6 +1913,33 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/apps/visualizer/package.json b/apps/visualizer/package.json index 757fbe7..4c4d59f 100644 --- a/apps/visualizer/package.json +++ b/apps/visualizer/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "@xyflow/react": "^12.8.5", "chart.js": "^4.4.1", "chartjs-adapter-luxon": "^1.3.1", "lightweight-charts": "^5.0.8", diff --git a/apps/visualizer/src/diagram-main.tsx b/apps/visualizer/src/diagram-main.tsx new file mode 100644 index 0000000..bc85393 --- /dev/null +++ b/apps/visualizer/src/diagram-main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import '@xyflow/react/dist/style.css'; +import './diagram.css'; +import { TradeSystemFlowPage } from './diagram/TradeSystemFlowPage'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/visualizer/src/diagram.css b/apps/visualizer/src/diagram.css new file mode 100644 index 0000000..5f803fb --- /dev/null +++ b/apps/visualizer/src/diagram.css @@ -0,0 +1,535 @@ +:root { + --rf-bg: #efe7db; + --rf-panel: rgba(255, 252, 246, 0.94); + --rf-card: #fffdf9; + --rf-ink: #1b1714; + --rf-muted: #625b52; + --rf-line: rgba(99, 91, 82, 0.18); + --rf-shadow: 0 24px 54px rgba(27, 23, 20, 0.12); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + width: 100%; + height: 100%; +} + +body { + font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; + color: var(--rf-ink); + background: + radial-gradient(circle at top left, rgba(31, 111, 139, 0.14), transparent 26%), + radial-gradient(circle at top right, rgba(176, 79, 53, 0.14), transparent 24%), + linear-gradient(180deg, #f8f3eb 0%, var(--rf-bg) 100%); +} + +.rfPage { + min-height: 100%; + padding: 16px; +} + +.rfToolbar, +.rfCanvas, +.rfInspector { + background: var(--rf-panel); + border: 1px solid rgba(217, 204, 187, 0.92); + border-radius: 24px; + box-shadow: var(--rf-shadow); + backdrop-filter: blur(10px); +} + +.rfToolbar { + min-height: 76px; + padding: 14px 18px; + display: grid; + grid-template-columns: minmax(260px, 1fr) auto auto; + gap: 16px; + align-items: center; +} + +.rfToolbar__eyebrow, +.rfInspector__eyebrow, +.rfCard__subtitle { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfToolbar__brand { + display: flex; + align-items: center; + gap: 14px; +} + +.rfToolbar__mark { + width: 16px; + height: 16px; + border-radius: 5px; + background: + linear-gradient(135deg, #1f6f8b 0%, #1f6f8b 46%, #b04f35 46%, #b04f35 100%); + box-shadow: 0 0 0 4px rgba(31, 111, 139, 0.1); +} + +.rfToolbar__copy { + min-width: 0; +} + +.rfToolbar__title { + margin-top: 2px; + font-size: clamp(1.1rem, 1.3vw, 1.45rem); + font-weight: 800; + line-height: 1.05; + font-family: 'IBM Plex Serif', Georgia, serif; +} + +.rfToolbar__summary, +.rfInspector p, +.rfCard__host, +.rfCard__bullets, +.rfSection li { + color: var(--rf-muted); + line-height: 1.5; +} + +.rfToolbar__summary { + margin-top: 3px; + font-size: 13px; +} + +.rfFilters { + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.rfFilter { + border: 1px solid rgba(99, 91, 82, 0.15); + border-radius: 999px; + background: #fffaf2; + color: var(--rf-ink); + font: inherit; + font-weight: 800; + padding: 9px 13px; + cursor: pointer; + transition: 140ms ease; +} + +.rfFilter:hover, +.rfFilter.isActive { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08); +} + +.rfToolbar__meta { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.rfToolbar__pill { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: rgba(255, 250, 242, 0.92); + color: var(--rf-ink); + font-size: 13px; + font-weight: 700; +} + +.rfToolbar__pillIcon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(27, 23, 20, 0.08); + font-size: 11px; + font-weight: 900; +} + +.rfLayout { + margin-top: 12px; + display: grid; + grid-template-columns: minmax(0, 1.3fr) 330px; + gap: 18px; + align-items: stretch; +} + +.rfCanvas { + height: calc(100vh - 126px); + min-height: 760px; + overflow: hidden; +} + +.rfInspector { + padding: 22px; +} + +.rfInspector h2 { + margin: 10px 0 8px; + font-size: 1.8rem; + line-height: 1.05; + font-family: 'IBM Plex Serif', Georgia, serif; +} + +.rfInspector__host { + font-weight: 700; +} + +.rfSection { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid rgba(99, 91, 82, 0.12); +} + +.rfSection h3 { + margin: 0 0 8px; + font-size: 0.98rem; +} + +.rfSection ul { + margin: 0; + padding-left: 18px; +} + +.rfSection li { + margin: 6px 0; +} + +.rfRouteList { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} + +.rfRouteItem { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: rgba(255, 252, 246, 0.84); +} + +.rfRouteItem__head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 13px; +} + +.rfRouteItem__meta { + margin-top: 6px; + font-size: 12px; + font-weight: 700; + color: var(--rf-ink); +} + +.rfRouteItem__desc { + margin-top: 4px; + font-size: 12px; + line-height: 1.45; + color: var(--rf-muted); +} + +.rfRouteBadge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.rfRouteBadge.is-in { + background: rgba(75, 85, 99, 0.12); + color: #3f4854; +} + +.rfRouteBadge.is-out { + background: rgba(20, 114, 109, 0.14); + color: #126863; +} + +.rfRouteBadge.is-path-read { + background: rgba(31, 111, 139, 0.12); + color: #1f6f8b; +} + +.rfRouteBadge.is-path-live { + background: rgba(176, 79, 53, 0.12); + color: #a04a33; +} + +.rfRouteBadge.is-path-write { + background: rgba(122, 98, 19, 0.14); + color: #7a6213; +} + +.rfRouteBadge.is-path-control { + background: rgba(75, 85, 99, 0.12); + color: #4b5563; +} + +.rfLegend { + background: rgba(255, 252, 246, 0.9); + border: 1px solid rgba(99, 91, 82, 0.12); + border-radius: 16px; + padding: 12px 14px; + box-shadow: 0 12px 28px rgba(27, 23, 20, 0.08); +} + +.rfLegend__title { + margin-bottom: 8px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfLegend__row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + font-size: 13px; + color: var(--rf-ink); +} + +.rfLegend__row span { + width: 12px; + height: 12px; + border-radius: 999px; + flex: 0 0 12px; +} + +.rfCard { + width: 248px; + border: 1px solid rgba(99, 91, 82, 0.14); + border-left: 6px solid var(--lane-color); + border-radius: 18px; + background: var(--rf-card); + box-shadow: 0 16px 32px rgba(27, 23, 20, 0.08); + padding: 12px; +} + +.rfCard.isSelected { + box-shadow: 0 18px 38px rgba(27, 23, 20, 0.16); + transform: translateY(-1px); +} + +.rfCard__head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} + +.rfCard__title { + margin-top: 4px; + font-size: 0.94rem; + font-weight: 800; + color: var(--rf-ink); +} + +.rfCard__host { + margin-top: 8px; + font-size: 12px; +} + +.rfCard__bullets { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; +} + +.rfCard__bullets li { + margin: 5px 0; +} + +.rfStatus { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 5px 8px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.rfStatus.isOk { + background: rgba(30, 107, 67, 0.11); + color: #185f39; +} + +.rfStatus.isWarn { + background: rgba(176, 79, 53, 0.11); + color: #8b3d24; +} + +.rfHandle { + width: 10px; + height: 10px; + background: var(--lane-color); + border: 2px solid #fff; +} + +.rfHandle.isRouteHandle { + width: 12px; + height: 12px; + border: 2px solid rgba(255, 253, 249, 0.98); + box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08); +} + +.rfZone { + width: 100%; + height: 100%; + border-radius: 26px; + border: 1px solid rgba(110, 101, 91, 0.14); + padding: 12px 14px; +} + +.rfZone__title { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: rgba(255, 251, 244, 0.96); + box-shadow: 0 6px 16px rgba(27, 23, 20, 0.06); + font-size: 0.96rem; + font-weight: 900; + color: var(--rf-ink); + font-family: 'IBM Plex Serif', Georgia, serif; +} + +.rfZone__subtitle { + margin-top: 6px; + max-width: 250px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--rf-muted); + line-height: 1.35; +} + +.rfZone.is-host { + background: + linear-gradient(180deg, rgba(255, 253, 249, 0.92), rgba(244, 236, 225, 0.66)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} + +.rfZone.is-namespace { + background: + linear-gradient(180deg, rgba(255, 251, 246, 0.78), rgba(236, 228, 214, 0.52)); + border-style: dashed; + border-color: rgba(98, 91, 82, 0.22); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22); +} + +.rfZone.is-subzone { + background: + linear-gradient(180deg, rgba(255, 254, 250, 0.72), rgba(248, 242, 233, 0.46)); + border-color: rgba(98, 91, 82, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); +} + +.react-flow__renderer { + background: + linear-gradient(rgba(225, 214, 198, 0.22) 1px, transparent 1px), + linear-gradient(90deg, rgba(225, 214, 198, 0.22) 1px, transparent 1px), + linear-gradient(180deg, #fffdf8 0%, #faf4ea 100%); + background-size: 26px 26px, 26px 26px, 100% 100%; +} + +.react-flow__nodes { + z-index: 10; +} + +.react-flow__edges, +.react-flow__edgelabel-renderer { + z-index: 30 !important; +} + +.react-flow__attribution { + display: none; +} + +.react-flow__controls, +.react-flow__minimap { + border: 1px solid rgba(99, 91, 82, 0.14); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08); +} + +.react-flow__controls button { + background: rgba(255, 252, 246, 0.92); + color: var(--rf-ink); + border-bottom-color: rgba(99, 91, 82, 0.12); +} + +.react-flow__edge-text { + font-weight: 700; +} + +.rfPage.isUltraWide .rfLayout { + grid-template-columns: minmax(0, 1.4fr) 300px; +} + +.rfPage.isUltraWide .rfCanvas { + height: calc(100vh - 118px); + min-height: 820px; +} + +code { + font-family: 'IBM Plex Mono', monospace; + background: rgba(27, 23, 20, 0.05); + padding: 2px 6px; + border-radius: 6px; +} + +@media (max-width: 1180px) { + .rfPage { + padding: 14px; + } + + .rfToolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .rfToolbar__meta { + justify-content: flex-start; + flex-wrap: wrap; + } + + .rfLayout { + grid-template-columns: 1fr; + } + + .rfCanvas { + height: 72vh; + min-height: 620px; + } +} diff --git a/apps/visualizer/src/diagram/TradeSystemFlowPage.tsx b/apps/visualizer/src/diagram/TradeSystemFlowPage.tsx new file mode 100644 index 0000000..05a73aa --- /dev/null +++ b/apps/visualizer/src/diagram/TradeSystemFlowPage.tsx @@ -0,0 +1,1310 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { + ReactFlow, + Background, + BaseEdge, + Controls, + Handle, + MarkerType, + MiniMap, + Panel, + Position, +} from '@xyflow/react'; +import type { Edge, EdgeProps, Node, NodeProps } from '@xyflow/react'; + +type Lane = 'external' | 'web' | 'api' | 'data' | 'chain' | 'exec'; +type PathType = 'read' | 'write' | 'control' | 'live'; + +type FlowNodeData = { + title: string; + subtitle: string; + lane: Lane; + host: string; + status?: 'ok' | 'warn'; + bullets: string[]; + exposes?: string[]; + consumes?: string[]; + payloads?: string[]; +}; + +type FlowEdgeData = { + path: PathType; + description: string; + waypoints?: Array<{ x: number; y: number }>; +}; + +type ZoneNodeData = { + title: string; + subtitle: string; + tone: 'host' | 'namespace' | 'subzone'; +}; + +type HandleSpec = { + id: string; + top: string; + color: string; +}; + +type SelectedRoute = { + id: string; + direction: 'in' | 'out'; + path: PathType; + peerTitle: string; + label: string; + description: string; +}; + +const laneColors: Record = { + external: '#4b5563', + web: '#1f6f8b', + api: '#14726d', + data: '#425466', + chain: '#b04f35', + exec: '#7a6213', +}; + +const pathLabels: Record = { + read: 'read', + write: 'write', + control: 'control', + live: 'live', +}; + +const ultraWidePositions: Record = { + browser: { x: 18, y: 310 }, + frontend: { x: 320, y: 230 }, + api: { x: 700, y: 230 }, + postgres: { x: 1080, y: 230 }, + strategy: { x: 1460, y: 230 }, + hasura: { x: 700, y: 470 }, + redis: { x: 1080, y: 470 }, + server: { x: 1460, y: 470 }, + ingestor: { x: 700, y: 710 }, + publisher: { x: 1080, y: 710 }, + workers: { x: 1460, y: 710 }, + agave: { x: 780, y: 1010 }, + yellowstone: { x: 1160, y: 1010 }, +}; + +function ServiceNode({ id, data, selected }: NodeProps>) { + const style = { ['--lane-color' as string]: laneColors[data.lane] } as CSSProperties; + const incomingHandles = routeHandles.incoming.get(id) || []; + const outgoingHandles = routeHandles.outgoing.get(id) || []; + + return ( +
+ {incomingHandles.map((handle) => ( + + ))} +
+
+
{data.subtitle}
+
{data.title}
+
+ {data.status ? ( + + {data.status === 'ok' ? 'ready' : 'needs attention'} + + ) : null} +
+
{data.host}
+
    + {data.bullets.map((bullet) => ( +
  • {bullet}
  • + ))} +
+ {outgoingHandles.map((handle) => ( + + ))} +
+ ); +} + +function ZoneNode({ data }: NodeProps>) { + return ( +
+
{data.title}
+
{data.subtitle}
+
+ ); +} + +function buildOrthogonalPath(points: Array<{ x: number; y: number }>) { + if (points.length === 0) return ''; + return points.reduce((acc, point, index) => { + if (index === 0) return `M ${point.x} ${point.y}`; + return `${acc} L ${point.x} ${point.y}`; + }, ''); +} + +type Rect = { left: number; right: number; top: number; bottom: number }; + +const serviceNodeHeights: Record = { + browser: 170, + frontend: 160, + api: 168, + postgres: 150, + strategy: 136, + hasura: 156, + redis: 126, + server: 152, + ingestor: 136, + publisher: 136, + workers: 164, + agave: 136, + yellowstone: 126, +}; + +function compressPoints(points: Array<{ x: number; y: number }>) { + const compact: Array<{ x: number; y: number }> = []; + for (const point of points) { + const last = compact[compact.length - 1]; + if (last && last.x === point.x && last.y === point.y) continue; + compact.push(point); + } + + if (compact.length < 3) return compact; + + const reduced: Array<{ x: number; y: number }> = [compact[0]]; + for (let index = 1; index < compact.length - 1; index += 1) { + const prev = reduced[reduced.length - 1]; + const current = compact[index]; + const next = compact[index + 1]; + const sameX = prev.x === current.x && current.x === next.x; + const sameY = prev.y === current.y && current.y === next.y; + if (!sameX && !sameY) reduced.push(current); + } + reduced.push(compact[compact.length - 1]); + return reduced; +} + +function layoutPositions() { + const isUltraWide = typeof window !== 'undefined' ? window.innerWidth >= 1900 : true; + return isUltraWide + ? ultraWidePositions + : (Object.fromEntries(baseNodes.map((node) => [node.id, node.position])) as Record< + string, + { x: number; y: number } + >); +} + +function serviceObstacles(excludedIds: string[]) { + const positions = layoutPositions(); + return baseNodes + .filter((node) => !excludedIds.includes(node.id)) + .map((node) => { + const position = positions[node.id] || node.position; + const height = serviceNodeHeights[node.id] || 150; + return { + left: position.x - 18, + right: position.x + 248 + 18, + top: position.y - 16, + bottom: position.y + height + 16, + } satisfies Rect; + }); +} + +function pointInsideRect(point: { x: number; y: number }, rect: Rect) { + return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom; +} + +function snap(value: number, step: number) { + return Math.round(value / step) * step; +} + +function keyFor(x: number, y: number) { + return `${x}:${y}`; +} + +function obstacleRoute( + sourceId: string, + targetId: string, + sourceX: number, + sourceY: number, + targetX: number, + targetY: number +) { + const step = 18; + const start = { x: sourceX + 18, y: sourceY }; + const end = { x: targetX - 18, y: targetY }; + const startGrid = { x: snap(start.x, step), y: snap(start.y, step) }; + const endGrid = { x: snap(end.x, step), y: snap(end.y, step) }; + const bounds = { minX: -36, maxX: 1820, minY: 96, maxY: 1320 }; + const obstacles = serviceObstacles([sourceId, targetId]); + + const blocked = new Set(); + for (const rect of obstacles) { + const left = Math.floor(rect.left / step) * step; + const right = Math.ceil(rect.right / step) * step; + const top = Math.floor(rect.top / step) * step; + const bottom = Math.ceil(rect.bottom / step) * step; + for (let x = left; x <= right; x += step) { + for (let y = top; y <= bottom; y += step) { + if (pointInsideRect({ x, y }, rect)) blocked.add(keyFor(x, y)); + } + } + } + + blocked.delete(keyFor(startGrid.x, startGrid.y)); + blocked.delete(keyFor(endGrid.x, endGrid.y)); + + const queue: Array<{ x: number; y: number }> = [startGrid]; + const prev = new Map(); + prev.set(keyFor(startGrid.x, startGrid.y), null); + + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + if (current.x === endGrid.x && current.y === endGrid.y) break; + + const neighbors = [ + { x: current.x + step, y: current.y }, + { x: current.x - step, y: current.y }, + { x: current.x, y: current.y + step }, + { x: current.x, y: current.y - step }, + ]; + + for (const next of neighbors) { + if (next.x < bounds.minX || next.x > bounds.maxX || next.y < bounds.minY || next.y > bounds.maxY) continue; + const key = keyFor(next.x, next.y); + if (blocked.has(key) || prev.has(key)) continue; + prev.set(key, keyFor(current.x, current.y)); + queue.push(next); + } + } + + const endKey = keyFor(endGrid.x, endGrid.y); + if (!prev.has(endKey)) { + return compressPoints([ + start, + { x: startGrid.x, y: start.y }, + { x: startGrid.x, y: end.y }, + end, + ]); + } + + const gridPath: Array<{ x: number; y: number }> = []; + let cursor: string | null = endKey; + while (cursor) { + const [x, y] = cursor.split(':').map(Number); + gridPath.push({ x, y }); + cursor = prev.get(cursor) || null; + } + gridPath.reverse(); + + return compressPoints([ + start, + { x: startGrid.x, y: start.y }, + { x: startGrid.x, y: startGrid.y }, + ...gridPath.slice(1, -1), + { x: endGrid.x, y: endGrid.y }, + { x: endGrid.x, y: end.y }, + end, + ]); +} + +function RoutedEdge({ + id, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + markerEnd, + style, + data, +}: EdgeProps>) { + const points = [ + { x: sourceX, y: sourceY }, + ...(data?.waypoints || obstacleRoute(source, target, sourceX, sourceY, targetX, targetY)), + { x: targetX, y: targetY }, + ]; + return ; +} + +const nodeTypes = { + service: ServiceNode, + zone: ZoneNode, +}; + +const edgeTypes = { + routed: RoutedEdge, +}; + +const baseNodes: Node[] = [ + { + id: 'browser', + type: 'service', + position: { x: 10, y: 220 }, + data: { + title: 'Browser / operator', + subtitle: 'external client', + lane: 'external', + host: 'outside cluster', + bullets: ['opens SPA', 'keeps auth session cookie', 'uses REST + GraphQL WS'], + exposes: ['GET /', 'GET /whoami'], + consumes: ['trade-frontend NodePort'], + payloads: ['HTML', 'JS bundle', 'JSON', 'GraphQL frames'], + }, + }, + { + id: 'frontend', + type: 'service', + position: { x: 300, y: 120 }, + data: { + title: 'trade-frontend', + subtitle: 'web entrypoint', + lane: 'web', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['NodePort 30081 -> 8081', 'serves SPA', 'proxies /api/* and /graphql', 'owns auth/session flow'], + exposes: ['GET /whoami', 'POST /auth/login', 'POST /auth/logout', '/api/*', '/graphql'], + consumes: ['read token file', 'Hasura upstream', 'trade-api upstream'], + payloads: ['HTML', 'session cookie', 'proxied API JSON', 'proxied GraphQL WS'], + }, + }, + { + id: 'api', + type: 'service', + position: { x: 640, y: 40 }, + data: { + title: 'trade-api', + subtitle: 'REST application API', + lane: 'api', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['ClusterIP :8787', 'GET /v1/chart', 'GET /v1/ticks', 'POST /v1/ingest/tick', 'admin token endpoints'], + exposes: ['/v1/chart', '/v1/ticks', '/v1/ingest/tick', '/v1/admin/tokens'], + consumes: ['Hasura GraphQL', 'admin secret', 'API tokens'], + payloads: ['candles JSON', 'tick JSON', 'token admin JSON'], + }, + }, + { + id: 'hasura', + type: 'service', + position: { x: 640, y: 300 }, + data: { + title: 'Hasura', + subtitle: 'query + subscription plane', + lane: 'api', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['ClusterIP :8080', 'GraphQL HTTP', 'GraphQL WS subscriptions', 'feeds DLOB widgets to UI'], + exposes: ['/v1/graphql via frontend proxy'], + consumes: ['Postgres', 'derived DLOB tables/views'], + payloads: ['GraphQL query results', 'subscription rows'], + }, + }, + { + id: 'postgres', + type: 'service', + position: { x: 1000, y: 80 }, + data: { + title: 'Postgres', + subtitle: 'stateful storage', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['StatefulSet + headless service', 'stores drift_ticks', 'stores API tokens', 'backing DB for Hasura'], + exposes: ['TCP 5432 inside cluster'], + consumes: ['writes from trade-api / ingest path'], + payloads: ['rows', 'materialized / derived DLOB data'], + }, + }, + { + id: 'redis', + type: 'service', + position: { x: 1000, y: 320 }, + data: { + title: 'dlob-redis', + subtitle: 'fast DLOB state', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['ClusterIP :6379', 'ephemeral state for DLOB', 'fast cache and pub/sub style fanout'], + exposes: ['TCP 6379 inside cluster'], + consumes: ['dlob-publisher writes'], + payloads: ['serialized orderbook state'], + }, + }, + { + id: 'ingestor', + type: 'service', + position: { x: 640, y: 560 }, + data: { + title: 'trade-ingestor', + subtitle: 'app data ingest', + lane: 'api', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['background worker', 'normalizes incoming data', 'writes application data to DB'], + consumes: ['internal producers', 'market feeds'], + payloads: ['normalized tick-like records'], + }, + }, + { + id: 'publisher', + type: 'service', + position: { x: 1000, y: 560 }, + data: { + title: 'dlob-publisher', + subtitle: 'Drift / DLOB bootstrap', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'warn', + bullets: ['uses Yellowstone mode', 'still depends on Agave RPC reads', 'publishes DLOB state into Redis'], + consumes: ['Agave getAccountInfo/getMultipleAccounts', 'Yellowstone gRPC'], + payloads: ['Redis-ready DLOB snapshots'], + }, + }, + { + id: 'server', + type: 'service', + position: { x: 1320, y: 320 }, + data: { + title: 'dlob-server', + subtitle: 'orderbook read service', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'warn', + bullets: ['ClusterIP :6969', 'serves DLOB read-side', 'depends on Redis state populated by publisher'], + exposes: ['HTTP 6969 inside cluster'], + consumes: ['Redis', 'worker-derived state'], + payloads: ['orderbook views for internal consumers'], + }, + }, + { + id: 'workers', + type: 'service', + position: { x: 1320, y: 560 }, + data: { + title: 'DLOB workers', + subtitle: 'depth / slippage / orderbook projections', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['dlob-worker', 'dlob-depth-worker', 'dlob-slippage-worker', 'compute tables/views for subscriptions'], + consumes: ['Redis state', 'DLOB base data'], + payloads: ['depth bands', 'slippage rows', 'latest stats'], + }, + }, + { + id: 'agave', + type: 'service', + position: { x: 640, y: 860 }, + data: { + title: 'Agave RPC', + subtitle: 'validator source of truth', + lane: 'chain', + host: 'mevnode_sol', + bullets: ['JSON-RPC point reads', 'getAccountInfo / getMultipleAccounts', 'getHealth / getSlot', 'local write path later via proxy'], + exposes: ['127.0.0.1:8899', '10.91.0.1:8899 proxy path'], + consumes: ['snapshot, ledger, accounts db'], + payloads: ['JSON-RPC request/response'], + }, + }, + { + id: 'yellowstone', + type: 'service', + position: { x: 1000, y: 860 }, + data: { + title: 'Yellowstone gRPC', + subtitle: 'live feed over wg0', + lane: 'chain', + host: 'mevnode_sol', + status: 'ok', + bullets: ['10.91.0.1:10000', 'accounts / tx / slots stream', 'token-protected live stream'], + exposes: ['gRPC stream endpoint'], + consumes: ['Agave Geyser plugin'], + payloads: ['account updates', 'slot updates', 'transactions'], + }, + }, + { + id: 'strategy', + type: 'service', + position: { x: 1320, y: 60 }, + data: { + title: 'strategy-engine + tx-router', + subtitle: 'execution plane', + lane: 'exec', + host: 'mevnode_bot future execution stack', + bullets: ['consumes live data', 'decides actions', 'chooses RPC / TPU / Jito write path'], + consumes: ['Yellowstone consumer', 'DLOB state', 'risk rules'], + payloads: ['signed tx intents', 'write path selection'], + }, + }, +]; + +function zoneNodes(isUltraWide: boolean): Node[] { + if (isUltraWide) { + return [ + { + id: 'zone-external', + type: 'zone', + position: { x: -10, y: 220 }, + draggable: false, + selectable: false, + data: { + title: 'external', + subtitle: 'browser / operator outside cluster', + tone: 'host', + }, + style: { width: 270, height: 405, zIndex: 0 }, + }, + { + id: 'zone-bot', + type: 'zone', + position: { x: 250, y: 30 }, + draggable: false, + selectable: false, + data: { + title: 'mevnode_bot', + subtitle: 'single-node k3s / web + api + data + execution', + tone: 'host', + }, + style: { width: 1560, height: 930, zIndex: 0 }, + }, + { + id: 'zone-trade-staging', + type: 'zone', + position: { x: 290, y: 110 }, + draggable: false, + selectable: false, + data: { + title: 'trade-staging namespace', + subtitle: 'runtime workloads currently deployed in k3s', + tone: 'namespace', + }, + style: { width: 1490, height: 815, zIndex: 1 }, + }, + { + id: 'zone-web-surface', + type: 'zone', + position: { x: 300, y: 170 }, + draggable: false, + selectable: false, + data: { + title: 'web surface', + subtitle: 'public entrypoint and browser-facing flow', + tone: 'subzone', + }, + style: { width: 330, height: 340, zIndex: 2 }, + }, + { + id: 'zone-api-query', + type: 'zone', + position: { x: 670, y: 170 }, + draggable: false, + selectable: false, + data: { + title: 'api / query plane', + subtitle: 'REST, GraphQL, subscriptions, ingest', + tone: 'subzone', + }, + style: { width: 330, height: 770, zIndex: 2 }, + }, + { + id: 'zone-data-plane', + type: 'zone', + position: { x: 1045, y: 170 }, + draggable: false, + selectable: false, + data: { + title: 'data plane', + subtitle: 'persistent state, redis cache, read-side DLOB', + tone: 'subzone', + }, + style: { width: 330, height: 770, zIndex: 2 }, + }, + { + id: 'zone-execution-plane', + type: 'zone', + position: { x: 1420, y: 170 }, + draggable: false, + selectable: false, + data: { + title: 'execution plane', + subtitle: 'strategy, read-side services and tx routing', + tone: 'subzone', + }, + style: { width: 330, height: 770, zIndex: 3 }, + }, + { + id: 'zone-sol', + type: 'zone', + position: { x: 720, y: 960 }, + draggable: false, + selectable: false, + data: { + title: 'mevnode_sol', + subtitle: 'bare metal / validator + geyser + rpc', + tone: 'host', + }, + style: { width: 910, height: 300, zIndex: 0 }, + }, + { + id: 'zone-sol-validator', + type: 'zone', + position: { x: 750, y: 1015 }, + draggable: false, + selectable: false, + data: { + title: 'validator core', + subtitle: 'ledger, accounts db, snapshots, rpc state', + tone: 'subzone', + }, + style: { width: 360, height: 195, zIndex: 2 }, + }, + { + id: 'zone-sol-read', + type: 'zone', + position: { x: 1130, y: 1015 }, + draggable: false, + selectable: false, + data: { + title: 'read interfaces', + subtitle: 'yellowstone gRPC and plugin metrics over wg0', + tone: 'subzone', + }, + style: { width: 360, height: 195, zIndex: 2 }, + }, + ]; + } + + return [ + { + id: 'zone-external', + type: 'zone', + position: { x: -20, y: 160 }, + draggable: false, + selectable: false, + data: { + title: 'external', + subtitle: 'browser / operator', + tone: 'host', + }, + style: { width: 290, height: 350, zIndex: 0 }, + }, + { + id: 'zone-bot', + type: 'zone', + position: { x: 260, y: 10 }, + draggable: false, + selectable: false, + data: { + title: 'mevnode_bot', + subtitle: 'single-node k3s / trade-staging workloads', + tone: 'host', + }, + style: { width: 1500, height: 770, zIndex: 0 }, + }, + { + id: 'zone-trade-staging', + type: 'zone', + position: { x: 290, y: 75 }, + draggable: false, + selectable: false, + data: { + title: 'trade-staging namespace', + subtitle: 'current app runtime in k3s', + tone: 'namespace', + }, + style: { width: 1430, height: 680, zIndex: 1 }, + }, + { + id: 'zone-web-surface', + type: 'zone', + position: { x: 315, y: 120 }, + draggable: false, + selectable: false, + data: { + title: 'web surface', + subtitle: 'frontend and browser-facing auth', + tone: 'subzone', + }, + style: { width: 360, height: 250, zIndex: 2 }, + }, + { + id: 'zone-api-query', + type: 'zone', + position: { x: 700, y: 120 }, + draggable: false, + selectable: false, + data: { + title: 'api / query plane', + subtitle: 'REST, GraphQL, subscriptions', + tone: 'subzone', + }, + style: { width: 360, height: 640, zIndex: 2 }, + }, + { + id: 'zone-data-plane', + type: 'zone', + position: { x: 1080, y: 120 }, + draggable: false, + selectable: false, + data: { + title: 'data plane', + subtitle: 'DB, redis and DLOB read-side', + tone: 'subzone', + }, + style: { width: 600, height: 640, zIndex: 2 }, + }, + { + id: 'zone-execution-plane', + type: 'zone', + position: { x: 1295, y: 120 }, + draggable: false, + selectable: false, + data: { + title: 'execution plane', + subtitle: 'future strategy + tx routing', + tone: 'subzone', + }, + style: { width: 355, height: 250, zIndex: 3 }, + }, + { + id: 'zone-sol', + type: 'zone', + position: { x: 690, y: 830 }, + draggable: false, + selectable: false, + data: { + title: 'mevnode_sol', + subtitle: 'bare metal validator host', + tone: 'host', + }, + style: { width: 850, height: 280, zIndex: 0 }, + }, + { + id: 'zone-sol-validator', + type: 'zone', + position: { x: 720, y: 885 }, + draggable: false, + selectable: false, + data: { + title: 'validator core', + subtitle: 'ledger, snapshot, accounts db, rpc', + tone: 'subzone', + }, + style: { width: 380, height: 190, zIndex: 2 }, + }, + { + id: 'zone-sol-read', + type: 'zone', + position: { x: 1120, y: 885 }, + draggable: false, + selectable: false, + data: { + title: 'read interfaces', + subtitle: 'yellowstone and metrics over wg0', + tone: 'subzone', + }, + style: { width: 390, height: 190, zIndex: 2 }, + }, + ]; +} + +const baseEdges: Edge[] = [ + { + id: 'browser-frontend', + source: 'browser', + target: 'frontend', + label: 'SPA + auth', + data: { path: 'control', description: 'browser loads SPA and keeps session cookie' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'frontend-api', + source: 'frontend', + target: 'api', + label: '/api/v1/chart /v1/ticks', + data: { path: 'read', description: 'frontend proxies REST calls and injects read token' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'frontend-hasura', + source: 'frontend', + target: 'hasura', + label: '/graphql + WS', + data: { path: 'live', description: 'frontend proxies GraphQL HTTP and websocket subscriptions' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'api-postgres', + source: 'api', + target: 'postgres', + label: 'Hasura GraphQL', + data: { path: 'read', description: 'trade-api reads and writes through Hasura into Postgres' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'hasura-postgres', + source: 'hasura', + target: 'postgres', + label: 'query / subscription backing', + data: { path: 'read', description: 'Hasura uses Postgres as source for queries and subscriptions' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'ingestor-postgres', + source: 'ingestor', + target: 'postgres', + label: 'normalized writes', + data: { path: 'control', description: 'ingestor writes normalized data into database' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'publisher-redis', + source: 'publisher', + target: 'redis', + label: 'DLOB state', + data: { path: 'live', description: 'publisher pushes Redis state used by read-side DLOB services' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'redis-server', + source: 'redis', + target: 'server', + label: 'read-side cache', + data: { path: 'read', description: 'dlob-server reads from Redis state' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'redis-workers', + source: 'redis', + target: 'workers', + label: 'worker inputs', + data: { path: 'read', description: 'workers consume Redis DLOB state to derive projections' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'workers-hasura', + source: 'workers', + target: 'hasura', + label: 'derived views', + data: { path: 'live', description: 'workers feed tables or views consumed by Hasura subscriptions' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'agave-publisher', + source: 'agave', + target: 'publisher', + label: 'JSON-RPC reads', + data: { path: 'read', description: 'publisher needs point reads from Agave RPC' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'yellowstone-publisher', + source: 'yellowstone', + target: 'publisher', + label: 'gRPC stream', + data: { path: 'live', description: 'publisher uses Yellowstone live stream mode' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'yellowstone-strategy', + source: 'yellowstone', + target: 'strategy', + label: 'future live feed', + data: { path: 'live', description: 'strategy layer consumes Yellowstone for low-latency decisions' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'server-frontend', + source: 'server', + target: 'frontend', + label: 'internal read path', + data: { path: 'read', description: 'frontend can eventually consume internal DLOB read-side data through API' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, +]; + +const nodeTitleById: Record = Object.fromEntries( + baseNodes.map((node) => [node.id, node.data.title]) +) as Record; + +function handleOffset(index: number) { + return `${34 + index * 20}px`; +} + +function buildHandleMaps(edges: Edge[]) { + const incoming = new Map(); + const outgoing = new Map(); + + for (const edge of edges) { + const sourceSpecs = outgoing.get(edge.source) || []; + sourceSpecs.push({ + id: `source-${edge.id}`, + top: handleOffset(sourceSpecs.length), + color: edgeColor(edge.data.path), + }); + outgoing.set(edge.source, sourceSpecs); + + const targetSpecs = incoming.get(edge.target) || []; + targetSpecs.push({ + id: `target-${edge.id}`, + top: handleOffset(targetSpecs.length), + color: edgeColor(edge.data.path), + }); + incoming.set(edge.target, targetSpecs); + } + + return { incoming, outgoing }; +} + +const routeHandles = buildHandleMaps(baseEdges); + +function edgeColor(path: PathType) { + switch (path) { + case 'read': + return '#1f6f8b'; + case 'live': + return '#b04f35'; + case 'write': + return '#7a6213'; + case 'control': + return '#4b5563'; + default: + return '#4b5563'; + } +} + +function withFilter( + items: T[], + activePath: 'all' | PathType +) { + return items.map((item) => { + const path = item.data?.path; + const dimmed = activePath !== 'all' && path && path !== activePath; + return { + ...item, + style: { + ...(item.style || {}), + opacity: dimmed ? 0.2 : 1, + }, + }; + }); +} + +export function TradeSystemFlowPage() { + const [selectedId, setSelectedId] = useState('frontend'); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [activePath, setActivePath] = useState<'all' | PathType>('all'); + const [isUltraWide, setIsUltraWide] = useState(() => + typeof window !== 'undefined' ? window.innerWidth >= 1900 : false + ); + + useEffect(() => { + if (typeof window === 'undefined') return; + const onResize = () => setIsUltraWide(window.innerWidth >= 1900); + onResize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const selectedNode = useMemo( + () => baseNodes.find((node) => node.id === selectedId) || baseNodes[0], + [selectedId] + ); + + const selectedEdge = useMemo( + () => (selectedEdgeId ? baseEdges.find((edge) => edge.id === selectedEdgeId) || null : null), + [selectedEdgeId] + ); + + const selectedRoutes = useMemo( + () => + baseEdges + .filter((edge) => edge.source === selectedId || edge.target === selectedId) + .map((edge) => { + const outgoing = edge.source === selectedId; + const peerId = outgoing ? edge.target : edge.source; + return { + id: edge.id, + direction: outgoing ? 'out' : 'in', + path: edge.data.path, + peerTitle: nodeTitleById[peerId] || peerId, + label: String(edge.label || ''), + description: edge.data.description, + }; + }) + .sort((a, b) => { + if (a.direction === b.direction) return a.peerTitle.localeCompare(b.peerTitle); + return a.direction === 'out' ? -1 : 1; + }), + [selectedId] + ); + + const nodes = useMemo( + () => [ + ...zoneNodes(isUltraWide), + ...baseNodes.map((node) => ({ + ...node, + position: isUltraWide ? ultraWidePositions[node.id] || node.position : node.position, + selected: node.id === selectedId, + style: { + zIndex: 10, + opacity: activePath === 'all' || node.id === selectedId ? 1 : 0.95, + }, + })), + ], + [activePath, isUltraWide, selectedId] + ); + + const edges = useMemo( + () => { + const mapped = baseEdges.map((edge) => { + const isSelected = edge.id === selectedEdgeId; + const isConnected = edge.source === selectedId || edge.target === selectedId; + const matchesPath = activePath === 'all' || edge.data.path === activePath; + return { + ...edge, + type: 'routed', + sourceHandle: `source-${edge.id}`, + targetHandle: `target-${edge.id}`, + label: isSelected || isConnected ? edge.label : undefined, + style: { + stroke: edgeColor(edge.data.path), + strokeWidth: isSelected ? 4.4 : isConnected ? 3.1 : 1.8, + opacity: matchesPath ? (isSelected ? 1 : isConnected ? 0.96 : 0.4) : 0.08, + }, + labelStyle: { + fill: '#f3efe8', + fontSize: isSelected ? 13 : 12, + fontWeight: 800, + }, + labelBgStyle: { + fill: edgeColor(edge.data.path), + opacity: isSelected ? 1 : 0.92, + }, + labelBgPadding: [7, 4] as [number, number], + labelBgBorderRadius: 10, + interactionWidth: 28, + }; + }); + + return mapped.sort((a, b) => { + const weight = (edge: (typeof mapped)[number]) => { + const selected = edge.id === selectedEdgeId ? 100 : 0; + const connected = edge.source === selectedId || edge.target === selectedId ? 10 : 0; + const pathMatch = activePath === 'all' || edge.data.path === activePath ? 1 : 0; + return selected + connected + pathMatch; + }; + return weight(a) - weight(b); + }); + }, + [activePath, selectedEdgeId, selectedId] + ); + + return ( +
+
+
+
+
+
runtime architecture / react flow
+
Trade system flow
+
endpoints, payloads, read paths, live feeds
+
+
+ +
+ {(['all', 'read', 'live', 'control', 'write'] as const).map((path) => ( + + ))} +
+ +
+
+ B + bot +
+
+ S + sol +
+
+ K + k3s +
+
+
+ +
+
+ { + setSelectedId(node.id); + setSelectedEdgeId(null); + }} + onEdgeClick={(_, edge) => setSelectedEdgeId(edge.id)} + onPaneClick={() => setSelectedEdgeId(null)} + proOptions={{ hideAttribution: true }} + elevateEdgesOnSelect + minZoom={0.55} + maxZoom={1.35} + fitViewOptions={ + isUltraWide + ? { padding: 0.015, minZoom: 0.9, maxZoom: 1.25 } + : { padding: 0.07, minZoom: 0.72, maxZoom: 1.2 } + } + defaultEdgeOptions={{ + type: 'routed', + markerEnd: { type: MarkerType.ArrowClosed }, + }} + > + + laneColors[(node.data as FlowNodeData).lane]} + maskColor="rgba(11, 13, 18, 0.54)" + /> + + +
lanes
+
external
+
web entry
+
api / graphql
+
data plane
+
chain access
+
execution
+
+
+
+ + +
+
+ ); +} diff --git a/apps/visualizer/vite.config.ts b/apps/visualizer/vite.config.ts index 9b42296..8f68509 100644 --- a/apps/visualizer/vite.config.ts +++ b/apps/visualizer/vite.config.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; const DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -14,8 +14,8 @@ function stripTrailingSlashes(p: string): string { return out || '/'; } -function readApiReadToken(): string | undefined { - if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN; +function readApiReadToken(env: Record): string | undefined { + if (env.API_READ_TOKEN) return env.API_READ_TOKEN; const p = path.join(ROOT, 'tokens', 'read.json'); if (!fs.existsSync(p)) return undefined; try { @@ -38,11 +38,11 @@ function parseBasicAuth(value: string | undefined): BasicAuth | undefined { return { username, password }; } -function readProxyBasicAuth(): BasicAuth | undefined { - const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH); +function readProxyBasicAuth(env: Record): BasicAuth | undefined { + const fromEnv = parseBasicAuth(env.API_PROXY_BASIC_AUTH); if (fromEnv) return fromEnv; - const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim(); + const fileRaw = String(env.API_PROXY_BASIC_AUTH_FILE || '').trim(); if (!fileRaw) return undefined; const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw); @@ -59,10 +59,6 @@ function readProxyBasicAuth(): BasicAuth | undefined { } } -const apiReadToken = readApiReadToken(); -const proxyBasicAuth = readProxyBasicAuth(); -const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787'; - function parseUrl(v: string): URL | undefined { try { return new URL(v); @@ -71,10 +67,6 @@ function parseUrl(v: string): URL | undefined { } } -const apiProxyTargetUrl = parseUrl(apiProxyTarget); -const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/'); -const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api'); - function inferUiProxyTarget(apiTarget: string): string | undefined { try { const u = new URL(apiTarget); @@ -91,15 +83,6 @@ function inferUiProxyTarget(apiTarget: string): string | undefined { } } -const uiProxyTarget = - process.env.FRONTEND_PROXY_TARGET || - process.env.UI_PROXY_TARGET || - process.env.AUTH_PROXY_TARGET || - inferUiProxyTarget(apiProxyTarget) || - (apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined); - -const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget; - function applyProxyBasicAuth(proxyReq: any) { if (!proxyBasicAuth) return false; const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64'); @@ -119,60 +102,88 @@ function rewriteSetCookieForLocalDevHttp(proxyRes: any) { proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v)); } -const proxy: Record = { - '/api': { - target: apiProxyTarget, - changeOrigin: true, - rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p), - configure: (p: any) => { - p.on('proxyReq', (proxyReq: any) => { - if (applyProxyBasicAuth(proxyReq)) return; - if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`); - }); +export default defineConfig(({ mode }) => { + const env = { ...process.env, ...loadEnv(mode, DIR, '') } as Record; + const apiReadToken = readApiReadToken(env); + const proxyBasicAuth = readProxyBasicAuth(env); + const apiProxyTarget = env.API_PROXY_TARGET || 'http://localhost:8787'; + const apiProxyTargetUrl = parseUrl(apiProxyTarget); + const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/'); + const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api'); + const uiProxyTarget = + env.FRONTEND_PROXY_TARGET || + env.UI_PROXY_TARGET || + env.AUTH_PROXY_TARGET || + inferUiProxyTarget(apiProxyTarget) || + (apiProxyTargetUrl && apiProxyTargetPath === '/' + ? stripTrailingSlashes(apiProxyTargetUrl.toString()) + : undefined); + const graphqlProxyTarget = env.GRAPHQL_PROXY_TARGET || env.HASURA_PROXY_TARGET || uiProxyTarget; + const portRaw = env.VITE_DEV_PORT || env.DEV_PORT || '5173'; + const port = Number.parseInt(String(portRaw), 10); + + function applyProxyBasicAuth(proxyReq: any) { + if (!proxyBasicAuth) return false; + const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64'); + proxyReq.setHeader('Authorization', `Basic ${b64}`); + return true; + } + + const proxy: Record = { + '/api': { + target: apiProxyTarget, + changeOrigin: true, + rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p), + configure: (p: any) => { + p.on('proxyReq', (proxyReq: any) => { + if (applyProxyBasicAuth(proxyReq)) return; + if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`); + }); + }, }, - }, -}; + }; -if (graphqlProxyTarget) { - for (const prefix of ['/graphql', '/graphql-ws']) { - proxy[prefix] = { - target: graphqlProxyTarget, - changeOrigin: true, - ws: true, - configure: (p: any) => { - p.on('proxyReq', (proxyReq: any) => { - applyProxyBasicAuth(proxyReq); - }); - p.on('proxyReqWs', (proxyReq: any) => { - applyProxyBasicAuth(proxyReq); - }); - }, - }; + if (graphqlProxyTarget) { + for (const prefix of ['/graphql', '/graphql-ws']) { + proxy[prefix] = { + target: graphqlProxyTarget, + changeOrigin: true, + ws: true, + configure: (p: any) => { + p.on('proxyReq', (proxyReq: any) => { + applyProxyBasicAuth(proxyReq); + }); + p.on('proxyReqWs', (proxyReq: any) => { + applyProxyBasicAuth(proxyReq); + }); + }, + }; + } } -} -if (uiProxyTarget) { - for (const prefix of ['/whoami', '/auth', '/logout']) { - proxy[prefix] = { - target: uiProxyTarget, - changeOrigin: true, - configure: (p: any) => { - p.on('proxyReq', (proxyReq: any) => { - applyProxyBasicAuth(proxyReq); - }); - p.on('proxyRes', (proxyRes: any) => { - rewriteSetCookieForLocalDevHttp(proxyRes); - }); - }, - }; + if (uiProxyTarget) { + for (const prefix of ['/whoami', '/auth', '/logout']) { + proxy[prefix] = { + target: uiProxyTarget, + changeOrigin: true, + configure: (p: any) => { + p.on('proxyReq', (proxyReq: any) => { + applyProxyBasicAuth(proxyReq); + }); + p.on('proxyRes', (proxyRes: any) => { + rewriteSetCookieForLocalDevHttp(proxyRes); + }); + }, + }; + } } -} -export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - strictPort: true, - proxy, - }, + return { + plugins: [react()], + server: { + port: Number.isInteger(port) && port > 0 ? port : 5173, + strictPort: true, + proxy, + }, + }; }); diff --git a/doc/data-ingest-strategy.md b/doc/data-ingest-strategy.md new file mode 100644 index 0000000..c05c3c2 --- /dev/null +++ b/doc/data-ingest-strategy.md @@ -0,0 +1,344 @@ +# Data ingest strategy + +## Cel + +Zachowac niski latency dla logiki tradingowej i read-side, ale rownoczesnie miec trwala persystencje danych do UI, replayu, debugowania i analityki. + +Nie robimy modelu `DB-first` jako jedynej sciezki przetwarzania. + +## Rekomendowany model + +Stosujemy `dual-path`: + +1. `hot path` +`Yellowstone gRPC / Agave RPC -> consumer -> Redis / internal state -> strategy / read-side` + +2. `durable path` +`ten sam consumer -> Postgres -> UI / Hasura / replay / debug` + +Opcjonalnie pozniej: + +3. `history / analytics path` +`consumer lub batch exporter -> ClickHouse` + +## Dlaczego nie DB-first + +Model: + +`gRPC / RPC -> DB -> dopiero potem przetwarzanie` + +nie jest dobry jako glowna sciezka, bo: + +- dodaje latency do kazdego eventu +- uzaleznia ingest od kondycji bazy +- pogarsza odpornosc calego pipeline +- zwieksza write amplification przy duzym wolumenie zdarzen +- utrudnia low-latency strategie i read-side + +DB ma byc miejscem persystencji i read modelu, nie bramka przed logika runtime. + +## Rola Yellowstone gRPC + +Yellowstone ma byc glownym zrodlem `live feed`: + +- account updates +- tx updates +- slot updates +- program updates + +Sciezka: + +`Yellowstone -> consumer -> hot state` + +oraz rownolegle: + +`Yellowstone -> normalization / persistence -> Postgres` + +Yellowstone nie powinien byc uzalezniony od tego, czy Postgres aktualnie pisze szybko. + +## Rola Agave RPC + +RPC nie jest naturalnym feedem eventowym. + +RPC powinno sluzyc glownie do: + +- point reads +- bootstrapu klienta +- recovery +- getAccountInfo / getMultipleAccounts +- getHealth / getSlot +- write path dla tx, jesli nie idziemy przez TPU/Jito + +RPC nie powinno byc traktowane jak glowny event stream do zrzucania wszystkiego do DB. + +## Rola Redis + +Redis powinien byc `hot state layer`: + +- szybki stan DLOB +- cache +- fanout wewnetrzny +- ewentualnie stream / queue dla workerow + +To jest warstwa pod: + +- strategy engine +- orderbook read-side +- szybkie projekcje + +Nie trzymamy tam historii jako source of truth. + +## Rola Postgres + +Postgres powinien byc `durable read model`: + +- dane dla UI +- dane dla Hasury +- znormalizowane ticki +- pochodne tabele DLOB +- stan aplikacji +- konfiguracja +- historia operacyjna + +Postgres jest dobry do: + +- frontend +- dashboard +- replay lekkiego zakresu +- audyt aplikacyjny + +## Kiedy ClickHouse + +ClickHouse dodajemy dopiero, gdy: + +- wolumen raw feedu bedzie duzy +- potrzebna bedzie ciezsza analityka historyczna +- bedziemy chcieli trzymac dluzsza historie eventow i tx + +Na obecnym etapie nie jest konieczny do uruchomienia runtime. + +## Docelowy przeplyw + +```text +mevnode_sol + Agave RPC + Yellowstone gRPC + | + v +mevnode_bot + consumer + |\ + | +--> Redis -> DLOB/read-side -> strategy/workers + | + +----> Postgres -> Hasura/API -> frontend +``` + +## Zasada architektoniczna + +- `gRPC / RPC` dostarcza dane do runtime +- `Redis` utrzymuje szybki stan operacyjny +- `Postgres` utrzymuje trwaly stan i read model +- `frontend` i `Hasura` czytaja z Postgresa +- `strategy` i `workers` nie czekaja na zapis do DB, aby przetwarzac dane + +## Rekomendacja dla projektu + +Na teraz: + +1. `Yellowstone -> consumer -> Redis` +2. `consumer -> Postgres` +3. `Hasura / API / UI <- Postgres` +4. `strategy / read-side <- Redis` +5. `RPC` zostawic do bootstrapu, point reads i write path + +To daje najlepszy kompromis: + +- niski latency +- odpornosc runtime +- sensowna persystencja +- dobra baza pod UI i replay + +## Ocena obecnego ukladu na k3s + +Obecny runtime na `mevnode_bot / trade-staging` jest tylko czesciowo zgodny z docelowym modelem. + +### Co jest zgodne + +- `postgres` jest postawiony jako `StatefulSet`, wiec nadaje sie na trwały storage +- `dlob-redis` jest osobna lekka usluga i pasuje do roli cache / hot state +- `hasura` i `trade-api` siedza nad `Postgresem`, co jest poprawne dla durable read modelu +- `frontend` korzysta z `API/Hasury`, a nie bezposrednio z `Redis` + +### Co nie jest zgodne + +- `dlob-publisher` i `dlob-server` nie sa zdrowe, wiec hot path nie jest operacyjnie domkniety +- `dlob-publisher` jest podpiety do `gRPC`, ale nie jest glownym i pewnym writerem do `Postgresa` +- `trade-ingestor` nie ingestuje danych bezposrednio z chain source, tylko polluje dane pochodne z `Hasury` +- persistent path jest wiec dzisiaj czesciowo odwrocony: + - `derived view -> ingest -> Postgres` + - zamiast: + - `chain source -> normalize -> Postgres` + +## Plan zmian + +### Cel koncowy + +Chcemy dojsc do ukladu: + +```text +Yellowstone gRPC / Agave RPC + | + v +consumer + |\ + | +--> Redis -> hot state / workers / strategy + | + +----> Postgres -> Hasura / API / frontend / replay +``` + +### Etap 1. Naprawa hot path + +Najpierw trzeba przywrocic dzialanie: + +- `dlob-publisher` +- `dlob-server` + +Bez zdrowego publishera nie ma sensu budowac dalszej warstwy `Redis = hot state`. + +Zakres: + +- ustabilizowac `Agave RPC` i `Yellowstone` +- doprowadzic `dlob-publisher` do `Ready` +- potwierdzic, ze `Redis` dostaje aktualny stan +- doprowadzic `dlob-server` do `Ready` + +### Etap 2. Uczynic publisher glownym consumerem danych chain + +Obecnie `trade-ingestor` korzysta z danych pochodnych z `Hasury`. + +To trzeba zmienic tak, aby glownym miejscem wejscia danych byl komponent konsumujacy: + +- `Yellowstone gRPC` +- `Agave RPC` + +Rekomendacja: + +- rozszerzyc `dlob-publisher` +- albo wydzielic nowy `market-consumer` + +ale w obu przypadkach komponent ma robic: + +- odczyt live z `Yellowstone` +- wymagane point reads z `RPC` +- normalizacje danych +- jednoczesny zapis do `Redis` i `Postgresa` + +### Etap 3. Ustalic twardy podzial rol Redis / Postgres + +#### Redis + +Do `Redis` trafia tylko to, co potrzebne jako szybki stan runtime: + +- latest DLOB state +- latest L2 +- quick cache dla workerow +- fanout do read-side i strategii +- dane z TTL lub dane odtwarzalne + +`Redis` nie jest source of truth. + +#### Postgres + +Do `Postgresa` trafia trwaly model danych: + +- znormalizowane ticki +- znormalizowane snapshoty rynku +- pochodne tabele pod `Hasure` +- historia operacyjna +- stan aplikacji + +`Postgres` jest source of truth dla aplikacji i UI. + +### Etap 4. Przebudowac worker path + +Docelowy model dla workerow: + +- `Redis in` +- `Postgres out` + +To znaczy: + +- workery czytaja szybki stan z `Redis` +- licza projekcje +- zapisują wynik do `Postgresa` + +Dotyczy to szczegolnie: + +- `dlob-worker` +- `dlob-depth-worker` +- `dlob-slippage-worker` + +### Etap 5. Wygasic obecny odwrocony ingest + +Obecny `trade-ingestor` zapisuje dane do `Postgresa` na podstawie danych juz pochodnych z `Hasury`. + +To nie powinno byc glowna sciezka ingestu. + +Mozliwe role po zmianie: + +- backfill +- sanity check +- fallback +- testowy importer + +Ale nie: + +- glowny chain ingest + +### Etap 6. Utrwalic read model pod UI + +Po zmianach: + +- `Hasura` czyta tylko z `Postgresa` +- `trade-api` czyta tylko z `Postgresa` +- `frontend` czyta przez `API/Hasure` + +Frontend nie powinien zalezec od `Redis`. + +### Etap 7. Zostawic RPC w waskiej roli + +`Agave RPC` zostawiamy do: + +- bootstrapu +- point reads +- recovery +- write path dla tx + +Nie robimy z `RPC` glownej magistrali trwałego ingestu. + +## Plan wdrozenia krok po kroku + +1. Naprawic `dlob-publisher` i `dlob-server`. +2. Potwierdzic, ze `Redis` jest zasilany poprawnym hot state. +3. Rozszerzyc publisher albo dodac nowy consumer tak, aby pisal rownolegle do: + - `Redis` + - `Postgresa` +4. Zdefiniowac schemat tabel normalized data w `Postgresie`. +5. Przepiac workery na model: + - read z `Redis` + - write do `Postgresa` +6. Zostawic `Hasure` i `trade-api` jako warstwe odczytowa nad `Postgresem`. +7. Zdegradowac `trade-ingestor` z roli glownego ingestu do roli pomocniczej albo go usunac z glownej sciezki. + +## Rekomendacja praktyczna + +Najmniejsza sensowna zmiana to nie budowac od razu nowego systemu od zera, tylko: + +1. naprawic obecny `dlob-publisher` +2. dodac mu zapis do `Postgresa` +3. stopniowo wyprowadzac `trade-ingestor` z glownego path + +To jest najmniej ryzykowna droga do modelu: + +- `Redis = cache / hot state` +- `Postgres = persistent normalized store` diff --git a/doc/mevnode-bot-architecture.md b/doc/mevnode-bot-architecture.md new file mode 100644 index 0000000..3f8da00 --- /dev/null +++ b/doc/mevnode-bot-architecture.md @@ -0,0 +1,245 @@ +# Mevnode Bot Architecture + +## Zasada glowna + +`mevnode_bot` nigdy nie uruchamia warstwy Solana node: + +- bez `agave` +- bez `ledger` +- bez `yellowstone server` +- bez validator stack + +`mevnode_sol` pozostaje jedynym hostem node-level: + +- `agave-validator` +- `yellowstone geyser plugin` +- `yellowstone gRPC` +- plugin metrics +- lokalny RPC +- opcjonalnie pozniej RPC proxy + +## Schemat `mevnode_bot` + +```text ++--------------------------------------------------------------+ +| mevnode_bot | +| single-node k3s only | +| | +| ingress/web | +| - frontend | +| - api | +| | +| trading/read | +| - yellowstone-consumer | +| - market ingest / parser | +| - dlob-worker | +| | +| trading/core | +| - strategy-engine | +| - risk-manager | +| - order-manager | +| - tx-router | +| | +| tx paths | +| - tpu client | +| - jito client | +| - rpc client / later rpc proxy target on mevnode_sol | +| | +| data | +| - postgres | +| - redis optional | +| - clickhouse later optional | +| | +| ops | +| - prometheus | +| - grafana | +| - loki optional | +| | +| platform | +| - argo/flux | +| - sealed-secrets or sops | +| - internal registry access | ++--------------------------------------------------------------+ +``` + +## Przeplyw danych + +```text +Yellowstone on mevnode_sol + | + v +yellowstone-consumer + | + v +parser / ingest / dlob-worker + | + v +strategy-engine + | + v +risk-manager / order-manager + | + v +tx-router + | | | + v v v + TPU Jito RPC +``` + +## Namespace plan + +- `web` +- `trade` +- `data` +- `ops` +- `infra` + +## Kolejnosc wdrozenia + +1. `web`: `frontend`, `api` +2. `data`: `postgres` +3. `trade`: `yellowstone-consumer` +4. `trade`: `strategy-engine`, `tx-router` +5. `ops`: `prometheus`, `grafana` + +## Read i write path + +Read path: + +- `mevnode_bot -> wg0 -> mevnode_sol:10000` + +Metrics path: + +- `mevnode_bot -> wg0 -> mevnode_sol:8999` + +Write path pozniej: + +- `mevnode_bot -> TPU/Jito` +- albo `mevnode_bot -> RPC proxy -> mevnode_sol:127.0.0.1:8899` + +## Zasada architektoniczna + +- `mevnode_sol` = source of truth dla chain access +- `mevnode_bot` = execution and product node + +## K3s object map + +### Namespace `web` + +- `Deployment` `frontend` +- `Service` `frontend` typu `ClusterIP` +- `Deployment` `api` +- `Service` `api` typu `ClusterIP` +- `Ingress` `web` + +Przeznaczenie: + +- `frontend` wystawia UI +- `api` wystawia backend HTTP +- `Ingress` publikuje tylko web entrypoint + +### Namespace `trade` + +- `Deployment` `yellowstone-consumer` +- `Deployment` `dlob-worker` +- `Deployment` `strategy-engine` +- `Deployment` `risk-manager` +- `Deployment` `order-manager` +- `Deployment` `tx-router` +- `Service` `strategy-engine` typu `ClusterIP` jesli inne komponenty maja sie do niego odwolywac +- `Service` `tx-router` typu `ClusterIP` tylko jesli potrzebne bedzie sterowanie po HTTP/gRPC + +Przeznaczenie: + +- `yellowstone-consumer` czyta stream z `mevnode_sol` +- `dlob-worker` robi ingest i przygotowanie danych +- `strategy-engine` podejmuje decyzje +- `risk-manager` egzekwuje limity +- `order-manager` sklada i nadzoruje lifecycle zlecen +- `tx-router` wybiera `TPU`, `Jito` albo `RPC` + +Uwagi: + +- `strategy-engine`, `order-manager` i `tx-router` mozna uruchamiac z `hostNetwork: true`, jesli bedzie potrzeba minimalizacji narzutu sieciowego +- `yellowstone-consumer` powinien miec `Secret` z `x-token` i endpointem `10.91.0.1:10000` + +### Namespace `data` + +- `StatefulSet` `postgres` +- `Service` `postgres` typu `ClusterIP` +- `PersistentVolumeClaim` dla `postgres` +- `StatefulSet` `redis` opcjonalnie +- `Service` `redis` typu `ClusterIP` +- `PersistentVolumeClaim` dla `redis` tylko jesli ma byc trwaly cache +- `StatefulSet` `clickhouse` dopiero pozniej +- `Service` `clickhouse` typu `ClusterIP` +- `PersistentVolumeClaim` dla `clickhouse` + +Przeznaczenie: + +- `postgres` dla stanu systemu, pozycji, filli, konfiguracji i jobow +- `redis` dla cache, kolejek, lockow i rate limitu +- `clickhouse` dla ciezszej analityki i archiwizacji eventow + +### Namespace `ops` + +- `StatefulSet` `prometheus` +- `Service` `prometheus` typu `ClusterIP` +- `Deployment` `grafana` +- `Service` `grafana` typu `ClusterIP` +- `Deployment` `promtail` opcjonalnie +- `StatefulSet` albo `Deployment` `loki` opcjonalnie + +Przeznaczenie: + +- `prometheus` zbiera metryki z `k3s` i z aplikacji +- `grafana` daje dashboardy +- `loki/promtail` sa opcjonalne do logow + +### Namespace `infra` + +- `Deployment` `argocd-server` albo kontrolery `flux` +- `Deployment` `argocd-repo-server` jesli wybierzemy `Argo CD` +- `Deployment` `sealed-secrets-controller` albo integracja `sops` +- `Secret` / `ExternalSecret` dla tokenow i kluczy + +Przeznaczenie: + +- GitOps sync z Gitea +- zarzadzanie sekretami +- bootstrap aplikacji i manifestow + +## Service exposure rules + +- tylko `frontend` i `api` wychodza przez `Ingress` +- `postgres`, `redis`, `clickhouse`, `strategy-engine`, `tx-router` zostaja wewnetrzne jako `ClusterIP` +- bez `LoadBalancer` +- bez publicznego wystawiania komponentow tradingowych +- `yellowstone-consumer` nie potrzebuje publicznego `Service` + +## Storage rules + +- `postgres` na trwałym wolumenie +- `clickhouse` tylko na szybkim lokalnym storage +- `redis` bez PVC jesli ma byc tylko cache +- aplikacje tradingowe jako stateless `Deployment`, jesli stan trzymamy w `postgres` + +## Config and secrets + +- `ConfigMap` dla endpointow, feature flags i runtime config +- `Secret` dla: + - tokenu Yellowstone + - kluczy API + - danych do `postgres` + - danych do `Jito` + - kluczy signing/delegate + +## Kolejnosc wdrozenia w k3s + +1. `infra`: GitOps i secrets +2. `data`: `postgres` +3. `web`: `frontend`, `api`, `Ingress` +4. `trade`: `yellowstone-consumer` +5. `trade`: `strategy-engine`, `risk-manager`, `order-manager` +6. `trade`: `tx-router` +7. `ops`: `prometheus`, `grafana` diff --git a/doc/mevnode-bot-topology-interactive.html b/doc/mevnode-bot-topology-interactive.html new file mode 100644 index 0000000..82b9f8c --- /dev/null +++ b/doc/mevnode-bot-topology-interactive.html @@ -0,0 +1,1082 @@ + + + + + + mevnode interactive topology + + + +
+
+
+
Interactive topology / click to inspect
+

Interaktywny diagram uslug na mevnode_bot i zaleznosci od mevnode_sol

+

+ Klikaj komponenty albo filtruj sciezki. Diagram rozdziela read plane, + write plane, data plane i ops plane. Celem jest utrzymanie twardego podzialu: + validator zostaje na sol, a produkt, ingest i execution + dzialaja na bot w k3s. +

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

mevnode_sol

+ bare metal +
+ +
+
validator / rpc
+

Agave validator

+

Source of truth dla chain state, snapshotow i lokalnego RPC.

+
+ 127.0.0.1:8899 + getAccountInfo + getMultipleAccounts +
+
+ +
+
geyser / live stream
+

Yellowstone gRPC

+

Pushuje account updates, sloty, tx i block meta po wg0.

+
+ 10.91.0.1:10000 + x-token +
+
+ +
+
monitoring
+

Plugin metrics

+

Metryki pluginu i gRPC do Prometheusa i diagnostyki.

+
+ 10.91.0.1:8999 +
+
+
+ +
+ +
yellowstone / live feed
+ +
rpc / tpu / jito
+ +
+ +
+
+

mevnode_bot

+ single-node k3s +
+ +
+
trade namespace
+

yellowstone-consumer

+

Bierze live feed z Yellowstone i zamienia go na dane wewnetrzne.

+
+ accounts + slots + transactions +
+
+ +
+
trade namespace
+

trade-ingestor

+

Transformuje dane i zapisuje je do DB lub feedow uslugowych.

+
+ +
+
trade namespace
+

dlob-publisher

+

Czyta RPC Agavy i buduje stan DLOB, ktory trafia do Redis.

+
+ getMultipleAccounts + Redis +
+
+ +
+
trade namespace
+

dlob-server

+

Serwuje gotowy widok DLOB dla API i wizualizacji.

+
+ +
+
execution
+

strategy-engine

+

Konsumuje feed i decyduje o akcjach tradingowych.

+
+ +
+
execution
+

tx-router

+

Wybiera write path: RPC, TPU albo Jito.

+
+ +
+
data namespace
+

Postgres + Hasura

+

Trwaly storage, stan aplikacji, historia, pozycje i GraphQL.

+
+ +
+
data namespace
+

Redis

+

Cache, pub/sub i szybki stan DLOB dla serwisow read-side.

+
+ +
+
web namespace
+

Frontend + API + Ingress

+

Warstwa produktu. UI pokazuje dane, API agreguje odpowiedzi dla klienta.

+
+ +
+
ops namespace
+

Prometheus / Grafana / Portainer / GitOps

+

Monitoring, obsluga klastra, deployment z Gitea i obserwowalnosc.

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

k3s object map

+

+ To jest aktualny rozklad obiektow w klastrze mevnode_bot: + workloady, uslugi i typy ekspozycji. Tu widac juz konkretne + Deployment, StatefulSet, ClusterIP, + NodePort i stan gotowosci. +

+
+ +
+
+

kube-system

+ Podstawowe komponenty klastra potrzebne, zeby single-node k3s dzialal stabilnie. + +
+
+
+ metrics-server +
Deployment + ClusterIP
+
+ 1/1 ready +
+

Service metrics-server na porcie 443.

+
+ +
+
+
+ coredns +
Deployment + ClusterIP
+
+ 1/1 ready +
+

Service kube-dns z DNS wewnatrz klastra.

+
+ +
+
+
+ local-path-provisioner +
Deployment
+
+ 1/1 ready +
+

Provisioning lokalnego storage dla PVC na pojedynczym nodzie.

+
+
+ +
+

portainer

+ Most pomiedzy Dockerowym Portainerem na hoscie a lokalnym klastrem k3s. + +
+
+
+ portainer-agent +
Deployment + NodePort
+
+ 1/1 ready +
+

Service portainer-agent wystawiony jako NodePort 30778 -> 9001.

+
+ +
+
+
+ portainer-agent-headless +
Headless Service
+
+ cluster wiring +
+

Uzywany przez agenta do wewnetrznego cluster discovery.

+
+
+ +
+

trade-staging

+ Glowny namespace produktu: web, ingest, DLOB i bazy danych. + +
+
+
+ trade-frontend +
Deployment + NodePort
+
+ 1/1 ready +
+

Service trade-frontend: NodePort 30081 -> 8081.

+
+ +
+
+
+ trade-api +
Deployment + ClusterIP
+
+ 1/1 ready +
+

Service trade-api na porcie 8787.

+
+ +
+
+
+ trade-ingestor +
Deployment
+
+ 1/1 ready +
+

Ingest pipeline bez osobnego Service. Przetwarza dane w tle.

+
+ +
+
+
+ dlob-redis +
Deployment + ClusterIP
+
+ 1/1 ready +
+

Service dlob-redis na porcie 6379.

+
+ +
+
+
+ dlob-server +
Deployment + ClusterIP
+
+ 0/1 not ready +
+

Service dlob-server na porcie 6969. Zalezy od poprawnie napelnionego stanu DLOB.

+
+ +
+
+
+ dlob-publisher +
Deployment
+
+ 0/1 not ready +
+

Nie ma Service. To komponent zalezn y od zdrowego Agave RPC i bootstrapu Drift account reads.

+
+ +
+
+
+ dlob-worker / depth / slippage +
Deployments
+
+ 1/1 ready +
+

Workery pochodnych widokow orderbooka, bez ekspozycji zewnetrznej.

+
+ +
+
+
+ postgres +
StatefulSet + Headless Service
+
+ 1/1 ready +
+

Service postgres na 5432, headless dla stabilnej tozsamosci StatefulSet.

+
+ +
+
+
+ hasura +
Deployment + ClusterIP
+
+ 1/1 ready +
+

Service hasura na porcie 8080.

+
+
+
+
+
+ + + + diff --git a/doc/mevnode-bot-topology.html b/doc/mevnode-bot-topology.html new file mode 100644 index 0000000..0de0005 --- /dev/null +++ b/doc/mevnode-bot-topology.html @@ -0,0 +1,424 @@ + + + + + + mevnode topology + + + +
+
+
+
Topology / mev stack
+

mevnode_sol daje chain access. mevnode_bot robi produkt, ingest i execution.

+

+ Ten układ rozdziela warstwę validatora od warstwy aplikacyjnej. Na sol + trzymasz tylko Agave, Yellowstone i lokalne RPC. Na bot działa + single-node k3s z frontendem, API, DLOB, strategiami i bazami danych. +

+
+ + +
+ +
+
+
+

mevnode_sol

+ bare metal / validator host +
+
+
+

Agave validator

+

Źródło stanu chaina, snapshotów i lokalnego read/write RPC.

+
    +
  • 127.0.0.1:8899 RPC
  • +
  • 10.91.0.1:10000 Yellowstone gRPC
  • +
  • 10.91.0.1:8999 Prometheus plugin metrics
  • +
+
+ +
+

Write capability

+

Bot może później używać lokalnego RPC przez proxy lub iść bezpośrednio przez TPU/Jito.

+
+ +
+

Node-only responsibilities

+
    +
  • snapshot recovery
  • +
  • accounts index
  • +
  • geyser / yellowstone plugin
  • +
  • node metrics
  • +
+
+
+
+ + + +
+
+

mevnode_bot

+ single-node k3s / app + execution host +
+
+
+

Read plane

+
    +
  • yellowstone-consumer: bierze live stream z 10.91.0.1:10000
  • +
  • trade-ingestor: transformuje i dystrybuuje dane aplikacyjne
  • +
  • dlob-publisher: dociąga stan kont przez RPC i buduje DLOB
  • +
  • dlob-server: wystawia gotowy widok dla API i UI
  • +
+
+ +
+

Execution plane

+
    +
  • strategy-engine: decyzje tradingowe
  • +
  • risk-manager: limity i walidacja
  • +
  • order-manager: lifecycle zleceń
  • +
  • tx-router: wybiera RPC, TPU albo Jito
  • +
+
+ +
+

Data plane

+
    +
  • Postgres: trwały storage, pozycje, historia, config
  • +
  • Hasura: warstwa GraphQL nad Postgres
  • +
  • Redis: cache, pub/sub, szybki stan DLOB
  • +
  • ClickHouse: opcjonalnie później pod cięższą analitykę
  • +
+
+ +
+

Product and ops

+
    +
  • frontend + api + ingress
  • +
  • Prometheus + Grafana
  • +
  • Portainer agent dla widoczności klastra w Portainerze
  • +
  • GitOps: Argo lub Flux z Gitea
  • +
+
+
+
+
+ +
+
+

Read path

+

+ Yellowstone gRPC daje live account updates, sloty i transakcje. + Agave RPC zostaje do odczytów punktowych typu + getAccountInfo, getMultipleAccounts, + getHealth i getLatestBlockhash. +

+
+ +
+

Write path

+

+ Wysyłka transakcji nie idzie przez Yellowstone. To robi + RPC, TPU albo Jito. + Dzięki temu read plane i write plane są od siebie operacyjnie odseparowane. +

+
+ +
+

Deployment rule

+

+ Wszystko aplikacyjne i webowe ląduje na mevnode_bot. + Wszystko validatorowe zostaje na mevnode_sol. + To upraszcza debug, upgrade i kontrolę ryzyka. +

+
+
+ +
+

+ Docelowy przepływ: mevnode_sol -> wg0 -> yellowstone-consumer / dlob-publisher -> redis / postgres -> api -> frontend. + Osobno działa execution path: strategy-engine -> tx-router -> RPC / TPU / Jito. +

+
+
+ + diff --git a/doc/trade-system-flow.html b/doc/trade-system-flow.html new file mode 100644 index 0000000..f82f5b2 --- /dev/null +++ b/doc/trade-system-flow.html @@ -0,0 +1,692 @@ + + + + + + Trade system flow + + + +
+
+
Trade architecture / runtime map
+

Skąd dane trafiają, w jakiej postaci i które endpointy naprawdę wystawiamy

+

+ Ta strona opisuje faktyczny przepływ runtime dla waszej appki: browser -> frontend -> API/Hasura -> Postgres/Redis + oraz zależności od Agave RPC i Yellowstone gRPC. To nie jest ogólny diagram architektury, + tylko mapa wejść, wyjść, formatów danych i tras HTTP/WS używanych przez produkt. +

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

Public web surface

+ To widzi użytkownik i to jest jedyny realny entrypoint produktu na zewnątrz. +
+
+
NodePort / browser entry
+

trade-frontend

+

Serwuje SPA, obsługuje logowanie, robi proxy do API i Hasury.

+
+ NodePort 30081 + service 8081 + /api/* + /graphql +
+
+
+
Browser calls
+

Frontend SPA

+

Wykres bierze REST JSON. DLOB live bierze GraphQL WebSocket. Auth idzie przez sesję cookie.

+
+ GET /whoami + POST /auth/login + GET /api/v1/chart + WS /graphql +
+
+
+
+ +
+

App services in k3s

+ Wewnętrzne usługi aplikacyjne. Tylko frontend jest dziś wystawiony na zewnątrz jako NodePort. +
+
+
ClusterIP / app logic
+

trade-api

+

REST backend. Zwraca candles, przyjmuje tick ingest, zarządza tokenami.

+
+ ClusterIP 8787 + /v1/chart + /v1/ticks + /v1/ingest/tick +
+
+
+
ClusterIP / query plane
+

Hasura + Postgres

+

Frontend subskrybuje Hasurę po GraphQL WS. API czyta i zapisuje przez GraphQL do Hasury.

+
+ Hasura 8080 + Postgres 5432 + GraphQL + subscriptions +
+
+
+
Redis-backed read side
+

DLOB stack

+

publisher 0/1 server 0/1 bo bootstrapping zależy od zdrowego Agave RPC.

+
+ Redis 6379 + dlob-server 6969 + workers +
+
+
+
+ +
+

Chain and execution plane

+ To nie jest wystawiane użytkownikowi końcowemu. To feed i write side dla botów. +
+
+
mevnode_sol / source of truth
+

Agave RPC

+

Punktowe odczyty: getAccountInfo, getMultipleAccounts, getSlot, getHealth.

+
+ 127.0.0.1:8899 + RPC only +
+
+
+
mevnode_sol / live feed
+

Yellowstone gRPC

+

Pushowy stream kont, slotów i transakcji po wg0.

+
+ 10.91.0.1:10000 + token auth +
+
+
+
future execution split
+

tx-router / TPU / Jito

+

To jest write path. Nie mylić z Yellowstone, który jest tylko read streamem.

+
+
+
+
+ +
+
End-to-end runtime flow
+
+
+
1. BrowserUżytkownik otwiera SPA i trzyma sesję cookie.
+
+
2. trade-frontendSerwuje UI i robi reverse proxy do /api i /graphql.
+
+
3. trade-api / HasuraREST dla chartów, GraphQL WS dla live DLOB.
+
+
+
4. Postgres / RedisTrwały stan i szybki stan pośredni.
+
+
5. DLOB workersPrzeliczają depth, slippage, orderbook views.
+
+
6. Agave / YellowstoneRPC do odczytów punktowych, gRPC do streamu live.
+
+
+
+ +
+

+ Najważniejszy podział: chart path = REST JSON przez trade-api, a DLOB live = GraphQL WS przez Hasurę. + To oznacza, że nie cały frontend jedzie jednym typem transportu. Produkt ma dwa równoległe read pathy. +

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SurfaceEndpointTransportInputOutputWhere it goes
FrontendGET /whoamiHTTP JSONcookie session{ ok, user, mode }handled directly by trade-frontend
FrontendPOST /auth/loginHTTP JSON/formusername, password{ ok, user } + session cookiehandled directly by trade-frontend
FrontendPOST /auth/logoutHTTP JSONsession cookie{ ok: true }handled directly by trade-frontend
Frontend proxy/api/*HTTPbrowser request, frontend injects read tokenproxied API responsetrade-api:8787
Frontend proxy/graphqlHTTP GraphQLGraphQL query/mutationGraphQL responsehasura:8080/v1/graphql
Frontend proxyWS /graphqlGraphQL WebSocketsubscription payloadlive subscription frameshasura:8080/v1/graphql
APIGET /v1/chartHTTP JSONsymbol, tf, limit, optional sourcecandles + indicators + flow rowsHasura function get_drift_candles + table drift_ticks
APIPOST /v1/ingest/tickHTTP JSONtick payload{ ok, id }writes tick through Hasura into DB
APIGET /v1/ticksHTTP JSONsymbol, optional source, limit, from, totick listreads drift_ticks through Hasura
API adminPOST /v1/admin/tokensHTTP JSONname, scopesnew API tokenstored in DB via Hasura
API adminPOST /v1/admin/tokens/revokeHTTP JSONtoken idrevocation statusupdates token row in DB
Chain readAgave RPCJSON-RPCaccount/state requestspoint readsmevnode_sol
Chain liveYellowstone gRPCgRPC streamingsubscription config + tokenaccount/slot/tx streammevnode_sol
+
+
+ +
+
+
+
Chart response
+
{
+  "ok": true,
+  "symbol": "SOL-PERP",
+  "tf": "1m",
+  "bucketSeconds": 60,
+  "candles": [
+    {
+      "time": 1710000000,
+      "open": 132.1,
+      "high": 133.2,
+      "low": 131.8,
+      "close": 132.9,
+      "volume": 412,
+      "oracle": 132.7,
+      "flow": { "up": 0.46, "down": 0.41, "flat": 0.13 },
+      "flowRows": [1, 1, 0, -1],
+      "flowMoves": [0.2, 0.1, 0, 0.3]
+    }
+  ],
+  "indicators": {
+    "sma20": [{ "time": 1710000000, "value": 131.4 }],
+    "ema20": [{ "time": 1710000000, "value": 131.8 }],
+    "bb20": { "upper": [], "lower": [], "mid": [] },
+    "rsi14": [],
+    "macd": { "macd": [], "signal": [] }
+  }
+}
+
+ +
+
Tick ingest body
+
{
+  "ts": "2026-03-13T11:20:00.000Z",
+  "market_index": 0,
+  "symbol": "SOL-PERP",
+  "oracle_price": "132.70",
+  "mark_price": "132.91",
+  "oracle_slot": 406125100,
+  "source": "drift",
+  "raw": {
+    "provider": "internal"
+  }
+}
+
+ +
+
GraphQL DLOB subscriptions
+
subscription DlobStats($market: String!) {
+  dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
+    market_name
+    mark_price
+    oracle_price
+    best_bid_price
+    best_ask_price
+    mid_price
+    spread_abs
+    spread_bps
+    depth_bid_usd
+    depth_ask_usd
+    imbalance
+    updated_at
+  }
+}
+
+ +
+
L2 payload shape
+
{
+  "market_name": "SOL-PERP",
+  "bids": [
+    { "price": 132910000, "size": 2500000000 }
+  ],
+  "asks": [
+    { "price": 132930000, "size": 1700000000 }
+  ],
+  "updated_at": "2026-03-13T11:20:01.000Z"
+}
+
+Frontend przelicza to przez:
+- pricePrecision = 1_000_000
+- basePrecision = 1_000_000_000
+
+
+ +
+

+ Krytyczne rozróżnienie: DLOB live nie idzie z REST API. DLOB do UI idzie przez Hasura GraphQL subscriptions, + a chart przez REST JSON z trade-api. To są dwa osobne transporty, dwa osobne modele danych i dwa osobne źródła opóźnień. +

+
+
+
+ + + +