mobile download

This commit is contained in:
Macbook
2026-05-28 14:44:19 +09:00
parent e2816b037f
commit 3503ef33f5
152 changed files with 11021 additions and 687 deletions
+3
View File
@@ -3,3 +3,6 @@
# Spring Boot 백엔드 URL (Vite 프록시 사용 시 /api 로 통일)
VITE_API_BASE_URL=/api
# 로컬 dev: /api/mobile-app → exdev (QR·APK 다운로드 URL). vite.config.ts 기본값과 동일
# VITE_MOBILE_APP_PROXY_TARGET=http://exdev.co.kr
+14
View File
@@ -109,6 +109,20 @@ server {
proxy_buffering off;
}
# ── 모바일 APK 다운로드 (대용량) ─────────────────────────────────────
location /api/mobile-app/download/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_buffering off;
client_max_body_size 200m;
}
# ── React SPA 폴백 ────────────────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
+449 -116
View File
@@ -13,11 +13,13 @@
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
@@ -27,13 +29,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -42,9 +44,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -52,21 +54,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -83,14 +85,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -100,14 +102,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -117,9 +119,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -127,29 +129,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -159,9 +161,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -169,9 +171,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -179,9 +181,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -189,9 +191,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -199,27 +201,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -229,13 +231,13 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -245,13 +247,13 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -261,33 +263,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -295,14 +297,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1252,6 +1254,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1259,10 +1271,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"version": "18.3.29",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1340,10 +1362,34 @@
"d3-zoom": "^3.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1387,10 +1433,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"dev": true,
"funding": [
{
@@ -1414,6 +1469,35 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1551,13 +1635,34 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.356",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
"integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==",
"version": "1.5.363",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz",
"integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==",
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1634,6 +1739,19 @@
"node": ">=0.8.0"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1659,6 +1777,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/http-parser-js": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
@@ -1671,6 +1798,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1714,13 +1850,23 @@
},
"node_modules/lightweight-charts-indicators": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/lightweight-charts-indicators/-/lightweight-charts-indicators-0.4.1.tgz",
"integrity": "sha512-l/ULwRz63xr1m9huktB0US/iginUQYDxEbeowqpPV/ZzLb9LRoSewlOEb0qJVygANUaXeaRmchPLDyMLmFYhAA==",
"license": "MIT",
"peerDependencies": {
"oakscriptjs": "^0.2.8"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1769,16 +1915,17 @@
}
},
"node_modules/node-releases": {
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/oakscriptjs": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/oakscriptjs/-/oakscriptjs-0.2.8.tgz",
"integrity": "sha512-SRLR1QpnO3k8MNFzT5ZLEauWeULAcDTJ+0L0JXtshMvfDc5f8ezXVwgQ3D9Lv2g4aiUbJNmK+sNuZ8NKeC1I7Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -1787,6 +1934,51 @@
"lightweight-charts": "^5.0.0"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1794,10 +1986,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -1815,7 +2016,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1823,6 +2024,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -1864,6 +2082,21 @@
"node": ">=0.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -1954,6 +2187,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/sockjs-client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
@@ -1992,6 +2231,32 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2006,6 +2271,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2139,6 +2411,32 @@
"node": ">=0.8.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -2146,6 +2444,41 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+2
View File
@@ -14,11 +14,13 @@
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f0f23" />
<title>GoldenChart 앱 설치</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(160deg, #1e1e3f 0%, #0f0f23 55%);
color: #fff;
min-height: 100vh;
padding: 24px 20px 40px;
}
.wrap { max-width: 420px; margin: 0 auto; text-align: center; }
.logo {
width: 72px; height: 72px; border-radius: 18px;
background: linear-gradient(135deg, #8b5cf6, #2196f3);
display: flex; align-items: center; justify-content: center;
font-size: 28px; font-weight: 800; margin: 0 auto 16px;
}
h1 { font-size: 22px; margin-bottom: 8px; }
.muted { color: #9ca3af; font-size: 14px; line-height: 1.5; margin-bottom: 24px; }
.card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
padding: 24px;
margin-bottom: 16px;
}
#qr { width: 200px; height: 200px; border-radius: 12px; background: #fff; padding: 8px; }
.btn {
display: block; width: 100%; padding: 16px;
background: #8b5cf6; color: #fff; border: none; border-radius: 14px;
font-size: 16px; font-weight: 700; text-decoration: none; margin-top: 16px;
}
.btn:disabled { opacity: 0.5; pointer-events: none; }
ol { text-align: left; font-size: 13px; color: #d1d5db; line-height: 1.6; padding-left: 20px; }
.err { color: #f87171; font-size: 14px; }
</style>
</head>
<body>
<div class="wrap">
<div class="logo">GC</div>
<h1>GoldenChart 앱 설치</h1>
<p class="muted">가상매매·전략편집기·푸시 알림을 모바일에서 사용하세요.</p>
<div class="card" id="content">
<p class="muted">로딩 중…</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script>
(async function () {
const el = document.getElementById('content');
try {
const res = await fetch('/api/mobile-app/info');
const info = await res.json();
if (!info.available) {
el.innerHTML = '<p class="err">설치 파일이 아직 준비되지 않았습니다.<br>관리자에게 APK 업로드를 요청하세요.</p>';
return;
}
const url = info.downloadUrl;
if (!url) {
el.innerHTML = '<p class="err">다운로드 URL을 받지 못했습니다.</p>';
return;
}
el.innerHTML =
'<canvas id="qr"></canvas>' +
'<p class="muted" style="margin-top:12px">' + (info.version ? 'v' + info.version + ' · ' : '') +
(info.sizeBytes ? (info.sizeBytes / 1048576).toFixed(1) + ' MB' : '') + '</p>' +
'<a class="btn" href="' + url + '" download>APK 다운로드</a>' +
'<ol style="margin-top:20px"><li>다운로드 후 APK 파일을 열어 설치</li>' +
'<li>「알 수 없는 출처」 허용 필요 시 설정에서 활성화</li>' +
'<li>앱 실행 후 API URL 설정</li></ol>';
QRCode.toCanvas(document.getElementById('qr'), url, { width: 200, margin: 1 });
} catch (e) {
el.innerHTML = '<p class="err">앱 정보를 불러오지 못했습니다.</p>';
}
})();
</script>
</body>
</html>
+74
View File
@@ -11627,6 +11627,25 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
}
.tnl-row-chart:hover { background: var(--bg4); color: var(--text); }
/* TopMenuBar 앱 다운로드 버튼 */
.tmb-app-download-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
flex-shrink: 0;
}
.tmb-app-download-btn:hover {
background: var(--bg4);
color: var(--accent, #8b5cf6);
}
/* TopMenuBar 알림 버튼 */
.tmb-notify-group {
display: inline-flex;
@@ -12517,3 +12536,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
border-radius: 10px;
background: rgba(255, 193, 7, 0.1);
}
/* ── 앱 다운로드 모달 ── */
.app-download-body { padding: 4px 2px 8px; }
.app-download-muted { color: var(--text3); font-size: 13px; }
.app-download-error { color: #ef4444; font-size: 13px; }
.app-download-empty { text-align: center; padding: 16px 8px; }
.app-download-empty-icon { font-size: 40px; margin-bottom: 8px; }
.app-download-empty h3 { font-size: 16px; margin-bottom: 8px; }
.app-download-empty p { font-size: 13px; color: var(--text2); line-height: 1.55; }
.app-download-empty code { font-size: 11px; word-break: break-all; }
.app-download-hero {
display: flex; align-items: center; gap: 14px;
padding: 12px 14px; margin-bottom: 16px;
background: var(--bg3); border-radius: 12px; border: 1px solid var(--border);
}
.app-download-app-icon {
width: 52px; height: 52px; border-radius: 14px;
background: linear-gradient(135deg, #8b5cf6, #2196f3);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 18px; color: #fff; flex-shrink: 0;
}
.app-download-app-name { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
.app-download-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text3); }
.app-download-qr-block {
display: flex; flex-direction: column; align-items: center; gap: 14px;
padding: 16px; margin-bottom: 16px;
background: var(--bg2); border-radius: 14px; border: 1px solid var(--border);
}
.app-download-qr {
border-radius: 10px; background: #fff;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.app-download-qr--placeholder {
width: 220px; height: 220px; display: flex; align-items: center; justify-content: center;
color: var(--text3); font-size: 13px;
}
.app-download-qr-help { text-align: center; max-width: 320px; }
.app-download-qr-help strong { display: block; font-size: 14px; margin-bottom: 6px; }
.app-download-qr-help p { font-size: 12px; color: var(--text2); line-height: 1.5; margin: 0 0 6px; }
.app-download-url { font-size: 10px; word-break: break-all; opacity: 0.85; }
.app-download-btn-primary {
width: 100%; padding: 14px; border: none; border-radius: 12px;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
}
.app-download-btn-primary:hover { filter: brightness(1.06); }
.app-download-steps {
margin-top: 14px; font-size: 12px; color: var(--text2);
border-top: 1px solid var(--border); padding-top: 12px;
}
.app-download-steps summary { cursor: pointer; font-weight: 600; color: var(--text); margin-bottom: 8px; }
.app-download-steps ol { padding-left: 18px; line-height: 1.6; }
.app-download-ios-note {
margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45;
}
+23 -22
View File
@@ -39,7 +39,7 @@ import {
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig';
import { normalizeIchimokuConfig } from './utils/ichimokuConfig';
import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
import { getFavorites, saveFavorites, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types';
import type { ChartManager } from './utils/ChartManager';
@@ -105,6 +105,7 @@ import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
import { useIsMobile } from './hooks/useMediaQuery';
import MobileChartDock from './components/MobileChartDock';
import LoginModal from './components/LoginModal';
import AppDownloadModal from './components/AppDownloadModal';
import SplashScreen from './components/SplashScreen';
import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth';
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
@@ -161,6 +162,7 @@ function App() {
isAppEntered() && !getAuthSession(),
);
const [loginOpen, setLoginOpen] = useState(false);
const [appDownloadOpen, setAppDownloadOpen] = useState(false);
// 전역 지표 파라미터 + 시각 설정 DB 동기화
const {
@@ -217,16 +219,8 @@ function App() {
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => {
const saved = localStorage.getItem('chartLayout');
return LAYOUTS.find(l => l.id === saved) ?? LAYOUTS[0];
});
const [syncOptions, setSyncOptions] = useState<SyncOptions>(() => {
try {
const s = localStorage.getItem('chartLayoutSync');
return s ? JSON.parse(s) : DEFAULT_SYNC;
} catch { return DEFAULT_SYNC; }
});
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => LAYOUTS[0]);
const [syncOptions, setSyncOptions] = useState<SyncOptions>(DEFAULT_SYNC);
const [activeSlot, setActiveSlot] = useState(0);
// 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용)
const [activeSlotIndicators, setActiveSlotIndicators] = useState<IndicatorConfig[]>([]);
@@ -387,13 +381,18 @@ function App() {
saveAppDef({ defaultLayoutId: def.id });
}, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]);
// 레이아웃/싱크 변경 시 localStorage 저장
const appLayoutHydratedRef = useRef(false);
useEffect(() => {
localStorage.setItem('chartLayout', layoutDef.id);
}, [layoutDef]);
useEffect(() => {
localStorage.setItem('chartLayoutSync', JSON.stringify(syncOptions));
}, [syncOptions]);
if (!appSettingsLoaded || appLayoutHydratedRef.current) return;
appLayoutHydratedRef.current = true;
if (appDefaults.layoutId) {
const def = LAYOUTS.find(l => l.id === appDefaults.layoutId);
if (def) setLayoutDef(def);
}
if (appDefaults.syncOptions) {
setSyncOptions(appDefaults.syncOptions);
}
}, [appSettingsLoaded, appDefaults.layoutId, appDefaults.syncOptions]);
// 다중 → 싱글 전환 시 activeSlotIndicators 초기화
useEffect(() => {
@@ -1118,10 +1117,6 @@ function App() {
return map;
}, [watchlist]);
// ── 상태 저장 (localStorage) ───────────────────────────────────────────
useEffect(() => {
saveState({ symbol, timeframe, chartType, theme, indicators, watchlist, alertPrices: alerts.map(a => a.price) });
}, [symbol, timeframe, chartType, theme, indicators, watchlist, alerts]);
// body 포털(알림 팝업 등) 테마 CSS 변수 동기화
useEffect(() => {
@@ -1201,10 +1196,10 @@ function App() {
},
onWatchlistLoad: (items: WatchlistItem[]) => {
const syms = items.map(i => i.symbol);
initFavoritesFromDb(syms);
if (syms.length > 0) {
setWatchlist(syms);
setFavoritesList(syms);
saveFavorites(syms, false);
}
},
noAutoSave: !dbLoaded,
@@ -1672,6 +1667,7 @@ function App() {
theme={theme}
onPage={guardedSetMenuPage}
onTheme={handleThemeToggle}
onOpenAppDownload={() => setAppDownloadOpen(true)}
authUser={authUser}
guestMode={guestMode}
menuPermissions={menuPermissions}
@@ -1691,6 +1687,10 @@ function App() {
onSuccess={handleLoginSuccess}
/>
{appDownloadOpen && (
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
)}
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
{menuPage === 'dashboard' && (
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
@@ -1900,6 +1900,7 @@ function App() {
<ChartToolbarNotify
onOpenNotifyList={openNotificationsPage}
onOpenAppDownload={() => setAppDownloadOpen(true)}
symbol={symbol} timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe} chartType={chartType}
theme={theme} mode={mode} logScale={logScale} showGrid={showGrid}
activeIndicators={toolbarActiveIndicators}
@@ -0,0 +1,151 @@
/**
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
*/
import React, { useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import DraggableModalFrame from './DraggableModalFrame';
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
interface Props {
onClose: () => void;
}
function formatBytes(n?: number): string {
if (n == null || !Number.isFinite(n)) return '—';
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
if (!info?.available) return '';
return info.downloadUrl?.trim() ?? '';
}
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
const [loading, setLoading] = useState(true);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]);
useEffect(() => {
let cancelled = false;
void loadMobileAppReleaseInfo()
.then(data => {
if (!cancelled) setInfo(data);
})
.catch(() => {
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!downloadUrl) {
setQrDataUrl(null);
return;
}
let cancelled = false;
void QRCode.toDataURL(downloadUrl, {
width: 220,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
}).then(url => {
if (!cancelled) setQrDataUrl(url);
}).catch(() => {
if (!cancelled) setQrDataUrl(null);
});
return () => { cancelled = true; };
}, [downloadUrl]);
const handleDownload = () => {
if (!downloadUrl) return;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = info?.fileName ?? 'goldenchart-android.apk';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
};
return (
<DraggableModalFrame
onClose={onClose}
title="Mobile App"
titleKo="GoldenChart 앱 설치"
badge="Android"
width={440}
dialogClassName="sp-modal app-download-modal"
>
<div className="app-download-body">
{loading && <p className="app-download-muted"> </p>}
{!loading && error && <p className="app-download-error">{error}</p>}
{!loading && !error && !info?.available && (
<div className="app-download-empty">
<div className="app-download-empty-icon" aria-hidden>📱</div>
<h3> </h3>
<p>
Android APK가 .
<br />
<code>data/mobile-releases/goldenchart-android.apk</code> .
</p>
</div>
)}
{!loading && info?.available && (
<>
<div className="app-download-hero">
<div className="app-download-app-icon" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart</h3>
<p className="app-download-meta">
{info.version && <span>v{info.version}</span>}
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
{info.updatedAt && <span>{info.updatedAt}</span>}
</p>
</div>
</div>
<div className="app-download-qr-block">
{qrDataUrl ? (
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
) : (
<div className="app-download-qr app-download-qr--placeholder">QR </div>
)}
<div className="app-download-qr-help">
<strong> QR </strong>
<p>Android에서 APK . .</p>
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
</div>
</div>
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
Android APK
</button>
<details className="app-download-steps">
<summary> (Android)</summary>
<ol>
<li>QR APK를 .</li>
<li> .</li>
<li> APK .</li>
<li> API URL을 ( ).</li>
</ol>
</details>
<p className="app-download-ios-note">
iOS는 App Store / TestFlight . QR·APK Android .
</p>
</>
)}
</div>
</DraggableModalFrame>
);
};
export default AppDownloadModal;
+7 -1
View File
@@ -13,6 +13,7 @@ interface NotifyTopMenuBarProps {
theme: Theme;
onPage: (page: MenuPage) => void;
onTheme: () => void;
onOpenAppDownload?: () => void;
authUser?: AuthSession | null;
onLoginClick?: () => void;
onLogout?: () => void;
@@ -47,8 +48,12 @@ export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
import Toolbar, { type ToolbarProps } from './Toolbar';
export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: () => void }> = ({
export const ChartToolbarNotify: React.FC<ToolbarProps & {
onOpenNotifyList: () => void;
onOpenAppDownload?: () => void;
}> = ({
onOpenNotifyList,
onOpenAppDownload,
...toolbarProps
}) => {
const { unreadCount } = useTradeNotification();
@@ -57,6 +62,7 @@ export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: ()
{...toolbarProps}
tradeNotifyUnread={unreadCount}
onOpenTradeNotifications={onOpenNotifyList}
onOpenAppDownload={onOpenAppDownload}
/>
);
};
+242 -159
View File
@@ -5,7 +5,8 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react';
import DraggableModalFrame from './DraggableModalFrame';
import type { Theme } from '../types/index';
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import type { LogicNode } from '../utils/strategyTypes';
import {
buildStrategyEditorDefFromSettings,
@@ -35,7 +36,7 @@ import {
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
import {
START_NODE_ID,
defaultStartMeta,
@@ -48,10 +49,10 @@ import {
} from '../utils/strategyPaletteStorage';
import {
emptySignalFlowLayout,
loadStrategyFlowLayout,
migrateStrategyFlowLayout,
saveStrategyFlowLayout,
deleteStrategyFlowLayout,
buildStrategyFlowLayoutStore,
normalizeStrategyFlowLayout,
readLegacyFlowLayoutFromLocalStorage,
clearLegacyFlowLayoutFromLocalStorage,
type SignalFlowLayoutSnapshot,
type FlowLayoutChangePayload,
type StrategyFlowLayoutStore,
@@ -97,21 +98,6 @@ interface Props {
theme: Theme;
}
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
const stored = loadStrategyFlowLayout(strategyKey);
const snap = stored?.[tab];
if (!snap) return emptySignalFlowLayout();
return {
positions: snap.positions ?? {},
edgeHandles: snap.edgeHandles ?? {},
orphans: snap.orphans ?? [],
startMeta: snap.startMeta ?? defaultStartMeta(),
extraStartIds: snap.extraStartIds ?? [],
extraRoots: snap.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
};
}
function toEditorState(
root: LogicNode | null,
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
@@ -136,6 +122,28 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
};
}
function applyFlowLayoutFromStore(
stored: StrategyFlowLayoutStore | null,
setBuyLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
) {
if (stored) {
setBuyLayout(normalizeTabLayoutState(stored.buy));
setSellLayout(normalizeTabLayoutState(stored.sell));
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
}
}
export default function StrategyEditorPage({ theme }: Props) {
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo(
@@ -164,44 +172,32 @@ export default function StrategyEditorPage({ theme }: Props) {
const [descOpen, setDescOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const initialDraftBuy = readTabLayout('draft', 'buy');
const initialDraftSell = readTabLayout('draft', 'sell');
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
const [saveToast, setSaveToast] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const terminalHeightRef = useRef(terminalHeight);
const [buyLayout, setBuyLayout] = useState(() => ({
positions: initialDraftBuy.positions,
edgeHandles: initialDraftBuy.edgeHandles,
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
extraStartIds: initialDraftBuy.extraStartIds ?? [],
extraRoots: initialDraftBuy.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
}));
const [sellLayout, setSellLayout] = useState(() => ({
positions: initialDraftSell.positions,
edgeHandles: initialDraftSell.edgeHandles,
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
extraStartIds: initialDraftSell.extraStartIds ?? [],
extraRoots: initialDraftSell.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
}));
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const buyLayoutRef = useRef(buyLayout);
const sellLayoutRef = useRef(sellLayout);
const buyConditionRef = useRef(buyCondition);
const sellConditionRef = useRef(sellCondition);
const buyOrphansRef = useRef(buyOrphans);
const sellOrphansRef = useRef(sellOrphans);
buyLayoutRef.current = buyLayout;
sellLayoutRef.current = sellLayout;
buyConditionRef.current = buyCondition;
sellConditionRef.current = sellCondition;
const timeframeAutosaveRef = useRef<number | null>(null);
buyOrphansRef.current = buyOrphans;
sellOrphansRef.current = sellOrphans;
const layoutRevisionRef = useRef(0);
const persistLayoutTimerRef = useRef<number | null>(null);
const strategyAutosaveTimerRef = useRef<number | null>(null);
const layoutPersistReadyRef = useRef(false);
const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
terminalHeightRef.current = terminalHeight;
@@ -268,20 +264,67 @@ export default function StrategyEditorPage({ theme }: Props) {
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
const persistFlowLayout = useCallback((strategyKey: string) => {
saveStrategyFlowLayout(strategyKey, {
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
});
}, [buyOrphans, sellOrphans]);
const buildCurrentFlowLayout = useCallback(
(): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
buyLayoutRef.current,
sellLayoutRef.current,
buyOrphansRef.current,
sellOrphansRef.current,
),
[],
);
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
persistLayoutTimerRef.current = window.setTimeout(() => {
persistLayoutTimerRef.current = null;
persistFlowLayout(strategyKey);
}, 150);
}, [persistFlowLayout]);
const persistStrategyToDb = useCallback(async () => {
if (selectedId == null || !stratName.trim()) return;
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) return;
const flowLayout = buildCurrentFlowLayout();
const saved = await persistStrategyEditorState(
selectedId,
stratName,
stratDesc,
aligned.buy,
aligned.sell,
flowLayout,
);
setStrategies(prev => prev.map(s => s.id === selectedId
? {
...s,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
updatedAt: saved?.updatedAt ?? s.updatedAt,
}
: s));
}, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
const flushStrategyPersist = useCallback(() => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (selectedId == null || !stratName.trim()) return;
void persistStrategyToDb().catch(err => {
console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
});
}, [selectedId, stratName, persistStrategyToDb]);
const scheduleStrategyPersist = useCallback(() => {
if (selectedId == null || !stratName.trim()) return;
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
}
strategyAutosaveTimerRef.current = window.setTimeout(() => {
strategyAutosaveTimerRef.current = null;
void persistStrategyToDb().catch(err => {
console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
});
}, STRATEGY_AUTOSAVE_MS);
}, [selectedId, stratName, persistStrategyToDb]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
@@ -318,50 +361,11 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
}));
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
if (selectedId != null && stratName.trim()) scheduleStrategyPersist();
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const stored = loadStrategyFlowLayout(strategyKey);
if (stored) {
const nextBuy = {
positions: stored.buy.positions ?? {},
edgeHandles: stored.buy.edgeHandles ?? {},
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
extraStartIds: stored.buy.extraStartIds ?? [],
extraRoots: stored.buy.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
};
const nextSell = {
positions: stored.sell.positions ?? {},
edgeHandles: stored.sell.edgeHandles ?? {},
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
extraStartIds: stored.sell.extraStartIds ?? [],
extraRoots: stored.sell.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
};
setBuyLayout(nextBuy);
setSellLayout(nextSell);
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
}
applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans);
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
@@ -372,24 +376,34 @@ export default function StrategyEditorPage({ theme }: Props) {
};
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
else setSellLayout(prev => ({ ...prev, ...patch }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [scheduleStrategyPersist]);
const handleRootChange = useCallback((root: LogicNode | null) => {
setCurrentRoot(root);
scheduleStrategyPersist();
}, [setCurrentRoot, scheduleStrategyPersist]);
const handleOrphansChange = useCallback((orphans: LogicNode[]) => {
setCurrentOrphans(orphans);
scheduleStrategyPersist();
}, [setCurrentOrphans, scheduleStrategyPersist]);
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [setCurrentLayout, scheduleStrategyPersist]);
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
schedulePersistFlowLayout(layoutStrategyKey);
scheduleStrategyPersist();
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
}, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [setCurrentLayout, scheduleStrategyPersist]);
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
setCurrentRoot(next.root);
@@ -400,24 +414,8 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: next.extraRoots,
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
const scheduleTimeframePersist = useCallback(() => {
if (selectedId == null || !stratName.trim()) return;
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
const id = selectedId;
const name = stratName;
const desc = stratDesc;
timeframeAutosaveRef.current = window.setTimeout(() => {
timeframeAutosaveRef.current = null;
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
});
}, 700);
}, [selectedId, stratName, stratDesc]);
scheduleStrategyPersist();
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (startId === START_NODE_ID) {
@@ -438,20 +436,16 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: synced.sell.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
scheduleTimeframePersist();
scheduleStrategyPersist();
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
scheduleTimeframePersist();
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
layoutStrategyKey,
schedulePersistFlowLayout,
scheduleTimeframePersist,
scheduleStrategyPersist,
signalTab,
]);
@@ -461,18 +455,18 @@ export default function StrategyEditorPage({ theme }: Props) {
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
if (editorMode === 'graph') layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
flushStrategyPersist();
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
setSignalTab(tab);
setSelectedNodeId(null);
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
}, [layoutStrategyKey, flushStrategyPersist, editorMode]);
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
if (mode === editorMode) return;
if (editorMode === 'graph') {
layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
flushStrategyPersist();
}
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
@@ -480,7 +474,7 @@ export default function StrategyEditorPage({ theme }: Props) {
setEditorMode(mode);
saveEditorMode(mode);
setSelectedNodeId(null);
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
const layoutSeed = useMemo((): FlowLayoutSeed => {
const source = signalTab === 'buy' ? buyLayout : sellLayout;
@@ -491,17 +485,29 @@ export default function StrategyEditorPage({ theme }: Props) {
};
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
useEffect(() => () => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
}, []);
const persistStrategyToDbRef = useRef(persistStrategyToDb);
persistStrategyToDbRef.current = persistStrategyToDb;
useEffect(() => {
const id = selectedId;
const name = stratName;
return () => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (id != null && name.trim()) {
void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
}
};
}, [selectedId, stratName]);
useEffect(() => {
if (!layoutPersistReadyRef.current) {
layoutPersistReadyRef.current = true;
return;
}
schedulePersistFlowLayout(layoutStrategyKey);
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
@@ -514,6 +520,7 @@ export default function StrategyEditorPage({ theme }: Props) {
description: s.description,
buyCondition: s.buyCondition as LogicNode | null ?? null,
sellCondition: s.sellCondition as LogicNode | null ?? null,
flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
enabled: s.enabled ?? true,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
@@ -553,7 +560,10 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleSelectStrategy = (s: StrategyDto) => {
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
const stored = loadStrategyFlowLayout(String(s.id));
let stored = normalizeStrategyFlowLayout(s.flowLayout);
if (!stored) {
stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
}
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
@@ -600,6 +610,58 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setSelectedNodeId(null);
bumpLayoutSeed(String(s.id), signalTab);
if (!s.flowLayout && stored) {
void (async () => {
try {
const aligned = alignBuySellStartCandleTypesForSave(
toEditorState(buySynced.root, {
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
}),
toEditorState(sellSynced.root, {
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
}),
);
await saveStrategy({
id: s.id,
name: s.name,
description: s.description ?? '',
buyCondition: encodeConditionForSave(aligned.buy),
sellCondition: encodeConditionForSave(aligned.sell),
flowLayout: stored,
enabled: s.enabled ?? true,
});
clearLegacyFlowLayoutFromLocalStorage(String(s.id));
setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
} catch {
/* ignore */
}
})();
}
// flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
void (async () => {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
if (!synced) return;
try {
const fresh = await loadStrategy(s.id);
if (!fresh) return;
const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
} catch {
/* ignore */
}
})();
};
const handleNew = () => {
@@ -620,39 +682,59 @@ export default function StrategyEditorPage({ theme }: Props) {
}
setSaveNameError(false);
if (editorMode === 'graph') layoutFlushRef.current?.();
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true);
try {
const flowLayout = buildCurrentFlowLayout();
const payload: ApiStrategyDto = {
id: selectedId ?? undefined,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
};
const saved = await saveStrategy(payload);
const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now();
const wasDraft = selectedId == null;
setStrategies(prev => {
const existing = prev.find(s => s.id === selectedId);
if (existing && selectedId) {
return prev.map(s => s.id === selectedId
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
? {
...s,
id: dbId,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
updatedAt: saved?.updatedAt ?? now,
}
: s);
}
setSelectedId(dbId);
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
return [...prev, {
id: dbId,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
createdAt: saved?.createdAt ?? now,
updatedAt: saved?.updatedAt ?? now,
}];
});
if (!selectedId) setSelectedId(dbId);
if (wasDraft) {
migrateStrategyFlowLayout('draft', String(dbId));
}
persistFlowLayout(String(dbId));
bumpLayoutSeed(String(dbId), signalTab);
setSaveOpen(false);
setSaveToast(true);
@@ -668,7 +750,7 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleDeleteConfirm = async () => {
if (!deleteId) return;
try { await deleteStrategy(deleteId); } catch { /* local */ }
deleteStrategyFlowLayout(String(deleteId));
clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
setStrategies(prev => prev.filter(s => s.id !== deleteId));
if (selectedId === deleteId) handleNew();
setDeleteOpen(false);
@@ -696,7 +778,6 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setBuyOrphans(layout.buy.orphans ?? []);
setSellOrphans(layout.sell.orphans ?? []);
saveStrategyFlowLayout('draft', layout);
} else {
resetFlowLayout('draft', tab);
}
@@ -773,7 +854,7 @@ export default function StrategyEditorPage({ theme }: Props) {
}
const items = strategies.map(s => strategyDtoToListExportItem(
s,
loadStrategyFlowLayout(String(s.id)),
s.flowLayout ?? null,
));
downloadStrategyJson(
`전략목록_${new Date().toISOString().slice(0, 10)}`,
@@ -794,7 +875,6 @@ export default function StrategyEditorPage({ theme }: Props) {
const baseId = Date.now();
const imported = result.data.strategies.map((item, index) => {
const id = baseId + index;
if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
return listImportItemToStrategyDto(item, id);
});
setStrategies(prev => [...prev, ...imported]);
@@ -819,7 +899,8 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!root) setCurrentRoot(newNode);
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
const applyPaletteItem = useCallback((item: PaletteItem) => {
const composite = item.kind === 'composite';
@@ -827,7 +908,8 @@ export default function StrategyEditorPage({ theme }: Props) {
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
@@ -846,9 +928,10 @@ export default function StrategyEditorPage({ theme }: Props) {
resetFlowLayout(layoutStrategyKey, targetTab);
if (targetTab !== signalTab) setSignalTab(targetTab);
scheduleStrategyPersist();
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
@@ -1068,10 +1151,10 @@ export default function StrategyEditorPage({ theme }: Props) {
theme={theme}
root={currentRoot}
orphans={currentOrphans}
onOrphansChange={setCurrentOrphans}
onOrphansChange={handleOrphansChange}
def={DEF}
signalTab={signalTab}
onChange={setCurrentRoot}
onChange={handleRootChange}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
layoutSeed={layoutSeed}
@@ -1097,14 +1180,14 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!selectedNodeId) return;
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
if (inOrphans) {
setCurrentOrphans(currentOrphans.map(o => (
handleOrphansChange(currentOrphans.map(o => (
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
)));
return;
}
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
if (findLogicNode(currentRoot, [], selectedNodeId)) {
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
return;
}
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
@@ -1131,7 +1214,7 @@ export default function StrategyEditorPage({ theme }: Props) {
onEditorStateChange={handleEditorStateChange}
onAddStart={handleAddStartSection}
orphans={currentOrphans}
onOrphansChange={setCurrentOrphans}
onOrphansChange={handleOrphansChange}
/>
)}
+2 -5
View File
@@ -66,11 +66,8 @@ interface ValidationResult {
let _cnt = 0;
const genId = () => `n_${++_cnt}_${Date.now()}`;
const STORAGE_KEY = 'gc_strat_v2';
const loadStratsLocal = (): StrategyDto[] => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
};
const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
const loadStratsLocal = (): StrategyDto[] => [];
const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
+30 -23
View File
@@ -40,30 +40,18 @@ import LayoutPicker from './LayoutPicker';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
const LOCAL_BT_KEY = 'gc_strat_v2';
interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; }
interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
function loadBtStrategies(): Promise<BtStrategy[]> {
return loadStrategies()
.then(list => {
if (list && list.length > 0) {
return list.map(s => ({ id: s.id, name: s.name, source: 'db' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
}
// localStorage 폴백
try {
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
} catch { return []; }
})
.catch(() => {
try {
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
} catch { return []; }
});
.then(list => (list ?? []).map(s => ({
id: s.id,
name: s.name,
source: 'db' as const,
buyCondition: s.buyCondition,
sellCondition: s.sellCondition,
})))
.catch(() => []);
}
export interface ToolbarProps {
@@ -98,6 +86,8 @@ export interface ToolbarProps {
/** 매매 시그널 알림 (미확인 개수) */
tradeNotifyUnread?: number;
onOpenTradeNotifications?: () => void;
/** 모바일 앱 다운로드 */
onOpenAppDownload?: () => void;
/** 자석모드 현재 상태 */
magnetMode?: 'off' | 'weak' | 'strong';
/** 자석모드 토글 (off↔strong) */
@@ -233,6 +223,14 @@ const IcBell = () => (
<circle cx="7.5" cy="13" r="0.8" fill="currentColor" stroke="none"/>
</svg>
);
/** 모바일 앱 다운로드 */
const IcAppDownload = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="1.5" width="7" height="12" rx="1.5"/>
<line x1="7.5" y1="9" x2="7.5" y2="12"/>
<polyline points="5.5,10.5 7.5,12.5 9.5,10.5"/>
</svg>
);
/** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */
const IcTradeSignal = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -785,7 +783,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onIndicatorOrderChange, onRemoveByType,
onAutoFib, onClearFib, onScreenshot,
onToggleStats, onToggleWatch, onToggleAlert,
tradeNotifyUnread = 0, onOpenTradeNotifications,
tradeNotifyUnread = 0, onOpenTradeNotifications, onOpenAppDownload,
magnetMode = 'off', onToggleMagnet,
onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings,
onToggleGrid,
@@ -1091,7 +1089,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
<li key={i} className="bt-drop-item">
<span className="bt-drop-name">
{s.name}
{s.source === 'local' && <span className="bt-drop-local-badge"></span>}
</span>
<button
className={`bt-drop-run${isRunning ? ' running' : ''}`}
@@ -1283,6 +1280,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
</svg>
</button>
)}
{onOpenAppDownload && (
<button
className="tv-icon-btn"
onClick={onOpenAppDownload}
title="GoldenChart 모바일 앱 다운로드"
aria-label="앱 다운로드"
>
<IcAppDownload />
</button>
)}
{onOpenTradeNotifications && (
<span className="tv-trade-notify-wrap">
<button
+22
View File
@@ -21,6 +21,8 @@ interface TopMenuBarProps {
/** 미확인 매매 시그널 알림 수 */
tradeNotifyUnread?: number;
onOpenNotifications?: () => void;
/** 모바일 앱 다운로드 모달 */
onOpenAppDownload?: () => void;
/** 우측에 떠 있는 알림 팝업(토스트) 개수 */
tradeNotifyToastCount?: number;
/** 모든 알림 팝업 닫기 */
@@ -79,6 +81,14 @@ const IcSettings = () => (
</svg>
);
const IcAppDownload = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/>
<line x1="8" y1="9" x2="8" y2="12.5"/>
<polyline points="6,10.5 8,12.5 10,10.5"/>
</svg>
);
const IcNotify = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
@@ -160,6 +170,7 @@ export const TopMenuBar = memo(function TopMenuBar({
activePage, theme, onPage, onTheme,
tradeNotifyUnread = 0,
onOpenNotifications,
onOpenAppDownload,
tradeNotifyToastCount = 0,
onDismissAllNotifyPopups,
tradeAlertPopupPosition = 'right',
@@ -209,6 +220,17 @@ export const TopMenuBar = memo(function TopMenuBar({
{/* 우측 영역 */}
<div className="tmb-right">
{onOpenAppDownload && (
<button
type="button"
className="tmb-app-download-btn"
onClick={onOpenAppDownload}
title="GoldenChart 모바일 앱 다운로드"
aria-label="앱 다운로드"
>
<IcAppDownload />
</button>
)}
{onOpenNotifications && showNotifications && (
<div className="tmb-notify-group">
<button
+2 -2
View File
@@ -221,8 +221,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevTheme = useRef<Theme>(theme);
const prevLogScale = useRef<boolean>(logScale);
const indicatorsRef = useRef(indicators);
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const recoveryTimerRef = useRef<number | null>(null);
const resizeDebounceRef = useRef<number | null>(null);
const chartVisibleRef = useRef(chartVisible);
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
+8 -13
View File
@@ -33,16 +33,6 @@ import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
const DISPLAY_MODE_KEY = 'tsd-display-mode';
function loadDisplayMode(): TrendSearchDisplayMode {
try {
const v = localStorage.getItem(DISPLAY_MODE_KEY);
if (v === 'chart' || v === 'summary') return v;
} catch { /* ignore */ }
return 'summary';
}
interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
@@ -68,7 +58,9 @@ const TrendSearchPage: React.FC<Props> = ({
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(
() => tsSettings.displayMode ?? 'summary',
);
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters);
@@ -84,6 +76,7 @@ const TrendSearchPage: React.FC<Props> = ({
if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
lastSyncedSettingsSigRef.current = tsSettingsSig;
setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
if (tsSettings.displayMode) setDisplayMode(tsSettings.displayMode);
}, [appSettingsLoaded, tsSettingsSig, tsSettings]);
useEffect(() => {
@@ -168,8 +161,10 @@ const TrendSearchPage: React.FC<Props> = ({
}, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
}, [displayMode]);
if (!appSettingsLoaded) return;
if (tsSettings.displayMode === displayMode) return;
persistTrendSettings({ displayMode });
}, [displayMode, appSettingsLoaded, tsSettings.displayMode, persistTrendSettings]);
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
setSelectedMarket(row.market);
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { getUiPreferences, patchUiPreferences } from '../../utils/uiPreferencesDb';
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
@@ -49,9 +50,9 @@ export function usePanelResize(
export function readStoredSize(key: string, fallback: number): number {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const n = Number(raw);
const raw = getUiPreferences().panels?.[key];
if (raw == null) return fallback;
const n = typeof raw === 'number' ? raw : Number(raw);
return Number.isFinite(n) ? n : fallback;
} catch {
return fallback;
@@ -59,22 +60,20 @@ export function readStoredSize(key: string, fallback: number): number {
}
export function storeSize(key: string, value: number): void {
try {
localStorage.setItem(key, String(Math.round(value)));
} catch { /* ignore */ }
const panels = { ...getUiPreferences().panels, [key]: Math.round(value) };
patchUiPreferences({ panels });
}
export function readStoredBool(key: string, fallback: boolean): boolean {
try {
const raw = localStorage.getItem(key);
if (raw === '1' || raw === 'true') return true;
if (raw === '0' || raw === 'false') return false;
const raw = getUiPreferences().panels?.[key];
if (raw === true || raw === '1' || raw === 'true') return true;
if (raw === false || raw === '0' || raw === 'false') return false;
} catch { /* ignore */ }
return fallback;
}
export function storeBool(key: string, value: boolean): void {
try {
localStorage.setItem(key, value ? '1' : '0');
} catch { /* ignore */ }
const panels = { ...getUiPreferences().panels, [key]: value };
patchUiPreferences({ panels });
}
@@ -27,8 +27,7 @@ import {
type TradeAlertSoundId,
} from '../utils/tradeAlertSound';
const STORAGE_KEY = 'gc_trade_notify_read_v1';
const HIDDEN_STORAGE_KEY = 'gc_trade_notify_hidden_v1';
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
const MAX_HISTORY = 300;
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
const MAX_TOAST_QUEUE = 50;
@@ -94,35 +93,21 @@ function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotification
}
function loadReadIds(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
const ids = getUiPreferences().tradeNotifications?.readIds;
return new Set(ids ?? []);
}
function saveReadIds(ids: Set<string>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
} catch { /* ignore */ }
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
}
function loadHiddenIds(): Set<string> {
try {
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
const ids = getUiPreferences().tradeNotifications?.hiddenIds;
return new Set(ids ?? []);
}
function saveHiddenIds(ids: Set<string>) {
try {
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...ids]));
} catch { /* ignore */ }
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
}
export function useTradeNotification(): TradeNotificationContextValue {
+12 -1
View File
@@ -49,6 +49,7 @@ import {
resolveChartPaneSeparatorOptions,
type ChartPaneSeparatorOptions,
} from '../types/chartPaneSeparator';
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
let _cache: AppSettingsDto | null = null;
@@ -73,8 +74,18 @@ export function subscribeAppSettings(listener: AppSettingsListener): () => void
function ensureLoaded(): Promise<AppSettingsDto> {
if (_cache !== null) return Promise.resolve(_cache);
if (_loadPromise) return _loadPromise;
_loadPromise = loadAppSettings().then(data => {
_loadPromise = loadAppSettings().then(async data => {
_cache = data ?? {};
const migrated = migrateLocalStorageToUiPreferences(_cache);
if (migrated?.changed) {
try {
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
} catch (e) {
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
}
}
_loadPromise = null;
notifyAppSettingsListeners();
return _cache;
+39
View File
@@ -0,0 +1,39 @@
import type { IndicatorConfig } from './index';
import type { PaletteItem } from '../utils/strategyPaletteStorage';
import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage';
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
/** gc_app_settings.ui_preferences_json 구조 */
export interface UiPreferences {
v?: number;
strategyEditor?: {
editorMode?: StrategyEditorMode;
canvasInteraction?: string;
paletteAuxiliary?: PaletteItem[];
paletteComposite?: PaletteItem[];
};
indicator?: {
customTabs?: IndicatorCustomTab[];
mainTabOrder?: string[];
listOrder?: string[];
};
chart?: {
alertPrices?: number[];
legacyIndicators?: IndicatorConfig[];
};
panels?: Record<string, number | boolean | string>;
virtual?: {
targets?: VirtualTargetItem[];
session?: VirtualSessionConfig;
cardViewMode?: VirtualCardViewMode;
};
tradeNotifications?: {
readIds?: string[];
hiddenIds?: string[];
};
}
export const UI_PREFERENCES_VERSION = 1;
export const EMPTY_UI_PREFERENCES: UiPreferences = { v: UI_PREFERENCES_VERSION };
+22
View File
@@ -5,6 +5,8 @@
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
*/
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
/** 백엔드 REST base (Docker 빌드: `/api`, 로컬 Vite: `.env` 또는 localhost:8080) */
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
@@ -444,6 +446,8 @@ export interface AppSettingsDto {
displayTimezone?: string;
/** 추세검색 기본 설정 */
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
}
export interface LiveSummaryDto {
@@ -555,6 +559,22 @@ export async function sendFcmTest(): Promise<{ ok: boolean; available: boolean }
return request<{ ok: boolean; available: boolean }>('/fcm/test', { method: 'POST' });
}
// ── 모바일 앱 배포 ─────────────────────────────────────────────────────────────
export interface MobileAppReleaseInfoDto {
available: boolean;
fileName?: string;
version?: string;
sizeBytes?: number;
updatedAt?: string;
downloadUrl?: string;
installPageUrl?: string;
}
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {
return request<MobileAppReleaseInfoDto>('/mobile-app/info');
}
// ── 모의투자 ───────────────────────────────────────────────────────────────────
export interface PaperPositionDto {
@@ -811,6 +831,8 @@ export interface StrategyDto {
description?: string;
buyCondition?: unknown; // LogicNode DSL
sellCondition?: unknown; // LogicNode DSL
/** 편집기 캔버스·START 분봉·고아 노드 (gc_strategy.flow_layout_json) */
flowLayout?: StrategyFlowLayoutStore;
enabled?: boolean;
createdAt?: string;
updatedAt?: string;
@@ -1,4 +1,5 @@
/** 지표 추가 팝업 — 사용자 정의 탭 (localStorage) */
/** 지표 추가 팝업 — 사용자 정의 탭 (DB) */
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
export interface IndicatorCustomTab {
id: string;
@@ -7,33 +8,16 @@ export interface IndicatorCustomTab {
indicatorTypes: string[];
}
const STORAGE_KEY = 'gc-indicator-custom-tabs-v1';
function newId(): string {
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
}
export function loadCustomTabs(): IndicatorCustomTab[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const arr = JSON.parse(raw) as unknown;
if (!Array.isArray(arr)) return [];
return arr.filter(
(t): t is IndicatorCustomTab =>
t != null
&& typeof t === 'object'
&& typeof (t as IndicatorCustomTab).id === 'string'
&& typeof (t as IndicatorCustomTab).name === 'string'
&& Array.isArray((t as IndicatorCustomTab).indicatorTypes),
);
} catch {
return [];
}
return getUiPreferences().indicator?.customTabs ?? [];
}
export function saveCustomTabs(tabs: IndicatorCustomTab[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
patchUiPreferences({ indicator: { customTabs: tabs } });
window.dispatchEvent(new CustomEvent('gc-indicator-custom-tabs-changed'));
}
+6 -17
View File
@@ -1,30 +1,19 @@
/**
* 보조지표 설정 목록 표시·차트 pane 순서 (localStorage)
* 보조지표 설정 목록 표시·차트 pane 순서 (DB)
*/
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
import type { IndicatorConfig } from '../types';
import { getPaneHostId } from './indicatorPaneMerge';
const STORAGE_KEY = 'gc_indicator_settings_list_order';
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
export function loadIndicatorListOrder(): string[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
} catch {
return null;
}
const order = getUiPreferences().indicator?.listOrder;
if (!order?.length) return null;
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
}
export function saveIndicatorListOrder(types: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
} catch {
/* quota */
}
patchUiPreferences({ indicator: { listOrder: types } });
}
/** 저장 순서 + registry 신규 type 병합 */
+6 -17
View File
@@ -1,29 +1,18 @@
/**
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서 (DB)
*/
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
import { mergeIndicatorListOrder } from './indicatorListOrder';
const STORAGE_KEY = 'gc_indicator_main_tab_order';
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
export function loadMainTabOrder(): string[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
} catch {
return null;
}
const order = getUiPreferences().indicator?.mainTabOrder;
if (!order?.length) return null;
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
}
export function saveMainTabOrder(types: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
} catch {
/* quota */
}
patchUiPreferences({ indicator: { mainTabOrder: types } });
}
export function getOrderedMainIndicatorTypes(): string[] {
+19 -36
View File
@@ -1,50 +1,36 @@
/**
* 관심종목(즐겨찾기) / 보유종목 관리
* - DB(gc_watchlist)가 기준 저장소
* - localStorage(gc_favorites)는 오프라인 미러·UI 동기화용
* - DB(gc_watchlist / gc_holdings)가 기준 저장소
* - 메모리 캐시만 사용 (localStorage 미사용)
*/
import { addWatchlistItem, removeWatchlistItem } from './backendApi';
const FAV_KEY = 'gc_favorites';
const LEGACY_FAV_KEY = 'upbit_market_favorites';
const HOLD_KEY = 'gc_holdings';
/** 관심종목 변경 시 MarketSearchPanel·App 등이 동기화하도록 발행 */
export const FAVORITES_CHANGED_EVENT = 'gc-favorites-changed';
// ── 즐겨찾기 (관심종목) ─────────────────────────────────────────────────────
let favoritesCache: string[] | null = null;
let holdingsCache: string[] | null = null;
/** 구버전 MarketSearchPanel 키 → gc_favorites 로 1회 마이그레이션 */
function migrateLegacyFavorites(): void {
try {
const raw = localStorage.getItem(FAV_KEY);
if (raw) {
const current = JSON.parse(raw) as string[];
if (Array.isArray(current) && current.length > 0) return;
}
const legacy = localStorage.getItem(LEGACY_FAV_KEY);
if (!legacy) return;
const parsed = JSON.parse(legacy) as string[];
if (Array.isArray(parsed) && parsed.length > 0) {
saveFavorites(parsed, false);
}
} catch { /* ignore */ }
/** DB 로드 후 App·워크스페이스 훅에서 호출 */
export function initFavoritesFromDb(symbols: string[]): void {
favoritesCache = [...symbols];
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: favoritesCache }));
}
export function initHoldingsFromDb(symbols: string[]): void {
holdingsCache = [...symbols];
}
export function getFavorites(): string[] {
migrateLegacyFavorites();
try { return JSON.parse(localStorage.getItem(FAV_KEY) ?? '[]'); }
catch { return []; }
return favoritesCache ?? [];
}
export function saveFavorites(markets: string[], notify = true): void {
try {
localStorage.setItem(FAV_KEY, JSON.stringify(markets));
if (notify) {
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
}
} catch { /* ignore */ }
favoritesCache = [...markets];
if (notify) {
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
}
}
/**
@@ -76,15 +62,12 @@ export function isFavorite(market: string): boolean {
return getFavorites().includes(market);
}
// ── 보유종목 ─────────────────────────────────────────────────────────────────
export function getHoldings(): string[] {
try { return JSON.parse(localStorage.getItem(HOLD_KEY) ?? '[]'); }
catch { return []; }
return holdingsCache ?? [];
}
export function saveHoldings(markets: string[]): void {
try { localStorage.setItem(HOLD_KEY, JSON.stringify(markets)); } catch { /* ignore */ }
holdingsCache = [...markets];
}
/** 추가 → 변경 후 전체 목록 반환 (중복 무시) */
+8 -22
View File
@@ -12,8 +12,7 @@ interface ChartState {
mainChartStyle?: MainChartStyle;
}
const KEY = 'trading_chart_state';
const CUR_VERSION = 8; // v8: 기본 지표 제거 — 초기 로딩 시 캔들차트만 표시
const CUR_VERSION = 8;
/** 기본 보조지표 없음 — 처음 로딩 시 캔들차트만 표시 */
const DEFAULT_INDICATORS: IndicatorConfig[] = [];
@@ -25,8 +24,7 @@ const DEFAULT_WATCHLIST = [
/**
* 절대적 최후 fallback 기본값.
* 우선순위: DB(gc_app_settings) > localStorage > 이 값.
* 타임프레임을 '1h' 에서 '1D' 로 통일 (ChartSlot 기본값과 일치).
* 우선순위: DB(gc_app_settings + gc_chart_workspace) > 이 값.
*/
const DEFAULT_STATE: ChartState = {
version: CUR_VERSION,
@@ -39,31 +37,19 @@ const DEFAULT_STATE: ChartState = {
alertPrices: [],
};
export function saveState(state: Partial<ChartState>): void {
try {
const existing = loadState();
localStorage.setItem(KEY, JSON.stringify({ ...existing, ...state, version: CUR_VERSION }));
} catch { /* ignore */ }
/** @deprecated DB·워크스페이스 사용 — no-op */
export function saveState(_state: Partial<ChartState>): void {
/* chart state는 gc_chart_workspace·gc_app_settings로 저장 */
}
/** 초기 마운트 fallback (DB 로드 전 1회) */
export function loadState(): ChartState {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const parsed = JSON.parse(raw) as ChartState;
// 버전이 다르면 기본값으로 재설정 (마이그레이션)
if (parsed.version !== CUR_VERSION) {
localStorage.removeItem(KEY);
return DEFAULT_STATE;
}
return parsed;
}
} catch { /* ignore */ }
return DEFAULT_STATE;
}
/** @deprecated */
export function clearState(): void {
localStorage.removeItem(KEY);
/* no-op */
}
export const DEFAULT_MAIN_CHART_STYLE: MainChartStyle = {
@@ -1,20 +1,14 @@
export type StrategyCanvasInteractionMode = 'pan' | 'select';
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
const STORAGE_KEY = 'gc_se_canvas_interaction';
export type CanvasInteractionMode = 'pan' | 'select';
/** @deprecated CanvasInteractionMode */
export type StrategyCanvasInteractionMode = CanvasInteractionMode;
export function loadCanvasInteractionMode(): StrategyCanvasInteractionMode {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === 'pan' ? 'pan' : 'select';
} catch {
return 'select';
}
export function loadCanvasInteractionMode(): CanvasInteractionMode {
const mode = getUiPreferences().strategyEditor?.canvasInteraction;
return mode === 'pan' ? 'pan' : 'select';
}
export function saveCanvasInteractionMode(mode: StrategyCanvasInteractionMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
/* ignore */
}
export function saveCanvasInteractionMode(mode: CanvasInteractionMode): void {
patchUiPreferences({ strategyEditor: { canvasInteraction: mode } });
}
+16 -18
View File
@@ -432,35 +432,33 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
}
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
function pickMergedStartCandleTypes(decoded: string[], stored?: string[]): string[] {
const fromDb = normalizeCandleTypesList(decoded);
const fromStored = stored?.length ? normalizeCandleTypesList(stored) : [];
const dbIsLegacyDefault = fromDb.length === 1 && fromDb[0] === '1m';
const storedDiffers = fromStored.length > 0
&& (fromStored.length !== fromDb.length || fromStored.some((ct, i) => ct !== fromDb[i]));
if (storedDiffers && (dbIsLegacyDefault || fromDb.length === 0)) {
return fromStored;
}
return fromDb;
}
/**
* 전략 로드 시 startMeta 병합 — DB가 1m 기본값만 있고 localStorage에 다른 분봉이 있으면 localStorage 우선.
* 전략 로드 시 startMeta 병합 — flow_layout_json(편집기)이 있으면 DSL TIMEFRAME보다 우선.
*/
export function mergeStartMetaForLoad(
decoded: Record<string, StartNodeMeta>,
stored?: Record<string, StartNodeMeta> | null,
): Record<string, StartNodeMeta> {
if (!stored || Object.keys(stored).length === 0) {
const fromDsl: Record<string, StartNodeMeta> = { ...defaultStartMeta(), ...decoded };
for (const [startId, meta] of Object.entries(decoded)) {
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
fromDsl[startId] = { candleTypes: types, candleType: types[0] };
}
return fromDsl;
}
const merged: Record<string, StartNodeMeta> = {
...defaultStartMeta(),
...stored,
};
for (const [startId, meta] of Object.entries(decoded)) {
const types = pickMergedStartCandleTypes(
getStartCandleTypes(meta),
stored?.[startId] ? getStartCandleTypes(stored[startId]) : undefined,
);
merged[startId] = { candleTypes: types, candleType: types[0] };
if (stored[startId]) {
const types = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
merged[startId] = { candleTypes: types, candleType: types[0] };
} else {
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
merged[startId] = { candleTypes: types, candleType: types[0] };
}
}
return merged;
}
@@ -25,7 +25,8 @@ export type StrategyFlowLayoutStore = {
sell: SignalFlowLayoutSnapshot;
};
const STORAGE_KEY = 'gc_se_flow_layout_v1';
/** @deprecated 레거시 localStorage 키 — 신규 저장은 DB flow_layout_json만 사용 */
const LEGACY_STORAGE_KEY = 'gc_se_flow_layout_v1';
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
return {
@@ -42,44 +43,67 @@ export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
return { buy: emptySignalFlowLayout(), sell: emptySignalFlowLayout() };
}
export function loadStrategyFlowLayout(strategyKey: string): StrategyFlowLayoutStore | null {
export function normalizeSignalFlowLayout(
snap: SignalFlowLayoutSnapshot | null | undefined,
): SignalFlowLayoutSnapshot {
if (!snap) return emptySignalFlowLayout();
return {
positions: snap.positions ?? {},
edgeHandles: snap.edgeHandles ?? {},
orphans: snap.orphans ?? [],
startMeta: snap.startMeta ?? defaultStartMeta(),
extraStartIds: snap.extraStartIds ?? [],
extraRoots: snap.extraRoots ?? {},
startCombineOp: snap.startCombineOp,
};
}
export function normalizeStrategyFlowLayout(
store: StrategyFlowLayoutStore | null | undefined,
): StrategyFlowLayoutStore | null {
if (!store) return null;
return {
buy: normalizeSignalFlowLayout(store.buy),
sell: normalizeSignalFlowLayout(store.sell),
};
}
export function buildStrategyFlowLayoutStore(
buy: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
sell: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
buyOrphans: LogicNode[],
sellOrphans: LogicNode[],
): StrategyFlowLayoutStore {
return {
buy: { ...buy, orphans: buyOrphans },
sell: { ...sell, orphans: sellOrphans },
};
}
/** DB에 flow layout이 없을 때만 1회 읽기 — 이후 저장은 서버로 이전 */
export function readLegacyFlowLayoutFromLocalStorage(
strategyKey: string,
): StrategyFlowLayoutStore | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!raw) return null;
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
return all[strategyKey] ?? null;
return normalizeStrategyFlowLayout(all[strategyKey] ?? null);
} catch {
return null;
}
}
export function saveStrategyFlowLayout(strategyKey: string, layout: StrategyFlowLayoutStore): void {
export function clearLegacyFlowLayoutFromLocalStorage(strategyKey: string): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const all: Record<string, StrategyFlowLayoutStore> = raw ? JSON.parse(raw) : {};
all[strategyKey] = layout;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore quota / private mode */
}
}
export function deleteStrategyFlowLayout(strategyKey: string): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!raw) return;
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
if (!(strategyKey in all)) return;
delete all[strategyKey];
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
if (Object.keys(all).length === 0) localStorage.removeItem(LEGACY_STORAGE_KEY);
else localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore */
}
}
export function migrateStrategyFlowLayout(fromKey: string, toKey: string): void {
if (fromKey === toKey) return;
const layout = loadStrategyFlowLayout(fromKey);
if (!layout) return;
saveStrategyFlowLayout(toKey, layout);
deleteStrategyFlowLayout(fromKey);
}
@@ -1,20 +1,12 @@
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
export type StrategyEditorMode = 'graph' | 'list';
const STORAGE_KEY = 'gc_se_editor_mode';
export function loadEditorMode(): StrategyEditorMode {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === 'list' ? 'list' : 'graph';
} catch {
return 'graph';
}
const mode = getUiPreferences().strategyEditor?.editorMode;
return mode === 'list' ? 'list' : 'graph';
}
export function saveEditorMode(mode: StrategyEditorMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
/* ignore */
}
patchUiPreferences({ strategyEditor: { editorMode: mode } });
}
+7 -5
View File
@@ -56,12 +56,15 @@ import {
} from '../utils/thresholdSymbols';
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
export interface StrategyDto {
id: number;
name: string;
description?: string;
buyCondition?: LogicNode | null;
sellCondition?: LogicNode | null;
flowLayout?: StrategyFlowLayoutStore;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
@@ -78,11 +81,10 @@ export type DefType = typeof DEF_DEFAULTS;
let _cnt = 0;
export const genId = () => `n_${++_cnt}_${Date.now()}`;
export const STORAGE_KEY = 'gc_strat_v2';
export const loadStratsLocal = (): StrategyDto[] => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
};
export const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
/** @deprecated DB(gc_strategy)만 사용 */
export const loadStratsLocal = (): StrategyDto[] => [];
/** @deprecated */
export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
+22 -24
View File
@@ -1,4 +1,5 @@
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (localStorage) */
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
export type PaletteItemKind = 'auxiliary' | 'composite';
@@ -18,9 +19,6 @@ export interface PaletteItem {
builtIn?: boolean;
}
const STORAGE_AUX = 'se-palette-auxiliary-v1';
const STORAGE_COMP = 'se-palette-composite-v1';
export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', builtIn: true },
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', builtIn: true },
@@ -111,32 +109,32 @@ function mergeMissingBuiltIns(
}
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
try {
const stored = parseStored(localStorage.getItem(key));
if (stored && stored.length > 0) {
return mergeMissingBuiltIns(
stored.map(i => ({
...i,
kind,
...(i.builtIn
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
: {}),
})),
const prefs = getUiPreferences().strategyEditor;
const raw = kind === 'auxiliary' ? prefs?.paletteAuxiliary : prefs?.paletteComposite;
const stored = raw?.length ? raw : null;
if (stored && stored.length > 0) {
return mergeMissingBuiltIns(
stored.map(i => ({
...i,
kind,
defaults,
);
}
} catch { /* ignore */ }
...(i.builtIn
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
: {}),
})),
kind,
defaults,
);
}
return seedItems(kind, defaults);
}
export function savePaletteItems(kind: PaletteItemKind, items: PaletteItem[]): void {
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
try {
localStorage.setItem(key, JSON.stringify(items));
} catch { /* ignore */ }
if (kind === 'auxiliary') {
patchUiPreferences({ strategyEditor: { paletteAuxiliary: items } });
} else {
patchUiPreferences({ strategyEditor: { paletteComposite: items } });
}
}
export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
+47 -29
View File
@@ -1,5 +1,4 @@
import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
import {
alignBuySellStartCandleTypesForSave,
collectDslBranches,
@@ -14,8 +13,7 @@ import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNo
import type { LogicNode } from './strategyTypes';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
function collectFromFlowLayout(strategyId: number): string[] {
const layout = loadStrategyFlowLayout(String(strategyId));
function collectFromFlowLayout(layout: StrategyFlowLayoutStore | null | undefined): string[] {
if (!layout) return [];
const set = new Set<string>();
for (const side of ['buy', 'sell'] as const) {
@@ -43,9 +41,9 @@ function collectFromStrategyDto(strategy: StrategyDto | null | undefined): strin
return [...set];
}
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 (서버 DSL보다 우선) */
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 */
export function collectUiEvaluationTimeframes(
strategyId: number,
_strategyId: number,
strategy?: StrategyDto | null,
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
): string[] {
@@ -56,7 +54,7 @@ export function collectUiEvaluationTimeframes(
].map(normalizeStartCandleType);
if (fromEditor.length > 0) return [...new Set(fromEditor)];
}
const fromLayout = collectFromFlowLayout(strategyId);
const fromLayout = collectFromFlowLayout(strategy?.flowLayout);
if (fromLayout.length > 0) return fromLayout;
return collectFromStrategyDto(strategy);
}
@@ -85,7 +83,6 @@ export async function warnStrategyTimeframeMismatch(
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
// 양쪽 모두 1m만 — 일치로 간주
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
return true;
}
@@ -94,14 +91,14 @@ export async function warnStrategyTimeframeMismatch(
const serverLabel = [...serverSet].join(', ') || '1m';
window.alert(
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 자동 저장된 뒤 실시간 체크를 켜 주세요.',
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
);
return false;
}
function buildEditorStateFromStrategy(
strategy: StrategyDto,
layout: StrategyFlowLayoutStore | null,
layout: StrategyFlowLayoutStore | null | undefined,
side: 'buy' | 'sell',
): EditorConditionState {
const decoded = decodeConditionForEditor(
@@ -118,14 +115,16 @@ function buildEditorStateFromStrategy(
}
/**
* flow layout(localStorage)의 START 분봉이 서버 DSL과 다르면 편집기 저장 없이 자동 동기화.
* @returns false — layout 없음 등으로 동기화 불가
* DB flow layout의 START 분봉이 서버 DSL과 다르면 자동 동기화.
*/
export async function syncStrategyTimeframesFromLayoutIfNeeded(
strategyId: number,
strategy?: StrategyDto | null,
): Promise<boolean> {
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy);
const strat = strategy ?? await loadStrategy(strategyId);
if (!strat) return true;
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
if (uiTfs.length === 0) return true;
let serverTfs: string[];
@@ -139,14 +138,11 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
if (missingOnServer.length === 0) return true;
const layout = loadStrategyFlowLayout(String(strategyId));
const layout = strat.flowLayout;
if (!layout) {
return warnStrategyTimeframeMismatch(strategyId, strategy);
return warnStrategyTimeframeMismatch(strategyId, strat);
}
const strat = strategy ?? await loadStrategy(strategyId);
if (!strat) return true;
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
const sellState = buildEditorStateFromStrategy(strat, layout, 'sell');
@@ -156,6 +152,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
strat.description ?? '',
buyState,
sellState,
layout,
);
try {
await repairStrategyTimeframes(strategyId);
@@ -165,25 +162,46 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
return true;
}
/** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
/** 편집기 상태(DSL + flow layout)를 DB에 저장 — 실시간 평가 캐시는 서버에서 무효화됨 */
export async function persistStrategyEditorState(
strategyId: number,
name: string,
description: string,
buy: EditorConditionState,
sell: EditorConditionState,
flowLayout: StrategyFlowLayoutStore,
): Promise<StrategyDto> {
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
const saved = await saveStrategy({
id: strategyId,
name,
description,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
});
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('gc:strategy-saved', {
detail: { strategyId, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout },
}));
}
return saved;
}
/** @deprecated persistStrategyEditorState 사용 */
export async function persistStrategyEvaluationTimeframes(
strategyId: number,
name: string,
description: string,
buy: EditorConditionState,
sell: EditorConditionState,
flowLayout?: StrategyFlowLayoutStore,
): Promise<void> {
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
await saveStrategy({
id: strategyId,
name,
description,
buyCondition: encodedBuy,
sellCondition: encodedSell,
enabled: true,
});
if (!flowLayout) return;
await persistStrategyEditorState(strategyId, name, description, buy, sell, flowLayout);
}
export type { StrategyFlowLayoutStore };
@@ -9,7 +9,12 @@ import {
type BullishWeightKey,
} from './trendSearchBullishWeights';
/** 결과 목록 표시: chart | summary(신호) */
export type TrendSearchDisplayMode = 'chart' | 'summary';
export interface TrendSearchAppSettings {
/** 결과 UI 표시 모드 (기본 summary) */
displayMode?: TrendSearchDisplayMode;
weightMaAlignment: number;
weightMaSlope: number;
weightAdxTrend: number;
@@ -32,6 +37,7 @@ export interface TrendSearchAppSettings {
}
export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
displayMode: 'summary',
weightMaAlignment: DEFAULT_BULLISH_WEIGHTS.weightMaAlignment,
weightMaSlope: DEFAULT_BULLISH_WEIGHTS.weightMaSlope,
weightAdxTrend: DEFAULT_BULLISH_WEIGHTS.weightAdxTrend,
@@ -59,7 +65,11 @@ export function resolveTrendSearchAppSettings(
const rank = Number(raw.autoAddTopRank);
const limit = Number(raw.limit);
const scanLimit = Number(raw.scanLimit);
const displayMode = raw.displayMode === 'chart' || raw.displayMode === 'summary'
? raw.displayMode
: d.displayMode ?? 'summary';
return {
displayMode,
weightMaAlignment: clampWeight(raw.weightMaAlignment, d.weightMaAlignment),
weightMaSlope: clampWeight(raw.weightMaSlope, d.weightMaSlope),
weightAdxTrend: clampWeight(raw.weightAdxTrend, d.weightAdxTrend),
+106
View File
@@ -0,0 +1,106 @@
/**
* UI 설정 — gc_app_settings.ui_preferences_json (DB 단일 소스)
*/
import {
getAppSettingsCache,
subscribeAppSettings,
} from '../hooks/useAppSettings';
import { saveAppSettings, type AppSettingsDto } from './backendApi';
import {
EMPTY_UI_PREFERENCES,
UI_PREFERENCES_VERSION,
type UiPreferences,
} from '../types/uiPreferences';
const SAVE_DEBOUNCE_MS = 400;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let pendingPatch: Partial<UiPreferences> | null = null;
function isPlainObject(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === 'object' && !Array.isArray(v);
}
function deepMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
const out = { ...base } as T;
for (const key of Object.keys(patch) as (keyof T)[]) {
const pv = patch[key];
if (pv === undefined) continue;
const bv = base[key];
if (isPlainObject(bv) && isPlainObject(pv)) {
out[key] = deepMerge(bv as Record<string, unknown>, pv as Record<string, unknown>) as T[keyof T];
} else {
out[key] = pv as T[keyof T];
}
}
return out;
}
export function resolveUiPreferences(raw: unknown): UiPreferences {
if (!raw || typeof raw !== 'object') {
return { ...EMPTY_UI_PREFERENCES };
}
const o = raw as UiPreferences;
return {
...EMPTY_UI_PREFERENCES,
...o,
v: UI_PREFERENCES_VERSION,
strategyEditor: { ...EMPTY_UI_PREFERENCES.strategyEditor, ...o.strategyEditor },
indicator: { ...EMPTY_UI_PREFERENCES.indicator, ...o.indicator },
chart: { ...EMPTY_UI_PREFERENCES.chart, ...o.chart },
panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels },
virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual },
tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications },
};
}
/** DB 캐시 기준 UI 설정 (앱 설정 로드 후 유효) */
export function getUiPreferences(): UiPreferences {
return resolveUiPreferences(getAppSettingsCache().uiPreferences);
}
/** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */
export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = false): void {
const current = getUiPreferences();
const next = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>) as UiPreferences;
next.v = UI_PREFERENCES_VERSION;
const cache = getAppSettingsCache();
const merged: AppSettingsDto = { ...cache, uiPreferences: next };
Object.assign(cache, merged);
pendingPatch = pendingPatch
? (deepMerge(pendingPatch as Record<string, unknown>, patch as Record<string, unknown>) as Partial<UiPreferences>)
: patch;
const flush = () => {
saveTimer = null;
const toSave = pendingPatch
? resolveUiPreferences(deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>))
: next;
pendingPatch = null;
void saveAppSettings({ uiPreferences: toSave }).catch(err => {
console.warn('[uiPreferences] DB 저장 실패:', err);
});
};
if (immediate) {
if (saveTimer != null) clearTimeout(saveTimer);
pendingPatch = null;
void saveAppSettings({ uiPreferences: next }).catch(err => {
console.warn('[uiPreferences] DB 저장 실패:', err);
});
return;
}
if (saveTimer != null) clearTimeout(saveTimer);
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
}
export function subscribeUiPreferences(listener: () => void): () => void {
return subscribeAppSettings(listener);
}
/** 중첩 경로 헬퍼 */
export function getUiPref<K extends keyof UiPreferences>(key: K): UiPreferences[K] {
return getUiPreferences()[key];
}
+165
View File
@@ -0,0 +1,165 @@
/**
* localStorage → gc_app_settings.ui_preferences_json 1회 이전
*/
import type { AppSettingsDto } from './backendApi';
import type { UiPreferences } from '../types/uiPreferences';
import { resolveUiPreferences } from './uiPreferencesDb';
const MIGRATED_FLAG = 'gc_ui_prefs_migrated_v1';
function readJson<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function removeKeys(keys: string[]): void {
for (const k of keys) {
try { localStorage.removeItem(k); } catch { /* ignore */ }
}
}
/** 이미 DB에 uiPreferences가 있으면 스킵. 없으면 localStorage 병합 후 키 제거. */
export function migrateLocalStorageToUiPreferences(
current: AppSettingsDto,
): { uiPreferences: UiPreferences; changed: boolean } | null {
try {
if (localStorage.getItem(MIGRATED_FLAG) === '1' && current.uiPreferences) {
return null;
}
} catch { /* ignore */ }
const base = resolveUiPreferences(current.uiPreferences);
const patch: UiPreferences = { ...base };
let changed = false;
const editorMode = localStorage.getItem('gc_se_editor_mode');
if (editorMode === 'list' || editorMode === 'graph') {
patch.strategyEditor = { ...patch.strategyEditor, editorMode };
changed = true;
}
const canvasMode = localStorage.getItem('gc_se_canvas_interaction');
if (canvasMode) {
patch.strategyEditor = { ...patch.strategyEditor, canvasInteraction: canvasMode };
changed = true;
}
const paletteAux = readJson<unknown[]>('se-palette-auxiliary-v1');
if (paletteAux?.length) {
patch.strategyEditor = { ...patch.strategyEditor, paletteAuxiliary: paletteAux as NonNullable<UiPreferences['strategyEditor']>['paletteAuxiliary'] };
changed = true;
}
const paletteComp = readJson<unknown[]>('se-palette-composite-v1');
if (paletteComp?.length) {
patch.strategyEditor = { ...patch.strategyEditor, paletteComposite: paletteComp as NonNullable<UiPreferences['strategyEditor']>['paletteComposite'] };
changed = true;
}
const customTabs = readJson<NonNullable<UiPreferences['indicator']>['customTabs']>('gc-indicator-custom-tabs-v1' as never);
if (customTabs?.length) {
patch.indicator = { ...patch.indicator, customTabs };
changed = true;
}
const mainTabOrder = readJson<string[]>('gc_indicator_main_tab_order');
if (mainTabOrder?.length) {
patch.indicator = { ...patch.indicator, mainTabOrder };
changed = true;
}
const listOrder = readJson<string[]>('gc_indicator_settings_list_order');
if (listOrder?.length) {
patch.indicator = { ...patch.indicator, listOrder };
changed = true;
}
const chartState = readJson<{ alertPrices?: number[]; indicators?: unknown[] }>('trading_chart_state');
if (chartState?.alertPrices?.length || chartState?.indicators?.length) {
patch.chart = {
...patch.chart,
alertPrices: chartState.alertPrices,
legacyIndicators: chartState.indicators as NonNullable<UiPreferences['chart']>['legacyIndicators'],
};
changed = true;
}
const panels: Record<string, number | boolean | string> = { ...patch.panels };
for (const key of ['se-left-width', 'se-terminal-height', 'btd-left-width', 'btd-right-width']) {
const v = localStorage.getItem(key);
if (v != null) {
const n = Number(v);
panels[key] = Number.isFinite(n) ? n : v;
changed = true;
}
}
const virtualTargets = readJson<unknown[]>('gc_virtual_targets_v1');
const virtualSession = readJson<unknown>('gc_virtual_session_v1');
const cardView = localStorage.getItem('gc_virtual_card_view_v1');
if (virtualTargets?.length || virtualSession || cardView) {
patch.virtual = {
...patch.virtual,
...(virtualTargets?.length ? { targets: virtualTargets as NonNullable<UiPreferences['virtual']>['targets'] } : {}),
...(virtualSession ? { session: virtualSession as NonNullable<UiPreferences['virtual']>['session'] } : {}),
...(cardView === 'detail' || cardView === 'summary' ? { cardViewMode: cardView } : {}),
};
changed = true;
}
const readIds = readJson<string[]>('gc_trade_notify_read_v1');
const hiddenIds = readJson<string[]>('gc_trade_notify_hidden_v1');
if (readIds?.length || hiddenIds?.length) {
patch.tradeNotifications = {
readIds: readIds ?? [],
hiddenIds: hiddenIds ?? [],
};
changed = true;
}
const displayMode = localStorage.getItem('tsd-display-mode');
if (displayMode === 'chart' || displayMode === 'summary') {
changed = true;
}
if (!changed && current.uiPreferences) {
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
return null;
}
if (!changed) return null;
removeKeys([
'gc_se_editor_mode',
'gc_se_canvas_interaction',
'se-palette-auxiliary-v1',
'se-palette-composite-v1',
'gc-indicator-custom-tabs-v1',
'gc_indicator_main_tab_order',
'gc_indicator_settings_list_order',
'trading_chart_state',
'chartLayout',
'chartLayoutSync',
'gc_virtual_targets_v1',
'gc_virtual_session_v1',
'gc_virtual_card_view_v1',
'gc_trade_notify_read_v1',
'gc_trade_notify_hidden_v1',
'tsd-display-mode',
'gc_se_flow_layout_v1',
'gc_strat_v2',
'gc_strategies_v1',
'se-left-width',
'se-terminal-height',
'btd-left-width',
'btd-right-width',
]);
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
return { uiPreferences: resolveUiPreferences(patch), changed: true };
}
+49 -69
View File
@@ -1,7 +1,8 @@
import { normalizeStartCandleType } from './strategyStartNodes';
import { resolveVirtualTargetNames } from './virtualTargetNames';
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
/** 가상투자 대상 종목·세션 설정 localStorage */
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
export interface VirtualTargetItem {
market: string;
@@ -22,68 +23,9 @@ export interface VirtualSessionConfig {
running: boolean;
}
const TARGETS_KEY = 'gc_virtual_targets_v1';
const SESSION_KEY = 'gc_virtual_session_v1';
const CARD_VIEW_KEY = 'gc_virtual_card_view_v1';
/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */
export type VirtualCardViewMode = 'summary' | 'detail';
export function loadVirtualTargets(): VirtualTargetItem[] {
try {
const raw = localStorage.getItem(TARGETS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as VirtualTargetItem[];
if (!Array.isArray(parsed)) return [];
return parsed.map(t => {
const { koreanName, englishName } = resolveVirtualTargetNames(
t.market,
t.koreanName,
t.englishName,
);
return { ...t, koreanName, englishName };
});
} catch {
return [];
}
}
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
try {
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
notifyVirtualSessionChanged();
} catch { /* ignore */ }
}
export function loadVirtualSession(): VirtualSessionConfig {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return defaultSession();
const parsed = JSON.parse(raw) as Partial<VirtualSessionConfig>;
return {
globalStrategyId: parsed.globalStrategyId ?? null,
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
running: parsed.running === true,
};
} catch {
return defaultSession();
}
}
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
notifyVirtualSessionChanged();
} catch { /* ignore */ }
}
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
export function notifyVirtualSessionChanged(): void {
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
}
function defaultSession(): VirtualSessionConfig {
return {
globalStrategyId: null,
@@ -93,19 +35,57 @@ function defaultSession(): VirtualSessionConfig {
};
}
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
return items.map(t => {
const { koreanName, englishName } = resolveVirtualTargetNames(
t.market,
t.koreanName,
t.englishName,
);
return { ...t, koreanName, englishName };
});
}
export function loadVirtualTargets(): VirtualTargetItem[] {
const raw = getUiPreferences().virtual?.targets;
if (!raw?.length) return [];
return normalizeTargets(raw);
}
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
patchUiPreferences({ virtual: { targets: items } });
notifyVirtualSessionChanged();
}
export function loadVirtualSession(): VirtualSessionConfig {
const parsed = getUiPreferences().virtual?.session;
if (!parsed) return defaultSession();
return {
globalStrategyId: parsed.globalStrategyId ?? null,
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
running: parsed.running === true,
};
}
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
patchUiPreferences({ virtual: { session: cfg } });
notifyVirtualSessionChanged();
}
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
export function notifyVirtualSessionChanged(): void {
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
}
export function loadVirtualCardViewMode(): VirtualCardViewMode {
try {
const raw = localStorage.getItem(CARD_VIEW_KEY);
return raw === 'detail' ? 'detail' : 'summary';
} catch {
return 'summary';
}
const mode = getUiPreferences().virtual?.cardViewMode;
return mode === 'detail' ? 'detail' : 'summary';
}
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
try {
localStorage.setItem(CARD_VIEW_KEY, mode);
} catch { /* ignore */ }
patchUiPreferences({ virtual: { cardViewMode: mode } });
}
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
+7
View File
@@ -21,6 +21,13 @@ export default defineConfig({
},
server: {
proxy: {
// ── 모바일 APK 정보/다운로드 → exdev (로컬 QR·PC 다운로드가 exdev APK 사용) ──
'/api/mobile-app': {
target: process.env.VITE_MOBILE_APP_PROXY_TARGET || 'http://exdev.co.kr',
changeOrigin: true,
secure: true,
},
// ── GoldenChart Backend API 프록시 ───────────────────────────
'/api': {
target: 'http://localhost:8080',