diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 92be2540..5f9cebbf 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -49,13 +49,13 @@
"license": "MIT"
},
"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"
},
@@ -64,9 +64,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
- "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "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": {
@@ -74,21 +74,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",
@@ -105,14 +105,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"
@@ -122,14 +122,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"
@@ -139,9 +139,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": {
@@ -149,29 +149,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"
@@ -181,9 +181,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": {
@@ -191,9 +191,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": {
@@ -201,9 +201,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": {
@@ -211,27 +211,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.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
- "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "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"
@@ -241,33 +241,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": {
@@ -275,23 +275,23 @@
}
},
"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"
}
},
"node_modules/@codemirror/autocomplete": {
- "version": "6.20.1",
- "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
- "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
@@ -355,20 +355,20 @@
}
},
"node_modules/@codemirror/lint": {
- "version": "6.9.5",
- "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
- "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.35.0",
+ "@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
- "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
@@ -398,9 +398,9 @@
}
},
"node_modules/@codemirror/view": {
- "version": "6.41.1",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz",
- "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
+ "version": "6.43.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
+ "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
@@ -601,29 +601,43 @@
}
},
"node_modules/@humanfs/core": {
- "version": "0.19.1",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
- "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
- "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@humanfs/core": "^0.19.1",
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -727,9 +741,9 @@
}
},
"node_modules/@lezer/python": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
- "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
+ "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
@@ -755,14 +769,14 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
- "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
+ "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@tybys/wasm-util": "^0.10.1"
+ "@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
@@ -813,9 +827,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.127.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
- "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "version": "0.133.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
+ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -859,9 +873,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
+ "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"cpu": [
"arm64"
],
@@ -876,9 +890,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
+ "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"cpu": [
"arm64"
],
@@ -893,9 +907,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
+ "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"cpu": [
"x64"
],
@@ -910,9 +924,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
+ "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"cpu": [
"x64"
],
@@ -927,9 +941,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
- "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
+ "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"cpu": [
"arm"
],
@@ -944,13 +958,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
+ "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -961,13 +978,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
+ "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -978,13 +998,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
+ "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"cpu": [
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -995,13 +1018,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
+ "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"cpu": [
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1012,13 +1038,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
+ "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1029,13 +1058,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
+ "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1046,9 +1078,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
+ "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"cpu": [
"arm64"
],
@@ -1063,9 +1095,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
- "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
+ "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"cpu": [
"wasm32"
],
@@ -1082,9 +1114,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
+ "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"cpu": [
"arm64"
],
@@ -1099,9 +1131,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
+ "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"cpu": [
"x64"
],
@@ -1116,9 +1148,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.7",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
- "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -1171,49 +1203,49 @@
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
- "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
+ "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "^5.19.0",
- "jiti": "^2.6.1",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.2.2"
+ "tailwindcss": "4.3.1"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
- "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
+ "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.2.2",
- "@tailwindcss/oxide-darwin-arm64": "4.2.2",
- "@tailwindcss/oxide-darwin-x64": "4.2.2",
- "@tailwindcss/oxide-freebsd-x64": "4.2.2",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
- "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
- "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
- "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
- "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
- "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
+ "@tailwindcss/oxide-android-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.1",
+ "@tailwindcss/oxide-darwin-x64": "4.3.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
- "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
+ "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
"cpu": [
"arm64"
],
@@ -1228,9 +1260,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
- "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
+ "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
"cpu": [
"arm64"
],
@@ -1245,9 +1277,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
- "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
+ "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
"cpu": [
"x64"
],
@@ -1262,9 +1294,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
- "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
+ "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
"cpu": [
"x64"
],
@@ -1279,9 +1311,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
- "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
+ "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
"cpu": [
"arm"
],
@@ -1296,13 +1328,16 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
- "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
+ "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1313,13 +1348,16 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
- "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
+ "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1330,13 +1368,16 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
- "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
+ "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1347,13 +1388,16 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
- "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
+ "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1364,9 +1408,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
- "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
+ "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -1382,11 +1426,11 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.8.1",
- "@emnapi/runtime": "^1.8.1",
- "@emnapi/wasi-threads": "^1.1.0",
- "@napi-rs/wasm-runtime": "^1.1.1",
- "@tybys/wasm-util": "^0.10.1",
+ "@emnapi/core": "^1.10.0",
+ "@emnapi/runtime": "^1.10.0",
+ "@emnapi/wasi-threads": "^1.2.1",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
@@ -1394,9 +1438,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
- "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
+ "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
"cpu": [
"arm64"
],
@@ -1411,9 +1455,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
- "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
+ "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
"cpu": [
"x64"
],
@@ -1428,24 +1472,24 @@
}
},
"node_modules/@tailwindcss/vite": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
- "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz",
+ "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@tailwindcss/node": "4.2.2",
- "@tailwindcss/oxide": "4.2.2",
- "tailwindcss": "4.2.2"
+ "@tailwindcss/node": "4.3.1",
+ "@tailwindcss/oxide": "4.3.1",
+ "tailwindcss": "4.3.1"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.95.2",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
- "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
+ "version": "5.101.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
+ "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1453,12 +1497,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.95.2",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
- "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
+ "version": "5.101.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
+ "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.95.2"
+ "@tanstack/query-core": "5.101.0"
},
"funding": {
"type": "github",
@@ -1469,9 +1513,9 @@
}
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1552,9 +1596,9 @@
}
},
"node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/estree-jsx": {
@@ -1604,19 +1648,19 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "24.12.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
- "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
+ "version": "24.13.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
+ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.16.0"
+ "undici-types": "~7.18.0"
}
},
"node_modules/@types/react": {
- "version": "19.2.14",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
- "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1645,20 +1689,20 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
- "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
+ "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/type-utils": "8.57.2",
- "@typescript-eslint/utils": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/scope-manager": "8.61.1",
+ "@typescript-eslint/type-utils": "8.61.1",
+ "@typescript-eslint/utils": "8.61.1",
+ "@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1668,9 +1712,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.57.2",
+ "@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -1684,16 +1728,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
- "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
+ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/scope-manager": "8.61.1",
+ "@typescript-eslint/types": "8.61.1",
+ "@typescript-eslint/typescript-estree": "8.61.1",
+ "@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3"
},
"engines": {
@@ -1705,18 +1749,18 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
- "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
+ "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.57.2",
- "@typescript-eslint/types": "^8.57.2",
+ "@typescript-eslint/tsconfig-utils": "^8.61.1",
+ "@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3"
},
"engines": {
@@ -1727,18 +1771,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
- "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
+ "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2"
+ "@typescript-eslint/types": "8.61.1",
+ "@typescript-eslint/visitor-keys": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1749,9 +1793,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
- "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
+ "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1762,21 +1806,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
- "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
+ "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/utils": "8.57.2",
+ "@typescript-eslint/types": "8.61.1",
+ "@typescript-eslint/typescript-estree": "8.61.1",
+ "@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1787,13 +1831,13 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
- "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
+ "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1805,21 +1849,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
- "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
+ "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.57.2",
- "@typescript-eslint/tsconfig-utils": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/project-service": "8.61.1",
+ "@typescript-eslint/tsconfig-utils": "8.61.1",
+ "@typescript-eslint/types": "8.61.1",
+ "@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1829,7 +1873,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
@@ -1843,9 +1887,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
- "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1856,13 +1900,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "10.2.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
- "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^5.0.2"
+ "brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -1872,9 +1916,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
+ "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -1885,16 +1929,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
- "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
+ "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2"
+ "@typescript-eslint/scope-manager": "8.61.1",
+ "@typescript-eslint/types": "8.61.1",
+ "@typescript-eslint/typescript-estree": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1905,17 +1949,17 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
- "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
+ "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1940,19 +1984,19 @@
}
},
"node_modules/@ungap/structured-clone": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
- "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
+ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
- "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
+ "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@rolldown/pluginutils": "1.0.0-rc.7"
+ "@rolldown/pluginutils": "^1.0.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -1993,9 +2037,9 @@
}
},
"node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -2016,9 +2060,9 @@
}
},
"node_modules/ajv": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
- "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2072,9 +2116,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.10",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
- "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
+ "version": "2.10.38",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2085,9 +2129,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2096,9 +2140,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dev": true,
"funding": [
{
@@ -2116,11 +2160,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
@@ -2140,9 +2184,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001781",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
- "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
@@ -2532,30 +2576,30 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.321",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
- "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
+ "version": "1.5.376",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
+ "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
- "version": "5.20.1",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
- "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+ "version": "5.21.6",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
+ "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
- "tapable": "^2.3.0"
+ "tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/es-toolkit": {
- "version": "1.46.1",
- "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
- "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
+ "version": "1.47.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
+ "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
"license": "MIT",
"workspaces": [
"docs",
@@ -2646,9 +2690,9 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
- "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2662,13 +2706,13 @@
"node": ">=18"
},
"peerDependencies": {
- "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
- "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz",
+ "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -2926,9 +2970,9 @@
}
},
"node_modules/globals": {
- "version": "17.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
- "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
+ "version": "17.6.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
+ "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3186,9 +3230,9 @@
}
},
"node_modules/jiti": {
- "version": "2.6.1",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
- "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -3203,9 +3247,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -3428,6 +3482,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3449,6 +3506,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3470,6 +3530,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3491,6 +3554,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3547,9 +3613,9 @@
}
},
"node_modules/lightweight-charts": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
- "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
+ "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
@@ -3599,9 +3665,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.0.1.tgz",
- "integrity": "sha512-lih7tKEczCYOQjVEzpFuxEuNzlwf+1yhvlMlEkGWJM3va8Pugv8bYXc/pRtcjPncaP7k84X0Pt/71ufxvqEPtQ==",
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
+ "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -4492,9 +4558,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.13",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
+ "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
"dev": true,
"funding": [
{
@@ -4518,11 +4584,14 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.36",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
- "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "version": "2.0.48",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
+ "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/optionator": {
"version": "0.9.4",
@@ -4689,9 +4758,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.13",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
- "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -4709,7 +4778,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -4728,9 +4797,9 @@
}
},
"node_modules/property-information": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
- "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -4748,30 +4817,30 @@
}
},
"node_modules/react": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
- "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.4"
+ "react": "^19.2.7"
}
},
"node_modules/react-is": {
- "version": "19.2.6",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
- "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
+ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
@@ -4826,9 +4895,9 @@
}
},
"node_modules/react-router": {
- "version": "7.13.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
- "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
+ "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4848,12 +4917,12 @@
}
},
"node_modules/react-router-dom": {
- "version": "7.13.2",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
- "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
+ "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
"license": "MIT",
"dependencies": {
- "react-router": "7.13.2"
+ "react-router": "7.18.0"
},
"engines": {
"node": ">=20.0.0"
@@ -4991,14 +5060,14 @@
}
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
- "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
+ "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.127.0",
- "@rolldown/pluginutils": "1.0.0-rc.17"
+ "@oxc-project/types": "=0.133.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -5007,29 +5076,22 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
- }
- },
- "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
- "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
- "dev": true,
- "license": "MIT"
+ "@rolldown/binding-android-arm64": "1.0.3",
+ "@rolldown/binding-darwin-arm64": "1.0.3",
+ "@rolldown/binding-darwin-x64": "1.0.3",
+ "@rolldown/binding-freebsd-x64": "1.0.3",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.3",
+ "@rolldown/binding-linux-arm64-musl": "1.0.3",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.3",
+ "@rolldown/binding-linux-x64-gnu": "1.0.3",
+ "@rolldown/binding-linux-x64-musl": "1.0.3",
+ "@rolldown/binding-openharmony-arm64": "1.0.3",
+ "@rolldown/binding-wasm32-wasi": "1.0.3",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.3",
+ "@rolldown/binding-win32-x64-msvc": "1.0.3"
+ }
},
"node_modules/scheduler": {
"version": "0.27.0",
@@ -5161,16 +5223,16 @@
}
},
"node_modules/tailwindcss": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
- "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
+ "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz",
- "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5188,9 +5250,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.16",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
- "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5273,16 +5335,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
- "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
+ "version": "8.61.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
+ "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.57.2",
- "@typescript-eslint/parser": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/utils": "8.57.2"
+ "@typescript-eslint/eslint-plugin": "8.61.1",
+ "@typescript-eslint/parser": "8.61.1",
+ "@typescript-eslint/typescript-estree": "8.61.1",
+ "@typescript-eslint/utils": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -5293,13 +5355,13 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
- "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
@@ -5521,17 +5583,17 @@
}
},
"node_modules/vite": {
- "version": "8.0.10",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
- "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "version": "8.0.16",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
+ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
- "postcss": "^8.5.10",
- "rolldown": "1.0.0-rc.17",
- "tinyglobby": "^0.2.16"
+ "postcss": "^8.5.15",
+ "rolldown": "1.0.3",
+ "tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@@ -5547,7 +5609,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
+ "@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5672,9 +5734,9 @@
}
},
"node_modules/zod": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"devOptional": true,
"license": "MIT",
"funding": {
diff --git a/handlers/bots/__init__.py b/handlers/bots/__init__.py
index 3ef6e6b3..230dd943 100644
--- a/handlers/bots/__init__.py
+++ b/handlers/bots/__init__.py
@@ -58,6 +58,7 @@
handle_deploy_use_default,
handle_edit_config,
handle_execute_deploy,
+# ── Grid Strike ───────────────────────────────────────────────────────────
handle_gs_accept_prices,
handle_gs_back_to_amount,
handle_gs_back_to_connector,
@@ -84,6 +85,7 @@
handle_gs_wizard_pair,
handle_gs_wizard_side,
handle_gs_wizard_take_profit,
+# ── PMM Mister ────────────────────────────────────────────────────────────
handle_pmm_adv_setting,
handle_pmm_back,
handle_pmm_edit_advanced,
@@ -100,6 +102,7 @@
handle_pmm_wizard_pair,
handle_pmm_wizard_spreads,
handle_pmm_wizard_tp,
+# ── PMM V1 ────────────────────────────────────────────────────────────────
handle_pv1_back,
handle_pv1_pair_select,
handle_pv1_review_back,
@@ -110,6 +113,217 @@
handle_pv1_wizard_spreads,
process_pv1_wizard_input,
show_new_pmm_v1_form,
+# ── Multi Grid Strike ─────────────────────────────────────────────────────
+ show_new_multi_grid_strike_form,
+ handle_mgs_wizard_connector,
+ handle_mgs_wizard_pair,
+ handle_mgs_wizard_leverage,
+ handle_mgs_wizard_amount,
+ handle_mgs_interval_change,
+ handle_mgs_save,
+ handle_mgs_back_to_connector,
+ handle_mgs_back_to_pair,
+ handle_mgs_back_to_leverage,
+ handle_mgs_back_to_amount,
+ handle_mgs_pair_select,
+ handle_mgs_grid_type,
+ handle_mgs_num_grids,
+ handle_mgs_position_mode,
+ handle_mgs_back_to_position_mode,
+ handle_mgs_back_to_grid_type,
+ handle_mgs_back_to_num_grids,
+ process_mgs_wizard_input,
+
+# ── DMan V3 ───────────────────────────────────────────────────────────────
+ show_new_dman_v3_form,
+ handle_dman_wizard_connector,
+ handle_dman_wizard_pair,
+ handle_dman_wizard_leverage,
+ handle_dman_wizard_amount,
+ handle_dman_position_mode,
+ handle_dman_back_to_position_mode,
+ handle_dman_interval_change,
+ handle_dman_set_strategy,
+ handle_dman_save,
+ handle_dman_pair_select,
+ handle_dman_back_to_connector,
+ handle_dman_back_to_pair,
+ handle_dman_back_to_leverage,
+ handle_dman_back_to_amount,
+ process_dman_wizard_input,
+# ── Arbitrage Controller ──────────────────────────────────────────────────
+ show_new_arbitrage_controller_form,
+ handle_arb_wizard_connector_1,
+ handle_arb_wizard_connector_2,
+ handle_arb_wizard_pair_1,
+ handle_arb_wizard_pair_2,
+ handle_arb_wizard_amount,
+ handle_arb_save,
+ handle_arb_back_to_connector_1,
+ handle_arb_back_to_connector_2,
+ handle_arb_back_to_pair_1,
+ handle_arb_back_to_amount,
+ process_arb_wizard_input,
+ handle_arb_pair_select,
+ handle_arb_proceed_anyway,
+# ── XEMM Multiple Levels ──────────────────────────────────────────────────
+ show_new_xemm_multiple_levels_form,
+ handle_xemm_maker_connector,
+ handle_xemm_maker_pair,
+ handle_xemm_taker_connector,
+ handle_xemm_taker_pair,
+ handle_xemm_wizard_amount,
+ handle_xemm_save,
+ handle_xemm_back_to_maker_connector,
+ handle_xemm_back_to_taker_connector,
+ handle_xemm_back_to_pair,
+ handle_xemm_back_to_amount,
+ handle_xemm_proceed_anyway,
+ process_xemm_wizard_input,
+
+# ── MACD-BB Levels ──────────────────────────────────────────────────
+ show_new_macd_bb_v1_form,
+ handle_macdbb_wizard_connector,
+ handle_macdbb_wizard_pair,
+ handle_macdbb_wizard_leverage,
+ handle_macdbb_wizard_amount,
+ handle_macdbb_position_mode,
+ handle_macdbb_back_to_position_mode,
+ handle_macdbb_interval_change,
+ handle_macdbb_save,
+ handle_macdbb_back_to_connector,
+ handle_macdbb_back_to_pair,
+ handle_macdbb_back_to_leverage,
+ handle_macdbb_back_to_amount,
+ process_macdbb_wizard_input,
+ handle_macdbb_pair_select,
+ handle_macdbb_set_strategy,
+# ── Supertrend ──────────────────────────────────────────────────
+ show_new_supertrend_v1_form,
+ handle_st_wizard_connector,
+ handle_st_wizard_pair,
+ handle_st_wizard_leverage,
+ handle_st_wizard_amount,
+ handle_st_interval_change,
+ handle_st_save,
+ handle_st_back_to_connector,
+ handle_st_back_to_pair,
+ handle_st_back_to_leverage,
+ handle_st_back_to_amount,
+ handle_st_position_mode,
+ handle_st_back_to_position_mode,
+ handle_st_set_strategy,
+ process_st_wizard_input,
+# ── Anti-Folla ──────────────────────────────────────────────────────────
+ show_new_anti_folla_v1_form,
+ handle_af_wizard_connector,
+ handle_af_wizard_pair,
+ handle_af_wizard_leverage,
+ handle_af_wizard_amount,
+ handle_af_interval_change,
+ handle_af_save,
+ handle_af_back_to_connector,
+ handle_af_back_to_pair,
+ handle_af_back_to_leverage,
+ handle_af_back_to_amount,
+ process_af_wizard_input,
+ handle_af_position_mode,
+ handle_af_back_to_position_mode,
+ handle_af_set_strategy,
+
+# ── Funding Rate Arbitrage ──────────────────────────────────────────────────────────
+ show_new_funding_rate_arb_form,
+ handle_fra_wizard_connector_1,
+ handle_fra_wizard_pair_1,
+ handle_fra_wizard_connector_2,
+ handle_fra_wizard_pair_2,
+ handle_fra_wizard_amount,
+ handle_fra_save,
+ handle_fra_back_to_connector_1,
+ handle_fra_back_to_pair_1,
+ handle_fra_back_to_connector_2,
+ handle_fra_back_to_pair_2,
+ handle_fra_back_to_amount,
+ process_fra_wizard_input,
+# ── Delta Neutral mm ──────────────────────────────────────────────────────────────
+ show_new_delta_neutral_mm_form,
+ handle_dnmm_wizard_maker_connector,
+ handle_dnmm_wizard_maker_pair,
+ handle_dnmm_wizard_hedge_connector,
+ handle_dnmm_wizard_hedge_pair,
+ handle_dnmm_wizard_amount,
+ handle_dnmm_save,
+ handle_dnmm_back_to_maker_connector,
+ handle_dnmm_back_to_maker_pair,
+ handle_dnmm_back_to_hedge_connector,
+ handle_dnmm_back_to_hedge_pair,
+ handle_dnmm_back_to_amount,
+ process_dnmm_wizard_input,
+# ── Bollinger Grid wizard ──────────────────────────────────────────────────────────────
+ show_new_bollingrid_form,
+ handle_bg_wizard_connector,
+ handle_bg_wizard_pair,
+ handle_bg_wizard_leverage,
+ handle_bg_position_mode,
+ handle_bg_wizard_amount,
+ handle_bg_save,
+ handle_bg_interval_change,
+ handle_bg_back_to_connector,
+ handle_bg_back_to_pair,
+ handle_bg_back_to_leverage,
+ handle_bg_back_to_amount,
+ handle_bg_back_to_position_mode,
+ handle_bg_pair_select,
+ process_bg_wizard_input,
+# ── Quantum Grid Allocator wizard ──────────────────────────────────────────────────────────────
+ show_new_quantum_grid_allocator_form,
+ handle_qga_wizard_connector,
+ handle_qga_wizard_quote,
+ handle_qga_add_asset,
+ handle_qga_alloc_next,
+ handle_qga_amount_step,
+ handle_qga_wizard_amount,
+ handle_qga_save,
+ handle_qga_back_to_connector,
+ handle_qga_back_to_quote,
+ handle_qga_back_to_portfolio,
+ handle_qga_amount,
+ handle_qga_back_to_grid_params,
+ handle_qga_back_to_amount,
+ process_qga_wizard_input,
+# ── StatArb V2 ───────────────────────────────────────────────────────────
+ show_new_stat_arb_v2_form,
+ handle_stat_arb_wizard_connector,
+ handle_stat_arb_base_asset,
+ handle_stat_arb_quote_asset_1,
+ handle_stat_arb_quote_asset_2,
+ handle_stat_arb_back_to_connector,
+ handle_stat_arb_back_to_base_asset,
+ handle_stat_arb_back_to_quote_1,
+ handle_stat_arb_back_to_quote_2,
+ handle_stat_arb_back_to_amount,
+ handle_stat_arb_back_to_leverage, # <-- AGGIUNGI
+ handle_stat_arb_wizard_leverage,
+ handle_stat_arb_wizard_amount,
+ handle_stat_arb_save,
+ handle_stat_arb_interval_change,
+ process_stat_arb_wizard_input,
+# LM Multi Pair DEX wizard
+ show_new_lm_multi_pair_dex_form,
+ handle_lmp_wizard_connector,
+ handle_lmp_toggle_pair,
+ handle_lmp_next_markets,
+ handle_lmp_token,
+ handle_lmp_allocation,
+ handle_lmp_wizard_amount,
+ handle_lmp_save,
+ handle_lmp_back_to_connector,
+ handle_lmp_back_to_markets,
+ handle_lmp_back_to_token,
+ handle_lmp_back_to_allocation,
+ handle_lmp_back_to_amount,
+ process_lmp_wizard_input,
+ handle_lmp_pair_select,
handle_save_config,
handle_select_all,
handle_select_connector,
@@ -674,6 +888,470 @@ async def bots_callback_handler(
setting = action_parts[1]
await handle_pmm_adv_setting(update, context, setting)
+# ===== StatArb V2 wizard =====
+ elif main_action == "new_stat_arb_v2":
+ await show_new_stat_arb_v2_form(update, context)
+ elif main_action == "stat_arb_connector":
+ if len(action_parts) > 1:
+ connector = action_parts[1]
+ await handle_stat_arb_wizard_connector(update, context, connector)
+ elif main_action == "stat_arb_base_asset":
+ if len(action_parts) > 1:
+ asset = action_parts[1]
+ await handle_stat_arb_base_asset(update, context, asset)
+ elif main_action == "stat_arb_quote_1":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_stat_arb_quote_asset_1(update, context, pair)
+ elif main_action == "stat_arb_quote_2":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_stat_arb_quote_asset_2(update, context, pair)
+ elif main_action == "stat_arb_back_to_base_asset":
+ await handle_stat_arb_back_to_base_asset(update, context)
+ elif main_action == "stat_arb_back_to_quote_1":
+ await handle_stat_arb_back_to_quote_1(update, context)
+ elif main_action == "stat_arb_back_to_quote_2":
+ await handle_stat_arb_back_to_quote_2(update, context)
+ elif main_action == "stat_arb_back_to_leverage":
+ await handle_stat_arb_back_to_leverage(update, context)
+ elif main_action == "stat_arb_leverage":
+ if len(action_parts) > 1:
+ leverage = int(action_parts[1])
+ await handle_stat_arb_wizard_leverage(update, context, leverage)
+ elif main_action == "stat_arb_amount":
+ if len(action_parts) > 1:
+ amount = float(action_parts[1])
+ await handle_stat_arb_wizard_amount(update, context, amount)
+ elif main_action == "stat_arb_save":
+ await handle_stat_arb_save(update, context)
+ elif main_action == "stat_arb_back_to_connector":
+ await handle_stat_arb_back_to_connector(update, context)
+ elif main_action == "stat_arb_back_to_amount":
+ await handle_stat_arb_back_to_amount(update, context)
+ elif main_action == "stat_arb_interval":
+ if len(action_parts) > 1:
+ interval = action_parts[1]
+ await handle_stat_arb_interval_change(update, context, interval)
+# ===== Dman v3 =====
+ elif main_action == "new_dman_v3":
+ await show_new_dman_v3_form(update, context)
+ elif main_action == "dman_connector":
+ if len(action_parts) > 1:
+ await handle_dman_wizard_connector(update, context, action_parts[1])
+ elif main_action == "dman_pair":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_dman_wizard_pair(update, context, pair)
+ elif main_action == "dman_pair_select":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_dman_pair_select(update, context, pair)
+ elif main_action == "dman_leverage":
+ if len(action_parts) > 1:
+ await handle_dman_wizard_leverage(update, context, int(action_parts[1]))
+ elif main_action == "dman_position_mode":
+ if len(action_parts) > 1:
+ mode = action_parts[1]
+ await handle_dman_position_mode(update, context, mode)
+ elif main_action == "dman_back_to_position_mode":
+ await handle_dman_back_to_position_mode(update, context)
+ elif main_action == "dman_amount":
+ if len(action_parts) > 1:
+ await handle_dman_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "dman_interval":
+ if len(action_parts) > 1:
+ await handle_dman_interval_change(update, context, action_parts[1])
+ elif main_action == "dman_set_strat":
+ if len(action_parts) > 1:
+ strategy_name = action_parts[1]
+ await handle_dman_set_strategy(update, context, strategy_name)
+ elif main_action == "dman_save":
+ await handle_dman_save(update, context)
+ elif main_action == "dman_back_to_connector":
+ await handle_dman_back_to_connector(update, context)
+ elif main_action == "dman_back_to_pair":
+ await handle_dman_back_to_pair(update, context)
+ elif main_action == "dman_back_to_leverage":
+ await handle_dman_back_to_leverage(update, context)
+ elif main_action == "dman_back_to_amount":
+ await handle_dman_back_to_amount(update, context)
+# ===== Arbitrage =====
+ elif main_action == "new_arbitrage_controller":
+ await show_new_arbitrage_controller_form(update, context)
+ elif main_action == "arb_connector_1":
+ if len(action_parts) > 1:
+ await handle_arb_wizard_connector_1(update, context, action_parts[1])
+ elif main_action == "arb_connector_2":
+ if len(action_parts) > 1:
+ await handle_arb_wizard_connector_2(update, context, action_parts[1])
+ elif main_action == "arb_pair_1":
+ if len(action_parts) > 1:
+ await handle_arb_wizard_pair_1(update, context, action_parts[1])
+ elif main_action == "arb_pair_2":
+ if len(action_parts) > 1:
+ pair = ":".join(action_parts[1:])
+ await handle_arb_wizard_pair_2(update, context, pair)
+ elif main_action == "arb_amount":
+ if len(action_parts) > 1:
+ await handle_arb_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "arb_save":
+ await handle_arb_save(update, context)
+ elif main_action == "arb_back_to_connector_1":
+ await handle_arb_back_to_connector_1(update, context)
+ elif main_action == "arb_back_to_connector_2":
+ await handle_arb_back_to_connector_2(update, context)
+ elif main_action == "arb_back_to_pair_1":
+ await handle_arb_back_to_pair_1(update, context)
+ elif main_action == "arb_back_to_pair_2":
+ await handle_arb_back_to_pair_1(update, context)
+ elif main_action == "arb_back_to_amount":
+ await handle_arb_back_to_amount(update, context)
+ elif main_action == "arb_pair_select":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_arb_pair_select(update, context, pair)
+ elif main_action == "arb_proceed_anyway":
+ await handle_arb_proceed_anyway(update, context)
+# ===== Xemm =====
+ elif main_action == "new_xemm_multiple_levels":
+ await show_new_xemm_multiple_levels_form(update, context)
+ elif main_action == "xemm_maker_connector":
+ if len(action_parts) > 1:
+ await handle_xemm_maker_connector(update, context, action_parts[1])
+ elif main_action == "xemm_taker_connector":
+ if len(action_parts) > 1:
+ await handle_xemm_taker_connector(update, context, action_parts[1])
+ elif main_action == "xemm_maker_pair":
+ if len(action_parts) > 1:
+ pair = ":".join(action_parts[1:])
+ await handle_xemm_maker_pair(update, context, pair)
+ elif main_action == "xemm_taker_pair":
+ if len(action_parts) > 1:
+ pair = ":".join(action_parts[1:])
+ await handle_xemm_taker_pair(update, context, pair)
+ elif main_action == "xemm_amount":
+ if len(action_parts) > 1:
+ await handle_xemm_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "xemm_save":
+ await handle_xemm_save(update, context)
+ elif main_action == "xemm_back_to_maker_connector":
+ await handle_xemm_back_to_maker_connector(update, context)
+ elif main_action == "xemm_back_to_taker_connector":
+ await handle_xemm_back_to_taker_connector(update, context)
+ elif main_action == "xemm_back_to_pair":
+ await handle_xemm_back_to_pair(update, context)
+ elif main_action == "xemm_back_to_amount":
+ await handle_xemm_back_to_amount(update, context)
+ elif main_action == "xemm_proceed_anyway":
+ await handle_xemm_proceed_anyway(update, context)
+#MACDBB
+ elif main_action == "new_macd_bb_v1":
+ await show_new_macd_bb_v1_form(update, context)
+ elif main_action == "macdbb_connector":
+ if len(action_parts) > 1:
+ await handle_macdbb_wizard_connector(update, context, action_parts[1])
+ elif main_action == "macdbb_pair":
+ if len(action_parts) > 1:
+ await handle_macdbb_wizard_pair(update, context, action_parts[1])
+ elif main_action == "macdbb_pair_select":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_macdbb_pair_select(update, context, pair)
+ elif main_action == "macdbb_leverage":
+ if len(action_parts) > 1:
+ await handle_macdbb_wizard_leverage(update, context, int(action_parts[1]))
+ elif main_action == "macdbb_amount":
+ if len(action_parts) > 1:
+ await handle_macdbb_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "macdbb_position_mode":
+ if len(action_parts) > 1:
+ mode = action_parts[1]
+ await handle_macdbb_position_mode(update, context, mode)
+ elif main_action == "macdbb_interval":
+ if len(action_parts) > 1:
+ await handle_macdbb_interval_change(update, context, action_parts[1])
+ elif main_action == "macdbb_save":
+ await handle_macdbb_save(update, context)
+ elif main_action == "macdbb_back_to_connector":
+ await handle_macdbb_back_to_connector(update, context)
+ elif main_action == "macdbb_back_to_pair":
+ await handle_macdbb_back_to_pair(update, context)
+ elif main_action == "macdbb_back_to_leverage":
+ await handle_macdbb_back_to_leverage(update, context)
+ elif main_action == "macdbb_back_to_amount":
+ await handle_macdbb_back_to_amount(update, context)
+ elif main_action == "macdbb_back_to_position_mode":
+ await handle_macdbb_back_to_position_mode(update, context)
+ elif main_action == "macdbb_set_strat":
+ if len(action_parts) > 1:
+ strategy_name = action_parts[1]
+ await handle_macdbb_set_strategy(update, context, strategy_name)
+#SUPERTREND
+ elif main_action == "new_supertrend_v1":
+ await show_new_supertrend_v1_form(update, context)
+ elif main_action == "st_connector":
+ if len(action_parts) > 1:
+ await handle_st_wizard_connector(update, context, action_parts[1])
+ elif main_action == "st_pair":
+ if len(action_parts) > 1:
+ await handle_st_wizard_pair(update, context, action_parts[1])
+ elif main_action == "st_pair_select":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_st_wizard_pair(update, context, pair)
+ elif main_action == "st_leverage":
+ if len(action_parts) > 1:
+ await handle_st_wizard_leverage(update, context, int(action_parts[1]))
+ elif main_action == "st_position_mode":
+ if len(action_parts) > 1:
+ mode = action_parts[1]
+ await handle_st_position_mode(update, context, mode)
+ elif main_action == "st_back_to_position_mode":
+ await handle_st_back_to_position_mode(update, context)
+ elif main_action == "st_amount":
+ if len(action_parts) > 1:
+ await handle_st_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "st_interval":
+ if len(action_parts) > 1:
+ await handle_st_interval_change(update, context, action_parts[1])
+ elif main_action == "st_set_strat":
+ if len(action_parts) > 1:
+ strategy_name = action_parts[1]
+ await handle_st_set_strategy(update, context, strategy_name)
+ elif main_action == "st_save":
+ await handle_st_save(update, context)
+ elif main_action == "st_back_to_connector":
+ await handle_st_back_to_connector(update, context)
+ elif main_action == "st_back_to_pair":
+ await handle_st_back_to_pair(update, context)
+ elif main_action == "st_back_to_leverage":
+ await handle_st_back_to_leverage(update, context)
+ elif main_action == "st_back_to_amount":
+ await handle_st_back_to_amount(update, context)
+#ANTI-FOLLA
+ elif main_action == "new_anti_folla_v1":
+ await show_new_anti_folla_v1_form(update, context)
+ elif main_action == "af_connector":
+ if len(action_parts) > 1:
+ await handle_af_wizard_connector(update, context, action_parts[1])
+ elif main_action == "af_pair":
+ if len(action_parts) > 1:
+ await handle_af_wizard_pair(update, context, action_parts[1])
+
+ elif main_action == "af_pair_select":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_af_wizard_pair(update, context, pair)
+ elif main_action == "af_position_mode":
+ if len(action_parts) > 1:
+ mode = action_parts[1]
+ await handle_af_position_mode(update, context, mode)
+ elif main_action == "af_back_to_position_mode":
+ await handle_af_back_to_position_mode(update, context)
+ elif main_action == "af_leverage":
+ if len(action_parts) > 1:
+ await handle_af_wizard_leverage(update, context, int(action_parts[1]))
+ elif main_action == "af_amount":
+ if len(action_parts) > 1:
+ await handle_af_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "af_interval":
+ if len(action_parts) > 1:
+ await handle_af_interval_change(update, context, action_parts[1])
+ elif main_action == "af_set_strat":
+ if len(action_parts) > 1:
+ strategy_name = action_parts[1]
+ await handle_af_set_strategy(update, context, strategy_name)
+ elif main_action == "af_save":
+ await handle_af_save(update, context)
+ elif main_action == "af_back_to_connector":
+ await handle_af_back_to_connector(update, context)
+ elif main_action == "af_back_to_pair":
+ await handle_af_back_to_pair(update, context)
+ elif main_action == "af_back_to_leverage":
+ await handle_af_back_to_leverage(update, context)
+ elif main_action == "af_back_to_amount":
+ await handle_af_back_to_amount(update, context)
+# Funding Rate Arbitrage
+ elif main_action == "new_funding_rate_arb":
+ await show_new_funding_rate_arb_form(update, context)
+ elif main_action == "fra_connector_1":
+ if len(action_parts) > 1:
+ await handle_fra_wizard_connector_1(update, context, action_parts[1])
+ elif main_action == "fra_pair_1":
+ if len(action_parts) > 1:
+ await handle_fra_wizard_pair_1(update, context, action_parts[1])
+ elif main_action == "fra_connector_2":
+ if len(action_parts) > 1:
+ await handle_fra_wizard_connector_2(update, context, action_parts[1])
+ elif main_action == "fra_pair_2":
+ if len(action_parts) > 1:
+ await handle_fra_wizard_pair_2(update, context, action_parts[1])
+ elif main_action == "fra_amount":
+ if len(action_parts) > 1:
+ await handle_fra_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "fra_save":
+ await handle_fra_save(update, context)
+ elif main_action == "fra_back_to_connector_1":
+ await handle_fra_back_to_connector_1(update, context)
+ elif main_action == "fra_back_to_pair_1":
+ await handle_fra_back_to_pair_1(update, context)
+ elif main_action == "fra_back_to_connector_2":
+ await handle_fra_back_to_connector_2(update, context)
+ elif main_action == "fra_back_to_pair_2":
+ await handle_fra_back_to_pair_2(update, context)
+ elif main_action == "fra_back_to_amount":
+ await handle_fra_back_to_amount(update, context)
+# Delta Neutral MM
+ elif main_action == "new_delta_neutral_mm":
+ await show_new_delta_neutral_mm_form(update, context)
+ elif main_action == "dnmm_maker_connector":
+ if len(action_parts) > 1:
+ await handle_dnmm_wizard_maker_connector(update, context, action_parts[1])
+ elif main_action == "dnmm_maker_pair":
+ if len(action_parts) > 1:
+ await handle_dnmm_wizard_maker_pair(update, context, action_parts[1])
+ elif main_action == "dnmm_hedge_connector":
+ if len(action_parts) > 1:
+ await handle_dnmm_wizard_hedge_connector(update, context, action_parts[1])
+ elif main_action == "dnmm_hedge_pair":
+ if len(action_parts) > 1:
+ await handle_dnmm_wizard_hedge_pair(update, context, action_parts[1])
+ elif main_action == "dnmm_amount":
+ if len(action_parts) > 1:
+ await handle_dnmm_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "dnmm_save":
+ await handle_dnmm_save(update, context)
+ elif main_action == "dnmm_back_to_maker_connector":
+ await handle_dnmm_back_to_maker_connector(update, context)
+ elif main_action == "dnmm_back_to_maker_pair":
+ await handle_dnmm_back_to_maker_pair(update, context)
+ elif main_action == "dnmm_back_to_hedge_connector":
+ await handle_dnmm_back_to_hedge_connector(update, context)
+ elif main_action == "dnmm_back_to_hedge_pair":
+ await handle_dnmm_back_to_hedge_pair(update, context)
+ elif main_action == "dnmm_back_to_amount":
+ await handle_dnmm_back_to_amount(update, context)
+# Bollinger Grid (wizard)
+ elif main_action == "new_bollingrid":
+ await show_new_bollingrid_form(update, context)
+ elif main_action == "bg_connector":
+ if len(action_parts) > 1:
+ await handle_bg_wizard_connector(update, context, action_parts[1])
+ elif main_action == "bg_pair":
+ if len(action_parts) > 1:
+ await handle_bg_wizard_pair(update, context, action_parts[1])
+ elif main_action == "bg_pair_select":
+ if len(action_parts) > 1:
+ await handle_bg_pair_select(update, context, action_parts[1])
+ elif main_action == "bg_leverage":
+ if len(action_parts) > 1:
+ await handle_bg_wizard_leverage(update, context, int(action_parts[1]))
+ elif main_action == "bg_position_mode":
+ if len(action_parts) > 1:
+ mode = action_parts[1]
+ await handle_bg_position_mode(update, context, mode)
+ elif main_action == "bg_amount":
+ if len(action_parts) > 1:
+ await handle_bg_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "bg_interval":
+ if len(action_parts) > 1:
+ await handle_bg_interval_change(update, context, action_parts[1])
+ elif main_action == "bg_save":
+ await handle_bg_save(update, context)
+ elif main_action == "bg_back_to_connector":
+ await handle_bg_back_to_connector(update, context)
+ elif main_action == "bg_back_to_pair":
+ await handle_bg_back_to_pair(update, context)
+ elif main_action == "bg_back_to_leverage":
+ await handle_bg_back_to_leverage(update, context)
+ elif main_action == "bg_back_to_position_mode":
+ await handle_bg_back_to_position_mode(update, context)
+ elif main_action == "bg_back_to_amount":
+ await handle_bg_back_to_amount(update, context)
+# Quantum Grid Allocator
+ elif main_action == "new_quantum_grid_allocator":
+ await show_new_quantum_grid_allocator_form(update, context)
+ elif main_action == "qga_connector":
+ if len(action_parts) > 1:
+ await handle_qga_wizard_connector(update, context, action_parts[1])
+ elif main_action == "qga_quote":
+ if len(action_parts) > 1:
+ await handle_qga_wizard_quote(update, context, action_parts[1])
+ elif main_action == "qga_add_asset":
+ if len(action_parts) > 2:
+ asset = action_parts[1]
+ allocation = float(action_parts[2])
+ await handle_qga_add_asset(update, context, asset, allocation)
+ elif main_action == "qga_alloc_next":
+ await handle_qga_alloc_next(update, context)
+ elif main_action == "qga_amount":
+ if len(action_parts) > 1:
+ await handle_qga_wizard_amount(update, context, float(action_parts[1]))
+ elif main_action == "qga_save":
+ await handle_qga_save(update, context)
+ elif main_action == "qga_back_to_connector":
+ await handle_qga_back_to_connector(update, context)
+ elif main_action == "qga_back_to_quote":
+ await handle_qga_back_to_quote(update, context)
+ elif main_action == "qga_back_to_grid_params":
+ await handle_qga_back_to_grid_params(update, context)
+ elif main_action == "qga_back_to_amount":
+ await handle_qga_back_to_amount(update, context)
+ elif main_action == "qga_next":
+ await handle_qga_amount(update, context)
+ elif main_action == "qga_back_to_portfolio":
+ await handle_qga_back_to_portfolio(update, context)
+
+ # ===== LM Multi Pair DEX wizard =====
+ elif main_action == "new_lm_multi_pair_dex":
+ await show_new_lm_multi_pair_dex_form(update, context)
+ elif main_action == "lmp_connector":
+ if len(action_parts) > 1:
+ connector = action_parts[1]
+ await handle_lmp_wizard_connector(update, context, connector)
+ elif main_action == "lmp_toggle_pair":
+ if len(action_parts) > 1:
+ pair = action_parts[1]
+ await handle_lmp_toggle_pair(update, context, pair)
+ elif main_action == "lmp_next_markets":
+ await handle_lmp_next_markets(update, context)
+ elif main_action == "lmp_token":
+ if len(action_parts) > 1:
+ token = action_parts[1]
+ await handle_lmp_token(update, context, token)
+ elif main_action == "lmp_allocation":
+ if len(action_parts) > 1:
+ allocation = float(action_parts[1])
+ await handle_lmp_allocation(update, context, allocation)
+ elif main_action == "lmp_amount":
+ if len(action_parts) > 1:
+ amount = float(action_parts[1])
+ await handle_lmp_wizard_amount(update, context, amount)
+ elif main_action == "lmp_save":
+ await handle_lmp_save(update, context)
+ elif main_action == "lmp_back_to_connector":
+ await handle_lmp_back_to_connector(update, context)
+ elif main_action == "lmp_back_to_markets":
+ await handle_lmp_back_to_markets(update, context)
+ elif main_action == "lmp_back_to_token":
+ await handle_lmp_back_to_token(update, context)
+ elif main_action == "lmp_back_to_allocation":
+ await handle_lmp_back_to_allocation(update, context)
+ elif main_action == "lmp_back_to_amount":
+ await handle_lmp_back_to_amount(update, context)
+
+
+
+
+
+
+
+
+
+
+
+
# Bot detail
elif main_action == "bot_detail":
if len(action_parts) > 1:
@@ -855,6 +1533,31 @@ async def bots_message_handler(
# Handle PMM V1 wizard input
elif bots_state == "pv1_wizard_input":
await process_pv1_wizard_input(update, context, user_input)
+ elif bots_state == "dman_wizard_input":
+ await process_dman_wizard_input(update, context, user_input)
+ elif bots_state == "arb_wizard_input":
+ await process_arb_wizard_input(update, context, user_input)
+ elif bots_state == "xemm_wizard_input":
+ await process_xemm_wizard_input(update, context, update.message.text)
+ elif bots_state == "macdbb_wizard_input":
+ await process_macdbb_wizard_input(update, context, user_input)
+ elif bots_state == "st_wizard_input":
+ await process_st_wizard_input(update, context, user_input)
+ elif bots_state == "af_wizard_input":
+ await process_af_wizard_input(update, context, user_input)
+ elif bots_state == "fra_wizard_input":
+ await process_fra_wizard_input(update, context, user_input)
+ elif bots_state == "dnmm_wizard_input":
+ await process_dnmm_wizard_input(update, context, user_input)
+ elif bots_state == "bg_wizard_input":
+ await process_bg_wizard_input(update, context, user_input)
+ elif bots_state == "qga_wizard_input":
+ await process_qga_wizard_input(update, context, user_input)
+ elif bots_state == "stat_arb_wizard_input":
+ await process_stat_arb_wizard_input(update, context, user_input)
+ elif bots_state == "lmp_wizard_input":
+ await process_lmp_wizard_input(update, context, user_input)
+
# Handle config edit loop field input (legacy single field)
elif bots_state.startswith("cfg_edit_input:"):
await process_cfg_edit_input(update, context, user_input)
diff --git a/handlers/bots/controller_handlers.py b/handlers/bots/controller_handlers.py
index e6d65a35..cad6ace9 100644
--- a/handlers/bots/controller_handlers.py
+++ b/handlers/bots/controller_handlers.py
@@ -20,7 +20,7 @@
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.error import BadRequest
from telegram.ext import ContextTypes
-
+from importlib import import_module
from handlers.cex._shared import (
get_cex_balances,
get_correct_pair_format,
@@ -63,10 +63,65 @@
)
from .controllers.pmm_mister import FIELD_ORDER as PMM_FIELD_ORDER
from .controllers.pmm_mister import FIELDS as PMM_FIELDS
-
+from .controllers.lm_multi_pair_dex import LMMultiPairDEXController, generate_id
logger = logging.getLogger(__name__)
+def _flatten_dict(data: dict, parent_key: str = '', sep: str = '.') -> dict:
+ """Appiattisce un dizionario annidato in chiavi con dot notation."""
+ items = []
+ for k, v in data.items():
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
+ if isinstance(v, dict):
+ # Esclude alcuni dizionari che non vogliamo appiattire
+ if k in ('candles_config', 'triple_barrier_config', 'grids'):
+ items.append((new_key, v))
+ continue
+ items.extend(_flatten_dict(v, new_key, sep=sep).items())
+ else:
+ items.append((new_key, v))
+ return dict(items)
+
+
+def _set_nested_value(data: dict, key_path: str, value: any) -> None:
+ """Imposta un valore in un dizionario annidato usando la dot notation."""
+ keys = key_path.split('.')
+ d = data
+ for key in keys[:-1]:
+ if key not in d or not isinstance(d[key], dict):
+ d[key] = {}
+ d = d[key]
+ d[keys[-1]] = value
+
+
+def _get_flat_fields_from_controller(config: dict, controller_type: str) -> dict:
+ """Tenta di ottenere i campi editabili usando get_flat_fields del controller."""
+ try:
+ module_name = f"handlers.bots.controllers.{controller_type}.config"
+ config_module = import_module(module_name)
+ if hasattr(config_module, "get_flat_fields"):
+ return config_module.get_flat_fields(config)
+ except (ImportError, AttributeError):
+ pass
+ return {}
+
+
+def _apply_flat_updates_to_controller(config: dict, controller_type: str, updates: dict) -> None:
+ """Applica gli aggiornamenti usando apply_flat_fields se esiste, altrimenti assegna direttamente."""
+ try:
+ module_name = f"handlers.bots.controllers.{controller_type}.config"
+ config_module = import_module(module_name)
+ if hasattr(config_module, "apply_flat_fields"):
+ config_module.apply_flat_fields(config, updates)
+ return
+ except (ImportError, AttributeError):
+ pass
+ # Fallback: assegnazione diretta (con gestione dot notation)
+ for key, value in updates.items():
+ if '.' in key:
+ _set_nested_value(config, key, value)
+ else:
+ config[key] = value
# ============================================
# CONTROLLER CONFIGS MENU
# ============================================
@@ -84,6 +139,15 @@ def _get_controller_type_display(controller_name: str) -> tuple[str, str]:
"dman_v3": ("DMan V3", "🤖"),
"xemm": ("XEMM", "🔄"),
"pmm": ("PMM", "📈"),
+ "arbitrage_controller": ("Arbitrage", "🎯"),
+ "macd_bb_v1": ("Macd BB", "💹"),
+ "supertrend_v1": ("Super Trend", "📉"),
+ "anti_folla_v1": (" Anti Folla", "🎯"),
+ "funding_rate_arb": (" Funding Rate", "🕔"),
+ "delta_neutral_mm": (" Delta Neutral", "⚖️"),
+ "bollingrid": ("Bollinger Grid", "📊"),
+ "quantum_grid_allocator": ("Quantum Grid Allocator", "📊"),
+ "stat_arb_v2": ("Stat Arbitrage", "⚡"),
}
controller_lower = controller_name.lower() if controller_name else ""
for key, (name, emoji) in type_map.items():
@@ -8859,6 +8923,14085 @@ async def _pv1_show_review(context, chat_id, message_id, config):
if "Message is not modified" not in str(e):
raise
+# ============================================
+# MULTI GRID STRIKE WIZARD
+# ============================================
+# Steps: connector → pair → grid_type → num_grids → leverage (perp) → amount → review+save
+# Prefisso handler: mgs_
+
+async def show_new_multi_grid_strike_form(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """Start the Multi Grid Strike wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Clear cached market data
+ for key in ["mgs_current_price", "mgs_candles", "mgs_candles_interval",
+ "mgs_chart_interval", "mgs_natr", "mgs_trading_rules"]:
+ context.user_data.pop(key, None)
+
+ # Fetch existing configs for sequence numbering
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs for sequencing: {e}")
+
+ # Initialize new config with defaults
+ init_new_controller_config(context, "multi_grid_strike")
+ context.user_data["bots_state"] = "mgs_wizard"
+ context.user_data["mgs_wizard_step"] = "connector_name"
+ context.user_data["mgs_wizard_message_id"] = query.message.message_id
+ context.user_data["mgs_wizard_chat_id"] = query.message.chat_id
+
+ await _mgs_show_connector_step(update, context)
+
+
+async def _mgs_show_connector_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step 1: Select Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*🔲 Multi Grid Strike \- New Config*" + "\n\n"
+ r"⚠️ No CEX connectors available\." + "\n\n"
+ r"You need to connect API keys for an exchange to deploy strategies\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(
+ f"🏦 {connector}", callback_data=f"bots:mgs_connector:{connector}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ # Escapa TUTTO il testo fisso
+ message_text = (
+ r"\*🔲 Multi Grid Strike\*" + "\n\n"
+ r"Multiple independent grids on the same trading pair, each covering "
+ r"a different price range\. Ideal for layered market making strategies\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"\*Step 1: Select Exchange\*"
+ )
+
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ disable_web_page_preview=True,
+ )
+
+ except Exception as e:
+ logger.error(f"MGS connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_mgs_wizard_connector(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, connector: str
+) -> None:
+ """Handle connector selection"""
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["mgs_wizard_step"] = "trading_pair"
+ await _mgs_show_pair_step(update, context)
+
+async def _mgs_show_pair_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step 2: Enter Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "trading_pair"
+
+ # Recent pairs from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:mgs_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 7 if is_perp else 6
+
+ # Escapa il connector
+ escaped_connector = escape_markdown_v2(connector)
+
+ await query.message.edit_text(
+ rf"*🔲 Multi Grid Strike \- Step 2/{total_steps}*" + "\n\n"
+ f"🏦 `{escaped_connector}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or type a new one:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+async def handle_mgs_wizard_pair(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str
+) -> None:
+ """Handle pair selection via button"""
+ config = get_controller_config(context)
+ config["trading_pair"] = pair
+ set_controller_config(context, config)
+ context.user_data["mgs_wizard_step"] = "grid_type"
+ await _mgs_show_grid_type_step(update, context)
+
+async def _mgs_show_grid_type_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step 3: Select Strategy Type"""
+ # Usa i dati salvati
+ chat_id = context.user_data.get("mgs_wizard_chat_id")
+ message_id = context.user_data.get("mgs_wizard_message_id")
+
+ if not chat_id or not message_id:
+ logger.error("MGS: No chat_id or message_id saved for grid_type step")
+ return
+
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "grid_type"
+
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 7 if is_perp else 6
+ current_step = 3
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ from .controllers.multi_grid_strike.config import GRID_TYPES
+
+ keyboard = []
+ for grid_type_key, grid_type_info in GRID_TYPES.items():
+ # Crea un unico bottone per strategia con nome e descrizione su due righe
+ button_text = f"{grid_type_info['label']}\n {grid_type_info['description']}"
+ keyboard.append([
+ InlineKeyboardButton(
+ button_text,
+ callback_data=f"bots:mgs_grid_type:{grid_type_key}"
+ )
+ ])
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ # ========== DEFINISCI message_text ==========
+ message_text = (
+ rf"*🔲 Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}`" + "\n\n"
+ r"🎯 *Select Strategy Type*" + "\n\n"
+ r"Choose how your grids will be structured:"
+ )
+ # ===========================================
+
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def _mgs_show_num_grids_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step 4: Select Number of Grids"""
+ query = update.callback_query
+
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ grid_type = config.get("grid_strategy_type", "accumulation_distribution")
+
+ from .controllers.multi_grid_strike.config import GRID_TYPES
+ grid_info = GRID_TYPES.get(grid_type, GRID_TYPES["accumulation_distribution"])
+ min_grids = grid_info["min_grids"]
+ max_grids = grid_info["max_grids"]
+ default_grids = grid_info["default_grids"]
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "num_grids"
+
+ # 🔧 FIX: Salva i limiti nel contesto per validazione successiva
+ context.user_data["mgs_min_grids"] = min_grids
+ context.user_data["mgs_max_grids"] = max_grids
+
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 7 if is_perp else 6
+ current_step = 4
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+ escaped_label = escape_markdown_v2(grid_info['label'])
+
+ keyboard = []
+
+ # Suggest 3 values
+ suggested = [min_grids, default_grids, max_grids]
+ if len(set(suggested)) < 3:
+ suggested = [min_grids, (min_grids + max_grids) // 2, max_grids]
+
+ row = []
+ for num in suggested:
+ row.append(InlineKeyboardButton(
+ f"{num} grids", callback_data=f"bots:mgs_num_grids:{num}"
+ ))
+ keyboard.append(row)
+
+ # Custom option
+ keyboard.append([
+ InlineKeyboardButton("✏️ Custom", callback_data="bots:mgs_num_grids:custom")
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_grid_type"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ message_text = (
+ rf"*🔲 Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}`" + "\n\n"
+ f"📊 *Strategy:* {escaped_label}" + "\n\n"
+ f"🔢 *Number of Grids*" + "\n"
+ f"_Min: {min_grids} \\| Max: {max_grids} \\| Default: {default_grids}_" + "\n\n"
+ r"Select or type a number:"
+ )
+
+ # Cancella il messaggio corrente
+ if query and query.message:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ # Invia nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ context.user_data["mgs_wizard_message_id"] = new_msg.message_id
+ context.user_data["mgs_wizard_chat_id"] = update.effective_chat.id
+
+async def handle_mgs_grid_type(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, grid_type: str
+) -> None:
+ """Handle grid type selection"""
+ config = get_controller_config(context)
+ config["grid_strategy_type"] = grid_type
+ set_controller_config(context, config)
+
+ from .controllers.multi_grid_strike.config import GRID_TYPES
+ grid_info = GRID_TYPES.get(grid_type, GRID_TYPES["accumulation_distribution"])
+ default_grids = grid_info["default_grids"]
+
+ context.user_data["mgs_wizard_step"] = "num_grids"
+ context.user_data["mgs_default_grids"] = default_grids
+
+ await _mgs_show_num_grids_step(update, context)
+
+
+async def handle_mgs_num_grids(update, context, num_grids_str: str) -> None:
+ """Handle number of grids selection"""
+ config = get_controller_config(context)
+
+ # 🔧 FIX: Recupera i limiti dal contesto
+ min_grids = context.user_data.get("mgs_min_grids", 2)
+ max_grids = context.user_data.get("mgs_max_grids", 20)
+
+ if num_grids_str == "custom":
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "num_grids_custom"
+ context.user_data["mgs_waiting_for_num_grids"] = True
+
+ query = update.callback_query
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_num_grids")]]
+ await query.message.edit_text(
+ rf"*🔲 Multi Grid Strike*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"*Enter number of grids:*" + "\n"
+ rf"_Type a number between {min_grids} and {max_grids}_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ num_grids = int(num_grids_str)
+
+ # 🔧 FIX: Valida il numero di griglie
+ if num_grids < min_grids or num_grids > max_grids:
+ query = update.callback_query
+ await query.answer(f"Number must be between {min_grids} and {max_grids}", show_alert=True)
+ return
+
+ config["num_grids"] = num_grids
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ if connector.endswith("_perpetual"):
+ context.user_data["mgs_wizard_step"] = "leverage"
+ await _mgs_show_leverage_step(update, context)
+ else:
+ # Spot: salta leverage
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["mgs_wizard_step"] = "total_amount_quote"
+ await _mgs_show_amount_step(update, context)
+
+async def _mgs_show_leverage_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step 5 (perp only): Select Leverage"""
+ query = update.callback_query
+
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "leverage"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:mgs_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:mgs_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:mgs_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:mgs_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:mgs_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:mgs_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_num_grids"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ message_text = (
+ rf"*🔲 Multi Grid Strike \- Step 5/7*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_"
+ )
+
+ # ========== INVIA NUOVO MESSAGGIO ==========
+ # Cancella il messaggio corrente
+ if query and query.message:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ # Invia nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # Salva il nuovo message_id
+ context.user_data["mgs_wizard_message_id"] = new_msg.message_id
+ context.user_data["mgs_wizard_chat_id"] = update.effective_chat.id
+
+
+async def handle_mgs_wizard_leverage(update, context, leverage: int) -> None:
+ """Handle leverage selection"""
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+
+ # Step 6: Position Mode
+ context.user_data["mgs_wizard_step"] = "position_mode"
+ await _mgs_show_position_mode_step(update, context)
+
+async def _mgs_show_position_mode_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step: Select Position Mode (only for perpetual)"""
+ query = update.callback_query
+
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "position_mode"
+
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 7 if is_perp else 6
+ current_step = 6 if is_perp else 5
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ keyboard = [
+ [
+ InlineKeyboardButton("🔒 ONEWAY", callback_data="bots:mgs_position_mode:ONEWAY"),
+ InlineKeyboardButton("🔄 HEDGE", callback_data="bots:mgs_position_mode:HEDGE"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ rf"*🔲 Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}` \\| ⚡ `{leverage}x`" + "\n\n"
+ r"🎯 *Position Mode*" + "\n\n"
+ r"• *ONEWAY*: Can only hold positions in one direction \(long OR short\)" + "\n"
+ r"• *HEDGE*: Can hold both long and short positions simultaneously" + "\n\n"
+ r"_Select your position mode:_"
+ )
+
+ # ========== INVIA NUOVO MESSAGGIO ==========
+ # Cancella il messaggio corrente
+ if query and query.message:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ # Invia nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # Salva il nuovo message_id
+ context.user_data["mgs_wizard_message_id"] = new_msg.message_id
+ context.user_data["mgs_wizard_chat_id"] = update.effective_chat.id
+async def handle_mgs_back_to_position_mode(update, context) -> None:
+ """Go back to position mode step"""
+ context.user_data["mgs_wizard_step"] = "position_mode"
+ await _mgs_show_position_mode_step(update, context)
+
+async def handle_mgs_position_mode(update, context, mode: str) -> None:
+ """Handle position mode selection"""
+ config = get_controller_config(context)
+ config["position_mode"] = mode
+ set_controller_config(context, config)
+
+ # Step 7: Total Amount
+ context.user_data["mgs_wizard_step"] = "total_amount_quote"
+ await _mgs_show_amount_step(update, context)
+
+async def _mgs_show_amount_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """MGS Wizard Step: Enter Total Amount"""
+ # Determina chat_id e message_id
+ if update.callback_query:
+ chat_id = update.callback_query.message.chat_id
+ # Non abbiamo un message_id esistente perché abbiamo cancellato il vecchio
+ message_id = None
+ else:
+ chat_id = update.effective_chat.id
+ message_id = None
+
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ grid_type = config.get("grid_strategy_type", "accumulation_distribution")
+ num_grids = config.get("num_grids", 2)
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "total_amount_quote"
+
+ is_perp = connector.endswith("_perpetual")
+ total_steps = 7 if is_perp else 6
+ current_step = 7 if is_perp else 5
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+ escaped_grid_type = escape_markdown_v2(grid_type)
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for Multi Grid Strike amount step: {e}")
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:mgs_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:mgs_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:mgs_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:mgs_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:mgs_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:mgs_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_leverage" if is_perp else "bots:mgs_back_to_num_grids"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ rf"*🔲 Multi Grid Strike \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}` \\| ⚡ `{leverage}x`" + "\n"
+ f"📊 `{escaped_grid_type}` \\| 🔢 `{num_grids}` grids" + balance_text + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ # ========== INVIA NUOVO MESSAGGIO (non edit) ==========
+ try:
+ # Cancella eventuale messaggio precedente
+ old_msg_id = context.user_data.get("mgs_wizard_message_id")
+ if old_msg_id:
+ try:
+ await context.bot.delete_message(chat_id=chat_id, message_id=old_msg_id)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Invia nuovo messaggio
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = msg.message_id
+ context.user_data["mgs_wizard_chat_id"] = chat_id
+
+ # =============================================
+async def handle_mgs_wizard_amount(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, amount: float
+) -> None:
+ """Handle amount selection"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ pair = config.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*🔲 Multi Grid Strike \- New Config*" + "\n\n"
+ f"⏳ *Loading market data for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n"
+ r"_Fetching price and generating grids\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["mgs_wizard_step"] = "final"
+ await _mgs_show_final_step(update, context)
+
+async def _mgs_show_final_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str = None
+) -> None:
+ """MGS Final Step: Generate grids, show chart + config summary"""
+ import html
+
+ if update.callback_query:
+ query = update.callback_query
+ msg = query.message
+ else:
+ msg = update.message
+
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ position_mode = config.get("position_mode", "HEDGE")
+ grid_type = config.get("grid_strategy_type", "accumulation_distribution")
+ num_grids = config.get("num_grids", 2)
+
+ logger.info(f"MGS FINAL STEP: grid_type={grid_type}, num_grids={num_grids}, total_amount={total_amount}")
+
+ config["num_grids"] = num_grids
+ set_controller_config(context, config)
+
+ if interval is None:
+ interval = context.user_data.get("mgs_chart_interval", "5m")
+ context.user_data["mgs_chart_interval"] = interval
+
+ current_price = context.user_data.get("mgs_current_price")
+ candles = context.user_data.get("mgs_candles")
+ natr = context.user_data.get("mgs_natr")
+
+ try:
+ cached_interval = context.user_data.get("mgs_candles_interval", "5m")
+ if not current_price or interval != cached_interval:
+ try:
+ await msg.edit_text(
+ "🔲 Multi Grid Strike - New Config\n\n"
+ f"⏳ Fetching market data for {html.escape(pair)}...",
+ parse_mode="HTML",
+ )
+ except Exception:
+ pass
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ current_price = await fetch_current_price(client, connector, pair)
+
+ if current_price:
+ context.user_data["mgs_current_price"] = current_price
+ candles = await fetch_candles(
+ client, connector, pair, interval=interval, max_records=420
+ )
+ context.user_data["mgs_candles"] = candles
+ context.user_data["mgs_candles_interval"] = interval
+
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+ if candles_list:
+ natr = calculate_natr(candles_list, period=14)
+ context.user_data["mgs_natr"] = natr
+
+ try:
+ rules = await get_trading_rules(context.user_data, client, connector)
+ context.user_data["mgs_trading_rules"] = rules.get(pair, {})
+ except Exception:
+ context.user_data["mgs_trading_rules"] = {}
+
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ await msg.edit_text(
+ f"❌ Error\n\nCould not fetch price for {html.escape(pair)}.",
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+ if candles_list:
+ last_close = candles_list[-1].get("close") or candles_list[-1].get("c")
+ if last_close:
+ current_price = float(last_close)
+ context.user_data["mgs_current_price"] = current_price
+
+ min_order_amount = config.get("min_order_amount_quote", 5)
+ trading_rules = context.user_data.get("mgs_trading_rules", {})
+
+ from .controllers.multi_grid_strike.grid_analysis import suggest_multi_grid_params
+ suggestion = suggest_multi_grid_params(
+ current_price=current_price,
+ natr=natr or 0.02,
+ total_amount=total_amount,
+ min_order_amount=min_order_amount,
+ num_grids=num_grids,
+ grid_type=grid_type
+ )
+
+ config["grids"] = suggestion["grids"]
+ set_controller_config(context, config)
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.multi_grid_strike import generate_id as mgs_generate_id
+ config["id"] = mgs_generate_id(config, existing_configs)
+ set_controller_config(context, config)
+
+ min_spread = config.get("min_spread_between_orders", 0.001)
+ take_profit = config.get("triple_barrier_config", {}).get("take_profit", 0.001)
+ natr_pct = f"{natr*100:.2f}%" if natr else "N/A"
+
+ # ========== BUILD GRID DISPLAY CON LIMITE DI GRIGLIE VISUALIZZATE ==========
+ # Per evitare caption troppo lunghe, mostriamo massimo 5 griglie nel messaggio
+ max_grids_to_show = 5
+ total_grids = len(suggestion["grids"])
+
+ grid_lines = []
+ for i, grid in enumerate(suggestion["grids"][:max_grids_to_show]):
+ side_str = "LONG" if grid["side"] == SIDE_LONG else "SHORT"
+ grid_lines.append(
+ f"Grid {i+1}: {html.escape(grid['grid_id'])} ({side_str}, {grid['amount_quote_pct']*100:.0f}%)"
+ )
+ grid_lines.append(f" start={grid['start_price']:.6g}")
+ grid_lines.append(f" end={grid['end_price']:.6g}")
+ grid_lines.append(f" limit={grid['limit_price']:.6g}")
+
+ if total_grids > max_grids_to_show:
+ grid_lines.append(f"...and {total_grids - max_grids_to_show} more grids (edit via Configs menu)")
+ # ========================================================================
+
+ context.user_data["bots_state"] = "mgs_wizard_input"
+ context.user_data["mgs_wizard_step"] = "final"
+
+ interval_options = ["1m", "5m", "15m", "1h", "4h"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:mgs_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+
+ keyboard = [
+ interval_row,
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:mgs_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ is_perp = connector.endswith("_perpetual")
+ final_step = 7 if is_perp else 6
+
+ # ========== COSTRUISCI CONFIG_TEXT CON TAG BILANCIATI ==========
+ # Usa una lista e poi join per evitare problemi di concatenazione
+ config_text_parts = [
+ f"🔲 Multi Grid Strike - Step {final_step}/{final_step} (Final)",
+ "",
+ f"{html.escape(pair)}",
+ f"Price: {current_price:,.6g} | NATR: {html.escape(natr_pct)}",
+ "",
+ f"connector_name={html.escape(connector)}",
+ f"trading_pair={html.escape(pair)}",
+ f"total_amount_quote={total_amount:.0f}",
+ f"leverage={leverage}",
+ f"position_mode={html.escape(position_mode)}",
+ f"min_spread_between_orders={min_spread}",
+ f"min_order_amount_quote={min_order_amount}",
+ f"max_open_orders={config.get('max_open_orders', 2)}",
+ f"take_profit={take_profit}",
+ f"keep_position={str(config.get('keep_position', False)).lower()}",
+ "",
+ ]
+ config_text_parts.extend(grid_lines)
+ config_text_parts.extend(["", "Edit individual grids via Configs menu after saving"])
+
+ config_text = "\n".join(config_text_parts)
+ # ================================================================
+
+ # ========== TRONCAMENTO PIÙ SICURO ==========
+ MAX_CAPTION_LEN = 950
+ if len(config_text) > MAX_CAPTION_LEN:
+ truncation_note = "\n\n...truncated due to length limit. Edit via Configs menu."
+ max_allowed = MAX_CAPTION_LEN - len(truncation_note)
+ # Cerca l'ultimo newline prima del limite per non troncare a metà riga
+ last_newline = config_text.rfind('\n', 0, max_allowed)
+ if last_newline > 0:
+ config_text = config_text[:last_newline] + truncation_note
+ else:
+ config_text = config_text[:max_allowed] + truncation_note
+ # ===========================================
+
+ # Invia la foto
+ if candles_list and suggestion["grids"]:
+ first_grid = suggestion["grids"][0]
+ chart_bytes = generate_candles_chart(
+ candles_list,
+ pair,
+ start_price=first_grid["start_price"],
+ end_price=first_grid["end_price"],
+ limit_price=first_grid["limit_price"],
+ current_price=current_price,
+ side=first_grid["side"],
+ )
+ try:
+ await context.bot.edit_message_media(
+ chat_id=chat_id,
+ message_id=msg.message_id,
+ media=InputMediaPhoto(media=chart_bytes, caption=config_text, parse_mode="HTML"),
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = msg.message_id
+ except Exception as e:
+ logger.warning(f"edit_message_media fallito: {e}, fallback a delete+send")
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = new_msg.message_id
+ context.user_data["mgs_wizard_chat_id"] = chat_id
+ else:
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = msg.message_id
+ except Exception as e:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = new_msg.message_id
+ context.user_data["mgs_wizard_chat_id"] = chat_id
+
+ except Exception as e:
+ logger.error(f"MGS final step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ try:
+ await msg.edit_text(
+ f"Error\n\n{html.escape(str(e))}",
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+async def handle_mgs_interval_change(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, interval: str
+) -> None:
+ """Change chart interval"""
+ query = update.callback_query
+ context.user_data["mgs_candles"] = None
+ context.user_data["mgs_candles_interval"] = None
+
+ # Ricrea il final step con un nuovo messaggio
+ await _mgs_show_final_step(update, context, interval=interval)
+
+
+async def handle_mgs_save(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """Save the Multi Grid Strike configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # ========== PULISCI I CAMPI NON NECESSARI ==========
+ # Rimuovi campi interni del wizard
+ config.pop("grid_strategy_type", None)
+ config.pop("num_grids", None)
+ config.pop("candles_config", None)
+ config.pop("initial_positions", None)
+
+ # Rimuovi 'description' da ogni grid
+ if "grids" in config:
+ for grid in config["grids"]:
+ grid.pop("description", None)
+ # ========== NUOVO: PULISCI triple_barrier_config (SOLO CAMPI VALIDI) ==========
+ # Multi Grid Strike supporta SOLO: open_order_type, take_profit, take_profit_order_type
+ valid_tp_fields = {"open_order_type", "take_profit", "take_profit_order_type"}
+
+ if "triple_barrier_config" in config:
+ tp_config = config["triple_barrier_config"]
+ if isinstance(tp_config, dict):
+ # Filtra solo i campi validi
+ cleaned_tp = {
+ k: v for k, v in tp_config.items()
+ if k in valid_tp_fields and v is not None
+ }
+ if cleaned_tp:
+ config["triple_barrier_config"] = cleaned_tp
+ else:
+ config.pop("triple_barrier_config", None)
+ else:
+ config.pop("triple_barrier_config", None)
+
+ # ========== NUOVO: RIMUOVI CAMPI NON STANDARD (ereditati da altri wizard) ==========
+ invalid_fields = [
+ "candles_connector",
+ "candles_trading_pair",
+ "interval",
+ "bb_length",
+ "bb_std",
+ "bb_long_threshold",
+ "bb_short_threshold",
+ "macd_fast",
+ "macd_slow",
+ "macd_signal",
+ "dca_spreads",
+ "dca_amounts_pct",
+ "dynamic_order_spread",
+ "dynamic_target",
+ "cooldown_time",
+ "max_executors_per_side",
+ "stop_loss",
+ "time_limit",
+ "trailing_stop",
+ "trailing_stop_activation",
+ "trailing_stop_delta",
+ ]
+ for field in invalid_fields:
+ config.pop(field, None)
+
+ # ========== GARANTISCI CHE I CAMPI OBBLIGATORI SIANO PRESENTI ==========
+ # Se manca triple_barrier_config ma c'è take_profit da qualche parte
+ if "take_profit" in config and "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = {
+ "take_profit": config.pop("take_profit")
+ }
+
+ # Assicurati che open_order_type e take_profit_order_type abbiano valori validi
+ if "triple_barrier_config" in config:
+ if "open_order_type" not in config["triple_barrier_config"]:
+ config["triple_barrier_config"]["open_order_type"] = ORDER_TYPE_LIMIT_MAKER
+ if "take_profit_order_type" not in config["triple_barrier_config"]:
+ config["triple_barrier_config"]["take_profit_order_type"] = ORDER_TYPE_LIMIT_MAKER
+
+ # ===================================================
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving configuration `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ # Cleanup wizard state
+ for key in ["mgs_wizard_step", "mgs_wizard_message_id", "mgs_wizard_chat_id",
+ "mgs_current_price", "mgs_candles", "mgs_candles_interval",
+ "mgs_chart_interval", "mgs_natr", "mgs_trading_rules",
+ "mgs_default_grids", "mgs_waiting_for_num_grids"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_multi_grid_strike")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ saved_msg = (
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.\n"
+ "Use 📋 Configs menu to edit individual grids or parameters\\."
+ )
+ await status_msg.edit_text(
+ saved_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"MGS save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:mgs_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+# ============================================
+# MGS BACK HANDLERS
+# ============================================
+
+async def handle_mgs_back_to_connector(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ context.user_data["mgs_wizard_step"] = "connector_name"
+ await _mgs_show_connector_step(update, context)
+
+
+async def handle_mgs_back_to_pair(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ context.user_data["mgs_wizard_step"] = "trading_pair"
+ await _mgs_show_pair_step(update, context)
+
+
+async def handle_mgs_back_to_grid_type(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ context.user_data["mgs_wizard_step"] = "grid_type"
+ await _mgs_show_grid_type_step(update, context)
+
+async def handle_mgs_back_to_leverage(update, context) -> None:
+ config = get_controller_config(context)
+ if config.get("connector_name", "").endswith("_perpetual"):
+ context.user_data["mgs_wizard_step"] = "leverage"
+
+ # Cancella messaggio corrente
+ query = update.callback_query
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ await _mgs_show_leverage_step(update, context)
+ else:
+ await handle_mgs_back_to_num_grids(update, context)
+
+async def handle_mgs_back_to_num_grids(update, context) -> None:
+ """Go back to num_grids step"""
+ query = update.callback_query
+
+ # Cancella il messaggio corrente
+ if query and query.message:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ context.user_data["mgs_wizard_step"] = "num_grids"
+ await _mgs_show_num_grids_step(update, context)
+
+async def handle_mgs_back_to_amount(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """Go back to amount step"""
+ query = update.callback_query
+
+ # Cancella il messaggio corrente
+ if query and query.message:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ context.user_data["mgs_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("mgs_current_price", None)
+ context.user_data.pop("mgs_candles", None)
+
+ await _mgs_show_amount_step(update, context)
+
+
+# ============================================
+# MGS PAIR SELECTION HANDLER
+# ============================================
+
+async def handle_mgs_pair_select(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str
+) -> None:
+ """Handle pair selection via button"""
+ await handle_mgs_wizard_pair(update, context, pair)
+
+
+# ============================================
+# MGS TEXT INPUT PROCESSOR
+# ============================================
+async def process_mgs_wizard_input(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, user_input: str
+) -> None:
+ """Process text input during MGS wizard"""
+ step = context.user_data.get("mgs_wizard_step")
+ logger.info(f"🔍 MGS DEBUG: step={step}, input={user_input}, bots_state={context.user_data.get('bots_state')}")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("mgs_wizard_message_id")
+ wizard_chat_id = context.user_data.get("mgs_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # ========== GESTISCI INPUT MANUALE PER TRADING PAIR ==========
+ if step == "trading_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_name", "")
+
+ # Validazione base: deve contenere un trattino
+ if "-" not in pair:
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*🔲 Multi Grid Strike \- Step 2*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again \(e\.g\. `BTC\-USDT`\):"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = msg.message_id
+ return
+
+ # Valida il trading pair sull'exchange
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ # Mostra suggerimenti se disponibili
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:mgs_pair:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:mgs_back_to_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*🔲 Multi Grid Strike \- Step 2*" + "\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["mgs_wizard_message_id"] = msg.message_id
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["trading_pair"] = pair
+ for key in ["mgs_current_price", "mgs_candles", "mgs_candles_interval", "mgs_natr"]:
+ context.user_data.pop(key, None)
+ set_controller_config(context, config)
+ # ========== DEBUG ==========
+ logger.info(f"🔍 MGS: pair set to {pair}, now calling _mgs_show_grid_type_step")
+ # ===========================
+ # Vai allo step successivo (grid_type)
+ context.user_data["mgs_wizard_step"] = "grid_type"
+ await _mgs_show_grid_type_step(update, context)
+
+ # ========== GESTISCI INPUT PER NUM_GRIDS_CUSTOM ==========
+ elif step == "num_grids_custom":
+ try:
+ num_grids = int(user_input.strip())
+
+ # 🔧 FIX: Recupera i limiti dal contesto
+ min_grids = context.user_data.get("mgs_min_grids", 2)
+ max_grids = context.user_data.get("mgs_max_grids", 20)
+
+ if num_grids < min_grids or num_grids > max_grids:
+ raise ValueError(f"Number must be between {min_grids} and {max_grids}")
+
+ config["num_grids"] = num_grids
+ set_controller_config(context, config)
+ context.user_data.pop("mgs_waiting_for_num_grids", None)
+
+ connector = config.get("connector_name", "")
+ if connector.endswith("_perpetual"):
+ context.user_data["mgs_wizard_step"] = "leverage"
+ await _mgs_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["mgs_wizard_step"] = "total_amount_quote"
+ await _mgs_show_amount_step(update, context)
+ except ValueError as e:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_num_grids")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*🔲 Multi Grid Strike*" + "\n\n"
+ f"❌ *Invalid number:* {escape_markdown_v2(str(e))}" + "\n\n"
+ rf"*Enter number of grids ({min_grids}-{max_grids}):*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif step == "leverage":
+ try:
+ val = int(float(user_input.strip().lower().replace("x", "")))
+ config["leverage"] = val
+ set_controller_config(context, config)
+ context.user_data["mgs_wizard_step"] = "total_amount_quote"
+ await _mgs_show_amount_step(update, context)
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_leverage")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*🔲 Multi Grid Strike*" + "\n\n"
+ r"❌ *Invalid leverage*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 20\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["mgs_wizard_step"] = "final"
+ # Show loading
+ tmp = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*🔲 Multi Grid Strike*" + "\n\n"
+ f"⏳ Loading market data for `{escape_markdown_v2(config.get('trading_pair', ''))}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["mgs_wizard_message_id"] = tmp.message_id
+ await _mgs_show_final_step(update, context)
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:mgs_back_to_amount")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*🔲 Multi Grid Strike*" + "\n\n"
+ r"❌ *Invalid amount*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 500\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "final":
+ # Handle field=value edits
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("min_spread_between_orders", "min_spread"):
+ val = float(value.replace("%", ""))
+ config["min_spread_between_orders"] = val / 100 if val > 1 else val
+ elif field in ("take_profit", "tp"):
+ val = float(value.replace("%", ""))
+ if "triple_barrier_config" not in config:
+ config["triple_barrier_config"] = {}
+ config["triple_barrier_config"]["take_profit"] = val / 100 if val > 1 else val
+ elif field in ("total_amount_quote", "amount"):
+ config["total_amount_quote"] = float(value)
+ elif field == "leverage":
+ config["leverage"] = int(float(value))
+ elif field == "max_open_orders":
+ config["max_open_orders"] = int(float(value))
+ elif field == "min_order_amount_quote":
+ config["min_order_amount_quote"] = float(value)
+ elif field == "keep_position":
+ config["keep_position"] = value.lower() in ("true", "yes", "1")
+ elif field == "position_mode":
+ config["position_mode"] = value.upper()
+ except Exception:
+ pass
+ set_controller_config(context, config)
+ # Refresh the final step
+ await _mgs_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"MGS wizard input error: {e}", exc_info=True)
+
+# ============================================
+# GENERIC SAVE HANDLER (for wizard fallback)
+# ============================================
+async def _show_new_generic_form(
+ update, context, controller_type: str
+) -> None:
+ """Generic handler for new bot configs - saves with defaults."""
+ from .controllers import get_controller
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs for sequencing: {e}")
+ configs = []
+
+ ctrl_cls = get_controller(controller_type)
+ config = ctrl_cls.get_defaults() if ctrl_cls else {}
+ config_id = ctrl_cls.generate_id(config, configs) if ctrl_cls else f"001_{controller_type}"
+ config["id"] = config_id
+
+ lines = [
+ f"*🆕 New {escape_markdown_v2(ctrl_cls.display_name if ctrl_cls else controller_type)}*",
+ "",
+ f"`{escape_markdown_v2(config_id)}`",
+ "",
+ "_Config created with default values\\._",
+ "_Use 📋 Configs menu to edit fields\\._",
+ "",
+ ]
+ for key, value in config.items():
+ if key in ("controller_name", "controller_type"):
+ continue
+ lines.append(f"`{escape_markdown_v2(str(key))}={escape_markdown_v2(str(value))}`")
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save with defaults", callback_data=f"bots:generic_save:{controller_type}")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:controller_configs")],
+ ]
+
+ set_controller_config(context, config)
+ context.user_data["generic_pending_id"] = config_id
+
+ try:
+ await query.message.edit_text(
+ "\n".join(lines),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error showing generic form: {e}", exc_info=True)
+
+
+async def handle_generic_save(
+ update, context, controller_type: str
+) -> None:
+ """Save a generic bot config with default values."""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ config_id = config.get("id", context.user_data.get("generic_pending_id", f"001_{controller_type}"))
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ keyboard = [
+ [InlineKeyboardButton("📋 Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ saved_text = (
+ "*✅ Config Saved\\!*\n\n"
+ + "`" + escape_markdown_v2(config_id) + "` saved with default values\\.\n"
+ + "Open 📋 Configs to edit the fields\\."
+ )
+ await status_msg.edit_text(
+ saved_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error saving generic config: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:controller_configs")]]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+# ============================================
+# DMAN V3 WIZARD
+# ============================================
+# Steps: connector → pair → (leverage) → amount → interval+chart → save
+# Prefisso handler: dman_
+
+async def show_new_dman_v3_form(
+ update, context
+) -> None:
+ """Start the DMan V3 wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # ========== PULISCI STATI DI ALTRI WIZARD ==========
+ # Rimuovi tutti i message_id e chat_id di altri wizard
+ for key in list(context.user_data.keys()):
+ if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"):
+ context.user_data.pop(key, None)
+ if key.endswith("_wizard_step"):
+ context.user_data.pop(key, None)
+ # ===================================================
+
+ # Clear cached data
+ for key in ["dman_current_price", "dman_candles", "dman_candles_interval",
+ "dman_chart_interval", "dman_trading_rules"]:
+ context.user_data.pop(key, None)
+
+ # Fetch existing configs for sequence numbering
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "dman_v3")
+ context.user_data["bots_state"] = "dman_wizard"
+ context.user_data["dman_wizard_step"] = "connector_name"
+ context.user_data["dman_wizard_message_id"] = query.message.message_id
+ context.user_data["dman_wizard_chat_id"] = query.message.chat_id
+
+ await _dman_show_connector_step(update, context)
+
+
+async def _dman_show_connector_step(update, context) -> None:
+ """DMan Step 1: Select Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*📉 DMan V3 \- New Config*" + "\n\n"
+ r"⚠️ No CEX connectors available\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(
+ f"🏦 {connector}", callback_data=f"bots:dman_connector:{connector}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*📉 DMan V3*" + "\n\n"
+ r"Mean reversion strategy using Bollinger Bands to detect overbought/oversold "
+ r"conditions, then enters with DCA orders at multiple levels\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select Exchange*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ disable_web_page_preview=True,
+ )
+
+ except Exception as e:
+ logger.error(f"DMan connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_dman_wizard_connector(update, context, connector: str) -> None:
+ """Handle connector selection"""
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ # Auto-set candles connector = same exchange
+ config["candles_connector"] = connector
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "trading_pair"
+ await _dman_show_pair_step(update, context)
+
+async def _dman_show_pair_step(update, context) -> None:
+ """DMan Step 2: Select Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "dman_wizard_input"
+ context.user_data["dman_wizard_step"] = "trading_pair"
+
+ # Recent pairs from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:dman_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 2
+ message_text = (
+ rf"*📉 DMan V3 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or type a new one:"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = query.message.message_id
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = new_msg.message_id
+ context.user_data["dman_wizard_chat_id"] = query.message.chat_id
+
+async def handle_dman_wizard_pair(update, context, pair: str) -> None:
+ """Handle pair selection"""
+ config = get_controller_config(context)
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "").lower()
+ is_perp = "_perpetual" in connector or "_margin" in connector
+
+ if is_perp:
+ # Step: Leverage
+ context.user_data["dman_wizard_step"] = "leverage"
+ await _dman_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY" # spot → ONEWAY obbligatorio
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ await _dman_show_amount_step(update, context)
+
+async def _dman_show_leverage_step(update, context) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "dman_wizard_input"
+ context.user_data["dman_wizard_step"] = "leverage"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:dman_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:dman_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:dman_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:dman_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:dman_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:dman_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # ========== SALVA message_id PER ERROR HANDLING ==========
+ context.user_data["dman_wizard_message_id"] = query.message.message_id
+ context.user_data["dman_wizard_chat_id"] = query.message.chat_id
+ # ========================================================
+
+ await query.message.edit_text(
+ r"*📉 DMan V3 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_dman_wizard_leverage(update, context, leverage: int) -> None:
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "position_mode"
+ await _dman_show_position_mode_step(update, context)
+
+async def _dman_show_position_mode_step(update, context) -> None:
+ """DMan Step 4 (derivati only): HEDGE vs ONEWAY"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+
+ context.user_data["bots_state"] = "dman_wizard_input"
+ context.user_data["dman_wizard_step"] = "position_mode"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("🔀 HEDGE ✅ recommended", callback_data="bots:dman_position_mode:HEDGE"),
+ InlineKeyboardButton("➡️ ONEWAY", callback_data="bots:dman_position_mode:ONEWAY"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # CORREZIONE: escape di tutti i caratteri speciali MarkdownV2
+ # I caratteri speciali sono: _ * [ ] ( ) ~ ` > # + - = | { } . !
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ await query.message.edit_text(
+ f"*📉 DMan V3 \\- Step 4/6*\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}` \\| ⚡ `{leverage}x`\n\n"
+ r"📐 *Position Mode*" + "\n\n"
+ r"• *HEDGE*: Can hold both long and short positions simultaneously" + "\n"
+ r"• *ONEWAY*: Can only hold positions in one direction \(long OR short\)",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+async def handle_dman_position_mode(update, context, position_mode: str) -> None:
+ config = get_controller_config(context)
+ config["position_mode"] = position_mode
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ await _dman_show_amount_step(update, context)
+
+async def _dman_show_amount_step(update, context) -> None:
+ """DMan Step: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ pos_mode = config.get("position_mode", "HEDGE")
+
+ context.user_data["bots_state"] = "dman_wizard_input"
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ connector = config.get("connector_name", "").lower()
+ is_perp = "_perpetual" in connector or "_margin" in connector
+ total_steps = 6 if is_perp else 4
+ current_step = 5 if is_perp else 3
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for D-Man v.3 amount step: {e}")
+ # Gestione dinamica del tasto Back
+ back_callback = "bots:dman_back_to_position_mode" if is_perp else "bots:dman_back_to_pair"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:dman_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:dman_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:dman_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:dman_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:dman_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:dman_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # Testo del messaggio con riepilogo parametri scelti finora
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x` \\| 🎯 `{pos_mode}`"
+
+ message_text = (
+ rf"*📉 DMan V3 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + balance_text + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ target_chat_id = chat_id
+ if query and query.message:
+ target_chat_id = query.message.chat_id
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = query.message.message_id
+ return
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_message(
+ chat_id=target_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = new_msg.message_id
+ context.user_data["dman_wizard_chat_id"] = target_chat_id
+
+async def handle_dman_wizard_amount(update, context, amount: float) -> None:
+ """Handle amount selection"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ pair = config.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*📉 DMan V3 \- New Config*" + "\n\n"
+ f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n"
+ r"_Fetching market data\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["dman_wizard_step"] = "final"
+ await _dman_show_final_step(update, context)
+
+
+async def _dman_show_final_step(update, context, interval: str = None) -> None:
+ """DMan Final Step: Chart + Config Summary"""
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ bb_length = config.get("bb_length", 100)
+
+ if interval is None:
+ interval = context.user_data.get("dman_chart_interval", config.get("interval", "5m"))
+ context.user_data["dman_chart_interval"] = interval
+ config["interval"] = interval
+ set_controller_config(context, config)
+
+ current_price = context.user_data.get("dman_current_price")
+ candles = context.user_data.get("dman_candles")
+
+ try:
+ cached_interval = context.user_data.get("dman_candles_interval", interval)
+ if not current_price or interval != cached_interval:
+ try:
+ await msg.edit_text(
+ r"*📉 DMan V3 \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ # --- INIZIO MODIFICA FIX GRAFICO ---
+ # Puliamo il nome del connettore per le candele (es: kucoin_perpetual -> kucoin)
+ # Questo evita l'errore 500 sui futures
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ # -----------------------------------
+
+ current_price = await fetch_current_price(client, connector, pair)
+
+ if current_price:
+ context.user_data["dman_current_price"] = current_price
+ candles = await fetch_candles(
+ client, candles_connector, pair, interval=interval, max_records=420 # <--- Usiamo candles_connector
+ )
+ context.user_data["dman_candles"] = candles
+ context.user_data["dman_candles_interval"] = interval
+
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ await msg.edit_text(
+ r"*❌ Error*" + "\n\n"
+ f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+
+ if candles_list:
+ last_close = candles_list[-1].get("close") or candles_list[-1].get("c")
+ if last_close:
+ current_price = float(last_close)
+ context.user_data["dman_current_price"] = current_price
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.dman_v3 import generate_id as dman_generate_id
+ config["id"] = dman_generate_id(config, existing_configs)
+
+ from .controllers.dman_v3.analysis import analyze_candles_for_dman, format_dman_analysis, get_dca_strategy_suggestions
+ bb_length = config.get("bb_length", 20)
+ bb_std_val = config.get("bb_std", 2.0)
+ analysis = analyze_candles_for_dman(candles_list, bb_length=bb_length, bb_std=bb_std_val)
+
+ config["bb_long_threshold"] = analysis["suggested_long_threshold"]
+ config["bb_short_threshold"] = analysis["suggested_short_threshold"]
+ if analysis["suggested_dca_spreads"]:
+ config["dca_spreads"] = ",".join(str(s) for s in analysis["suggested_dca_spreads"])
+ if "dca_amounts_pct" not in config or config["dca_amounts_pct"] is None:
+ config["dca_amounts_pct"] = ""
+ set_controller_config(context, config)
+
+ config_id = config.get("id", "")
+ position_mode = config.get("position_mode", "HEDGE")
+ stop_loss = config.get("stop_loss", 0.05)
+ take_profit = config.get("take_profit", 0.03)
+ max_exec = config.get("max_executors_per_side", 1)
+ cooldown = config.get("cooldown_time", 60)
+ dca_spreads = config.get("dca_spreads", "0.001,0.018,0.15,0.25")
+ bb_std = config.get("bb_std", 2.0)
+ bb_long = config.get("bb_long_threshold", 0.0)
+ bb_short = config.get("bb_short_threshold", 1.0)
+ ts = config.get("trailing_stop", {}) or {}
+ ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015
+ ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005
+
+ context.user_data["bots_state"] = "dman_wizard_input"
+ context.user_data["dman_wizard_step"] = "final"
+
+ interval_options = ["1m", "5m", "15m", "1h", "8h"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:dman_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+# Recuperiamo il NATR dall'analisi fatta precedentemente (punto dove hai chiamato analyze_candles_for_dman)
+ natr_val = analysis.get("natr", 0.01)
+ # Salviamo l'analisi in user_data per recuperarla quando l'utente clicca i bottoni
+ context.user_data["dman_analysis"] = analysis
+
+ # Generiamo i bottoni usando le chiavi che abbiamo definito in analysis.py
+ strategy_row = [
+ InlineKeyboardButton("🎯 Scalp", callback_data="bots:dman_set_strat:scalping"),
+ InlineKeyboardButton("🎲 Marti", callback_data="bots:dman_set_strat:martingale"),
+ InlineKeyboardButton("⚖️ Def", callback_data="bots:dman_set_strat:standard"),
+ InlineKeyboardButton("🛡️ Cons", callback_data="bots:dman_set_strat:conservative"),
+ InlineKeyboardButton("🤖 Auto", callback_data="bots:dman_set_strat:auto"),
+ ]
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ final_step = 6 if is_perp else 4
+
+ keyboard = [
+ interval_row,
+ strategy_row, # <--- AGGIUNGI QUESTA RIGA QUI
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:dman_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ config_text = (
+ rf"*📉 DMan V3 \- Step {final_step}/{final_step} \(Final\)*" + "\n\n"
+ f"*{escape_markdown_v2(pair)}*\n"
+ f"Price: `{current_price:,.6g}` \\| BB: `{bb_length}` \\| Interval: `{interval}`\n\n"
+ f"`connector_name={connector}`\n"
+ f"`trading_pair={pair}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={position_mode}`\n"
+ f"`max_executors_per_side={max_exec}`\n"
+ f"`cooldown_time={cooldown}`\n"
+ f"`stop_loss={stop_loss}`\n"
+ f"`take_profit={take_profit}`\n"
+ f"`trailing_stop_activation={ts_act}`\n"
+ f"`trailing_stop_delta={ts_delta}`\n"
+ f"`interval={interval}`\n"
+ f"`bb_length={bb_length}`\n"
+ f"`bb_std={bb_std}`\n"
+ f"`bb_long_threshold={bb_long}`\n"
+ f"`bb_short_threshold={bb_short}`\n"
+ f"`dca_spreads={escape_markdown_v2(str(dca_spreads))}`\n"
+ f"`dca_amounts_pct={config.get('dca_amounts_pct', '')}`\n"
+ r"_Edit: `field=value`_"
+ )
+
+ analysis_text = format_dman_analysis(analysis)
+ config_text += "\n\n```\n" + analysis_text + "\n```"
+
+ if candles_list:
+ from .controllers.dman_v3.chart import generate_chart as dman_chart
+ chart_bytes = dman_chart(config, candles_list, current_price)
+
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = new_msg.message_id
+ context.user_data["dman_wizard_chat_id"] = chat_id
+ else:
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = new_msg.message_id
+
+ except Exception as e:
+ logger.error(f"DMan final step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ try:
+ await msg.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+async def handle_dman_interval_change(update, context, interval: str) -> None:
+ """Change chart interval"""
+ context.user_data["dman_candles"] = None
+ context.user_data["dman_candles_interval"] = None
+ await _dman_show_final_step(update, context, interval=interval)
+
+async def handle_dman_set_strategy(update, context, strat_key: str) -> None:
+ """Handle DCA strategy selection from the final step buttons"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Recuperiamo l'analisi NATR salvata in precedenza in _dman_show_final_step
+ analysis = context.user_data.get("dman_analysis", {})
+ natr = analysis.get("natr", 0.01)
+
+ from .controllers.dman_v3.analysis import get_dca_strategy_suggestions
+ strats = get_dca_strategy_suggestions(natr)
+
+ if strat_key in strats:
+ selected = strats[strat_key]
+ # Aggiorniamo la configurazione con i valori della strategia scelta
+ config["dca_spreads"] = ",".join(str(s) for s in selected["dca_spreads"])
+ config["dca_amounts_pct"] = ",".join(str(a) for a in selected["dca_amounts_pct"])
+
+ # Applichiamo anche i threshold suggeriti dall'analisi BB
+ config["bb_long_threshold"] = analysis.get("suggested_long_threshold", 0.0)
+ config["bb_short_threshold"] = analysis.get("suggested_short_threshold", 1.0)
+
+ set_controller_config(context, config)
+
+ # Feedback visivo all'utente
+ await query.answer(f"✅ Strategia {selected['label']} applicata")
+
+ # Ricarichiamo la schermata finale per mostrare i nuovi valori nel testo e nel grafico
+ return await _dman_show_final_step(update, context)
+
+async def handle_dman_save(update, context) -> None:
+ """Save DMan V3 configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # 1. Riconoscimento del tipo di connettore
+ connector = config.get("connector_name", "").lower()
+ is_perp = "_perpetual" in connector or "_margin" in connector
+
+ # 2. ========== LOGICA DI PULIZIA E VALIDAZIONE ==========
+ # Se è SPOT, forziamo i parametri corretti a prescindere dall'input utente
+ if not is_perp:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ else:
+ # Se è Perpetual/Margin e non è stato impostato un position_mode, mettiamo HEDGE
+ if not config.get("position_mode"):
+ config["position_mode"] = "HEDGE"
+
+ # ========== PULISCI I CAMPI NON NECESSARI ==========
+ config.pop("candles_config", None)
+ config.pop("manual_kill_switch", None)
+ # dca_amounts_pct vuoto → rimuovilo, hummingbot usa distribuzione uguale di default
+ if not config.get("dca_amounts_pct"):
+ config.pop("dca_amounts_pct", None)
+ # ===================================================
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ # Cleanup dati temporanei del wizard
+ for key in ["dman_wizard_step", "dman_wizard_message_id", "dman_wizard_chat_id",
+ "dman_current_price", "dman_candles", "dman_candles_interval",
+ "dman_chart_interval", "controller_config"]: # Aggiunto controller_config per sicurezza
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_dman_v3")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ saved_msg = (
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\."
+ )
+ await status_msg.edit_text(
+ saved_msg,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"DMan save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:dman_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+async def handle_dman_back_to_connector(update, context) -> None:
+ context.user_data["dman_wizard_step"] = "connector_name"
+ await _dman_show_connector_step(update, context)
+
+
+async def handle_dman_back_to_pair(update, context) -> None:
+ context.user_data["dman_wizard_step"] = "trading_pair"
+ await _dman_show_pair_step(update, context)
+
+async def handle_dman_back_to_leverage(update, context) -> None:
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ # Usa la stessa logica degli altri wizard
+ is_perp = "_perpetual" in connector or "_margin" in connector
+ if is_perp:
+ context.user_data["dman_wizard_step"] = "leverage"
+ await _dman_show_leverage_step(update, context)
+ else:
+ await handle_dman_back_to_pair(update, context)
+
+
+async def handle_dman_back_to_amount(update, context) -> None:
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("dman_current_price", None)
+ context.user_data.pop("dman_candles", None)
+ context.user_data.pop("dman_candles_interval", None)
+ await _dman_show_amount_step(update, context)
+
+async def handle_dman_back_to_position_mode(update, context) -> None:
+ context.user_data["dman_wizard_step"] = "position_mode"
+ await _dman_show_position_mode_step(update, context)
+
+async def handle_dman_pair_select(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str
+) -> None:
+ """Handle selection of a suggested trading pair in DMan wizard"""
+ config = get_controller_config(context)
+ chat_id = update.effective_chat.id
+
+ # Clear old market data
+ for key in ["dman_current_price", "dman_candles", "dman_candles_interval", "dman_chart_interval"]:
+ context.user_data.pop(key, None)
+
+ config["trading_pair"] = trading_pair
+ config["candles_trading_pair"] = trading_pair
+ set_controller_config(context, config)
+
+ # Move to next step based on connector type
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ if is_perp:
+ context.user_data["dman_wizard_step"] = "leverage"
+ await _dman_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ await _dman_show_amount_step(update, context)
+
+async def process_dman_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during DMan V3 wizard"""
+ step = context.user_data.get("dman_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("dman_wizard_message_id")
+ wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id)
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "trading_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_name", "")
+
+ # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO ==========
+ if "-" not in pair:
+ message_id = context.user_data.get("dman_wizard_message_id")
+ wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id)
+
+ # CORRETTO: keyboard come lista di liste
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ # Mostra il contesto con exchange selezionato
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*📉 DMan V3 \- Step 2*" + "\n\n"
+ + context_text
+ + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = msg.message_id
+ return
+
+ # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) ==========
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ message_id = context.user_data.get("dman_wizard_message_id")
+ wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id)
+
+ # CORRETTO: costruisci la keyboard come lista di liste
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:dman_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ # Mostra il contesto con exchange selezionato
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*📉 DMan V3 \- Step 2*" + "\n\n"
+ + context_text
+ + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = msg.message_id
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ for key in ["dman_current_price", "dman_candles", "dman_candles_interval"]:
+ context.user_data.pop(key, None)
+ set_controller_config(context, config) # Advance to next step
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ message_id = context.user_data.get("dman_wizard_message_id")
+ wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id)
+
+ if is_perp:
+ context.user_data["dman_wizard_step"] = "leverage"
+ leverage = config.get("leverage", 1)
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:dman_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:dman_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:dman_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:dman_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:dman_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:dman_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📉 DMan V3 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_"
+ )
+ else:
+ config["leverage"] = 1
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ context.user_data["bots_state"] = "dman_wizard_input"
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:dman_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:dman_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:dman_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:dman_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:dman_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:dman_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📉 DMan V3 \- Step 3/4*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dman_wizard_message_id"] = message_id
+ except Exception:
+ pass
+ return
+ elif step == "leverage":
+ val = int(float(user_input.strip().lower().replace("x", "")))
+ config["leverage"] = val
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "total_amount_quote"
+ message_id = context.user_data.get("dman_wizard_message_id")
+ wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:dman_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:dman_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:dman_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:dman_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:dman_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:dman_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dman_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📉 DMan V3 \- Step 4/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}` \\| ⚡ `{val}x`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+ context.user_data["bots_state"] = "dman_wizard_input"
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif step == "total_amount_quote":
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["dman_wizard_step"] = "final"
+ wizard_chat_id = context.user_data.get("dman_wizard_chat_id", chat_id)
+ pair = config.get("trading_pair", "")
+ tmp = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*📉 DMan V3*" + "\n\n"
+ f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["dman_wizard_message_id"] = tmp.message_id
+ await _dman_show_final_step(update, context)
+
+ elif step == "final":
+ # Handle field=value edits
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "stop_loss", "take_profit",
+ "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "trailing_stop_activation", "trailing_stop_delta"):
+ val = float(value)
+ if field == "trailing_stop_activation":
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["activation_price"] = val
+ elif field == "trailing_stop_delta":
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["trailing_delta"] = val
+ else:
+ config[field] = val
+ elif field in ("leverage", "bb_length", "max_executors_per_side",
+ "cooldown_time", "take_profit_order_type"):
+ config[field] = int(float(value))
+ elif field in ("dynamic_order_spread", "dynamic_target"):
+ config[field] = value.lower() in ("true", "yes", "1")
+ elif field == "interval":
+ config["interval"] = value
+ # Clear candles to refresh chart
+ context.user_data.pop("dman_candles", None)
+ context.user_data["dman_chart_interval"] = value
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+
+ except Exception as e:
+ logger.error(f"DMan wizard input error: {e}", exc_info=True)
+
+
+# ============================================
+# ARBITRAGE CONTROLLER WIZARD
+# ============================================
+# Steps: connector1 → pair1 → connector2 → pair2 → amount → final+save
+# Prefisso handler: arb_
+
+async def show_new_arbitrage_controller_form(update, context) -> None:
+ """Start the Arbitrage Controller wizard - Step 1: Exchange 1"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ for key in ["arb_price_1", "arb_price_2"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "arbitrage_controller")
+ context.user_data["bots_state"] = "arb_wizard"
+ context.user_data["arb_wizard_step"] = "connector_1" # Step 1: primo exchange
+ context.user_data["arb_wizard_message_id"] = query.message.message_id
+ context.user_data["arb_wizard_chat_id"] = query.message.chat_id
+ await _arb_show_connector_step(update, context, exchange_num=1)
+
+async def _arb_show_connector_step(update, context, exchange_num: int, target_message_id: int = None) -> None:
+ """Show connector selection for exchange 1 or 2"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ # Usa target_message_id se fornito
+ use_message_id = target_message_id
+ if not query and not use_message_id:
+ logger.error("_arb_show_connector_step called without callback_query and without target_message_id")
+ return
+
+ if not query and use_message_id:
+ wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id)
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ # DEX connectors via Gateway
+ dex_connectors = ["jupiter/router", "uniswap/ethereum", "uniswap/base",
+ "uniswap/arbitrum", "uniswap/bsc", "pancakeswap/bsc",
+ "raydium/solana"]
+
+ # ========== CORREZIONE STEP NUMBER ==========
+ # exchange_num=1 -> step 1, exchange_num=2 -> step 3
+ if exchange_num == 1:
+ step = 1
+ else:
+ step = 3
+ # ===========================================
+
+ total_steps = 6
+ emoji = "1️⃣" if exchange_num == 1 else "2️⃣"
+ role = "Buy" if exchange_num == 1 else "Sell"
+
+ # Show context for step 3 (second exchange)
+ header = ""
+ if exchange_num == 2:
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ header = f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n"
+
+ keyboard = []
+ if cex_connectors:
+ keyboard.append([InlineKeyboardButton("— CEX —", callback_data="bots:noop")])
+ row = []
+ for c in cex_connectors:
+ row.append(InlineKeyboardButton(c, callback_data=f"bots:arb_connector_{exchange_num}:{c}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ if dex_connectors:
+ keyboard.append([InlineKeyboardButton("— DEX (Gateway) —", callback_data="bots:noop")])
+ row = []
+ for d in dex_connectors:
+ row.append(InlineKeyboardButton(d, callback_data=f"bots:arb_connector_{exchange_num}:{d}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = "bots:main_menu" if exchange_num == 1 else "bots:arb_back_to_pair_1"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ message_text = (
+ rf"*⚡ Arbitrage \- Step {step}/{total_steps}*" + "\n\n"
+ r"Buy on one exchange, sell on another when spread exceeds min profitability\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ + header
+ + rf"*{emoji} Select Exchange {exchange_num} \({role}\):*"
+ )
+
+ # Invia/edita il messaggio
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["arb_wizard_message_id"] = new_msg.message_id
+
+ except Exception as e:
+ logger.error(f"Arb connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ error_text = format_error_message(f"Error: {str(e)}")
+
+ if query and query.message:
+ await query.message.edit_text(
+ error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+
+
+async def handle_arb_wizard_connector_1(update, context, connector: str) -> None:
+ """Handle Exchange 1 connector selection"""
+ config = get_controller_config(context)
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["connector_name"] = connector
+ # Auto-set rate_connector to first CEX (not DEX)
+ if "/" not in connector:
+ config["rate_connector"] = connector.replace("_perpetual", "").replace("_spot", "")
+ set_controller_config(context, config)
+
+ # VAI AL PAIR 1 (step 2)
+ context.user_data["arb_wizard_step"] = "pair_1"
+ await _arb_show_pair_step(update, context, exchange_num=1)
+
+async def handle_arb_wizard_connector_2(update, context, connector: str) -> None:
+ """Handle Exchange 2 connector selection"""
+ config = get_controller_config(context)
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["connector_name"] = connector
+ set_controller_config(context, config)
+
+ # VAI AL PAIR 2 (step 4)
+ context.user_data["arb_wizard_step"] = "pair_2"
+ await _arb_show_pair_step(update, context, exchange_num=2)
+
+async def _arb_show_pair_step(update, context, exchange_num: int) -> None:
+ """Show trading pair input for exchange 1 or 2"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ ep_key = f"exchange_pair_{exchange_num}"
+ connector = config.get(ep_key, {}).get("connector_name", "")
+
+ context.user_data["bots_state"] = "arb_wizard_input"
+ context.user_data["arb_wizard_step"] = f"pair_{exchange_num}"
+
+ # Calcola lo step corretto
+ if exchange_num == 1:
+ step = 2
+ emoji = "1️⃣"
+ else:
+ step = 4
+ emoji = "2️⃣"
+
+ total_steps = 6
+
+ # Show context per exchange 2
+ header = ""
+ if exchange_num == 2:
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = config.get("exchange_pair_2", {}).get("connector_name", "")
+ header = (
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}`\n\n"
+ )
+ else:
+ c1 = config.get("exchange_pair_1", {}).get("connector_name", "")
+ header = f"1️⃣ `{escape_markdown_v2(c1)}`\n\n"
+
+ # ... resto del codice invariato ...
+
+ # Recent pairs from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ ep = cfg.get(ep_key, {})
+ pair = ep.get("trading_pair", "") if isinstance(ep, dict) else ""
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ # Suggest same pair as exchange 1 for exchange 2
+ if exchange_num == 2:
+ p1 = config.get("exchange_pair_1", {}).get("trading_pair", "")
+ if p1 and p1 not in seen:
+ recent_pairs.insert(0, p1)
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs[:6]:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:arb_pair_{exchange_num}:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = f"bots:arb_back_to_connector_{exchange_num}"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_dex = "/" in connector
+ hint = r"_e\.g\. SOL\-USDC_" if is_dex else r"_e\.g\. SOL\-USDT_"
+
+ await query.message.edit_text(
+ rf"*⚡ Arbitrage \- Step {step}/6*" + "\n\n"
+ + header
+ + rf"*{emoji} Trading Pair on* `{escape_markdown_v2(connector)}`:" + "\n\n"
+ + hint + "\n\n"
+ r"Select or type a pair:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_arb_wizard_pair_1(update: Update, context: ContextTypes.DEFAULT_TYPE, pair: str) -> None:
+ """Handle pair selection for Exchange 1"""
+ config = get_controller_config(context)
+
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+
+ # VAI AL CONNECTOR 2 (step 3) - NON al pair 2
+ context.user_data["arb_wizard_step"] = "connector_2"
+
+ # Mostra la selezione del secondo exchange
+ await _arb_show_connector_step(update, context, exchange_num=2)
+
+async def handle_arb_wizard_pair_2(update, context, pair: str) -> None:
+ """Handle pair selection for Exchange 2"""
+ config = get_controller_config(context)
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["trading_pair"] = pair.upper()
+
+ # Auto-set quote_conversion_asset from first pair (or second)
+ ep1 = config.get("exchange_pair_1", {})
+ p1 = ep1.get("trading_pair", "")
+ if p1:
+ quote = p1.split("-")[1] if "-" in p1 else "USDT"
+ else:
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ config["quote_conversion_asset"] = quote
+
+ set_controller_config(context, config)
+
+ # VAI ALL'AMOUNT (step 5)
+ context.user_data["arb_wizard_step"] = "total_amount_quote"
+ await _arb_show_amount_step(update, context)
+
+
+async def _arb_show_amount_step(update, context) -> None:
+ """Arb Step 5: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "arb_wizard_input"
+ context.user_data["arb_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_pair_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*⚡ Arbitrage \- Step 5/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount per side \(Quote\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_arb_wizard_amount(update, context, amount: float) -> None:
+ """Handle amount selection from button click"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ ep1 = config.get("exchange_pair_1", {})
+ pair = ep1.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*⚡ Arbitrage \- New Config*" + "\n\n"
+ f"⏳ Fetching prices for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["arb_wizard_step"] = "final"
+ await _arb_show_final_step(update, context)
+
+
+async def _arb_show_final_step(update, context) -> None:
+ """Arb Final Step: Show chart + config summary with supported fields"""
+ from .controllers.arbitrage_controller import ArbitrageControllerController
+ from .controllers.arbitrage_controller.chart import generate_chart
+
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ min_prof = config.get("min_profitability", 0.01)
+ delay = config.get("delay_between_executors", 10)
+ max_imbalance = config.get("max_executors_imbalance", 1)
+ rate_connector = config.get("rate_connector", "binance")
+ quote_asset = config.get("quote_conversion_asset", "USDT")
+
+ if "controller_type" not in config:
+ config["controller_type"] = "generic"
+ set_controller_config(context, config)
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.arbitrage_controller.config import generate_id as arb_generate_id
+ config["id"] = arb_generate_id(config, existing_configs)
+ set_controller_config(context, config)
+
+ # ========== FETCH CANDLES ==========
+ candles1 = []
+ candles2 = []
+
+ # Prendi i parametri dalla config (con default)
+ interval = config.get("backtest_interval", "5m")
+ max_candles = config.get("backtest_candles", 500)
+
+ # Limita a un massimo di 1000 per evitare problemi
+ max_candles = min(max_candles, 1000)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ if "/" not in c1:
+ candles_data = await fetch_candles(client, c1, p1, interval=interval, max_records=max_candles)
+ candles1 = candles_data.get("data", []) if isinstance(candles_data, dict) else (candles_data or [])
+ logger.info(f"Fetched {len(candles1)} candles for {p1} ({interval})")
+
+ if "/" not in c2:
+ candles_data2 = await fetch_candles(client, c2, p2, interval=interval, max_records=max_candles)
+ candles2 = candles_data2.get("data", []) if isinstance(candles_data2, dict) else (candles_data2 or [])
+ logger.info(f"Fetched {len(candles2)} candles for {p2} ({interval})")
+ except Exception as e:
+ logger.warning(f"Could not fetch candles for arb chart: {e}")
+
+ # ========== RECUPERA FEES E ANALISI STORICA ==========
+ fee_1 = 0.001 # default 0.1%
+ fee_2 = 0.001 # default 0.1%
+ analysis_text = ""
+
+ if candles1 and candles2:
+ try:
+ from .controllers.arbitrage_controller.analysis import analyze_historical_spread
+
+ # Recupera trading rules per ottenere le fee reali
+ trading_rules_1 = await get_trading_rules(context.user_data, client, c1)
+ fee_1 = trading_rules_1.get(p1, {}).get('taker_fee', 0.001)
+
+ trading_rules_2 = await get_trading_rules(context.user_data, client, c2)
+ fee_2 = trading_rules_2.get(p2, {}).get('taker_fee', 0.001)
+
+ # Salva le fees nel config per il chart
+ config["fee_rate_exchange_1"] = fee_1
+ config["fee_rate_exchange_2"] = fee_2
+ set_controller_config(context, config)
+
+ logger.info(f"Fees: {c1}={fee_1*100:.2f}%, {c2}={fee_2*100:.2f}%")
+
+ # Esegui analisi storica dello spread
+ analysis_result = await analyze_historical_spread(
+ candles1, candles2, config, fee_1, fee_2
+ )
+
+ if "error" not in analysis_result:
+ stats = analysis_result["statistics"]
+ suggested = analysis_result["suggested_min_profitability"]
+ is_arb = analysis_result["is_arbitrageable"]
+
+ # Aggiorna min_profitability con valore suggerito
+ if suggested > 0:
+ config["min_profitability"] = suggested
+ min_prof = suggested
+ set_controller_config(context, config)
+
+ # USA escape_markdown_v2 per tutti i valori numerici e testi dinamici
+ samples = analysis_result['total_samples']
+ mean_val = f"{stats.get('mean', 0):.3f}"
+ median_val = f"{stats.get('median', 0):.3f}"
+ p75_val = f"{stats.get('p75', 0):.3f}"
+ p90_val = f"{stats.get('p90', 0):.3f}"
+ max_val = f"{stats.get('max', 0):.3f}"
+ fees_val = f"{analysis_result['total_fees_percent']:.2f}"
+ raw_text = (
+ f"\n\n📊 Spread Statistics - last {analysis_result['total_samples']} candles, {interval}:\n"
+ f" Mean: {stats.get('mean', 0):.3f}% | Median: {stats.get('median', 0):.3f}%\n"
+ f" P75: {stats.get('p75', 0):.3f}% | P90: {stats.get('p90', 0):.3f}% | Max: {stats.get('max', 0):.3f}%\n"
+ f" Fees total: {analysis_result['total_fees_percent']:.2f}%\n"
+ )
+ # Escapa tutto il testo
+ analysis_text = escape_markdown_v2(raw_text)
+ if is_arb:
+ analysis_text += f" ✅ *Arbitrageable!* 💡 Suggested min_profitability: {suggested*100:.3f}%"
+ else:
+ analysis_text += " ⚠️ *Not profitable after fees* \\- spread P75 below fees"
+ except Exception as e:
+ logger.warning(f"Could not run historical analysis: {e}")
+ analysis_text = f"\n\n⚠️ *Could not analyze spread data* (historical analysis failed)"
+
+ # ========== FETCH LIVE PRICES FOR SPREAD (optional) ==========
+ spread_text = ""
+ price_1 = None
+ price_2 = None
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ if "/" not in c1:
+ price_1 = await fetch_current_price(client, c1, p1)
+ context.user_data["arb_price_1"] = price_1
+
+ if "/" not in c2:
+ price_2 = await fetch_current_price(client, c2, p2)
+ context.user_data["arb_price_2"] = price_2
+
+ if price_1 and price_2:
+ spread_pct = abs(price_1 - price_2) / min(price_1, price_2) * 100
+ spread_text = (
+ f"\n\n📊 *Live Spread:*\n"
+ f" `{escape_markdown_v2(c1)}`: `{price_1:,.6g}`\n"
+ f" `{escape_markdown_v2(c2)}`: `{price_2:,.6g}`\n"
+ f" Spread: `{spread_pct:.3f}%`\n"
+ )
+ if spread_pct > min_prof * 100:
+ spread_text += r" ✅ _Current spread \\> min profitability_"
+ else:
+ spread_text += r" ⚠️ _Current spread \\< min profitability_"
+ elif price_1:
+ spread_text = (
+ f"\n\n📊 *Live Price:*\n"
+ f" `{escape_markdown_v2(c1)}`: `{price_1:,.6g}`\n"
+ r" _DEX price not available via API_"
+ )
+ except Exception as e:
+ logger.warning(f"Could not fetch arb prices: {e}")
+
+ context.user_data["bots_state"] = "arb_wizard_input"
+ context.user_data["arb_wizard_step"] = "final"
+
+ # ========== PREPARE DATA FOR CHART ==========
+ # Passa le candele del secondo exchange come parte della configurazione
+ config["candles_exchange_2"] = candles2
+
+ # Genera il grafico se ci sono candele per il primo exchange
+ chart_bytes = None
+ if candles1:
+ try:
+ chart_bytes = ArbitrageControllerController.generate_chart(
+ config=config,
+ candles_data=candles1,
+ current_price=price_1 or (price_2 if price_2 else None),
+ grid_analysis=None
+ )
+ except Exception as e:
+ logger.error(f"Chart generation failed: {e}", exc_info=True)
+
+ # ========== BUILD CONFIG TEXT ==========
+ config_id = config.get("id", "")
+ config_text = (
+ r"*⚡ Arbitrage \- Final Review*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`"
+ + spread_text
+ + analysis_text
+ + "\n\n"
+ f"`id={escape_markdown_v2(config_id)}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`min_profitability={min_prof:.6f}`\n"
+ f"`delay_between_executors={delay}`\n"
+ f"`max_executors_imbalance={max_imbalance}`\n"
+ f"`rate_connector={escape_markdown_v2(rate_connector)}`\n"
+ f"`quote_conversion_asset={escape_markdown_v2(quote_asset)}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:arb_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # ========== SEND CHART OR PLAIN TEXT ==========
+ if chart_bytes and candles1:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ try:
+ await msg.edit_text(
+ config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["arb_wizard_message_id"] = new_msg.message_id
+ context.user_data["arb_wizard_chat_id"] = chat_id
+
+async def handle_arb_save(update, context) -> None:
+ """Save Arbitrage config - cleans unsupported fields before saving"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # ========== PULISCI I CAMPI NON SUPPORTATI ==========
+ # Campi supportati dal controller originale
+ supported_fields = [
+ "id",
+ "controller_name",
+ "controller_type",
+ "exchange_pair_1",
+ "exchange_pair_2",
+ "total_amount_quote",
+ "min_profitability",
+ "delay_between_executors",
+ "max_executors_imbalance",
+ "rate_connector",
+ "quote_conversion_asset",
+ "fee_rate_exchange_1", "fee_rate_exchange_2", "slippage"
+ ]
+ if "controller_type" not in config:
+ config["controller_type"] = "generic"
+ # Rimuovi tutti i campi non supportati
+ keys_to_remove = [k for k in config.keys() if k not in supported_fields]
+ for key in keys_to_remove:
+ config.pop(key, None)
+
+ # Assicurati che exchange_pair_1 e exchange_pair_2 abbiano solo i campi necessari
+ for ep_key in ["exchange_pair_1", "exchange_pair_2"]:
+ if ep_key in config:
+ # ConnectorPair supporta solo connector_name e trading_pair
+ ep = config[ep_key]
+ config[ep_key] = {
+ "connector_name": ep.get("connector_name", ""),
+ "trading_pair": ep.get("trading_pair", "")
+ }
+
+ # ===================================================
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["arb_wizard_step", "arb_wizard_message_id", "arb_wizard_chat_id",
+ "arb_price_1", "arb_price_2"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_arbitrage_controller")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Arb save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:arb_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+# Back handlers
+async def handle_arb_back_to_connector_1(update, context) -> None:
+ context.user_data["arb_wizard_step"] = "connector_1"
+ await _arb_show_connector_step(update, context, exchange_num=1)
+
+async def handle_arb_back_to_connector_2(update, context) -> None:
+ context.user_data["arb_wizard_step"] = "connector_2"
+ await _arb_show_connector_step(update, context, exchange_num=2)
+
+async def handle_arb_back_to_pair_1(update, context) -> None:
+ context.user_data["arb_wizard_step"] = "pair_1"
+ await _arb_show_pair_step(update, context, exchange_num=1)
+
+async def handle_arb_back_to_pair_2(update, context) -> None:
+ context.user_data["arb_wizard_step"] = "pair_2"
+ await _arb_show_pair_step(update, context, exchange_num=2)
+
+async def handle_arb_back_to_amount(update, context) -> None:
+ context.user_data["arb_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("arb_price_1", None)
+ context.user_data.pop("arb_price_2", None)
+ await _arb_show_amount_step(update, context)
+
+async def _arb_show_amount_step(update, context, target_message_id: int = None) -> None:
+ """Arb Step 5: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ # Usa target_message_id se fornito
+ use_message_id = target_message_id
+ if not query and not use_message_id:
+ logger.error("_arb_show_amount_step called without callback_query and without target_message_id")
+ return
+
+ if not query and use_message_id:
+ wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id)
+
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "arb_wizard_input"
+ context.user_data["arb_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_pair_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*⚡ Arbitrage \- Step 5/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount per side \(Quote\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ # Invia/edita il messaggio
+ if query and query.message:
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message in _arb_show_amount_step: {e}")
+ # Fallback: invia un nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["arb_wizard_message_id"] = new_msg.message_id
+ # ========== RIMUOVI IL BLOCCO elif step == "total_amount_quote" DA QUI ==========
+
+
+async def process_arb_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Arbitrage wizard"""
+ step = context.user_data.get("arb_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("arb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # ========== GESTISCI INPUT MANUALE PER PAIR 1 ==========
+ if step == "pair_1":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("exchange_pair_1", {}).get("connector_name", "")
+
+ # Validazione base
+ if "-" not in pair:
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_1")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*⚡ Arbitrage \- Step 2/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Validazione sul primo exchange
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:arb_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_1")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*⚡ Arbitrage \- Step 2/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ # Imposta la pair per il primo exchange
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # VAI AL CONNECTOR 2 (STEP 3)
+ context.user_data["arb_wizard_step"] = "connector_2"
+
+ # Mostra la selezione del secondo exchange
+ await _arb_show_connector_step(update, context, exchange_num=2, target_message_id=message_id)
+
+ # ========== GESTISCI INPUT MANUALE PER PAIR 2 ==========
+ elif step == "pair_2":
+ # message_id già definito all'inizio della funzione
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("exchange_pair_2", {}).get("connector_name", "")
+
+ # Validazione base
+ if "-" not in pair:
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_2")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*⚡ Arbitrage \- Step 4/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Validazione sul secondo exchange (solo se CEX)
+ is_cex = "/" not in connector
+ if is_cex:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:arb_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_connector_2")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+
+ error_text = (
+ r"*⚡ Arbitrage \- Step 4/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ # Imposta la pair per il secondo exchange
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["trading_pair"] = pair
+
+ # Auto-set quote_conversion_asset
+ ep1 = config.get("exchange_pair_1", {})
+ p1 = ep1.get("trading_pair", "")
+ if p1:
+ quote = p1.split("-")[1] if "-" in p1 else "USDT"
+ else:
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ config["quote_conversion_asset"] = quote
+
+ set_controller_config(context, config)
+
+ # VAI ALL'AMOUNT (STEP 5)
+ context.user_data["arb_wizard_step"] = "total_amount_quote"
+
+ # Mostra lo step dell'amount
+ await _arb_show_amount_step(update, context, target_message_id=message_id)
+
+ # ========== GESTISCI INPUT PER TOTAL_AMOUNT_QUOTE ==========
+ elif step == "total_amount_quote":
+ message_id = context.user_data.get("arb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("arb_wizard_chat_id", chat_id)
+ try:
+ # Rimuovi simboli di valuta e virgole
+ cleaned_input = user_input.strip().replace("$", "").replace(",", "")
+
+ # Controlla se è un numero valido
+ amount = float(cleaned_input)
+
+ if amount <= 0:
+ raise ValueError("Amount must be positive")
+
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["arb_wizard_step"] = "final"
+
+ # Mostra messaggio di caricamento
+ pair = config.get("exchange_pair_1", {}).get("trading_pair", "")
+
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*⚡ Arbitrage \- New Config*" + "\n\n"
+ f"⏳ Fetching prices for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+ else:
+ tmp_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*⚡ Arbitrage \- New Config*" + "\n\n"
+ f"⏳ Fetching prices for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["arb_wizard_message_id"] = tmp_msg.message_id
+
+ await _arb_show_final_step(update, context)
+
+ except ValueError:
+ # Input non valido - mostra errore
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:arb_back_to_pair_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ error_text = (
+ r"*⚡ Arbitrage \- Step 5/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"⚠️ *Invalid amount\. Enter a positive number \(e\.g\. 500\)*" + "\n\n"
+ r"_Select or type an amount:_"
+ )
+
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+ else:
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["arb_wizard_message_id"] = new_msg.message_id
+
+ # ========== GESTISCI INPUT PER FINAL (EDIT CAMPI) ==========
+ elif step == "final":
+ if "=" in user_input:
+ supported_fields = [
+ "id", "total_amount_quote", "min_profitability", "delay_between_executors",
+ "max_executors_imbalance", "rate_connector", "quote_conversion_asset",
+ "exchange_pair_1_connector", "exchange_pair_1_pair",
+ "exchange_pair_2_connector", "exchange_pair_2_pair",
+ ]
+
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+
+ if field not in supported_fields:
+ continue
+
+ try:
+ if field in ("min_profitability", "total_amount_quote"):
+ config[field] = float(value)
+ elif field in ("delay_between_executors", "max_executors_imbalance"):
+ config[field] = int(float(value))
+ elif field.startswith("exchange_pair_"):
+ from .controllers.arbitrage_controller.config import apply_flat_fields
+ apply_flat_fields(config, {field: value})
+ else:
+ config[field] = value
+ except Exception:
+ pass
+
+ set_controller_config(context, config)
+ await _arb_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"Arb wizard input error: {e}", exc_info=True)
+
+async def handle_arb_pair_select(update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str) -> None:
+ """Handle selection of a suggested trading pair in Arbitrage wizard"""
+ step = context.user_data.get("arb_wizard_step")
+ if step == "pair_1":
+ await handle_arb_wizard_pair_1(update, context, trading_pair)
+ elif step == "pair_2":
+ await handle_arb_wizard_pair_2(update, context, trading_pair)
+
+async def handle_arb_proceed_anyway(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle proceeding with DEX pair validation warning"""
+ pair = context.user_data.get("arb_pending_pair")
+ step = context.user_data.get("arb_wizard_step")
+ if step == "pair_1":
+ await handle_arb_wizard_pair_1(update, context, pair)
+ elif step == "pair_2":
+ await handle_arb_wizard_pair_2(update, context, pair)
+# ============================================
+# XEMM MULTIPLE LEVELS WIZARD
+# ============================================
+# Steps: maker_connector → maker_pair → taker_connector → taker_pair → amount → final+save
+# Prefisso handler: xemm_
+
+async def show_new_xemm_multiple_levels_form(update, context) -> None:
+ """Start the XEMM wizard - Step 1: Maker Exchange (come arbitrage)"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ for key in ["xemm_price_maker", "xemm_price_taker"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "xemm_multiple_levels")
+ context.user_data["bots_state"] = "xemm_wizard"
+ context.user_data["xemm_wizard_step"] = "maker_connector" # Step 1
+ context.user_data["xemm_wizard_message_id"] = query.message.message_id
+ context.user_data["xemm_wizard_chat_id"] = query.message.chat_id
+
+ await _xemm_show_connector_step(update, context, role="maker")
+
+async def _xemm_show_connector_step(update, context, role: str, target_message_id: int = None) -> None:
+ """Show connector selection for maker or taker"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ # Usa target_message_id se fornito
+ use_message_id = target_message_id
+ if not query and not use_message_id:
+ logger.error("_xemm_show_connector_step called without callback_query and without target_message_id")
+ return
+
+ if not query and use_message_id:
+ wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id)
+
+ is_maker = role == "maker"
+ step = 1 if is_maker else 3
+ emoji = "🏭" if is_maker else "⚡"
+ role_label = "Maker (less liquid, limit orders)" if is_maker else "Taker (more liquid, hedge)"
+ # Show context for taker step
+ header = ""
+ if not is_maker:
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ header = f"🏭 Maker: `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n\n"
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ # Taker can also be a DEX
+ dex_connectors = [] if is_maker else [
+ "jupiter/router", "uniswap/ethereum", "uniswap/base",
+ "uniswap/arbitrum", "uniswap/bsc", "pancakeswap/bsc",
+ ]
+
+ keyboard = []
+ if cex_connectors:
+ if dex_connectors:
+ keyboard.append([InlineKeyboardButton("— CEX —", callback_data="bots:noop")])
+ row = []
+ for c in cex_connectors:
+ row.append(InlineKeyboardButton(
+ c, callback_data=f"bots:xemm_{role}_connector:{c}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ if dex_connectors:
+ keyboard.append([InlineKeyboardButton("— DEX (Gateway) —", callback_data="bots:noop")])
+ row = []
+ for d in dex_connectors:
+ row.append(InlineKeyboardButton(
+ d, callback_data=f"bots:xemm_{role}_connector:{d}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = "bots:main_menu" if is_maker else "bots:xemm_back_to_pair"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ message_text = (
+ rf"*🔄 XEMM Multi Levels \- Step {step}/6*" + "\n\n"
+ r"Places limit orders on maker exchange, hedges instantly on taker\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ + header
+ + rf"*{emoji} Select {escape_markdown_v2(role_label)}:*"
+ )
+
+ # Invia/edita il messaggio
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message in _xemm_show_connector_step: {e}")
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["xemm_wizard_message_id"] = new_msg.message_id
+
+ except Exception as e:
+ logger.error(f"XEMM connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ error_text = format_error_message(f"Error: {str(e)}")
+
+ if query and query.message:
+ await query.message.edit_text(
+ error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+async def handle_xemm_maker_connector(update, context, connector: str) -> None:
+ """Handle Maker connector selection"""
+ config = get_controller_config(context)
+ config["maker_connector"] = connector
+ set_controller_config(context, config)
+
+ # VAI AL PAIR DEL MAKER (Step 2)
+ context.user_data["xemm_wizard_step"] = "maker_pair"
+ await _xemm_show_pair_step(update, context, role="maker")
+
+async def handle_xemm_taker_connector(update, context, connector: str) -> None:
+ """Handle Taker connector selection"""
+ config = get_controller_config(context)
+ config["taker_connector"] = connector
+ set_controller_config(context, config)
+
+ # VAI AL PAIR DEL TAKER (Step 4)
+ context.user_data["xemm_wizard_step"] = "taker_pair"
+ await _xemm_show_pair_step(update, context, role="taker")
+
+async def handle_xemm_wizard_pair(update, context, pair: str) -> None:
+ """Handle pair selection - applies to BOTH exchanges (come arbitrage)"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Applica lo stesso pair a entrambi (come arbitrage)
+ config["maker_trading_pair"] = pair
+ config["taker_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # Vai al taker connector (Step 3)
+ context.user_data["xemm_wizard_step"] = "taker_connector"
+ await _xemm_show_connector_step(update, context, role="taker")
+
+async def _xemm_show_pair_step(update, context, role: str) -> None:
+ """Mostra l'input per la trading pair (Maker o Taker)"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ is_maker = (role == "maker")
+ connector = config.get("maker_connector" if is_maker else "taker_connector", "")
+
+ context.user_data["bots_state"] = "xemm_wizard_input"
+ context.user_data["xemm_wizard_step"] = f"{role}_pair"
+
+ step = 2 if is_maker else 4
+ emoji = "🏭 Maker" if is_maker else "⚡ Taker"
+
+ # Header con riepilogo passi precedenti
+ if not is_maker:
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ header = f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n" \
+ f"⚡ `{escape_markdown_v2(connector)}`\n\n"
+ else:
+ header = f"🏭 `{escape_markdown_v2(connector)}`\n\n"
+
+ # Suggerimenti coppie recenti
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ p = cfg.get(f"{role}_trading_pair", "")
+ if p and p not in seen:
+ seen.add(p); recent_pairs.append(p)
+ if len(recent_pairs) >= 6: break
+
+ # Se Taker, suggerisci la stessa pair del Maker come prima opzione
+ if not is_maker:
+ mp = config.get("maker_trading_pair", "")
+ if mp and mp not in seen: recent_pairs.insert(0, mp)
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for p in recent_pairs[:6]:
+ row.append(InlineKeyboardButton(p, callback_data=f"bots:xemm_{role}_pair:{p}"))
+ if len(row) == 2: keyboard.append(row); row = []
+ if row: keyboard.append(row)
+
+ back_cb = "bots:xemm_back_to_maker_connector" if is_maker else "bots:xemm_back_to_taker_connector"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ await query.message.edit_text(
+ rf"*🔄 XEMM \- Step {step}/6*" + "\n\n" + header +
+ rf"*{emoji} Trading Pair:* " + "\n\n" +
+ r"_Esempio: BTC\-USDT_" + "\n\n" +
+ r"Seleziona o scrivi la coppia:",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard)
+ )
+
+async def handle_xemm_maker_pair(update, context, pair: str) -> None:
+ """Handle maker pair selection"""
+ config = get_controller_config(context)
+ config["maker_trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+ context.user_data["xemm_wizard_step"] = "taker_connector"
+ # Non serve target_message_id qui perché viene da callback
+ await _xemm_show_connector_step(update, context, role="taker")
+
+
+async def handle_xemm_taker_pair(update, context, pair: str) -> None:
+ """Handle taker pair selection"""
+ config = get_controller_config(context)
+ config["taker_trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+ context.user_data["xemm_wizard_step"] = "total_amount_quote"
+ # Non serve target_message_id perché viene da callback
+ await _xemm_show_amount_step(update, context)
+
+async def _show_xemm_pair_suggestions(
+ update: Update,
+ context: ContextTypes.DEFAULT_TYPE,
+ input_pair: str,
+ error_msg: str,
+ suggestions: list,
+ connector: str,
+ role: str, # "maker" o "taker"
+) -> None:
+ """Show trading pair suggestions when validation fails in XEMM wizard"""
+ message_id = context.user_data.get("xemm_wizard_message_id")
+ chat_id = context.user_data.get("xemm_wizard_chat_id")
+ wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", update.effective_chat.id)
+
+ # Memorizza la pair in sospeso per proceed_anyway
+ context.user_data["xemm_pending_pair"] = input_pair
+ # Memorizza anche lo step corrente
+ context.user_data["xemm_wizard_step"] = role + "_pair"
+
+ # Build suggestion message
+ help_text = f"❌ *{escape_markdown_v2(error_msg)}*\n\n"
+
+ if suggestions:
+ help_text += "💡 *Did you mean:*\n"
+ else:
+ help_text += "_No similar pairs found\\._\n"
+
+ # Build keyboard with suggestions
+ keyboard = []
+ for pair in suggestions[:4]:
+ keyboard.append(
+ [
+ InlineKeyboardButton(
+ f"📈 {pair}", callback_data=f"bots:xemm_{role}_pair:{pair}"
+ )
+ ]
+ )
+
+ # Back button based on role
+ back_cb = "bots:xemm_back_to_maker_connector" if role == "maker" else "bots:xemm_back_to_taker_connector"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ if message_id and chat_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ )
+ except Exception as e:
+ logger.debug(f"Could not update XEMM wizard message: {e}")
+ else:
+ await update.effective_chat.send_message(
+ help_text, parse_mode="MarkdownV2", reply_markup=reply_markup
+ )
+
+
+
+async def _xemm_show_amount_step(update, context, target_message_id: int = None) -> None:
+ """XEMM Step 5: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ # Usa target_message_id se fornito
+ use_message_id = target_message_id
+ if not query and not use_message_id:
+ logger.error("_xemm_show_amount_step called without callback_query and without target_message_id")
+ return
+
+ if not query and use_message_id:
+ wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id)
+
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ tc = config.get("taker_connector", "")
+ tp = config.get("taker_trading_pair", "")
+
+ context.user_data["bots_state"] = "xemm_wizard_input"
+ context.user_data["xemm_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:xemm_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:xemm_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:xemm_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:xemm_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:xemm_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:xemm_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*🔄 XEMM Multi Levels \- Step 5/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n"
+ f"⚡ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"_50% allocated to buy side, 50% to sell side_\n"
+ r"_Select or type an amount:_"
+ )
+
+ # Invia/edita il messaggio
+ if query and query.message:
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message in _xemm_show_amount_step: {e}")
+ # Fallback: invia un nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["xemm_wizard_message_id"] = new_msg.message_id
+
+
+async def handle_xemm_wizard_amount(update, context, amount: float) -> None:
+ """Handle amount selection"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ mp = config.get("maker_trading_pair", "")
+ await query.message.edit_text(
+ r"*🔄 XEMM Multi Levels \- New Config*" + "\n\n"
+ f"⏳ Fetching prices for `{escape_markdown_v2(mp)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["xemm_wizard_step"] = "final"
+ await _xemm_show_final_step(update, context)
+
+
+async def _xemm_show_final_step(update, context) -> None:
+ """XEMM Final Step: Show config + live spread + suggested levels"""
+ query = update.callback_query
+ if query:
+ msg = query.message
+ else:
+ msg = update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ tc = config.get("taker_connector", "")
+ tp = config.get("taker_trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ min_prof = config.get("min_profitability", 0.003)
+ max_prof = config.get("max_profitability", 0.01)
+ buy_levels = config.get("buy_levels_targets_amount", "0.003,10-0.006,20-0.009,30")
+ sell_levels = config.get("sell_levels_targets_amount", "0.003,10-0.006,20-0.009,30")
+
+ # Generate ID if not set
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.xemm_multiple_levels.config import generate_id as xemm_generate_id
+ config["id"] = xemm_generate_id(config, existing_configs)
+ set_controller_config(context, config)
+
+ # Fetch live prices and calculate spread
+ spread_line = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ price_maker = None
+ price_taker = None
+
+ if "/" not in mc:
+ price_maker = await fetch_current_price(client, mc, mp)
+ if price_maker:
+ context.user_data["xemm_price_maker"] = price_maker
+
+ if "/" not in tc:
+ price_taker = await fetch_current_price(client, tc, tp)
+ if price_taker:
+ context.user_data["xemm_price_taker"] = price_taker
+
+ if price_maker and price_taker:
+ spread_pct = abs(price_maker - price_taker) / min(price_maker, price_taker)
+ spread_pct_display = spread_pct * 100
+
+ # Auto-suggest levels based on observed spread
+ from .controllers.xemm_multiple_levels.config import suggest_levels_from_spread
+ suggested_levels = suggest_levels_from_spread(spread_pct, total_amount)
+ config["buy_levels_targets_amount"] = suggested_levels
+ config["sell_levels_targets_amount"] = suggested_levels
+ buy_levels = suggested_levels
+ sell_levels = suggested_levels
+ set_controller_config(context, config)
+
+ spread_line = (
+ "\n\n" +
+ r"📊 *Live Spread:*" + "\n"
+ f"🏭 `{escape_markdown_v2(mc)}`: `{price_maker:,.6g}`\n"
+ f"⚡ `{escape_markdown_v2(tc)}`: `{price_taker:,.6g}`\n"
+ f"Spread: `{spread_pct_display:.3f}%`\n"
+ r"_✅ Levels auto\-suggested from spread_"
+ )
+ elif price_maker:
+ spread_line = (
+ "\n\n" +
+ r"📊 *Live Price \(maker\):*" + "\n"
+ f"🏭 `{escape_markdown_v2(mc)}`: `{price_maker:,.6g}`\n"
+ r"_⚠️ Taker price not available — using default levels_"
+ )
+
+ except Exception as e:
+ logger.warning(f"Could not fetch XEMM prices: {e}")
+
+ context.user_data["bots_state"] = "xemm_wizard_input"
+ context.user_data["xemm_wizard_step"] = "final"
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:xemm_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ config_text = (
+ r"*🔄 XEMM Multi Levels \- Step 6/6 \(Final\)*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n"
+ f"⚡ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`"
+ + spread_line + "\n\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`buy_levels_targets_amount={escape_markdown_v2(str(buy_levels))}`\n"
+ f"`sell_levels_targets_amount={escape_markdown_v2(str(sell_levels))}`\n"
+ f"`min_profitability={min_prof}`\n"
+ f"`max_profitability={max_prof}`\n"
+ f"`max_executors_imbalance={config.get('max_executors_imbalance', 1)}`\n\n"
+ r"_Edit: `field=value`_" + "\n"
+ r"_Levels format: `profit,weight\-profit,weight`_"
+ )
+ try:
+ await msg.edit_text(
+ config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["xemm_wizard_message_id"] = new_msg.message_id
+ context.user_data["xemm_wizard_chat_id"] = chat_id
+
+
+async def handle_xemm_save(update, context) -> None:
+ """Save XEMM config"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config_id = config.get("id", "").replace("/", "-")
+ config["id"] = config_id # aggiorna anche nel config
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["xemm_wizard_step", "xemm_wizard_message_id", "xemm_wizard_chat_id",
+ "xemm_price_maker", "xemm_price_taker"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_xemm_multiple_levels")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"XEMM save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:xemm_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+# Back handlers
+async def handle_xemm_back_to_maker_connector(update, context) -> None:
+ context.user_data["xemm_wizard_step"] = "maker_connector"
+ await _xemm_show_connector_step(update, context, role="maker")
+
+async def handle_xemm_back_to_maker_pair(update, context) -> None:
+ context.user_data["xemm_wizard_step"] = "maker_trading_pair"
+ await _xemm_show_pair_step(update, context, role="maker")
+
+async def handle_xemm_back_to_taker_connector(update, context) -> None:
+ context.user_data["xemm_wizard_step"] = "taker_connector"
+ await _xemm_show_connector_step(update, context, role="taker")
+
+async def handle_xemm_back_to_taker_pair(update, context) -> None:
+ context.user_data["xemm_wizard_step"] = "taker_trading_pair"
+ await _xemm_show_pair_step(update, context, role="taker")
+
+async def handle_xemm_back_to_amount(update, context) -> None:
+ context.user_data["xemm_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("xemm_price_maker", None)
+ context.user_data.pop("xemm_price_taker", None)
+ await _xemm_show_amount_step(update, context)
+
+async def process_xemm_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during XEMM wizard"""
+ step = context.user_data.get("xemm_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("xemm_wizard_message_id")
+ wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # ========== GESTISCI INPUT MANUALE PER MAKER PAIR ==========
+ if step == "maker_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("maker_connector", "")
+
+ # Validazione base
+ if "-" not in pair:
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_maker_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*🔄 XEMM Multi Levels \- Step 2*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Validazione sul maker exchange (solo se CEX)
+ is_cex = "/" not in connector
+ if is_cex:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ await _show_xemm_pair_suggestions(
+ update, context, pair, error_msg, suggestions, connector, "maker"
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+ else:
+ # DEX - avvertimento ma permetti di continuare
+ context.user_data["xemm_pending_pair"] = pair
+ warning_text = (
+ r"*🔄 XEMM Multi Levels \- Step 2*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(connector)}` \\| `{escape_markdown_v2(pair)}`\n\n"
+ r"⚠️ *DEX pair validation not available*" + "\n\n"
+ r"_The pair may not exist on the DEX\. Proceed with caution\._"
+ )
+ keyboard = [[InlineKeyboardButton("✅ Proceed Anyway", callback_data="bots:xemm_proceed_anyway")]]
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=warning_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Imposta la pair per il maker exchange
+ config["maker_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # VAI AL TAKER CONNECTOR (Step 3)
+ context.user_data["xemm_wizard_step"] = "taker_connector"
+
+ # Mostra la selezione del taker exchange
+ await _xemm_show_connector_step(update, context, role="taker", target_message_id=message_id)
+
+ # ========== GESTISCI INPUT MANUALE PER TAKER PAIR ==========
+ elif step == "taker_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("taker_connector", "")
+
+ # Validazione base
+ if "-" not in pair:
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*🔄 XEMM Multi Levels \- Step 4*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n"
+ f"⚡ `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Validazione sul taker exchange (solo se CEX)
+ is_cex = "/" not in connector
+ if is_cex:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ await _show_xemm_pair_suggestions(
+ update, context, pair, error_msg, suggestions, connector, "taker"
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+ else:
+ # DEX - avvertimento ma permetti di continuare
+ context.user_data["xemm_pending_pair"] = pair
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ warning_text = (
+ r"*🔄 XEMM Multi Levels \- Step 4*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n"
+ f"⚡ `{escape_markdown_v2(connector)}` \\| `{escape_markdown_v2(pair)}`\n\n"
+ r"⚠️ *DEX pair validation not available*" + "\n\n"
+ r"_The pair may not exist on the DEX\. Proceed with caution\._"
+ )
+ keyboard = [[InlineKeyboardButton("✅ Proceed Anyway", callback_data="bots:xemm_proceed_anyway")]]
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=warning_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Imposta la pair per il taker exchange
+ config["taker_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # VAI ALL'AMOUNT (Step 5)
+ context.user_data["xemm_wizard_step"] = "total_amount_quote"
+
+ # Mostra lo step dell'amount
+ await _xemm_show_amount_step(update, context, target_message_id=message_id)
+ elif step == "taker_connector":
+ # Questo step è gestito esclusivamente dai callback button
+ pass
+ # ========== TOTAL AMOUNT QUOTE (ispirato a arbitrage) ==========
+ elif step == "total_amount_quote":
+ try:
+ # Rimuovi simboli di valuta e virgole
+ cleaned_input = user_input.strip().replace("$", "").replace(",", "")
+
+ # Controlla se è un numero valido
+ amount = float(cleaned_input)
+
+ if amount <= 0:
+ raise ValueError("Amount must be positive")
+
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["xemm_wizard_step"] = "final"
+
+ # Mostra messaggio di caricamento (come arbitrage)
+ wizard_chat_id = context.user_data.get("xemm_wizard_chat_id", chat_id)
+ mp = config.get("maker_trading_pair", "")
+
+ # Crea un messaggio temporaneo per il caricamento
+ tmp_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*🔄 XEMM Multi Levels \- New Config*" + "\n\n"
+ f"⏳ Fetching prices for `{escape_markdown_v2(mp)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["xemm_wizard_message_id"] = tmp_msg.message_id
+ await _xemm_show_final_step(update, context)
+
+ except ValueError:
+ # Input non valido - mostra errore (come arbitrage)
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ tc = config.get("taker_connector", "")
+ tp = config.get("taker_trading_pair", "")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:xemm_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:xemm_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:xemm_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:xemm_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:xemm_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:xemm_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ error_text = (
+ r"*🔄 XEMM Multi Levels \- Step 5/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n"
+ f"⚡ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"⚠️ *Invalid amount\. Enter a positive number \(e\.g\. 500\)*" + "\n\n"
+ r"_Select or type an amount:_"
+ )
+
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+ else:
+ # Se non c'è message_id, invia un nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["xemm_wizard_message_id"] = new_msg.message_id
+
+ # ========== FINAL (edit campi) ==========
+ elif step == "final":
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "min_profitability", "max_profitability"):
+ config[field] = float(value)
+ elif field == "max_executors_imbalance":
+ config[field] = int(float(value))
+ elif field in ("buy_levels_targets_amount", "sell_levels_targets_amount"):
+ # Validazione formato (es. "0.003,10-0.006,20")
+ parts = value.split("-")
+ valid = True
+ for part in parts:
+ vals = part.split(",")
+ if len(vals) != 2:
+ valid = False
+ break
+ try:
+ float(vals[0])
+ float(vals[1])
+ except ValueError:
+ valid = False
+ break
+ if valid:
+ config[field] = value
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+ await _xemm_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"XEMM wizard input error: {e}", exc_info=True)
+
+async def handle_xemm_back_to_pair(update, context) -> None:
+ """Go back to trading pair selection"""
+ context.user_data["xemm_wizard_step"] = "trading_pair"
+ await _xemm_show_pair_step(update, context)
+
+
+async def handle_xemm_proceed_anyway(update, context) -> None:
+ """Handle proceeding with DEX pair validation warning"""
+ query = update.callback_query
+ pair = context.user_data.get("xemm_pending_pair")
+ step = context.user_data.get("xemm_wizard_step")
+ config = get_controller_config(context)
+
+ if not pair:
+ await query.answer("No pending pair found", show_alert=True)
+ return
+
+ if step == "maker_pair":
+ # Imposta la pair per il maker exchange
+ config["maker_trading_pair"] = pair
+ set_controller_config(context, config)
+ # Vai al taker connector (Step 3)
+ context.user_data["xemm_wizard_step"] = "taker_connector"
+ await _xemm_show_connector_step(update, context, role="taker")
+
+ elif step == "taker_pair":
+ # Imposta la pair per il taker exchange
+ config["taker_trading_pair"] = pair
+ set_controller_config(context, config)
+ # Vai all'amount (Step 5)
+ context.user_data["xemm_wizard_step"] = "total_amount_quote"
+ await _xemm_show_amount_step(update, context)
+
+ else:
+ # Fallback: vai all'amount (comportamento originale)
+ context.user_data["xemm_wizard_step"] = "total_amount_quote"
+
+ mc = config.get("maker_connector", "")
+ mp = config.get("maker_trading_pair", "")
+ tc = config.get("taker_connector", "")
+ tp = config.get("taker_trading_pair", "")
+
+ # Se non c'è maker_pair, usa la pair in sospeso
+ if not mp and pair:
+ mp = pair
+ config["maker_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:xemm_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:xemm_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:xemm_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:xemm_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:xemm_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:xemm_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:xemm_back_to_taker_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ amount_text = (
+ r"*🔄 XEMM Multi Levels \- Step 5/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(mc)}` \\| `{escape_markdown_v2(mp)}`\n"
+ f"⚡ `{escape_markdown_v2(tc)}` \\| `{escape_markdown_v2(tp)}`\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ await query.message.edit_text(
+ amount_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+# ============================================
+# MACD BB V1 WIZARD
+# ============================================
+# Steps: connector → pair → (leverage) → amount → interval+chart → final
+# Prefisso handler: macdbb_
+
+async def show_new_macd_bb_v1_form(update, context) -> None:
+ """Start the MACD BB V1 wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Clear cached data
+ for key in ["macdbb_current_price", "macdbb_candles", "macdbb_candles_interval",
+ "macdbb_chart_interval", "macdbb_trading_rules"]:
+ context.user_data.pop(key, None)
+
+ # Fetch existing configs for sequence numbering
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "macd_bb_v1")
+ context.user_data["bots_state"] = "macdbb_wizard"
+ context.user_data["macdbb_wizard_step"] = "connector_name"
+ context.user_data["macdbb_wizard_message_id"] = query.message.message_id
+ context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id
+
+ await _macdbb_show_connector_step(update, context)
+
+
+async def _macdbb_show_connector_step(update, context) -> None:
+ """MACD BB Step 1: Select Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*📊 MACD BB V1 \- New Config*" + "\n\n"
+ r"⚠️ No CEX connectors available\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(
+ f"🏦 {connector}", callback_data=f"bots:macdbb_connector:{connector}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*📊 MACD BB V1*" + "\n\n"
+ r"Combines Bollinger Bands with MACD confirmation\. "
+ r"Enters LONG when BBP is low AND MACD histogram is rising\. "
+ r"Enters SHORT when BBP is high AND MACD histogram is falling\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select Exchange*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ disable_web_page_preview=True,
+ )
+
+ except Exception as e:
+ logger.error(f"MACD BB connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_macdbb_wizard_connector(update, context, connector: str) -> None:
+ """Handle connector selection"""
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ config["candles_connector"] = connector # Auto-set candles connector = same exchange
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "trading_pair"
+ await _macdbb_show_pair_step(update, context)
+
+async def _macdbb_show_pair_step(update, context) -> None:
+ """MACD BB Step 2: Select Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "macdbb_wizard_input"
+ context.user_data["macdbb_wizard_step"] = "trading_pair"
+
+ # Recent pairs from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:macdbb_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 2
+
+ message_text = (
+ rf"*📊 MACD BB V1 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or type a new one:"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = query.message.message_id
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = new_msg.message_id
+ context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id
+
+async def handle_macdbb_wizard_pair(update, context, pair: str) -> None:
+ """Handle pair selection via button"""
+ config = get_controller_config(context)
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "").lower()
+ is_perp = "_perpetual" in connector or "_margin" in connector
+
+ if is_perp:
+ context.user_data["macdbb_wizard_step"] = "leverage"
+ await _macdbb_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ await _macdbb_show_amount_step(update, context)
+
+async def _macdbb_show_leverage_step(update, context) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "macdbb_wizard_input"
+ context.user_data["macdbb_wizard_step"] = "leverage"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:macdbb_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:macdbb_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:macdbb_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:macdbb_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:macdbb_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:macdbb_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # ========== SALVA message_id PER ERROR HANDLING ==========
+ context.user_data["macdbb_wizard_message_id"] = query.message.message_id
+ context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id
+ # ========================================================
+
+ await query.message.edit_text(
+ r"*📊 MACD BB V1 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_macdbb_wizard_leverage(update, context, leverage: int) -> None:
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "position_mode"
+ await _macdbb_show_position_mode_step(update, context)
+
+async def _macdbb_show_position_mode_step(update, context) -> None:
+ """MACD BB Wizard Step: Select Position Mode (Perpetual/Margin only)"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = escape_markdown_v2(config.get("connector_name", ""))
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+
+ context.user_data["macdbb_wizard_message_id"] = query.message.message_id
+ context.user_data["macdbb_wizard_chat_id"] = query.message.chat_id
+
+ keyboard = [
+ [
+ InlineKeyboardButton("🔒 ONEWAY", callback_data="bots:macdbb_position_mode:ONEWAY"),
+ InlineKeyboardButton("🔄 HEDGE", callback_data="bots:macdbb_position_mode:HEDGE"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+
+ # CORREZIONE: escape di tutti i caratteri speciali MarkdownV2
+ # I caratteri speciali sono: _ * [ ] ( ) ~ ` > # + - = | { } . !
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ await query.message.edit_text(
+ f"*📊 MACD BB V1 \\- Step 4/6*\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}` \\| ⚡ `{leverage}x`\n\n"
+ r"🎯 *Position Mode*" + "\n\n"
+ r"• *HEDGE*: Can hold both long and short positions simultaneously" + "\n"
+ r"• *ONEWAY*: Can only hold positions in one direction \(long OR short\)",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_macdbb_position_mode(update, context, mode: str) -> None:
+ config = get_controller_config(context)
+ config["position_mode"] = mode
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ await _macdbb_show_amount_step(update, context)
+
+async def _macdbb_show_amount_step(update, context) -> None:
+ """MACD BB Step: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ pos_mode = config.get("position_mode", "HEDGE")
+
+ context.user_data["bots_state"] = "macdbb_wizard_input"
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ is_perp = "_perpetual" in connector.lower() or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 5 if is_perp else 3
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for MacdBB amount step: {e}")
+
+ # Gestione dinamica del tasto Back
+ back_callback = "bots:macdbb_back_to_position_mode" if is_perp else "bots:macdbb_back_to_pair"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:macdbb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:macdbb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:macdbb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:macdbb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:macdbb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:macdbb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x` \\| 🎯 `{pos_mode}`"
+
+ message_text = (
+ rf"*📊 MACD BB V1 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + balance_text + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ target_chat_id = chat_id
+ if query and query.message:
+ target_chat_id = query.message.chat_id
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = query.message.message_id
+ return
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_message(
+ chat_id=target_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = new_msg.message_id
+ context.user_data["macdbb_wizard_chat_id"] = target_chat_id
+
+async def handle_macdbb_wizard_amount(update, context, amount: float) -> None:
+ """Handle amount selection"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ pair = config.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*📊 MACD BB V1 \- New Config*" + "\n\n"
+ f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n"
+ r"_Fetching market data\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["macdbb_wizard_step"] = "final"
+ await _macdbb_show_final_step(update, context)
+
+async def _macdbb_show_final_step(update, context, interval: str = None) -> None:
+ """MACD BB Final Step: Chart + Config Summary + Analysis"""
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ bb_length = config.get("bb_length", 100)
+ macd_fast = config.get("macd_fast", 21)
+ macd_slow = config.get("macd_slow", 42)
+ macd_signal_period = config.get("macd_signal", 9)
+
+ if interval is None:
+ interval = context.user_data.get("macdbb_chart_interval", config.get("interval", "5m"))
+ context.user_data["macdbb_chart_interval"] = interval
+ config["interval"] = interval
+ set_controller_config(context, config)
+
+ current_price = context.user_data.get("macdbb_current_price")
+ candles = context.user_data.get("macdbb_candles")
+
+ try:
+ cached_interval = context.user_data.get("macdbb_candles_interval", interval)
+ if not current_price or interval != cached_interval:
+ try:
+ await msg.edit_text(
+ r"*📊 MACD BB V1 \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ current_price = await fetch_current_price(client, connector, pair)
+
+ if current_price:
+ # CORREZIONE QUI - usa macdbb_current_price
+ context.user_data["macdbb_current_price"] = current_price
+ candles = await fetch_candles(
+ client, candles_connector, pair, interval=interval, max_records=420
+ )
+ context.user_data["macdbb_candles"] = candles
+ context.user_data["macdbb_candles_interval"] = interval
+
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ await msg.edit_text(
+ r"*❌ Error*" + "\n\n"
+ f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+
+ if candles_list:
+ last_close = candles_list[-1].get("close") or candles_list[-1].get("c")
+ if last_close:
+ current_price = float(last_close)
+ context.user_data["macdbb_current_price"] = current_price
+
+ # Generate config ID if not set
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.macd_bb_v1.config import generate_id as macdbb_generate_id
+ config["id"] = macdbb_generate_id(config, existing_configs)
+
+ from .controllers.macd_bb_v1.analysis import analyze_candles_for_macd_bb, format_macd_bb_analysis
+ analysis = analyze_candles_for_macd_bb(candles_list, bb_length=config.get("bb_length", 100), bb_std=config.get("bb_std", 2.0), macd_fast=macd_fast, macd_slow=macd_slow, macd_signal=macd_signal_period)
+
+ # Auto-save suggested thresholds
+ if config.get("bb_long_threshold", 0.0) == 0.0:
+ config["bb_long_threshold"] = analysis["suggested_long_threshold"]
+ if config.get("bb_short_threshold", 1.0) == 1.0:
+ config["bb_short_threshold"] = analysis["suggested_short_threshold"]
+ set_controller_config(context, config)
+
+ position_mode = config.get("position_mode", "HEDGE")
+ stop_loss = config.get("stop_loss", 0.05)
+ take_profit = config.get("take_profit", 0.03)
+ max_exec = config.get("max_executors_per_side", 1)
+ cooldown = config.get("cooldown_time", 60)
+ bb_std = config.get("bb_std", 2.0)
+ bb_long = config.get("bb_long_threshold", 0.0)
+ bb_short = config.get("bb_short_threshold", 1.0)
+ ts = config.get("trailing_stop", {}) or {}
+ ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015
+ ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005
+
+ context.user_data["bots_state"] = "macdbb_wizard_input"
+ context.user_data["macdbb_wizard_step"] = "final"
+
+ interval_options = ["1m", "5m", "15m", "1h", "8h"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:macdbb_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+
+ # ========== SALVA ANALISI PER I BOTTONI STRATEGIA ==========
+ context.user_data["macdbb_analysis"] = analysis
+ strategy_row = [
+ InlineKeyboardButton("🎯 Scalp", callback_data="bots:macdbb_set_strat:scalping"),
+ InlineKeyboardButton("⚖️ Swing", callback_data="bots:macdbb_set_strat:swing"),
+ InlineKeyboardButton("🤖 Auto", callback_data="bots:macdbb_set_strat:auto"),
+ ]
+
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ final_step = 6 if is_perp else 4
+
+ keyboard = [
+ interval_row,
+ strategy_row, # <--- AGGIUNGI QUESTA RIGA
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:macdbb_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ config_text = (
+ rf"*📊 MACD BB V1 \- Step {final_step}/{final_step} \(Final\)*" + "\n\n"
+ f"*{escape_markdown_v2(pair)}*\n"
+ f"Price: `{current_price:,.6g}` \\| BB: `{bb_length}` \\| MACD: `{macd_fast}/{macd_slow}/{macd_signal_period}` \\| Interval: `{interval}`\n\n"
+ f"`connector_name={escape_markdown_v2(connector)}`\n"
+ f"`trading_pair={escape_markdown_v2(pair)}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={escape_markdown_v2(position_mode)}`\n"
+ f"`max_executors_per_side={max_exec}`\n"
+ f"`cooldown_time={cooldown}`\n"
+ f"`stop_loss={stop_loss}`\n"
+ f"`take_profit={take_profit}`\n"
+ f"`trailing_stop_activation={ts_act}`\n"
+ f"`trailing_stop_delta={ts_delta}`\n"
+ f"`interval={interval}`\n"
+ f"`bb_length={bb_length}`\n"
+ f"`bb_std={bb_std}`\n"
+ f"`bb_long_threshold={bb_long}`\n"
+ f"`bb_short_threshold={bb_short}`\n"
+ f"`macd_fast={macd_fast}`\n"
+ f"`macd_slow={macd_slow}`\n"
+ f"`macd_signal={macd_signal_period}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ # ========== ANALYSIS TEXT - USARE TRIPLI BACKTICK ==========
+ analysis_text = format_macd_bb_analysis(analysis)
+ config_text += "\n\n```\n" + analysis_text + "\n```"
+ # ==========================================================
+
+ # ========== FIX CAPTION TOO LONG ==========
+ MAX_CAPTION_LEN = 950
+ if len(config_text) > MAX_CAPTION_LEN:
+ truncation_note = "\n\n_...truncated due to length limit. Edit via Configs menu._"
+ max_allowed = MAX_CAPTION_LEN - len(truncation_note)
+ config_text = config_text[:max_allowed] + truncation_note
+ # ==========================================
+
+
+ if candles_list:
+ from .controllers.macd_bb_v1.chart import generate_chart as macdbb_chart
+ chart_bytes = macdbb_chart(config, candles_list, current_price)
+
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = new_msg.message_id
+ context.user_data["macdbb_wizard_chat_id"] = chat_id
+ else:
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = new_msg.message_id
+
+ except Exception as e:
+ logger.error(f"MACD BB final step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ try:
+ await msg.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+
+async def handle_macdbb_interval_change(update, context, interval: str) -> None:
+ """Change chart interval"""
+ context.user_data["macdbb_candles"] = None
+ context.user_data["macdbb_candles_interval"] = None
+ await _macdbb_show_final_step(update, context, interval=interval)
+
+async def handle_macdbb_set_strategy(update, context, strat_key: str) -> None:
+ """Handle MACD BB strategy selection from final step buttons."""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ analysis = context.user_data.get("macdbb_analysis", {})
+ natr = analysis.get("natr", 0.01)
+
+ from .controllers.macd_bb_v1.analysis import get_macd_bb_strategy_suggestions
+ strats = get_macd_bb_strategy_suggestions(natr, analysis)
+
+ if strat_key in strats:
+ selected = strats[strat_key]
+
+ # Aggiorna i parametri
+ config["bb_length"] = selected.get("bb_length", 100)
+ config["bb_std"] = selected.get("bb_std", 2.0)
+ config["macd_fast"] = selected.get("macd_fast", 21)
+ config["macd_slow"] = selected.get("macd_slow", 42)
+ config["macd_signal"] = selected.get("macd_signal", 9)
+ config["bb_long_threshold"] = selected.get("bb_long_threshold", 0.0)
+ config["bb_short_threshold"] = selected.get("bb_short_threshold", 1.0)
+ config["take_profit"] = selected.get("take_profit", 0.03)
+ config["stop_loss"] = selected.get("stop_loss", 0.05)
+
+ # ========== AGGIUNGI TRAILING STOP ==========
+ if "trailing_stop_activation" in selected:
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["activation_price"] = selected["trailing_stop_activation"]
+ config["trailing_stop"]["trailing_delta"] = selected["trailing_stop_delta"]
+ # ===========================================
+
+ set_controller_config(context, config)
+
+ vol_regime = selected.get("volatility_regime", "moderate")
+ await query.answer(f"✅ {selected['label']} applicata (vol: {vol_regime})")
+
+ return await _macdbb_show_final_step(update, context)
+
+async def handle_macdbb_save(update, context) -> None:
+ """Save MACD BB V1 configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # ========== PULISCI I CAMPI NON NECESSARI ==========
+ config.pop("candles_config", None)
+ config.pop("manual_kill_switch", None)
+ # ===================================================
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["macdbb_wizard_step", "macdbb_wizard_message_id", "macdbb_wizard_chat_id",
+ "macdbb_current_price", "macdbb_candles", "macdbb_candles_interval",
+ "macdbb_chart_interval"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_macd_bb_v1")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"MACD BB save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:macdbb_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+# Back handlers
+async def handle_macdbb_back_to_connector(update, context) -> None:
+ context.user_data["macdbb_wizard_step"] = "connector_name"
+ await _macdbb_show_connector_step(update, context)
+
+
+async def handle_macdbb_back_to_pair(update, context) -> None:
+ context.user_data["macdbb_wizard_step"] = "trading_pair"
+ await _macdbb_show_pair_step(update, context)
+
+
+async def handle_macdbb_back_to_leverage(update, context) -> None:
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ # Usa la stessa logica degli altri wizard
+ is_perp = "_perpetual" in connector or "_margin" in connector
+ if is_perp:
+ context.user_data["macdbb_wizard_step"] = "leverage"
+ await _macdbb_show_leverage_step(update, context)
+ else:
+ await handle_macdbb_back_to_pair(update, context)
+
+async def handle_macdbb_back_to_amount(update, context) -> None:
+ """Go back to amount step"""
+ # Non cancellare il messaggio!
+ # Lascia che _macdbb_show_amount_step lo editi
+
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("macdbb_current_price", None)
+ context.user_data.pop("macdbb_candles", None)
+ context.user_data.pop("macdbb_candles_interval", None)
+
+ await _macdbb_show_amount_step(update, context)
+
+async def handle_macdbb_back_to_position_mode(update, context) -> None:
+ """Go back to position mode step"""
+ context.user_data["macdbb_wizard_step"] = "position_mode"
+ await _macdbb_show_position_mode_step(update, context)
+
+async def handle_macdbb_pair_select(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str
+) -> None:
+ """Handle selection of a suggested trading pair in MACD BB V1 wizard"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ chat_id = update.effective_chat.id
+
+ # Clear old market data
+ for key in ["macdbb_current_price", "macdbb_candles", "macdbb_candles_interval"]:
+ context.user_data.pop(key, None)
+
+ config["trading_pair"] = trading_pair
+ config["candles_trading_pair"] = trading_pair
+ set_controller_config(context, config)
+
+ # Move to next step based on connector type
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ if is_perp:
+ context.user_data["macdbb_wizard_step"] = "leverage"
+ await _macdbb_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ await _macdbb_show_amount_step(update, context)
+
+async def process_macdbb_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during MACD BB V1 wizard"""
+ step = context.user_data.get("macdbb_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("macdbb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # ========== GESTISCI INPUT MANUALE PER TRADING PAIR ==========
+ if step == "trading_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_name", "")
+
+ # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO (come DMan/Arb) ==========
+ if "-" not in pair:
+ message_id = context.user_data.get("macdbb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id)
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_connector")]
+ ]
+
+ # Mostra il contesto con exchange selezionato (come DMan/Arb)
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*📊 MACD BB V1 \- Step 2*" + "\n\n"
+ + context_text
+ + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ + r"Type the pair again:"
+ )
+
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ # Fallback: invia nuovo messaggio
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = msg.message_id
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = msg.message_id
+ return
+
+ # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) come DMan/Arb ==========
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ message_id = context.user_data.get("macdbb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id)
+
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:macdbb_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ # Mostra il contesto con exchange selezionato (come DMan/Arb)
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*📊 MACD BB V1 \- Step 2*" + "\n\n"
+ + context_text
+ + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ + r"*Did you mean?*"
+ )
+
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = msg.message_id
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = msg.message_id
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ for key in ["macdbb_current_price", "macdbb_candles", "macdbb_candles_interval"]:
+ context.user_data.pop(key, None)
+ set_controller_config(context, config)
+ # Vai al passo successivo (leverage o amount)
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["macdbb_wizard_step"] = "leverage"
+ # ========== RICOSTRUISCI IL MESSAGGIO QUI (come DMan) ==========
+ message_id = context.user_data.get("macdbb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("macdbb_wizard_chat_id", chat_id)
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:macdbb_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:macdbb_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:macdbb_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:macdbb_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:macdbb_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:macdbb_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ text = (
+ r"*📊 MACD BB V1 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_"
+ )
+
+ try:
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = message_id
+ except Exception as e:
+ logger.error(f"Error showing leverage step: {e}")
+ # Fallback: invia nuovo messaggio
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = new_msg.message_id
+ context.user_data["macdbb_wizard_chat_id"] = chat_id
+ return # <--- IMPORTANTE: esci qui
+
+ else:
+ # Spot: vai direttamente all'amount
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ context.user_data["bots_state"] = "macdbb_wizard_input"
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:macdbb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:macdbb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:macdbb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:macdbb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:macdbb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:macdbb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:macdbb_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📊 MACD BB V1 \- Step 3/4*" + "\n\n" # <--- CAMBIA DA 3/5 A 3/4
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["macdbb_wizard_message_id"] = message_id
+ except Exception:
+ pass
+ return # <--- AGGIUNGI QUESTO return
+
+ # ========== GESTISCI INPUT PER LEVERAGE ==========
+ elif step == "leverage":
+ try:
+ val = int(float(user_input.strip().lower().replace("x", "")))
+ config["leverage"] = val
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "total_amount_quote"
+ await _macdbb_show_amount_step(update, context)
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:macdbb_back_to_leverage")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*📊 MACD BB V1*" + "\n\n"
+ r"❌ *Invalid leverage*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 20\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT PER TOTAL_AMOUNT_QUOTE ==========
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["macdbb_wizard_step"] = "final"
+
+ # Mostra loading
+ pair = config.get("trading_pair", "")
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*📊 MACD BB V1*" + "\n\n"
+ f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ",
+ parse_mode="MarkdownV2",
+ )
+ else:
+ tmp = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*📊 MACD BB V1*" + "\n\n"
+ f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["macdbb_wizard_message_id"] = tmp.message_id
+ await _macdbb_show_final_step(update, context)
+
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:macdbb_back_to_amount")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*📊 MACD BB V1*" + "\n\n"
+ r"❌ *Invalid amount*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 500\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT PER FINAL (EDIT CAMPI) ==========
+ elif step == "final":
+ if "=" in user_input:
+ supported_fields = [
+ "id", "connector_name", "trading_pair", "leverage", "position_mode",
+ "total_amount_quote", "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type", "time_limit",
+ "trailing_stop_activation", "trailing_stop_delta",
+ "candles_connector", "candles_trading_pair", "interval",
+ "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "macd_fast", "macd_slow", "macd_signal",
+ ]
+
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+
+ if field not in supported_fields:
+ continue
+
+ try:
+ if field in ("total_amount_quote", "stop_loss", "take_profit",
+ "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "trailing_stop_activation", "trailing_stop_delta"):
+ config[field] = float(value)
+ elif field in ("leverage", "max_executors_per_side", "cooldown_time",
+ "take_profit_order_type", "bb_length",
+ "macd_fast", "macd_slow", "macd_signal"):
+ config[field] = int(float(value))
+ elif field in ("position_mode", "interval"):
+ config[field] = value.upper()
+ else:
+ config[field] = value
+ except Exception:
+ pass
+
+ set_controller_config(context, config)
+ await _macdbb_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"MACD BB wizard input error: {e}", exc_info=True)
+
+# ============================================
+# SUPERTREND V1 WIZARD
+# ============================================
+# Steps: connector → pair → (leverage) → amount → interval+chart → save
+# Prefisso handler: st_
+
+async def show_new_supertrend_v1_form(update, context) -> None:
+ """Start the SuperTrend V1 wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ for key in ["st_current_price", "st_candles", "st_candles_interval", "st_chart_interval"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "supertrend_v1")
+ context.user_data["bots_state"] = "st_wizard"
+ context.user_data["st_wizard_step"] = "connector_name"
+ context.user_data["st_wizard_message_id"] = query.message.message_id
+ context.user_data["st_wizard_chat_id"] = query.message.chat_id
+
+ await _st_show_connector_step(update, context)
+
+
+async def _st_show_connector_step(update, context) -> None:
+ """ST Step 1: Select Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*📈 SuperTrend V1 \- New Config*" + "\n\n"
+ r"⚠️ No CEX connectors available\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(
+ f"🏦 {connector}", callback_data=f"bots:st_connector:{connector}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*📈 SuperTrend V1*" + "\n\n"
+ r"Trend\-following strategy using the SuperTrend indicator\. "
+ r"Enters LONG when price is above the ST line and within threshold\. "
+ r"Enters SHORT when price is below the ST line and within threshold\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select Exchange*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"ST connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_st_wizard_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ config["candles_connector"] = connector
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "trading_pair"
+ await _st_show_pair_step(update, context)
+
+async def _st_show_pair_step(update, context) -> None:
+ """ST Step 2: Select Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "st_wizard_input"
+ context.user_data["st_wizard_step"] = "trading_pair"
+
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:st_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 2
+
+ message_text = (
+ rf"*📈 SuperTrend V1 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or type a new one:"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = query.message.message_id
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = new_msg.message_id
+ context.user_data["st_wizard_chat_id"] = query.message.chat_id
+
+async def handle_st_wizard_pair(update, context, pair: str) -> None:
+ """Handle pair selection"""
+ config = get_controller_config(context)
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ set_controller_config(context, config)
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["st_wizard_step"] = "leverage"
+ await _st_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+ await _st_show_amount_step(update, context)
+
+async def _st_show_leverage_step(update, context) -> None:
+ """ST Step 3 (perp only): Select Leverage"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "st_wizard_input"
+ context.user_data["st_wizard_step"] = "leverage"
+ context.user_data["st_wizard_message_id"] = query.message.message_id
+ context.user_data["st_wizard_chat_id"] = query.message.chat_id
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:st_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:st_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:st_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:st_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:st_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:st_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ await query.message.edit_text(
+ r"*📈 SuperTrend V1 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_st_wizard_leverage(update, context, leverage: int) -> None:
+ """Handle leverage selection"""
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+ # Vai a position_mode (come DMan V3)
+ context.user_data["st_wizard_step"] = "position_mode"
+ await _st_show_position_mode_step(update, context)
+
+async def _st_show_position_mode_step(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """ST Wizard Step: Select Position Mode (Perpetual/Margin only)"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ context.user_data["bots_state"] = "st_wizard_input"
+ context.user_data["st_wizard_step"] = "position_mode"
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ keyboard = [
+ [
+ InlineKeyboardButton("🔒 ONEWAY", callback_data="bots:st_position_mode:ONEWAY"),
+ InlineKeyboardButton("🔄 HEDGE", callback_data="bots:st_position_mode:HEDGE"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ total_steps = 6
+ current_step = 4
+
+ await query.message.edit_text(
+ f"*📈 SuperTrend V1 \\- Step {current_step}/{total_steps}*\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}` \\| ⚡ `{leverage}x`\n\n"
+ r"🎯 *Position Mode*\n\n"
+ r"• *ONEWAY*: Can only hold positions in one direction\n"
+ r"• *HEDGE*: Can hold both long and short simultaneously",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_st_back_to_position_mode(update, context) -> None:
+ """Go back to position mode step"""
+ context.user_data["st_wizard_step"] = "position_mode"
+ await _st_show_position_mode_step(update, context)
+
+async def _st_show_amount_step(update, context) -> None:
+ """ST Step: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ pos_mode = config.get("position_mode", "HEDGE")
+
+ context.user_data["bots_state"] = "st_wizard_input"
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 5 if is_perp else 3
+
+ # Back callback
+ if is_perp:
+ back_callback = "bots:st_back_to_position_mode"
+ else:
+ back_callback = "bots:st_back_to_pair"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:st_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:st_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:st_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:st_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:st_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:st_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x` \\| 🎯 `{escape_markdown_v2(pos_mode)}`"
+
+ message_text = (
+ rf"*📈 SuperTrend V1 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ target_chat_id = chat_id
+ if query and query.message:
+ target_chat_id = query.message.chat_id
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = query.message.message_id
+ return
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_message(
+ chat_id=target_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = new_msg.message_id
+ context.user_data["st_wizard_chat_id"] = target_chat_id
+
+async def handle_st_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ pair = config.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*📈 SuperTrend V1 \- New Config*" + "\n\n"
+ f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n"
+ r"_Fetching market data\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["st_wizard_step"] = "final"
+ await _st_show_final_step(update, context)
+
+async def _st_show_final_step(update, context, interval: str = None) -> None:
+ """ST Final Step: Chart + Config Summary + Analysis"""
+ import asyncio
+
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ length = config.get("length", 20)
+ multiplier = config.get("multiplier", 4.0)
+ pct_threshold = config.get("percentage_threshold", 0.01)
+
+ if interval is None:
+ interval = context.user_data.get("st_chart_interval", config.get("interval", "15m"))
+ context.user_data["st_chart_interval"] = interval
+ config["interval"] = interval
+ set_controller_config(context, config)
+
+ current_price = context.user_data.get("st_current_price")
+ candles = context.user_data.get("st_candles")
+
+ try:
+ cached_interval = context.user_data.get("st_candles_interval", interval)
+ if not current_price or interval != cached_interval:
+ # Usa MarkdownV2 invece di HTML
+ try:
+ await msg.edit_text(
+ r"*📈 SuperTrend V1 \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ # ========== FIX: USA SEMPRE IL CONNETTORE SPOT PER LE CANDELE ==========
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ if not candles_connector or len(candles_connector) < 3:
+ candles_connector = "kucoin"
+
+ logger.info(f"ST: Trading on {connector}, using candles from {candles_connector}")
+
+ # Fetch current price
+ try:
+ current_price = await asyncio.wait_for(
+ fetch_current_price(client, connector, pair),
+ timeout=10.0
+ )
+ if current_price:
+ context.user_data["st_current_price"] = current_price
+ except (asyncio.TimeoutError, Exception) as e:
+ logger.warning(f"Could not fetch price for {pair}: {e}")
+ current_price = None
+
+ # Fetch candles
+ if current_price:
+ pair_variants = [pair, pair.replace("-", "/")]
+ candles = None
+
+ for try_pair in pair_variants:
+ try:
+ logger.info(f"ST: Trying candles from {candles_connector} for {try_pair}")
+ candles = await asyncio.wait_for(
+ fetch_candles(client, candles_connector, try_pair, interval=interval, max_records=420),
+ timeout=15.0
+ )
+ if candles:
+ candles_data = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+ if candles_data and len(candles_data) > 0:
+ logger.info(f"ST: Got {len(candles_data)} candles from {candles_connector} for {try_pair}")
+ break
+ else:
+ candles = None
+ else:
+ candles = None
+ except asyncio.TimeoutError:
+ logger.warning(f"ST: Timeout fetching candles from {candles_connector} for {try_pair}")
+ except Exception as e:
+ logger.warning(f"ST: Error fetching candles from {candles_connector} for {try_pair}: {e}")
+
+ if candles:
+ context.user_data["st_candles"] = candles
+ context.user_data["st_candles_interval"] = interval
+ else:
+ logger.warning(f"ST: Could not fetch candles for {pair}")
+
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ await msg.edit_text(
+ r"*❌ Error*" + "\n\n"
+ f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ candles_list = []
+ if candles:
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+ if candles_list:
+ last_close = candles_list[-1].get("close") or candles_list[-1].get("c")
+ if last_close:
+ current_price = float(last_close)
+ context.user_data["st_current_price"] = current_price
+
+ # Generate config ID if not set
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.supertrend_v1.config import generate_id as st_generate_id
+ config["id"] = st_generate_id(config, existing_configs)
+
+ # Run analysis
+ from .controllers.supertrend_v1.analysis import analyze_candles_for_supertrend, format_supertrend_analysis
+ analysis = analyze_candles_for_supertrend(
+ candles_list,
+ length=length,
+ multiplier=multiplier,
+ percentage_threshold=pct_threshold,
+ )
+
+ # Auto-save suggested threshold
+ config["percentage_threshold"] = analysis["suggested_percentage_threshold"]
+ pct_threshold = config["percentage_threshold"]
+ set_controller_config(context, config)
+
+ position_mode = config.get("position_mode", "HEDGE")
+ stop_loss = config.get("stop_loss", 0.05)
+ take_profit = config.get("take_profit", 0.03)
+ max_exec = config.get("max_executors_per_side", 1)
+ cooldown = config.get("cooldown_time", 60)
+ ts = config.get("trailing_stop", {}) or {}
+ ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015
+ ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005
+
+ context.user_data["bots_state"] = "st_wizard_input"
+ context.user_data["st_wizard_step"] = "final"
+
+ interval_options = ["1m", "5m", "15m", "1h", "8h"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:st_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+
+ context.user_data["st_analysis"] = analysis
+
+ strategy_row = [
+ InlineKeyboardButton("🎯 Scalp", callback_data="bots:st_set_strat:scalping"),
+ InlineKeyboardButton("⚖️ Swing", callback_data="bots:st_set_strat:swing"),
+ InlineKeyboardButton("🤖 Auto", callback_data="bots:st_set_strat:auto"),
+ ]
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ final_step = 6 if is_perp else 4
+
+ keyboard = [
+ interval_row,
+ strategy_row,
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:st_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # ========== COSTRUISCI CONFIG_TEXT IN MARKDOWNV2 (COME MACD BB) ==========
+ escaped_pair = escape_markdown_v2(pair)
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_position_mode = escape_markdown_v2(position_mode)
+ escaped_interval = escape_markdown_v2(interval)
+ escaped_title = escape_markdown_v2(f"📈 SuperTrend V1 - Step {final_step}/{final_step} (Final)")
+
+ # IMPORTANTE: escapa anche il pipe e il punto
+ price_line = f"Price: `{current_price:,.6g}` | ST length: `{length}` | Multiplier: `{multiplier}` | Interval: `{escaped_interval}`"
+ escaped_price_line = escape_markdown_v2(price_line)
+
+ config_text = (
+ f"*{escaped_title}*\n\n"
+ f"*{escaped_pair}*\n"
+ f"{escaped_price_line}\n\n"
+ f"`connector_name={escaped_connector}`\n"
+ f"`trading_pair={escaped_pair}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={escaped_position_mode}`\n"
+ f"`max_executors_per_side={max_exec}`\n"
+ f"`cooldown_time={cooldown}`\n"
+ f"`stop_loss={stop_loss}`\n"
+ f"`take_profit={take_profit}`\n"
+ f"`trailing_stop_activation={ts_act}`\n"
+ f"`trailing_stop_delta={ts_delta}`\n"
+ f"`interval={escaped_interval}`\n"
+ f"`length={length}`\n"
+ f"`multiplier={multiplier}`\n"
+ f"`percentage_threshold={pct_threshold}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ # Analysis con tripli backtick (non serve escape qui)
+ analysis_text = format_supertrend_analysis(analysis)
+ config_text += "\n\n```\n" + analysis_text + "\n```"
+
+ # ========== INVIA CON MARKDOWNV2 (NON HTML) ==========
+ if not candles_list:
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = msg.message_id
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = new_msg.message_id
+ return
+
+ # ========== TENTA DI GENERARE IL GRAFICO ==========
+ try:
+ from .controllers.supertrend_v1.chart import generate_chart as st_chart
+ chart_bytes = st_chart(config, candles_list, current_price)
+ chart_bytes.seek(0)
+
+ stored_msg_id = context.user_data.get("st_wizard_message_id")
+ stored_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+ if stored_msg_id:
+ try:
+ await context.bot.delete_message(chat_id=stored_chat_id, message_id=stored_msg_id)
+ except Exception:
+ pass
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = new_msg.message_id
+ context.user_data["st_wizard_chat_id"] = chat_id
+ except Exception as chart_err:
+ logger.warning(f"Chart generation failed: {chart_err}, sending text-only")
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = msg.message_id
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = new_msg.message_id
+
+ except Exception as e:
+ logger.error(f"ST final step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ try:
+ await msg.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+async def handle_st_interval_change(update, context, interval: str) -> None:
+ context.user_data["st_candles"] = None
+ context.user_data["st_candles_interval"] = None
+ await _st_show_final_step(update, context, interval=interval)
+
+async def handle_st_set_strategy(update, context, strat_key: str) -> None:
+ """Handle SuperTrend strategy selection from final step buttons."""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ analysis = context.user_data.get("st_analysis", {})
+ natr = analysis.get("natr", 0.01)
+
+ from .controllers.supertrend_v1.analysis import get_st_strategy_suggestions
+ strats = get_st_strategy_suggestions(natr, analysis)
+
+ if strat_key in strats:
+ selected = strats[strat_key]
+
+ # Aggiorna i parametri
+ config["length"] = selected.get("length", 20)
+ config["multiplier"] = selected.get("multiplier", 4.0)
+ config["percentage_threshold"] = selected.get("percentage_threshold", 0.01)
+ config["take_profit"] = selected.get("take_profit", 0.03)
+ config["stop_loss"] = selected.get("stop_loss", 0.05)
+
+ # Trailing stop
+ if "trailing_stop_activation" in selected:
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["activation_price"] = selected["trailing_stop_activation"]
+ config["trailing_stop"]["trailing_delta"] = selected["trailing_stop_delta"]
+
+ set_controller_config(context, config)
+
+ vol_regime = selected.get("volatility_regime", "moderate")
+ await query.answer(f"✅ {selected['label']} applicata (vol: {vol_regime})")
+
+ return await _st_show_final_step(update, context)
+
+async def handle_st_save(update, context) -> None:
+ """Save SuperTrend V1 configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config.pop("candles_config", None)
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["st_wizard_step", "st_wizard_message_id", "st_wizard_chat_id",
+ "st_current_price", "st_candles", "st_candles_interval", "st_chart_interval"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_supertrend_v1")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"ST save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:st_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_st_back_to_connector(update, context) -> None:
+ context.user_data["st_wizard_step"] = "connector_name"
+ await _st_show_connector_step(update, context)
+
+async def handle_st_back_to_pair(update, context) -> None:
+ context.user_data["st_wizard_step"] = "trading_pair"
+ await _st_show_pair_step(update, context)
+
+async def handle_st_back_to_leverage(update, context) -> None:
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ # CORREZIONE: supporta _margin
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ if is_perp:
+ context.user_data["st_wizard_step"] = "leverage"
+ await _st_show_leverage_step(update, context)
+ else:
+ await handle_st_back_to_pair(update, context)
+
+
+async def handle_st_back_to_amount(update, context) -> None:
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("st_current_price", None)
+ context.user_data.pop("st_candles", None)
+ await _st_show_amount_step(update, context)
+
+async def handle_st_position_mode(update, context, mode: str) -> None:
+ """Handle position mode selection"""
+ config = get_controller_config(context)
+ config["position_mode"] = mode
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+ await _st_show_amount_step(update, context)
+
+async def handle_st_pair_select(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str
+) -> None:
+ """Handle selection of a suggested trading pair in SuperTrend wizard"""
+ config = get_controller_config(context)
+ chat_id = update.effective_chat.id
+
+ # Clear old market data
+ for key in ["st_current_price", "st_candles", "st_candles_interval"]:
+ context.user_data.pop(key, None)
+
+ config["trading_pair"] = trading_pair
+ config["candles_trading_pair"] = trading_pair
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["st_wizard_step"] = "leverage"
+ await _st_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+ await _st_show_amount_step(update, context)
+
+async def process_st_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Supertrend V1 wizard"""
+ step = context.user_data.get("st_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("st_wizard_message_id")
+ wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "trading_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_name", "")
+
+ # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO ==========
+ if "-" not in pair:
+ message_id = context.user_data.get("st_wizard_message_id")
+ wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*📈 Supertrend V1 \- Step 2*" + "\n\n"
+ + context_text
+ + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = msg.message_id
+ return
+
+ # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) ==========
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ message_id = context.user_data.get("st_wizard_message_id")
+ wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:st_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*📈 Supertrend V1 \- Step 2*" + "\n\n"
+ + context_text
+ + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = msg.message_id
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ for key in ["st_current_price", "st_candles", "st_candles_interval"]:
+ context.user_data.pop(key, None)
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ message_id = context.user_data.get("st_wizard_message_id")
+ wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+
+ if is_perp:
+ context.user_data["st_wizard_step"] = "leverage"
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:st_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:st_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:st_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:st_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:st_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:st_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📈 Supertrend V1 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_"
+ )
+ else:
+ config["leverage"] = 1
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+ context.user_data["bots_state"] = "st_wizard_input"
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:st_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:st_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:st_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:st_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:st_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:st_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📈 Supertrend V1 \- Step 3/4*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["st_wizard_message_id"] = message_id
+ except Exception:
+ pass
+ return
+
+ elif step == "leverage":
+ val = int(float(user_input.strip().lower().replace("x", "")))
+ config["leverage"] = val
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "total_amount_quote"
+ message_id = context.user_data.get("st_wizard_message_id")
+ wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:st_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:st_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:st_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:st_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:st_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:st_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:st_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*📈 Supertrend V1 \- Step 4/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}` \\| ⚡ `{val}x`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+ context.user_data["bots_state"] = "st_wizard_input"
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "total_amount_quote":
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["st_wizard_step"] = "final"
+ wizard_chat_id = context.user_data.get("st_wizard_chat_id", chat_id)
+ pair = config.get("trading_pair", "")
+ tmp = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*📈 Supertrend V1*" + "\n\n"
+ f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["st_wizard_message_id"] = tmp.message_id
+ await _st_show_final_step(update, context)
+
+ elif step == "final":
+ # Handle field=value edits
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "stop_loss", "take_profit",
+ "trailing_stop_activation", "trailing_stop_delta",
+ "supertrend_multiplier", "supertrend_period"):
+ val = float(value)
+ if field == "trailing_stop_activation":
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["activation_price"] = val
+ elif field == "trailing_stop_delta":
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["trailing_delta"] = val
+ else:
+ config[field] = val
+ elif field in ("leverage", "max_executors_per_side", "cooldown_time",
+ "take_profit_order_type"):
+ config[field] = int(float(value))
+ elif field == "interval":
+ config["interval"] = value
+ context.user_data.pop("st_candles", None)
+ context.user_data["st_chart_interval"] = value
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+
+ except Exception as e:
+ logger.error(f"ST wizard input error: {e}", exc_info=True)
+# =============================================================================
+# ANTIFOLLA
+# =============================================================================
+
+async def show_new_anti_folla_v1_form(update, context) -> None:
+ """Start the Anti-Folla V1 wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ for key in list(context.user_data.keys()):
+ if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"):
+ context.user_data.pop(key, None)
+ if key.endswith("_wizard_step"):
+ context.user_data.pop(key, None)
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "anti_folla_v1")
+ context.user_data["bots_state"] = "af_wizard"
+ context.user_data["af_wizard_step"] = "connector_name"
+ context.user_data["af_wizard_message_id"] = query.message.message_id
+ context.user_data["af_wizard_chat_id"] = query.message.chat_id
+
+ await _af_show_connector_step(update, context)
+
+
+async def _af_show_connector_step(update, context) -> None:
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*🦅 Anti\-Folla V1 \- New Config*" + "\n\n" r"⚠️ No CEX connectors available\.",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:af_connector:{connector}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+ await query.message.edit_text(
+ r"*🦅 Anti\-Folla V1*" + "\n\n"
+ r"Crowd\-contrarian: VWAP, Donchian, OBV divergence, OBI, Volume Spike, Whale, Funding Rate\." + "\n\n"
+ r"─────────────────────────" + "\n\n" r"*Step 1: Select Exchange*",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"AF connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+
+
+async def handle_af_wizard_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ config["candles_connector"] = connector
+ # Auto-set is_perpetual
+ config["is_perpetual"] = connector.endswith("_perpetual")
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "trading_pair"
+ await _af_show_pair_step(update, context)
+
+async def _af_show_pair_step(update, context) -> None:
+ """AF Step 2: Select Trading Pair"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "af_wizard_input"
+ context.user_data["af_wizard_step"] = "trading_pair"
+
+ # Recent pairs from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:af_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 2
+
+ # NOTA: Il nome "Anti-Folla" ha un trattino che deve essere escapato con \-
+ # Oppure usa r"*🦅 Anti\-Folla V1 ..." come in DMan
+ message_text = (
+ rf"*🦅 Anti\-Folla V1 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or type a new one:"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = query.message.message_id
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = new_msg.message_id
+ context.user_data["af_wizard_chat_id"] = query.message.chat_id
+
+async def handle_af_wizard_pair(update, context, pair: str) -> None:
+ config = get_controller_config(context)
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ for key in list(context.user_data.keys()):
+ if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"):
+ context.user_data.pop(key, None)
+ if key.endswith("_wizard_step"):
+ context.user_data.pop(key, None)
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ # CORREZIONE: supporta _margin
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["af_wizard_step"] = "leverage"
+ await _af_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ await _af_show_amount_step(update, context)
+
+async def _af_show_leverage_step(update, context) -> None:
+ """AF Step 3 (perp only): Select Leverage"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "af_wizard_input"
+ context.user_data["af_wizard_step"] = "leverage"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:af_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:af_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:af_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:af_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:af_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:af_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # ========== SALVA message_id ==========
+ context.user_data["af_wizard_message_id"] = query.message.message_id
+ context.user_data["af_wizard_chat_id"] = query.message.chat_id
+ # ======================================
+
+ await query.message.edit_text(
+ r"*🦅 Anti\-Folla V1 \- Step 3/6*" + "\n\n" # <--- Anti\-Folla con backslash
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_af_wizard_leverage(update, context, leverage: int) -> None:
+ """Handle leverage selection"""
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+ # Vai a position_mode
+ context.user_data["af_wizard_step"] = "position_mode"
+ await _af_show_position_mode_step(update, context)
+
+async def _af_show_position_mode_step(update, context) -> None:
+ """AF Step 4 (derivati only): HEDGE vs ONEWAY"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+
+ context.user_data["bots_state"] = "af_wizard_input"
+ context.user_data["af_wizard_step"] = "position_mode"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("🔀 HEDGE ✅ recommended", callback_data="bots:af_position_mode:HEDGE"),
+ InlineKeyboardButton("➡️ ONEWAY", callback_data="bots:af_position_mode:ONEWAY"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # CORREZIONE: escape di tutti i caratteri speciali MarkdownV2
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_pair = escape_markdown_v2(pair)
+
+ # NOTA: "Anti-Folla" ha un trattino che deve essere escapato come "Anti\-Folla"
+ await query.message.edit_text(
+ f"*🦅 Anti\\-Folla V1 \\- Step 4/6*\n\n"
+ f"🏦 `{escaped_connector}` \\| 🔗 `{escaped_pair}` \\| ⚡ `{leverage}x`\n\n"
+ r"📐 *Position Mode*" + "\n\n"
+ r"• *HEDGE*: Can hold both long and short positions simultaneously" + "\n"
+ r"• *ONEWAY*: Can only hold positions in one direction \(long OR short\)",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_af_position_mode(update, context, mode: str) -> None:
+ """Handle position mode selection for Anti-Folla V1"""
+ config = get_controller_config(context)
+ config["position_mode"] = mode
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ await _af_show_amount_step(update, context)
+
+async def _af_show_amount_step(update, context) -> None:
+ """AF Step: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ pos_mode = config.get("position_mode", "HEDGE")
+
+ context.user_data["bots_state"] = "af_wizard_input"
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ connector = config.get("connector_name", "").lower()
+ is_perp = "_perpetual" in connector or "_margin" in connector
+ total_steps = 6 if is_perp else 4
+ current_step = 5 if is_perp else 3
+
+ back_callback = "bots:af_back_to_position_mode" if is_perp else "bots:af_back_to_pair"
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for AntiFolla v.1 amount step: {e}")
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:af_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:af_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:af_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:af_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:af_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:af_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x` \\| 🎯 `{escape_markdown_v2(pos_mode)}`"
+
+ message_text = (
+ rf"*🦅 Anti\-Folla V1 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + balance_text + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ # ========== SALVA MESSAGE_ID ==========
+ target_chat_id = chat_id
+ if query and query.message:
+ target_chat_id = query.message.chat_id
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = query.message.message_id
+ return
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_message(
+ chat_id=target_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = new_msg.message_id
+ context.user_data["af_wizard_chat_id"] = target_chat_id
+
+async def handle_af_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ pair = config.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*🦅 Anti\-Folla V1 \- New Config*" + "\n\n"
+ f"⏳ *Loading chart for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n"
+ r"_Fetching market data\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["af_wizard_step"] = "final"
+ await _af_show_final_step(update, context)
+
+async def _af_show_final_step(update, context, interval: str = None) -> None:
+ """Anti-Folla Final Step: Chart + Config Summary + Analysis"""
+ import asyncio
+
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ vwap_period = config.get("vwap_period", 20)
+ donchian_period = config.get("donchian_period", 20)
+ score_buy = config.get("score_buy_threshold", 50.0)
+ score_sell = config.get("score_sell_threshold", -50.0)
+
+ if interval is None:
+ interval = context.user_data.get("af_chart_interval", config.get("interval", "5m"))
+ context.user_data["af_chart_interval"] = interval
+ config["interval"] = interval
+ set_controller_config(context, config)
+
+ current_price = context.user_data.get("af_current_price")
+ candles = context.user_data.get("af_candles")
+
+ try:
+ cached_interval = context.user_data.get("af_candles_interval", interval)
+ if not current_price or interval != cached_interval:
+ # Usa MarkdownV2 invece di HTML
+ try:
+ await msg.edit_text(
+ r"*🦅 Anti\-Folla V1 \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ # ========== PULISCI IL CONNETTORE PER LE CANDELE ==========
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ if not candles_connector or len(candles_connector) < 3:
+ candles_connector = "kucoin"
+ logger.info(f"AF: Trading on {connector}, using candles from {candles_connector}")
+
+ # Fetch current price
+ try:
+ current_price = await asyncio.wait_for(
+ fetch_current_price(client, connector, pair),
+ timeout=10.0
+ )
+ if current_price:
+ context.user_data["af_current_price"] = current_price
+ except (asyncio.TimeoutError, Exception) as e:
+ logger.warning(f"Could not fetch price for {pair}: {e}")
+ current_price = None
+
+ # Fetch candles
+ if current_price:
+ pair_variants = [pair, pair.replace("-", "/")]
+ candles = None
+
+ for try_pair in pair_variants:
+ try:
+ logger.info(f"AF: Trying candles from {candles_connector} for {try_pair}")
+ candles = await asyncio.wait_for(
+ fetch_candles(client, candles_connector, try_pair, interval=interval, max_records=420),
+ timeout=15.0
+ )
+ if candles:
+ candles_data = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+ if candles_data and len(candles_data) > 0:
+ logger.info(f"AF: Got {len(candles_data)} candles from {candles_connector} for {try_pair}")
+ context.user_data["af_candles"] = {"data": candles_data}
+ context.user_data["af_candles_interval"] = interval
+ break
+ else:
+ candles = None
+ else:
+ candles = None
+ except asyncio.TimeoutError:
+ logger.warning(f"AF: Timeout fetching candles from {candles_connector} for {try_pair}")
+ except Exception as e:
+ logger.warning(f"AF: Error fetching candles from {candles_connector} for {try_pair}: {e}")
+
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ await msg.edit_text(
+ r"*❌ Error*" + "\n\n"
+ f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ candles_list = []
+ if candles:
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+ if candles_list:
+ last_close = candles_list[-1].get("close") or candles_list[-1].get("c")
+ if last_close:
+ current_price = float(last_close)
+ context.user_data["af_current_price"] = current_price
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.anti_folla_v1.config import generate_id as af_generate_id
+ config["id"] = af_generate_id(config, existing_configs)
+
+ from .controllers.anti_folla_v1.analysis import analyze_candles_for_anti_folla, format_anti_folla_analysis
+ analysis = analyze_candles_for_anti_folla(
+ candles_list, vwap_period=vwap_period, donchian_period=donchian_period,
+ score_buy_threshold=score_buy, score_sell_threshold=score_sell,
+ )
+ set_controller_config(context, config)
+
+ position_mode = config.get("position_mode", "HEDGE")
+ stop_loss = config.get("stop_loss", 0.05)
+ take_profit = config.get("take_profit", 0.03)
+ max_exec = config.get("max_executors_per_side", 1)
+ cooldown = config.get("cooldown_time", 60)
+ ts = config.get("trailing_stop", {}) or {}
+ ts_act = ts.get("activation_price", 0.015) if isinstance(ts, dict) else 0.015
+ ts_delta = ts.get("trailing_delta", 0.005) if isinstance(ts, dict) else 0.005
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ final_step = 6 if is_perp else 4
+
+ context.user_data["bots_state"] = "af_wizard_input"
+ context.user_data["af_wizard_step"] = "final"
+
+ interval_options = ["1m", "5m", "15m", "1h", "8h"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:af_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+
+ context.user_data["af_analysis"] = analysis
+
+ strategy_row = [
+ InlineKeyboardButton("🔥 Aggr", callback_data="bots:af_set_strat:aggressive"),
+ InlineKeyboardButton("⚖️ Balanced", callback_data="bots:af_set_strat:balanced"),
+ InlineKeyboardButton("🛡️ Cons", callback_data="bots:af_set_strat:conservative"),
+ ]
+
+ keyboard = [
+ interval_row,
+ strategy_row,
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:af_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # ========== COSTRUISCI CONFIG_TEXT IN MARKDOWNV2 (COME MACD BB) ==========
+ escaped_pair = escape_markdown_v2(pair)
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_position_mode = escape_markdown_v2(position_mode)
+ escaped_interval = escape_markdown_v2(interval)
+ escaped_title = escape_markdown_v2(f"🦅 Anti-Folla V1 - Step {final_step}/{final_step} (Final)")
+
+ # Escapa anche il pipe
+ price_line = f"Price: `{current_price:,.6g}` | VWAP: `{vwap_period}` | DC: `{donchian_period}` | Interval: `{escaped_interval}`"
+ escaped_price_line = escape_markdown_v2(price_line)
+
+ config_text = (
+ f"*{escaped_title}*\n\n"
+ f"*{escaped_pair}*\n"
+ f"{escaped_price_line}\n\n"
+ f"`connector_name={escaped_connector}`\n"
+ f"`trading_pair={escaped_pair}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={escaped_position_mode}`\n"
+ f"`max_executors_per_side={max_exec}`\n"
+ f"`cooldown_time={cooldown}`\n"
+ f"`stop_loss={stop_loss}`\n"
+ f"`take_profit={take_profit}`\n"
+ f"`trailing_stop_activation={ts_act}`\n"
+ f"`trailing_stop_delta={ts_delta}`\n"
+ f"`interval={escaped_interval}`\n"
+ f"`is_perpetual={str(is_perp).lower()}`\n"
+ f"`vwap_period={vwap_period}`\n"
+ f"`donchian_period={donchian_period}`\n"
+ f"`score_buy_threshold={score_buy}`\n"
+ f"`score_sell_threshold={score_sell}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ # Analysis con tripli backtick (non serve escape qui)
+ analysis_text = format_anti_folla_analysis(analysis)
+ config_text += "\n\n```\n" + analysis_text + "\n```"
+
+ # ========== SE NON CI SONO CANDELE, MOSTRA SOLO TESTO ==========
+ if not candles_list:
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = msg.message_id
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = new_msg.message_id
+ return
+
+ # ========== TENTA DI GENERARE IL GRAFICO ==========
+ chart_bytes = None
+ if candles_list:
+ try:
+ from .controllers.anti_folla_v1.chart import generate_chart as af_chart
+ chart_bytes = af_chart(config, candles_list, current_price)
+ except Exception as chart_err:
+ logger.warning(f"AF chart generation failed: {chart_err}")
+ chart_bytes = None
+
+ stored_msg_id = context.user_data.get("af_wizard_message_id")
+ stored_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+
+ if chart_bytes is not None:
+ chart_bytes.seek(0)
+ if stored_msg_id:
+ try:
+ await context.bot.delete_message(chat_id=stored_chat_id, message_id=stored_msg_id)
+ except Exception:
+ pass
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = new_msg.message_id
+ context.user_data["af_wizard_chat_id"] = chat_id
+ else:
+ # No chart: edit/send as plain text message
+ try:
+ if stored_msg_id:
+ await context.bot.edit_message_text(
+ chat_id=stored_chat_id,
+ message_id=stored_msg_id,
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ raise Exception("no stored_msg_id")
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2", # <-- CAMBIATO
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = new_msg.message_id
+ context.user_data["af_wizard_chat_id"] = chat_id
+
+ except Exception as e:
+ logger.error(f"AF final step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ try:
+ await msg.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+async def handle_af_interval_change(update, context, interval: str) -> None:
+ context.user_data["af_candles"] = None
+ context.user_data["af_candles_interval"] = None
+ await _af_show_final_step(update, context, interval=interval)
+
+async def handle_af_set_strategy(update, context, strat_key: str) -> None:
+ """Handle Anti-Folla strategy selection from final step buttons."""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ analysis = context.user_data.get("af_analysis", {})
+
+ from .controllers.anti_folla_v1.analysis import get_af_strategy_suggestions
+ strats = get_af_strategy_suggestions(analysis) # ← senza NATR!
+
+ if strat_key in strats:
+ selected = strats[strat_key]
+
+ # Aggiorna i parametri specifici di Anti-Folla
+ config["score_buy_threshold"] = selected.get("score_buy_threshold", 50.0)
+ config["score_sell_threshold"] = selected.get("score_sell_threshold", -50.0)
+
+ # Aggiorna i pesi
+ config["weight_vwap"] = selected.get("weight_vwap", 15)
+ config["weight_donchian"] = selected.get("weight_donchian", 10)
+ config["weight_obv"] = selected.get("weight_obv", 15)
+ config["weight_obi"] = selected.get("weight_obi", 20)
+ config["weight_volume_spike"] = selected.get("weight_volume_spike", 10)
+ config["weight_trade_flow"] = selected.get("weight_trade_flow", 15)
+ config["weight_funding"] = selected.get("weight_funding", 15)
+
+ # TP/SL
+ config["take_profit"] = selected.get("take_profit", 0.03)
+ config["stop_loss"] = selected.get("stop_loss", 0.05)
+
+ # Trailing stop
+ if "trailing_stop_activation" in selected:
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["activation_price"] = selected["trailing_stop_activation"]
+ config["trailing_stop"]["trailing_delta"] = selected["trailing_stop_delta"]
+
+ set_controller_config(context, config)
+
+ await query.answer(f"✅ {selected['label']} applicata")
+
+ return await _af_show_final_step(update, context)
+
+async def handle_af_save(update, context) -> None:
+ """Save Anti-Folla V1 configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config.pop("candles_config", None)
+ config.pop("manual_kill_switch", None)
+ # ========== FORZA IL CONNETTORE SPOT PER LE CANDELE ==========
+ connector = config.get("connector_name", "")
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ if not candles_connector or len(candles_connector) < 3:
+ candles_connector = "kucoin"
+
+ # Sovrascrivi candles_connector e candles_trading_pair
+ config["candles_connector"] = candles_connector
+ config["candles_trading_pair"] = config.get("trading_pair", "")
+ # =============================================================
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\. ",
+ parse_mode="MarkdownV2",
+ )
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+ for key in list(context.user_data.keys()):
+ if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"):
+ context.user_data.pop(key, None)
+ if key.endswith("_wizard_step"):
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_anti_folla_v1")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\nController `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"AF save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:af_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+
+# Back handlers
+async def handle_af_back_to_connector(update, context) -> None:
+ context.user_data["af_wizard_step"] = "connector_name"
+ await _af_show_connector_step(update, context)
+
+async def handle_af_back_to_pair(update, context) -> None:
+ context.user_data["af_wizard_step"] = "trading_pair"
+ await _af_show_pair_step(update, context)
+
+async def handle_af_back_to_leverage(update, context) -> None:
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ is_perp = "_perpetual" in connector or "_margin" in connector
+ if is_perp:
+ context.user_data["af_wizard_step"] = "leverage"
+ await _af_show_leverage_step(update, context)
+ else:
+ await handle_af_back_to_pair(update, context)
+
+async def handle_af_back_to_amount(update, context) -> None:
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("af_current_price", None)
+ context.user_data.pop("af_candles", None)
+ await _af_show_amount_step(update, context)
+
+async def handle_af_back_to_position_mode(update, context) -> None:
+ """Go back to position mode step"""
+ context.user_data["af_wizard_step"] = "position_mode"
+ await _af_show_position_mode_step(update, context)
+
+async def handle_af_pair_select(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str
+) -> None:
+ """Handle selection of a suggested trading pair in Anti-Folla wizard"""
+ config = get_controller_config(context)
+ chat_id = update.effective_chat.id
+
+ for key in list(context.user_data.keys()):
+ if key.endswith("_wizard_message_id") or key.endswith("_wizard_chat_id"):
+ context.user_data.pop(key, None)
+ if key.endswith("_wizard_step"):
+ context.user_data.pop(key, None)
+
+ config["trading_pair"] = trading_pair
+ config["candles_trading_pair"] = trading_pair
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["af_wizard_step"] = "leverage"
+ await _af_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ await _af_show_amount_step(update, context)
+
+async def process_af_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Anti-Folla V1 wizard"""
+ step = context.user_data.get("af_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("af_wizard_message_id")
+ wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "trading_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_name", "")
+
+ # ========== VALIDAZIONE BASE: DEVE CONTENERE IL TRATTINO ==========
+ if "-" not in pair:
+ message_id = context.user_data.get("af_wizard_message_id")
+ wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*🦅 Anti\-Folla V1 \- Step 2*" + "\n\n"
+ + context_text
+ + r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = msg.message_id
+ return
+
+ # ========== 2. VALIDAZIONE SULL'EXCHANGE (CON SUGGERIMENTI) ==========
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = (
+ await validate_trading_pair(context.user_data, client, connector, pair)
+ )
+
+ if not is_valid:
+ message_id = context.user_data.get("af_wizard_message_id")
+ wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:af_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ context_text = f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+
+ err_text = (
+ r"*🦅 Anti\-Folla V1 \- Step 2*" + "\n\n"
+ + context_text
+ + f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ msg = await context.bot.send_message(
+ chat_id=chat_id, text=err_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = msg.message_id
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ for key in ["af_current_price", "af_candles", "af_candles_interval"]:
+ context.user_data.pop(key, None)
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ message_id = context.user_data.get("af_wizard_message_id")
+ wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+
+ if is_perp:
+ context.user_data["af_wizard_step"] = "leverage"
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:af_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:af_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:af_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:af_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:af_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:af_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*🦅 Anti\-Folla V1 \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_"
+ )
+ else:
+ config["leverage"] = 1
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ context.user_data["bots_state"] = "af_wizard_input"
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:af_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:af_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:af_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:af_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:af_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:af_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*🦅 Anti\-Folla V1 \- Step 3/4*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["af_wizard_message_id"] = message_id
+ except Exception:
+ pass
+ return
+
+ elif step == "leverage":
+ val = int(float(user_input.strip().lower().replace("x", "")))
+ config["leverage"] = val
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "total_amount_quote"
+ message_id = context.user_data.get("af_wizard_message_id")
+ wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:af_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:af_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:af_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:af_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:af_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:af_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:af_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ text = (
+ r"*🦅 Anti\-Folla V1 \- Step 4/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}` \\| ⚡ `{val}x`" + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+ context.user_data["bots_state"] = "af_wizard_input"
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "total_amount_quote":
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["af_wizard_step"] = "final"
+ wizard_chat_id = context.user_data.get("af_wizard_chat_id", chat_id)
+ pair = config.get("trading_pair", "")
+ tmp = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*🦅 Anti\-Folla V1*" + "\n\n"
+ f"⏳ Loading chart for `{escape_markdown_v2(pair)}`\\.\\.\\. ",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["af_wizard_message_id"] = tmp.message_id
+ await _af_show_final_step(update, context)
+
+ elif step == "final":
+ # Handle field=value edits
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ line = line.strip()
+ if not line or "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "stop_loss", "take_profit",
+ "volume_spike_threshold", "obi_depth_percentage",
+ "obi_buy_threshold", "obi_sell_threshold",
+ "score_buy_threshold", "score_sell_threshold",
+ "weight_vwap", "weight_donchian", "weight_obv",
+ "weight_obi", "weight_volume_spike", "weight_trade_flow",
+ "weight_funding", "trailing_stop_activation", "trailing_stop_delta"):
+ val = float(value)
+ if field == "trailing_stop_activation":
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["activation_price"] = val
+ elif field == "trailing_stop_delta":
+ if not isinstance(config.get("trailing_stop"), dict):
+ config["trailing_stop"] = {"activation_price": 0.015, "trailing_delta": 0.005}
+ config["trailing_stop"]["trailing_delta"] = val
+ else:
+ config[field] = val
+ elif field in ("leverage", "max_executors_per_side", "cooldown_time",
+ "take_profit_order_type", "vwap_period", "donchian_period",
+ "atr_period", "obv_divergence_lookback"):
+ config[field] = int(float(value))
+ elif field in ("is_perpetual", "enable_order_book_imbalance"):
+ config[field] = value.lower() in ("true", "yes", "1")
+ elif field == "interval":
+ config["interval"] = value
+ context.user_data.pop("af_candles", None)
+ context.user_data["af_chart_interval"] = value
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+
+ except Exception as e:
+ logger.error(f"AF wizard input error: {e}", exc_info=True)
+
+
+
+
+# ============================================
+# FUNDING RATE ARBITRAGE WIZARD (basato su arbitrage_controller)
+# ============================================
+# Steps: connector1 → pair1 → connector2 → pair2 → amount → final+save
+# Prefisso handler: fra_
+
+async def show_new_funding_rate_arb_form(update, context) -> None:
+ """Start the Funding Rate Arbitrage wizard - Step 1: Exchange 1"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ for key in ["fra_price_1", "fra_price_2"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "funding_rate_arb")
+ context.user_data["bots_state"] = "fra_wizard"
+ context.user_data["fra_wizard_step"] = "connector_1"
+ context.user_data["fra_wizard_message_id"] = query.message.message_id
+ context.user_data["fra_wizard_chat_id"] = query.message.chat_id
+ await _fra_show_connector_step(update, context, exchange_num=1)
+
+async def _fra_show_connector_step(update, context, exchange_num: int, target_message_id: int = None) -> None:
+ """Show connector selection for exchange 1 or 2"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ if not query and not use_message_id:
+ logger.error("_fra_show_connector_step called without callback_query and without target_message_id")
+ return
+
+ if exchange_num == 1:
+ step = 1
+ emoji = "1️⃣"
+ role = "Exchange 1 \\(Long leg\\)"
+ else:
+ step = 3
+ emoji = "2️⃣"
+ role = "Exchange 2 \\(Short leg\\)"
+
+ header = ""
+ if exchange_num == 2:
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ header = f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n"
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ keyboard = []
+ if cex_connectors:
+ keyboard.append([InlineKeyboardButton("— CEX —", callback_data="bots:noop")])
+ row = []
+ for c in cex_connectors:
+ row.append(InlineKeyboardButton(c, callback_data=f"bots:fra_connector_{exchange_num}:{c}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = "bots:main_menu" if exchange_num == 1 else "bots:fra_back_to_pair_1"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ message_text = (
+ rf"*💰 Funding Rate Arbitrage \- Step {step}/6*" + "\n\n"
+ r"Arbitrage between funding rates across exchanges\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ + header
+ + rf"*{emoji} Select {role}:*"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"FRA connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ error_text = format_error_message(f"Error: {str(e)}")
+ if query and query.message:
+ await query.message.edit_text(
+ error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_fra_wizard_connector_1(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["connector_name"] = connector
+ # Auto-set rate_connector (opzionale per funding rate arb)
+ config["rate_connector"] = connector.replace("_perpetual", "").replace("_spot", "")
+ set_controller_config(context, config)
+ context.user_data["fra_wizard_step"] = "pair_1"
+ await _fra_show_pair_step(update, context, exchange_num=1)
+
+
+async def handle_fra_wizard_connector_2(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["fra_wizard_step"] = "pair_2"
+ await _fra_show_pair_step(update, context, exchange_num=2)
+
+async def _fra_show_pair_step(update, context, exchange_num: int, target_message_id: int = None) -> None:
+ """Show trading pair input for exchange 1 or 2"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ config = get_controller_config(context)
+
+ if not query and not use_message_id:
+ logger.error("_fra_show_pair_step called without callback_query and without target_message_id")
+ return
+
+ ep_key = f"exchange_pair_{exchange_num}"
+ connector = config.get(ep_key, {}).get("connector_name", "")
+
+ context.user_data["bots_state"] = "fra_wizard_input"
+ context.user_data["fra_wizard_step"] = f"pair_{exchange_num}"
+
+ step = 2 if exchange_num == 1 else 4
+ emoji = "1️⃣" if exchange_num == 1 else "2️⃣"
+
+ header = ""
+ if exchange_num == 2:
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ header = f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n"
+ else:
+ c1 = config.get("exchange_pair_1", {}).get("connector_name", "")
+ header = f"1️⃣ `{escape_markdown_v2(c1)}`\n\n"
+
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ ep = cfg.get(ep_key, {})
+ pair = ep.get("trading_pair", "") if isinstance(ep, dict) else ""
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ if exchange_num == 2:
+ p1 = config.get("exchange_pair_1", {}).get("trading_pair", "")
+ if p1 and p1 not in seen:
+ recent_pairs.insert(0, p1)
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs[:6]:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:fra_pair_{exchange_num}:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = f"bots:fra_back_to_connector_{exchange_num}"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ message_text = (
+ rf"*💰 Funding Rate Arbitrage \- Step {step}/6*" + "\n\n"
+ + header
+ + rf"*{emoji} Trading Pair on* `{escape_markdown_v2(connector)}`:" + "\n\n"
+ r"_e\.g\. SOL\-USDT_" + "\n\n"
+ r"Select or type a pair:"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("fra_wizard_chat_id", update.effective_chat.id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_fra_wizard_pair_1(update, context, pair: str) -> None:
+ config = get_controller_config(context)
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+ context.user_data["fra_wizard_step"] = "connector_2"
+ await _fra_show_connector_step(update, context, exchange_num=2)
+
+
+async def handle_fra_wizard_pair_2(update, context, pair: str) -> None:
+ config = get_controller_config(context)
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["trading_pair"] = pair.upper()
+ # Auto-set quote_conversion_asset
+ ep1 = config.get("exchange_pair_1", {})
+ p1 = ep1.get("trading_pair", "")
+ if p1:
+ quote = p1.split("-")[1] if "-" in p1 else "USDT"
+ else:
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ config["quote_conversion_asset"] = quote
+ set_controller_config(context, config)
+ context.user_data["fra_wizard_step"] = "total_amount_quote"
+ await _fra_show_amount_step(update, context)
+
+
+async def _fra_show_amount_step(update, context) -> None:
+ """FRA Step 5: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "fra_wizard_input"
+ context.user_data["fra_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:fra_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:fra_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:fra_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:fra_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:fra_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:fra_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_pair_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*💰 Funding Rate Arbitrage \- Step 5/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount \(split equally per leg\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_fra_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ ep1 = config.get("exchange_pair_1", {})
+ pair = ep1.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*💰 Funding Rate Arbitrage \- New Config*" + "\n\n"
+ f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["fra_wizard_step"] = "final"
+ await _fra_show_final_step(update, context)
+
+
+async def _fra_show_final_step(update, context) -> None:
+ """FRA Final Step: Show config with ONLY fields supported by the controller"""
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 100)
+ entry_threshold = config.get("entry_threshold", 0.000025)
+ exit_threshold = config.get("exit_threshold", 0.000005)
+ sl_global = config.get("sl_global", 0.03)
+ tp_global = config.get("tp_global", 0.05)
+ funding_check_interval = config.get("funding_check_interval", 300)
+ executor_refresh_time = config.get("executor_refresh_time", 60)
+ funding_interval_a_hours = config.get("funding_interval_a_hours", None)
+ funding_interval_b_hours = config.get("funding_interval_b_hours", None)
+
+ if "controller_type" not in config:
+ config["controller_type"] = "generic"
+ set_controller_config(context, config)
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.funding_rate_arb.config import generate_id as fra_generate_id
+ config["id"] = fra_generate_id(config, existing_configs)
+ set_controller_config(context, config)
+
+ context.user_data["bots_state"] = "fra_wizard_input"
+ context.user_data["fra_wizard_step"] = "final"
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:fra_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ config_id = config.get("id", "")
+
+ config_text = (
+ r"*💰 Funding Rate Arbitrage \- Final Review*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ f"`id={escape_markdown_v2(config_id)}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`entry_threshold={entry_threshold}`\n"
+ f"`exit_threshold={exit_threshold}`\n"
+ f"`sl_global={sl_global}`\n"
+ f"`tp_global={tp_global}`\n"
+ f"`funding_check_interval={funding_check_interval}`\n"
+ f"`executor_refresh_time={executor_refresh_time}`\n"
+ f"`funding_interval_a_hours={funding_interval_a_hours}`\n"
+ f"`funding_interval_b_hours={funding_interval_b_hours}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ try:
+ await msg.edit_text(
+ config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["fra_wizard_message_id"] = new_msg.message_id
+ context.user_data["fra_wizard_chat_id"] = chat_id
+
+
+async def handle_fra_save(update, context) -> None:
+ """Save Funding Rate Arbitrage config - pulisce i campi non supportati"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Campi supportati dal controller
+ supported_fields = [
+ "id",
+ "controller_name",
+ "controller_type",
+ "exchange_pair_1",
+ "exchange_pair_2",
+ "total_amount_quote",
+ "entry_threshold",
+ "exit_threshold",
+ "sl_global",
+ "tp_global",
+ "funding_check_interval",
+ "executor_refresh_time",
+ "funding_interval_a_hours",
+ "funding_interval_b_hours",
+ "leverage",
+ "position_mode",
+ ]
+
+ if "controller_type" not in config:
+ config["controller_type"] = "generic"
+
+ keys_to_remove = [k for k in list(config.keys()) if k not in supported_fields]
+ for key in keys_to_remove:
+ config.pop(key, None)
+
+ for ep_key in ["exchange_pair_1", "exchange_pair_2"]:
+ if ep_key in config and isinstance(config[ep_key], dict):
+ ep = config[ep_key]
+ config[ep_key] = {
+ "connector_name": ep.get("connector_name", ""),
+ "trading_pair": ep.get("trading_pair", "")
+ }
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["fra_wizard_step", "fra_wizard_message_id", "fra_wizard_chat_id",
+ "fra_price_1", "fra_price_2"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_funding_rate_arb")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"FRA save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:fra_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+# Back handlers
+async def handle_fra_back_to_connector_1(update, context) -> None:
+ context.user_data["fra_wizard_step"] = "connector_1"
+ await _fra_show_connector_step(update, context, exchange_num=1)
+
+
+async def handle_fra_back_to_connector_2(update, context) -> None:
+ context.user_data["fra_wizard_step"] = "connector_2"
+ await _fra_show_connector_step(update, context, exchange_num=2)
+
+
+async def handle_fra_back_to_pair_1(update, context) -> None:
+ context.user_data["fra_wizard_step"] = "pair_1"
+ await _fra_show_pair_step(update, context, exchange_num=1)
+
+
+async def handle_fra_back_to_pair_2(update, context) -> None:
+ context.user_data["fra_wizard_step"] = "pair_2"
+ await _fra_show_pair_step(update, context, exchange_num=2)
+
+
+async def handle_fra_back_to_amount(update, context) -> None:
+ context.user_data["fra_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("fra_price_1", None)
+ context.user_data.pop("fra_price_2", None)
+ await _fra_show_amount_step(update, context)
+
+
+async def _fra_show_amount_step(update, context, target_message_id: int = None) -> None:
+ """FRA Step 5: Enter Total Amount (versione per input testuale)"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ use_message_id = target_message_id
+ if not query and not use_message_id:
+ logger.error("_fra_show_amount_step called without callback_query and without target_message_id")
+ return
+
+ if not query and use_message_id:
+ wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id)
+
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ c2 = ep2.get("connector_name", "")
+ p2 = ep2.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "fra_wizard_input"
+ context.user_data["fra_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:fra_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:fra_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:fra_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:fra_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:fra_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:fra_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_pair_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*💰 Funding Rate Arbitrage \- Step 5/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount \(split equally per leg\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ if query and query.message:
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message in _fra_show_amount_step: {e}")
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["fra_wizard_message_id"] = new_msg.message_id
+
+
+async def process_fra_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Funding Rate Arbitrage wizard"""
+ step = context.user_data.get("fra_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("fra_wizard_message_id")
+ wizard_chat_id = context.user_data.get("fra_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "pair_1":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("exchange_pair_1", {}).get("connector_name", "")
+
+ if "-" not in pair:
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_1")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*💰 Funding Rate Arbitrage \- Step 2/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:fra_pair_1:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_1")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*💰 Funding Rate Arbitrage \- Step 2/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # VAI AL CONNECTOR 2 (STEP 3) - USA target_message_id
+ context.user_data["fra_wizard_step"] = "connector_2"
+ await _fra_show_connector_step(update, context, exchange_num=2, target_message_id=message_id)
+ return
+
+ elif step == "pair_2":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("exchange_pair_2", {}).get("connector_name", "")
+ # DEFINISCI c1 e p1 PRIMA di usarle (fuori dal blocco if)
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+ if "-" not in pair:
+ ep1 = config.get("exchange_pair_1", {})
+ c1 = ep1.get("connector_name", "")
+ p1 = ep1.get("trading_pair", "")
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_2")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*💰 Funding Rate Arbitrage \- Step 4/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ is_cex = "/" not in connector
+ if is_cex:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:fra_pair_2:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:fra_back_to_connector_2")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*💰 Funding Rate Arbitrage \- Step 4/6*" + "\n\n"
+ f"1️⃣ `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"2️⃣ `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["trading_pair"] = pair
+
+ ep1 = config.get("exchange_pair_1", {})
+ p1 = ep1.get("trading_pair", "")
+ if p1:
+ quote = p1.split("-")[1] if "-" in p1 else "USDT"
+ else:
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ config["quote_conversion_asset"] = quote
+ set_controller_config(context, config)
+
+ # VAI ALL'AMOUNT (STEP 5) - USA target_message_id
+ context.user_data["fra_wizard_step"] = "total_amount_quote"
+ await _fra_show_amount_step(update, context, target_message_id=message_id)
+ return
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["fra_wizard_step"] = "final"
+ pair = config.get("exchange_pair_1", {}).get("trading_pair", "")
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*💰 Funding Rate Arbitrage \- New Config*" + "\n\n"
+ f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+ else:
+ tmp_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*💰 Funding Rate Arbitrage \- New Config*" + "\n\n"
+ f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["fra_wizard_message_id"] = tmp_msg.message_id
+
+ await _fra_show_final_step(update, context)
+
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:fra_back_to_amount")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*💰 Funding Rate Arbitrage \- Step 5*" + "\n\n"
+ r"❌ *Invalid amount*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 500\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "final":
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ if "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "entry_threshold", "exit_threshold", "sl_global", "tp_global"):
+ config[field] = float(value)
+ elif field in ("funding_check_interval", "executor_refresh_time", "leverage"):
+ config[field] = int(float(value))
+ elif field in ("funding_interval_a_hours", "funding_interval_b_hours"):
+ config[field] = int(value) if value not in ("null", "None", "") else None
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+ await _fra_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"FRA wizard input error: {e}", exc_info=True)
+
+# ============================================
+# DELTA NEUTRAL MM WIZARD (basato su funding_rate_arb)
+# ============================================
+# Steps: maker_connector → maker_pair → hedge_connector → hedge_pair → amount → final+save
+# Prefisso handler: dnmm_
+
+async def show_new_delta_neutral_mm_form(update, context) -> None:
+ """Start the Delta Neutral MM wizard - Step 1: Maker Exchange"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ for key in ["dnmm_price_maker", "dnmm_price_hedge"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "delta_neutral_mm")
+ context.user_data["bots_state"] = "dnmm_wizard"
+ context.user_data["dnmm_wizard_step"] = "maker_connector"
+ context.user_data["dnmm_wizard_message_id"] = query.message.message_id
+ context.user_data["dnmm_wizard_chat_id"] = query.message.chat_id
+ await _dnmm_show_connector_step(update, context, role="maker")
+
+async def _dnmm_show_connector_step(update, context, role: str, target_message_id: int = None) -> None:
+ """Show connector selection for maker or hedge"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ if not query and not use_message_id:
+ logger.error("_dnmm_show_connector_step called without callback_query and without target_message_id")
+ return
+
+ is_maker = role == "maker"
+ step = 1 if is_maker else 3
+ emoji = "🏭" if is_maker else "🛡️"
+ role_label = "Maker \\(Spot\\)" if is_maker else "Hedge \\(Perpetual\\)"
+
+ header = ""
+ if not is_maker:
+ maker = config.get("connector_pair_maker", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+ if c1 and p1:
+ header = f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n"
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+
+ # Ottieni TUTTI i connector configurati
+ all_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ # Filtra in base al ruolo
+ if is_maker:
+ # Maker: solo connector SPOT (non perpetual)
+ available_connectors = [c for c in all_connectors if not c.endswith("_perpetual")]
+ else:
+ # Hedge: solo connector PERPETUAL
+ available_connectors = [c for c in all_connectors if c.endswith("_perpetual")]
+
+ if not available_connectors:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ error_text = (
+ f"⚠️ *No {role_label} connectors available*\n\n"
+ f"You need to configure API keys for a {role_label} exchange first."
+ )
+ if query and query.message:
+ await query.message.edit_text(
+ error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Costruisci keyboard (come in FRA)
+ keyboard = []
+ if available_connectors:
+ keyboard.append([InlineKeyboardButton("— CEX —", callback_data="bots:noop")])
+ row = []
+ for c in available_connectors:
+ row.append(InlineKeyboardButton(c, callback_data=f"bots:dnmm_{role}_connector:{c}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = "bots:main_menu" if is_maker else "bots:dnmm_back_to_maker_pair"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ message_text = (
+ rf"*⚡ Delta Neutral MM \- Step {step}/6*" + "\n\n"
+ r"Market making on spot with delta hedging on perpetual\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ + header
+ + rf"*{emoji} Select {role_label}:*"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"DNMM connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ error_text = format_error_message(f"Error: {str(e)}")
+ if query and query.message:
+ await query.message.edit_text(
+ error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=error_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_dnmm_wizard_maker_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ if "connector_pair_maker" not in config:
+ config["connector_pair_maker"] = {}
+ config["connector_pair_maker"]["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["dnmm_wizard_step"] = "maker_pair"
+ await _dnmm_show_pair_step(update, context, role="maker")
+
+
+async def handle_dnmm_wizard_hedge_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ if "connector_pair_hedge" not in config:
+ config["connector_pair_hedge"] = {}
+ config["connector_pair_hedge"]["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["dnmm_wizard_step"] = "hedge_pair"
+ await _dnmm_show_pair_step(update, context, role="hedge")
+
+
+async def _dnmm_show_pair_step(update, context, role: str) -> None:
+ """Show trading pair input for maker or hedge"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ cp_key = f"connector_pair_{role}"
+ connector = config.get(cp_key, {}).get("connector_name", "")
+
+ context.user_data["bots_state"] = "dnmm_wizard_input"
+ context.user_data["dnmm_wizard_step"] = f"{role}_pair"
+
+ step = 2 if role == "maker" else 4
+ emoji = "🏭 Maker" if role == "maker" else "🛡️ Hedge"
+
+ header = ""
+ if role == "hedge":
+ maker = config.get("connector_pair_maker", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+ header = f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n\n"
+ else:
+ c1 = config.get("connector_pair_maker", {}).get("connector_name", "")
+ header = f"🏭 `{escape_markdown_v2(c1)}`\n\n"
+
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ cp = cfg.get(cp_key, {})
+ pair = cp.get("trading_pair", "") if isinstance(cp, dict) else ""
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ if role == "hedge":
+ p1 = config.get("connector_pair_maker", {}).get("trading_pair", "")
+ if p1 and p1 not in seen:
+ recent_pairs.insert(0, p1)
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs[:6]:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:dnmm_{role}_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ back_cb = f"bots:dnmm_back_to_{role}_connector"
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data=back_cb),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ await query.message.edit_text(
+ rf"*⚡ Delta Neutral MM \- Step {step}/6*" + "\n\n"
+ + header
+ + rf"*{emoji} Trading Pair on* `{escape_markdown_v2(connector)}`:" + "\n\n"
+ r"_e\.g\. SOL\-USDT_" + "\n\n"
+ r"Select or type a pair:",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_dnmm_wizard_maker_pair(update, context, pair: str) -> None:
+ config = get_controller_config(context)
+ if "connector_pair_maker" not in config:
+ config["connector_pair_maker"] = {}
+ config["connector_pair_maker"]["trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+ context.user_data["dnmm_wizard_step"] = "hedge_connector"
+ await _dnmm_show_connector_step(update, context, role="hedge")
+
+
+async def handle_dnmm_wizard_hedge_pair(update, context, pair: str) -> None:
+ config = get_controller_config(context)
+ if "connector_pair_hedge" not in config:
+ config["connector_pair_hedge"] = {}
+ config["connector_pair_hedge"]["trading_pair"] = pair.upper()
+ set_controller_config(context, config)
+ context.user_data["dnmm_wizard_step"] = "total_amount_quote"
+ await _dnmm_show_amount_step(update, context)
+
+
+async def _dnmm_show_amount_step(update, context) -> None:
+ """DNMM Step 5: Enter Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ maker = config.get("connector_pair_maker", {})
+ hedge = config.get("connector_pair_hedge", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+ c2 = hedge.get("connector_name", "")
+ p2 = hedge.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "dnmm_wizard_input"
+ context.user_data["dnmm_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:dnmm_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:dnmm_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:dnmm_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:dnmm_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:dnmm_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:dnmm_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*⚡ Delta Neutral MM \- Step 5/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"🛡️ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount \(split between legs\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_dnmm_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ maker = config.get("connector_pair_maker", {})
+ pair = maker.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*⚡ Delta Neutral MM \- New Config*" + "\n\n"
+ f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["dnmm_wizard_step"] = "final"
+ await _dnmm_show_final_step(update, context)
+
+
+async def _dnmm_show_final_step(update, context) -> None:
+ """DNMM Final Step: Show config with ONLY fields supported by the controller"""
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ maker = config.get("connector_pair_maker", {})
+ hedge = config.get("connector_pair_hedge", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+ c2 = hedge.get("connector_name", "")
+ p2 = hedge.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 100)
+ order_amount_quote = config.get("order_amount_quote", 15)
+ buy_spreads = config.get("buy_spreads", "1.0,2.0,3.0")
+ sell_spreads = config.get("sell_spreads", "1.0,2.0,3.0")
+ order_refresh_time = config.get("order_refresh_time", 30)
+ hedge_threshold_quote = config.get("hedge_threshold_quote", 10)
+ max_delta_quote = config.get("max_delta_quote", 50)
+ sl_global = config.get("sl_global", 0.03)
+ tp_global = config.get("tp_global", 0.05)
+ hedge_position_timeout = config.get("hedge_position_timeout", 3600)
+ maker_tp_multiplier = config.get("maker_tp_multiplier", 1.0)
+ leverage = config.get("leverage", 1)
+ position_mode = config.get("position_mode", "HEDGE")
+ macd_fast = config.get("macd_fast", 21)
+ macd_slow = config.get("macd_slow", 42)
+ macd_signal = config.get("macd_signal", 9)
+ natr_length = config.get("natr_length", 14)
+ interval = config.get("interval", "3m")
+
+ if "controller_type" not in config:
+ config["controller_type"] = "generic"
+ set_controller_config(context, config)
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.delta_neutral_mm.config import generate_id as dnmm_generate_id
+ config["id"] = dnmm_generate_id(config, existing_configs)
+ set_controller_config(context, config)
+
+ context.user_data["bots_state"] = "dnmm_wizard_input"
+ context.user_data["dnmm_wizard_step"] = "final"
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:dnmm_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ config_id = config.get("id", "")
+
+ # Converti liste in stringhe per visualizzazione
+ buy_spreads_str = ",".join(str(s) for s in buy_spreads) if isinstance(buy_spreads, list) else buy_spreads
+ sell_spreads_str = ",".join(str(s) for s in sell_spreads) if isinstance(sell_spreads, list) else sell_spreads
+
+ config_text = (
+ r"*⚡ Delta Neutral MM \- Final Review*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"🛡️ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ f"`id={escape_markdown_v2(config_id)}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`order_amount_quote={order_amount_quote:.0f}`\n"
+ f"`buy_spreads={escape_markdown_v2(buy_spreads_str)}`\n"
+ f"`sell_spreads={escape_markdown_v2(sell_spreads_str)}`\n"
+ f"`order_refresh_time={order_refresh_time}`\n"
+ f"`hedge_threshold_quote={hedge_threshold_quote}`\n"
+ f"`max_delta_quote={max_delta_quote}`\n"
+ f"`sl_global={sl_global}`\n"
+ f"`tp_global={tp_global}`\n"
+ f"`hedge_position_timeout={hedge_position_timeout}`\n"
+ f"`maker_tp_multiplier={maker_tp_multiplier}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={escape_markdown_v2(position_mode)}`\n"
+ f"`macd_fast={macd_fast}`\n"
+ f"`macd_slow={macd_slow}`\n"
+ f"`macd_signal={macd_signal}`\n"
+ f"`natr_length={natr_length}`\n"
+ f"`interval={escape_markdown_v2(interval)}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ try:
+ await msg.edit_text(
+ config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dnmm_wizard_message_id"] = new_msg.message_id
+ context.user_data["dnmm_wizard_chat_id"] = chat_id
+
+
+async def handle_dnmm_save(update, context) -> None:
+ """Save Delta Neutral MM config - pulisce i campi non supportati"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Campi supportati dal controller
+ supported_fields = [
+ "id",
+ "controller_name",
+ "controller_type",
+ "connector_pair_maker",
+ "connector_pair_hedge",
+ "candles_connector",
+ "candles_trading_pair",
+ "interval",
+ "macd_fast",
+ "macd_slow",
+ "macd_signal",
+ "natr_length",
+ "buy_spreads",
+ "sell_spreads",
+ "order_amount_quote",
+ "order_refresh_time",
+ "hedge_threshold_quote",
+ "max_delta_quote",
+ "leverage",
+ "position_mode",
+ "sl_global",
+ "tp_global",
+ "hedge_position_timeout",
+ "maker_tp_multiplier",
+ ]
+
+ if "controller_type" not in config:
+ config["controller_type"] = "generic"
+
+ keys_to_remove = [k for k in list(config.keys()) if k not in supported_fields]
+ for key in keys_to_remove:
+ config.pop(key, None)
+
+ # Assicurati che connector_pair_maker e connector_pair_hedge abbiano la struttura corretta
+ for cp_key in ["connector_pair_maker", "connector_pair_hedge"]:
+ if cp_key in config and isinstance(config[cp_key], dict):
+ cp = config[cp_key]
+ config[cp_key] = {
+ "connector_name": cp.get("connector_name", ""),
+ "trading_pair": cp.get("trading_pair", "")
+ }
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["dnmm_wizard_step", "dnmm_wizard_message_id", "dnmm_wizard_chat_id",
+ "dnmm_price_maker", "dnmm_price_hedge"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_delta_neutral_mm")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"DNMM save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:dnmm_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+# Back handlers
+async def handle_dnmm_back_to_maker_connector(update, context) -> None:
+ context.user_data["dnmm_wizard_step"] = "maker_connector"
+ await _dnmm_show_connector_step(update, context, role="maker")
+
+
+async def handle_dnmm_back_to_hedge_connector(update, context) -> None:
+ context.user_data["dnmm_wizard_step"] = "hedge_connector"
+ await _dnmm_show_connector_step(update, context, role="hedge")
+
+
+async def handle_dnmm_back_to_maker_pair(update, context) -> None:
+ context.user_data["dnmm_wizard_step"] = "maker_pair"
+ await _dnmm_show_pair_step(update, context, role="maker")
+
+
+async def handle_dnmm_back_to_hedge_pair(update, context) -> None:
+ context.user_data["dnmm_wizard_step"] = "hedge_pair"
+ await _dnmm_show_pair_step(update, context, role="hedge")
+
+
+async def handle_dnmm_back_to_amount(update, context) -> None:
+ context.user_data["dnmm_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("dnmm_price_maker", None)
+ context.user_data.pop("dnmm_price_hedge", None)
+ await _dnmm_show_amount_step(update, context)
+
+
+async def _dnmm_show_amount_step(update, context, target_message_id: int = None) -> None:
+ """DNMM Step 5: Enter Total Amount (versione per input testuale)"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ use_message_id = target_message_id
+ if not query and not use_message_id:
+ logger.error("_dnmm_show_amount_step called without callback_query and without target_message_id")
+ return
+
+ if not query and use_message_id:
+ wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id)
+
+ maker = config.get("connector_pair_maker", {})
+ hedge = config.get("connector_pair_hedge", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+ c2 = hedge.get("connector_name", "")
+ p2 = hedge.get("trading_pair", "")
+
+ context.user_data["bots_state"] = "dnmm_wizard_input"
+ context.user_data["dnmm_wizard_step"] = "total_amount_quote"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:dnmm_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:dnmm_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:dnmm_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:dnmm_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:dnmm_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:dnmm_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*⚡ Delta Neutral MM \- Step 5/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"🛡️ `{escape_markdown_v2(c2)}` \\| `{escape_markdown_v2(p2)}`\n\n"
+ r"💰 *Total Amount \(split between legs\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ if query and query.message:
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ elif use_message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message in _dnmm_show_amount_step: {e}")
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["dnmm_wizard_message_id"] = new_msg.message_id
+
+
+async def process_dnmm_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Delta Neutral MM wizard"""
+ step = context.user_data.get("dnmm_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("dnmm_wizard_message_id")
+ wizard_chat_id = context.user_data.get("dnmm_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "maker_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_pair_maker", {}).get("connector_name", "")
+
+ if "-" not in pair:
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_maker_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*⚡ Delta Neutral MM \- Step 2/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:dnmm_maker_pair:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_maker_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*⚡ Delta Neutral MM \- Step 2/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ if "connector_pair_maker" not in config:
+ config["connector_pair_maker"] = {}
+ config["connector_pair_maker"]["trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # ========== VAI AL HEDGE CONNECTOR (STEP 3) - RICOSTRUISCI IL MESSAGGIO ==========
+ # VAI AL HEDGE CONNECTOR (STEP 3)
+ context.user_data["dnmm_wizard_step"] = "hedge_connector"
+ await _dnmm_show_connector_step(update, context, role="hedge", target_message_id=message_id)
+ return
+
+ elif step == "hedge_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_pair_hedge", {}).get("connector_name", "")
+
+ if "-" not in pair:
+ maker = config.get("connector_pair_maker", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*⚡ Delta Neutral MM \- Step 4/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"🛡️ `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. SOL\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ maker = config.get("connector_pair_maker", {})
+ c1 = maker.get("connector_name", "")
+ p1 = maker.get("trading_pair", "")
+
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:dnmm_hedge_pair:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:dnmm_back_to_hedge_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*⚡ Delta Neutral MM \- Step 4/6*" + "\n\n"
+ f"🏭 `{escape_markdown_v2(c1)}` \\| `{escape_markdown_v2(p1)}`\n"
+ f"🛡️ `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ if "connector_pair_hedge" not in config:
+ config["connector_pair_hedge"] = {}
+ config["connector_pair_hedge"]["trading_pair"] = pair
+ set_controller_config(context, config)
+
+ # ========== VAI ALL'AMOUNT (STEP 5) - RICOSTRUISCI IL MESSAGGIO ==========
+ # VAI ALL'AMOUNT (STEP 5)
+ context.user_data["dnmm_wizard_step"] = "total_amount_quote"
+ await _dnmm_show_amount_step(update, context, target_message_id=message_id)
+ return
+
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["dnmm_wizard_step"] = "final"
+
+ pair = config.get("connector_pair_maker", {}).get("trading_pair", "")
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*⚡ Delta Neutral MM \- New Config*" + "\n\n"
+ f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+ else:
+ tmp_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*⚡ Delta Neutral MM \- New Config*" + "\n\n"
+ f"⏳ Fetching configuration for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["dnmm_wizard_message_id"] = tmp_msg.message_id
+
+ await _dnmm_show_final_step(update, context)
+
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:dnmm_back_to_amount")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=r"*⚡ Delta Neutral MM \- Step 5*" + "\n\n"
+ r"❌ *Invalid amount*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 500\):",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "final":
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ if "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "order_amount_quote", "hedge_threshold_quote", "max_delta_quote",
+ "sl_global", "tp_global", "maker_tp_multiplier"):
+ config[field] = float(value)
+ elif field in ("order_refresh_time", "hedge_position_timeout", "leverage",
+ "macd_fast", "macd_slow", "macd_signal", "natr_length"):
+ config[field] = int(float(value))
+ elif field in ("buy_spreads", "sell_spreads"):
+ config[field] = value
+ elif field == "interval":
+ config["interval"] = value
+ elif field == "position_mode":
+ config["position_mode"] = value.upper()
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+ await _dnmm_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"DNMM wizard input error: {e}", exc_info=True)
+
+# ============================================
+# BOLLINGER GRID WIZARD (bollingrid)
+# ============================================
+# Steps: connector → pair → leverage (perp) → position_mode (perp) → amount → final+chart
+# Prefisso handler: bg_
+
+async def show_new_bollingrid_form(update, context) -> None:
+ """Start the Bollinger Grid wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Clear cached data
+ for key in ["bg_current_price", "bg_candles", "bg_candles_interval", "bg_chart_interval", "bg_bbp"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "bollingrid")
+ context.user_data["bots_state"] = "bg_wizard"
+ context.user_data["bg_wizard_step"] = "connector_name"
+ context.user_data["bg_wizard_message_id"] = query.message.message_id
+ context.user_data["bg_wizard_chat_id"] = query.message.chat_id
+
+ await _bg_show_connector_step(update, context)
+
+
+async def _bg_show_connector_step(update, context, target_message_id: int = None) -> None:
+ """BG Step 1: Select Connector"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ chat_id = update.effective_chat.id
+
+ if not query and not use_message_id:
+ logger.error("_bg_show_connector_step called without callback_query and without target_message_id")
+ return
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ error_text = r"*📊 Bollinger Grid \- New Config*" + "\n\n" + r"⚠️ No CEX connectors available\."
+ if query and query.message:
+ await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:bg_connector:{connector}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ message_text = (
+ r"*📊 Bollinger Grid*" + "\n\n"
+ r"Grid trading strategy activated by Bollinger Band Percent \(BBP\)\. "
+ r"Creates a grid when BBP indicates oversold \(\< long threshold\) or overbought \(\> short threshold\)\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select Exchange*"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("bg_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=use_message_id,
+ text=message_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"BG connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ error_text = format_error_message(f"Error: {str(e)}")
+ if query and query.message:
+ await query.message.edit_text(error_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+
+
+async def handle_bg_wizard_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ config["candles_connector"] = connector
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "trading_pair"
+ await _bg_show_pair_step(update, context)
+ logger.info(f"BG DEBUG: handle_bg_wizard_connector - connector = '{connector}'")
+
+
+async def _bg_show_pair_step(update, context, target_message_id: int = None) -> None:
+ """BG Step 2: Select Trading Pair"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ if not query and not use_message_id:
+ logger.error("_bg_show_pair_step called without callback_query and without target_message_id")
+ return
+
+ context.user_data["bots_state"] = "bg_wizard_input"
+ context.user_data["bg_wizard_step"] = "trading_pair"
+
+ # Recent pairs from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_pairs = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ pair = cfg.get("trading_pair", "")
+ if pair and pair not in seen:
+ seen.add(pair)
+ recent_pairs.append(pair)
+ if len(recent_pairs) >= 6:
+ break
+
+ keyboard = []
+ if recent_pairs:
+ row = []
+ for pair in recent_pairs:
+ row.append(InlineKeyboardButton(pair, callback_data=f"bots:bg_pair:{pair}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 2
+
+ message_text = (
+ rf"*📊 Bollinger Grid \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🔗 *Trading Pair*" + "\n\n"
+ r"Select a recent pair or type a new one:"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("bg_wizard_chat_id", update.effective_chat.id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=use_message_id,
+ text=message_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_bg_wizard_pair(update, context, pair: str) -> None:
+ config = get_controller_config(context)
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["bg_wizard_step"] = "leverage"
+ await _bg_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "total_amount_quote"
+ await _bg_show_amount_step(update, context)
+
+
+async def _bg_show_leverage_step(update, context, target_message_id: int = None) -> None:
+ """BG Step 3 (perp only): Select Leverage"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+
+ if not query and not use_message_id:
+ logger.error("_bg_show_leverage_step called without callback_query and without target_message_id")
+ return
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:bg_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:bg_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:bg_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:bg_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:bg_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:bg_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_pair"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ r"*📊 Bollinger Grid \- Step 3/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("bg_wizard_chat_id", update.effective_chat.id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=use_message_id,
+ text=message_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_bg_wizard_leverage(update, context, leverage: int) -> None:
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "position_mode"
+ await _bg_show_position_mode_step(update, context)
+
+
+async def _bg_show_position_mode_step(update, context, target_message_id: int = None) -> None:
+ """BG Step 4 (perp only): Position Mode"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+
+ if not query and not use_message_id:
+ logger.error("_bg_show_position_mode_step called without callback_query and without target_message_id")
+ return
+
+ keyboard = [
+ [
+ InlineKeyboardButton("🔀 HEDGE", callback_data="bots:bg_position_mode:HEDGE"),
+ InlineKeyboardButton("➡️ ONEWAY", callback_data="bots:bg_position_mode:ONEWAY"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_leverage"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ message_text = (
+ f"*📊 Bollinger Grid \\- Step 4/6*\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}` \\| ⚡ `{leverage}x`\n\n"
+ r"📐 *Position Mode*\n\n"
+ r"• *HEDGE*: Can hold both long and short positions simultaneously\n"
+ r"• *ONEWAY*: Can only hold positions in one direction"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("bg_wizard_chat_id", update.effective_chat.id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=use_message_id,
+ text=message_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_bg_position_mode(update, context, mode: str) -> None:
+ config = get_controller_config(context)
+ config["position_mode"] = mode
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "total_amount_quote"
+ await _bg_show_amount_step(update, context)
+
+async def _bg_show_amount_step(update, context, target_message_id: int = None) -> None:
+ """BG Step: Enter Total Amount"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ leverage = config.get("leverage", 1)
+ pos_mode = config.get("position_mode", "HEDGE")
+
+ if not query and not use_message_id:
+ logger.error("_bg_show_amount_step called without callback_query and without target_message_id")
+ return
+
+ context.user_data["bots_state"] = "bg_wizard_input"
+ context.user_data["bg_wizard_step"] = "total_amount_quote"
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 6 if is_perp else 4
+ current_step = 5 if is_perp else 3
+
+ back_callback = "bots:bg_back_to_position_mode" if is_perp else "bots:bg_back_to_pair"
+
+ # 🔧 FIX: Normalizza il nome del connector come in DMan
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for BollinGrid amount step: {e}")
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:bg_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:bg_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:bg_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:bg_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:bg_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:bg_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🔗 `{escape_markdown_v2(pair)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x` \\| 🎯 `{escape_markdown_v2(pos_mode)}`"
+
+ message_text = (
+ rf"*📊 Bollinger Grid \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + balance_text + "\n\n"
+ r"💰 *Total Amount \(USDT\)*" + "\n"
+ r"_Select or type an amount:_"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(message_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("bg_wizard_chat_id", chat_id)
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=use_message_id,
+ text=message_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_bg_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ pair = config.get("trading_pair", "")
+ await query.message.edit_text(
+ r"*📊 Bollinger Grid \- New Config*" + "\n\n"
+ f"⏳ *Loading market data for* `{escape_markdown_v2(pair)}`\\.\\.\\. " + "\n\n"
+ r"_Fetching price and calculating BBP\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["bg_wizard_step"] = "final"
+ await _bg_show_final_step(update, context)
+
+
+async def _bg_show_final_step(update, context, interval: str = None) -> None:
+ """BG Final Step: Chart + Config Summary + BBP Signal"""
+ import asyncio
+ from .controllers.bollingrid import BollinGridController
+ from .controllers.bollingrid import generate_id as bg_generate_id
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ pair = config.get("trading_pair", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ position_mode = config.get("position_mode", "HEDGE")
+ bb_length = config.get("bb_length", 100)
+ bb_std = config.get("bb_std", 2.0)
+ bb_long = config.get("bb_long_threshold", 0.0)
+ bb_short = config.get("bb_short_threshold", 1.0)
+ start_coeff = config.get("grid_start_price_coefficient", 0.25)
+ end_coeff = config.get("grid_end_price_coefficient", 0.75)
+ limit_coeff = config.get("grid_limit_price_coefficient", 0.35)
+ min_spread = config.get("min_spread_between_orders", 0.005)
+ order_freq = config.get("order_frequency", 2)
+ max_orders_batch = config.get("max_orders_per_batch", 1)
+ min_order_amt = config.get("min_order_amount_quote", 6)
+ max_open_orders = config.get("max_open_orders", 5)
+ stop_loss = config.get("stop_loss", 0.05)
+ take_profit = config.get("take_profit", 0.03)
+
+ if interval is None:
+ interval = context.user_data.get("bg_chart_interval", config.get("interval", "5m"))
+ context.user_data["bg_chart_interval"] = interval
+ config["interval"] = interval
+ set_controller_config(context, config)
+
+ current_price = context.user_data.get("bg_current_price")
+ candles = context.user_data.get("bg_candles")
+
+ try:
+ cached_interval = context.user_data.get("bg_candles_interval", interval)
+ if not current_price or interval != cached_interval:
+ try:
+ await msg.edit_text(
+ r"*📊 Bollinger Grid \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ if not candles_connector or len(candles_connector) < 3:
+ candles_connector = "kucoin"
+
+ current_price = await fetch_current_price(client, connector, pair)
+
+ if current_price:
+ context.user_data["bg_current_price"] = current_price
+ candles = await fetch_candles(
+ client, candles_connector, pair, interval=interval, max_records=420
+ )
+ context.user_data["bg_candles"] = candles
+ context.user_data["bg_candles_interval"] = interval
+
+ if not current_price:
+ keyboard = [[InlineKeyboardButton("⬅️ Back", callback_data="bots:main_menu")]]
+ await msg.edit_text(
+ r"*❌ Error*" + "\n\n" + f"Could not fetch price for `{escape_markdown_v2(pair)}`\\.",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ candles_list = candles.get("data", []) if isinstance(candles, dict) else (candles or [])
+
+ if candles_list:
+ last_close = candles_list[-1].get("close") or candles_list[-1].get("c")
+ if last_close:
+ current_price = float(last_close)
+ context.user_data["bg_current_price"] = current_price
+
+ # Calculate grid prices based on coefficients
+ start_price = current_price * (1 - start_coeff)
+ end_price = current_price * (1 + end_coeff)
+ limit_price = current_price * (1 - limit_coeff)
+
+ config["start_price"] = start_price
+ config["end_price"] = end_price
+ config["limit_price"] = limit_price
+
+
+ # Calculate BBP and signal
+ bbp = None
+ bbp_signal = ""
+ bb_width_pct = 0.02 # fallback 2%
+
+ if candles_list and len(candles_list) >= bb_length:
+ closes = [float(c.get("close") or c.get("c", 0)) for c in candles_list[-bb_length:]]
+ if closes:
+ mean = sum(closes) / len(closes)
+ std = (sum((x - mean) ** 2 for x in closes) / len(closes)) ** 0.5
+ upper = mean + bb_std * std
+ lower = mean - bb_std * std
+
+ # Calcola BB Width (larghezza normalizzata delle Bollinger Bands)
+ if upper > lower and current_price > 0:
+ bb_width_pct = (upper - lower) / current_price
+ logger.info(f"BG Chart: BB Width = {bb_width_pct:.4f} ({bb_width_pct*100:.2f}%)")
+
+ # Calcola BBP per il segnale
+ if upper != lower:
+ bbp = (current_price - lower) / (upper - lower)
+ if bbp < bb_long:
+ bbp_signal = r" 🟢 LONG READY (BBP < long threshold)"
+ elif bbp > bb_short:
+ bbp_signal = r" 🔴 SHORT READY (BBP > short threshold)"
+ else:
+ bbp_signal = r" ⚪ No signal (BBP in neutral zone)"
+
+ # Calcola prezzi della griglia come nel controller BollinGridController
+ # LONG: start = price * (1 - bb_width * start_coeff)
+ # end = price * (1 + bb_width * end_coeff)
+ # limit = price * (1 - bb_width * limit_coeff)
+ # SHORT: start = price * (1 - bb_width * end_coeff) (invertito)
+ # end = price * (1 + bb_width * start_coeff)
+ # limit = price * (1 + bb_width * limit_coeff)
+
+ side_value = config.get("side", 1) # 1=LONG, 2=SHORT
+
+ if side_value == 2: # SHORT
+ start_price = current_price * (1 - bb_width_pct * end_coeff)
+ end_price = current_price * (1 + bb_width_pct * start_coeff)
+ limit_price = current_price * (1 + bb_width_pct * limit_coeff)
+ else: # LONG (default)
+ start_price = current_price * (1 - bb_width_pct * start_coeff)
+ end_price = current_price * (1 + bb_width_pct * end_coeff)
+ limit_price = current_price * (1 - bb_width_pct * limit_coeff)
+
+ # Arrotonda per una migliore visualizzazione
+ start_price = round(start_price, 8)
+ end_price = round(end_price, 8)
+ limit_price = round(limit_price, 8)
+
+ logger.info(f"BG Chart: Grid prices - start={start_price}, end={end_price}, limit={limit_price}")
+
+ config["start_price"] = start_price
+ config["end_price"] = end_price
+ config["limit_price"] = limit_price
+
+ # Generate config ID if not set
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = bg_generate_id(config, existing_configs)
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ final_step = 6 if is_perp else 4
+
+ context.user_data["bots_state"] = "bg_wizard_input"
+ context.user_data["bg_wizard_step"] = "final"
+
+ interval_options = ["1m", "5m", "15m", "1h", "8h"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:bg_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+
+ keyboard = [
+ interval_row,
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:bg_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ bbp_info = f"BBP: `{bbp:.3f}`" if bbp else "BBP: N/A"
+ grid_range = f"Grid: `{start_price:.6g}` → `{end_price:.6g}`"
+
+ # Escapa i valori per MarkdownV2
+ escaped_title = escape_markdown_v2(f"📊 Bollinger Grid - Step {final_step}/{final_step} (Final)")
+ escaped_pair = escape_markdown_v2(pair)
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_position_mode = escape_markdown_v2(position_mode)
+ escaped_interval = escape_markdown_v2(interval)
+
+ # Linea del prezzo con BBP e interval
+ price_line = f"Price: {current_price:,.6g} | {bbp_info} | Interval: {interval}"
+ escaped_price_line = escape_markdown_v2(price_line)
+
+ # Grid range (già escapato da escape_markdown_v2)
+ escaped_grid_range = escape_markdown_v2(grid_range)
+
+ config_text = (
+ f"*{escaped_title}*\n\n"
+ f"*{escaped_pair}*\n"
+ f"{escaped_price_line}\n"
+ f"{escaped_grid_range}\n\n"
+ f"`connector_name={escaped_connector}`\n"
+ f"`trading_pair={escaped_pair}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={escaped_position_mode}`\n"
+ f"`interval={escaped_interval}`\n"
+ f"`bb_length={bb_length}`\n"
+ f"`bb_std={bb_std}`\n"
+ f"`bb_long_threshold={bb_long}`\n"
+ f"`bb_short_threshold={bb_short}`\n"
+ f"`grid_start_price_coefficient={start_coeff}`\n"
+ f"`grid_end_price_coefficient={end_coeff}`\n"
+ f"`grid_limit_price_coefficient={limit_coeff}`\n"
+ f"`min_spread_between_orders={min_spread}`\n"
+ f"`order_frequency={order_freq}`\n"
+ f"`max_orders_per_batch={max_orders_batch}`\n"
+ f"`min_order_amount_quote={min_order_amt}`\n"
+ f"`max_open_orders={max_open_orders}`\n"
+ f"`stop_loss={stop_loss}`\n"
+ f"`take_profit={take_profit}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+ # Generate chart with Bollinger Bands and grid lines
+ if candles_list:
+ chart_bytes = BollinGridController.generate_chart(config, candles_list, current_price)
+
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["bg_wizard_message_id"] = new_msg.message_id
+ context.user_data["bg_wizard_chat_id"] = chat_id
+ else:
+ try:
+ await msg.edit_text(text=config_text, parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard))
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id, text=config_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["bg_wizard_message_id"] = new_msg.message_id
+
+ except Exception as e:
+ logger.error(f"BG final step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ try:
+ await msg.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ pass
+
+
+# Back handlers per Bollinger Grid
+async def handle_bg_save(update, context) -> None:
+ """Save Bollinger Grid configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Clean up temporary fields
+ config.pop("candles_config", None)
+ config.pop("manual_kill_switch", None)
+ config.pop("start_price", None)
+ config.pop("end_price", None)
+ config.pop("limit_price", None)
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["bg_wizard_step", "bg_wizard_message_id", "bg_wizard_chat_id",
+ "bg_current_price", "bg_candles", "bg_candles_interval", "bg_chart_interval", "bg_bbp"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_bollingrid")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"BG save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:bg_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_bg_interval_change(update, context, interval: str) -> None:
+ """Change chart interval"""
+ context.user_data["bg_candles"] = None
+ context.user_data["bg_candles_interval"] = None
+ await _bg_show_final_step(update, context, interval=interval)
+
+
+async def handle_bg_back_to_connector(update, context) -> None:
+ context.user_data["bg_wizard_step"] = "connector_name"
+ await _bg_show_connector_step(update, context)
+
+
+async def handle_bg_back_to_pair(update, context) -> None:
+ context.user_data["bg_wizard_step"] = "trading_pair"
+ await _bg_show_pair_step(update, context)
+
+
+async def handle_bg_back_to_leverage(update, context) -> None:
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ if is_perp:
+ context.user_data["bg_wizard_step"] = "leverage"
+ await _bg_show_leverage_step(update, context)
+ else:
+ await handle_bg_back_to_pair(update, context)
+
+
+async def handle_bg_back_to_position_mode(update, context) -> None:
+ context.user_data["bg_wizard_step"] = "position_mode"
+ await _bg_show_position_mode_step(update, context)
+
+
+async def handle_bg_back_to_amount(update, context) -> None:
+ context.user_data["bg_wizard_step"] = "total_amount_quote"
+ context.user_data.pop("bg_current_price", None)
+ context.user_data.pop("bg_candles", None)
+ await _bg_show_amount_step(update, context)
+
+
+async def handle_bg_pair_select(update, context, pair: str) -> None:
+ """Handle selection of a suggested trading pair in Bollinger Grid wizard"""
+ await handle_bg_wizard_pair(update, context, pair)
+
+
+async def process_bg_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Bollinger Grid wizard"""
+ step = context.user_data.get("bg_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("bg_wizard_message_id")
+ wizard_chat_id = context.user_data.get("bg_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "trading_pair":
+ pair = user_input.upper().strip().replace("/", "-").replace("_", "-")
+ connector = config.get("connector_name", "")
+
+ if "-" not in pair:
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_connector")],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+ error_text = (
+ r"*📊 Bollinger Grid \- Step 2*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\. Use BASE\-QUOTE \(e\.g\. BTC\-USDT\)*" + "\n\n"
+ r"Type the pair again:"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:bg_pair_select:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:bg_back_to_connector")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ error_text = (
+ r"*📊 Bollinger Grid \- Step 2*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+ f"❌ `{escape_markdown_v2(pair)}` not found on `{escape_markdown_v2(connector)}`\\.\n\n"
+ r"*Did you mean?*"
+ )
+ if message_id:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=error_text, parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["trading_pair"] = pair
+ config["candles_trading_pair"] = pair
+ for key in ["bg_current_price", "bg_candles", "bg_candles_interval"]:
+ context.user_data.pop(key, None)
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["bg_wizard_step"] = "leverage"
+ await _bg_show_leverage_step(update, context, target_message_id=message_id)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "total_amount_quote"
+ await _bg_show_amount_step(update, context, target_message_id=message_id)
+ return
+
+ elif step == "leverage":
+ try:
+ val = int(float(user_input.strip().lower().replace("x", "")))
+ config["leverage"] = val
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "position_mode"
+ await _bg_show_position_mode_step(update, context, target_message_id=message_id)
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:bg_back_to_leverage")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=r"*📊 Bollinger Grid*" + "\n\n"
+ r"❌ *Invalid leverage*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 20\):",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["bg_wizard_step"] = "final"
+
+ pair = config.get("trading_pair", "")
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=r"*📊 Bollinger Grid*" + "\n\n"
+ f"⏳ Loading market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+ else:
+ tmp_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=r"*📊 Bollinger Grid*" + "\n\n"
+ f"⏳ Loading market data for `{escape_markdown_v2(pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ context.user_data["bg_wizard_message_id"] = tmp_msg.message_id
+
+ await _bg_show_final_step(update, context)
+
+ except ValueError:
+ keyboard = [[InlineKeyboardButton("❌ Cancel", callback_data="bots:bg_back_to_amount")]]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id, message_id=message_id,
+ text=r"*📊 Bollinger Grid*" + "\n\n"
+ r"❌ *Invalid amount*" + "\n\n"
+ r"Enter a positive number \(e\.g\. 500\):",
+ parse_mode="MarkdownV2", reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "final":
+ if "=" in user_input:
+ supported_fields = [
+ "total_amount_quote", "leverage", "position_mode", "interval",
+ "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "grid_start_price_coefficient", "grid_end_price_coefficient",
+ "grid_limit_price_coefficient", "min_spread_between_orders",
+ "order_frequency", "max_orders_per_batch", "min_order_amount_quote",
+ "max_open_orders", "stop_loss", "take_profit",
+ "trailing_stop_activation", "trailing_stop_delta"
+ ]
+
+ for line in user_input.strip().split("\n"):
+ if "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+
+ if field not in supported_fields:
+ continue
+
+ try:
+ if field in ("total_amount_quote", "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "grid_start_price_coefficient", "grid_end_price_coefficient",
+ "grid_limit_price_coefficient", "min_spread_between_orders",
+ "min_order_amount_quote", "stop_loss", "take_profit",
+ "trailing_stop_activation", "trailing_stop_delta"):
+ config[field] = float(value)
+ elif field in ("leverage", "bb_length", "order_frequency", "max_orders_per_batch",
+ "max_open_orders"):
+ config[field] = int(float(value))
+ elif field == "interval":
+ config["interval"] = value
+ context.user_data.pop("bg_candles", None)
+ context.user_data["bg_chart_interval"] = value
+ elif field == "position_mode":
+ config["position_mode"] = value.upper()
+ else:
+ config[field] = value
+ except Exception:
+ pass
+
+ set_controller_config(context, config)
+ await _bg_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"BG wizard input error: {e}", exc_info=True)
+
+
+# ============================================
+# QUANTUM GRID ALLOCATOR WIZARD (qga)
+# ============================================
+# Steps: connector → quote_asset → portfolio_allocation → grid_params → amount → final
+# Prefisso handler: qga_
+
+async def show_new_quantum_grid_allocator_form(update, context) -> None:
+ """Start the Quantum Grid Allocator wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # RESETTA LE ALLOCAZIONI
+ config = get_controller_config(context)
+ if config:
+ config["portfolio_allocation"] = {}
+ set_controller_config(context, config)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "quantum_grid_allocator")
+
+ # Resetta anche qui per sicurezza
+ config = get_controller_config(context)
+ if config:
+ config["portfolio_allocation"] = {}
+ set_controller_config(context, config)
+
+ context.user_data["bots_state"] = "qga_wizard"
+ context.user_data["qga_wizard_step"] = "connector_name"
+ context.user_data["qga_wizard_message_id"] = query.message.message_id
+ context.user_data["qga_wizard_chat_id"] = query.message.chat_id
+
+ await _qga_show_connector_step(update, context)
+async def _qga_show_connector_step(update, context) -> None:
+ """QGA Step 1: Select Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*⚡ Quantum Grid Allocator \- New Config*" + "\n\n"
+ r"⚠️ No CEX connectors available\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(
+ f"🏦 {connector}", callback_data=f"bots:qga_connector:{connector}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*⚡ Quantum Grid Allocator*" + "\n\n"
+ r"Portfolio rebalancing strategy that automatically allocates capital "
+ r"across multiple assets based on deviation from target allocations\. "
+ r"Uses grid trading to rebalance when deviations exceed thresholds\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select Exchange*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"QGA connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_qga_wizard_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["qga_wizard_step"] = "quote_asset"
+ await _qga_show_quote_asset_step(update, context)
+
+
+async def _qga_show_quote_asset_step(update, context) -> None:
+ """QGA Step 2: Select Quote Asset"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "qga_wizard_input"
+ context.user_data["qga_wizard_step"] = "quote_asset"
+
+ quote_options = ["USDT", "FDUSD", "USDC", "BUSD"]
+
+ keyboard = []
+ row = []
+ for quote in quote_options:
+ row.append(InlineKeyboardButton(quote, callback_data=f"bots:qga_quote:{quote}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_header = escape_markdown_v2("⚡ Quantum Grid Allocator - Step 2/5")
+
+ message_text = (
+ f"*{escaped_header}*\n\n"
+ f"🏦 `{escaped_connector}`\n\n"
+ f"💰 *Quote Asset*\n\n"
+ f"Select the quote currency for trading pairs:"
+ )
+
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = query.message.message_id
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = new_msg.message_id
+ context.user_data["qga_wizard_chat_id"] = query.message.chat_id
+
+async def handle_qga_wizard_quote(update, context, quote: str) -> None:
+ config = get_controller_config(context)
+ config["quote_asset"] = quote
+ set_controller_config(context, config)
+ context.user_data["qga_wizard_step"] = "portfolio_allocation"
+ await _qga_show_portfolio_step(update, context)
+
+async def _qga_show_portfolio_step(update, context, target_message_id: int = None) -> None:
+ """QGA Step 3: Configure Portfolio Allocation (solo input manuale)"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ quote = config.get("quote_asset", "FDUSD")
+
+ if not query and not use_message_id:
+ logger.error("_qga_show_portfolio_step called without callback_query and without target_message_id")
+ return
+
+ context.user_data["bots_state"] = "qga_wizard_input"
+ context.user_data["qga_wizard_step"] = "portfolio_allocation"
+
+ # Ottieni l'allocazione corrente
+ current_alloc = config.get("portfolio_allocation", {})
+ if isinstance(current_alloc, str):
+ import json
+ try:
+ current_alloc = json.loads(current_alloc)
+ except:
+ current_alloc = {}
+
+ total_pct = sum(current_alloc.values())
+ remaining_pct = 1 - total_pct
+
+ # Costruisci il testo dell'allocazione corrente
+ alloc_lines = []
+ for asset, pct in current_alloc.items():
+ pct_val = float(pct) if not isinstance(pct, float) else pct
+ alloc_lines.append(f" • {asset}: {pct_val*100:.0f}%")
+
+ # Mostra USDT solo se remaining > 0 (non mostrare 0%)
+ if remaining_pct > 0.01: # più dello 0.5%
+ alloc_lines.append(f" • {quote}: {remaining_pct*100:.0f}% (remaining)")
+ elif remaining_pct > 0:
+ alloc_lines.append(f" • {quote}: {remaining_pct*100:.1f}% (remaining)")
+ # Se remaining_pct <= 0, non mostrare USDT
+
+ current_text = "\n".join(alloc_lines) if alloc_lines else " • None"
+
+ # Escapa TUTTO il current_text
+ current_text_escaped = escape_markdown_v2(current_text)
+
+ # Costruisci i bottoni
+ keyboard = []
+
+ if total_pct >= 0.99: # Se abbiamo raggiunto ~100%
+ keyboard.append([InlineKeyboardButton("✅ Next", callback_data="bots:qga_next")])
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_quote"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_header = escape_markdown_v2("⚡ Quantum Grid Allocator - Step 3/5")
+ escaped_quote = escape_markdown_v2(quote)
+
+ # Mostra remaining solo se > 0
+ remaining_text = ""
+ if remaining_pct > 0:
+ escaped_remaining = escape_markdown_v2(f"{remaining_pct*100:.0f}%")
+ remaining_text = f"Remaining: {escaped_remaining}\n\n"
+
+ message_text = (
+ f"*{escaped_header}*\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_quote}`\n\n"
+ "*📊 Portfolio Allocation*\n\n"
+ f"Current allocation:\n{current_text_escaped}\n\n"
+ f"{remaining_text}"
+ "_Type: ASSET:PCT \\(e\\.g\\.\\, SOL:0\\.5 for 50%\\)_"
+ )
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ )
+ context.user_data["qga_wizard_message_id"] = query.message.message_id
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("qga_wizard_chat_id", update.effective_chat.id)
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ )
+ except Exception:
+ try:
+ await context.bot.delete_message(chat_id=wizard_chat_id, message_id=use_message_id)
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ )
+ context.user_data["qga_wizard_message_id"] = new_msg.message_id
+ context.user_data["qga_wizard_chat_id"] = wizard_chat_id
+
+
+async def handle_qga_add_asset(update, context, asset: str, allocation: float) -> None:
+ """Add asset to portfolio allocation"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ portfolio = config.get("portfolio_allocation", {})
+ if isinstance(portfolio, str):
+ portfolio = {}
+
+ portfolio[asset] = allocation
+ config["portfolio_allocation"] = portfolio
+ set_controller_config(context, config)
+
+ await query.answer(f"Added {asset} with {allocation*100:.0f}%")
+ await _qga_show_portfolio_step(update, context)
+
+
+async def handle_qga_alloc_next(update, context) -> None:
+ """Go to next step after portfolio allocation"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ portfolio = config.get("portfolio_allocation", {})
+ if not portfolio:
+ await query.answer("Please add at least one asset", show_alert=True)
+ return
+
+ total = sum(portfolio.values())
+ if total >= 1.0:
+ await query.answer(f"Total allocation {total*100:.0f}% must be less than 100%", show_alert=True)
+ return
+
+ context.user_data["qga_wizard_step"] = "grid_params"
+ await _qga_show_grid_params_step(update, context)
+
+async def handle_qga_amount(update, context) -> None:
+ """Passa allo step dell'amount"""
+ query = update.callback_query
+ await query.answer()
+
+ config = get_controller_config(context)
+
+ # Verifica che l'allocazione sia completa
+ portfolio = config.get("portfolio_allocation", {})
+ if isinstance(portfolio, str):
+ import json
+ try:
+ portfolio = json.loads(portfolio)
+ except:
+ portfolio = {}
+
+ total_pct = sum(portfolio.values())
+ if total_pct < 0.99:
+ remaining = 1.0 - total_pct
+ await query.answer(f"Allocation only {total_pct*100:.0f}%. Add {remaining*100:.0f}% more to continue.", show_alert=True)
+ return
+
+ context.user_data["qga_wizard_step"] = "total_amount_quote"
+ await _qga_show_amount_step(update, context)
+
+async def _qga_show_grid_params_step(update, context) -> None:
+ """QGA Step 4: Configure Grid Parameters"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ quote = config.get("quote_asset", "FDUSD")
+
+ context.user_data["bots_state"] = "qga_wizard_input"
+ context.user_data["qga_wizard_step"] = "grid_params"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("✅ Next", callback_data="bots:qga_amount"),
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_portfolio"),
+ ],
+ [InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")],
+ ]
+
+ # Get current values
+ base_grid_pct = config.get("base_grid_value_pct", 0.08) * 100
+ max_grid_pct = config.get("max_grid_value_pct", 0.15) * 100
+ grid_range = config.get("grid_range", 0.002) * 100
+ tp_sl_ratio = config.get("tp_sl_ratio", 0.8)
+ min_order = config.get("min_order_amount", 5)
+ max_open = config.get("max_open_orders", 2)
+ long_only = config.get("long_only_threshold", 0.2) * 100
+ short_only = config.get("short_only_threshold", 0.2) * 100
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_quote = escape_markdown_v2(quote)
+
+ message_text = (
+ f"*{escape_markdown_v2('⚡ Quantum Grid Allocator - Step 4/5')}*\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_quote}`\n\n"
+ r"⚙️ *Grid Parameters*" + "\n\n"
+ f"`base_grid_value_pct={base_grid_pct:.0f}%`\n"
+ f"`max_grid_value_pct={max_grid_pct:.0f}%`\n"
+ f"`grid_range={grid_range:.2f}%`\n"
+ f"`tp_sl_ratio={tp_sl_ratio}`\n"
+ f"`long_only_threshold={long_only:.0f}%`\n"
+ f"`short_only_threshold={short_only:.0f}%`\n"
+ f"`min_order_amount={min_order}`\n"
+ f"`max_open_orders={max_open}`\n\n"
+ r"_Edit: `field=value` \(e\.g\. `base_grid_value_pct=0.1`\)_"
+ )
+ try:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = query.message.message_id
+ except Exception:
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = new_msg.message_id
+ context.user_data["qga_wizard_chat_id"] = query.message.chat_id
+
+
+async def handle_qga_amount_step(update, context) -> None:
+ """Go to amount step"""
+ context.user_data["qga_wizard_step"] = "total_amount_quote"
+ await _qga_show_amount_step(update, context)
+
+async def _qga_show_amount_step(update, context, target_message_id: int = None) -> None:
+ """QGA Step 4: Enter Total Amount"""
+ query = update.callback_query
+ use_message_id = target_message_id
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ quote = config.get("quote_asset", "FDUSD")
+
+ if not query and not use_message_id:
+ logger.error("_qga_show_amount_step called without callback_query and without target_message_id")
+ return
+
+ context.user_data["bots_state"] = "qga_wizard_input"
+ context.user_data["qga_wizard_step"] = "total_amount_quote"
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for Quantum Grid Allocator amount step: {e}")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$1000", callback_data="bots:qga_amount:1000"),
+ InlineKeyboardButton("$5000", callback_data="bots:qga_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:qga_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("$25000", callback_data="bots:qga_amount:25000"),
+ InlineKeyboardButton("$50000", callback_data="bots:qga_amount:50000"),
+ InlineKeyboardButton("$100000", callback_data="bots:qga_amount:100000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_portfolio"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_quote = escape_markdown_v2(quote)
+ escaped_header = escape_markdown_v2("⚡ Quantum Grid Allocator - Step 4/5")
+
+ message_text = (
+ f"*{escaped_header}*\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_quote}`" + balance_text + "\n\n"
+ "*💰 Total Portfolio Value*\n"
+ "_Select or type an amount \\(will be split across assets\\):_"
+ )
+
+ if query and query.message:
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = query.message.message_id
+ elif use_message_id:
+ wizard_chat_id = context.user_data.get("qga_wizard_chat_id", chat_id)
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=use_message_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception:
+ try:
+ await context.bot.delete_message(chat_id=wizard_chat_id, message_id=use_message_id)
+ except Exception:
+ pass
+ new_msg = await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = new_msg.message_id
+ context.user_data["qga_wizard_chat_id"] = wizard_chat_id
+
+async def handle_qga_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ await query.message.edit_text(
+ r"*⚡ Quantum Grid Allocator \- New Config*" + "\n\n"
+ r"⏳ *Generating portfolio configuration\.\.\.*" + "\n\n"
+ r"_Building allocation and grid parameters\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["qga_wizard_step"] = "final"
+ await _qga_show_final_step(update, context)
+
+
+async def _qga_show_final_step(update, context, interval: str = None) -> None:
+ """QGA Final Step: Config Summary"""
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ quote = config.get("quote_asset", "FDUSD")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+ position_mode = config.get("position_mode", "HEDGE")
+ bb_length = config.get("bb_length", 100)
+ bb_std_dev = config.get("bb_std_dev", 2.0)
+ interval = config.get("interval", "1s")
+
+ # Portfolio allocation
+ portfolio = config.get("portfolio_allocation", {})
+ if isinstance(portfolio, str):
+ import json
+ try:
+ portfolio = json.loads(portfolio)
+ except:
+ portfolio = {}
+
+ # Build portfolio string
+ portfolio_lines = []
+ total_pct = 0
+ for asset, pct in portfolio.items():
+ pct_float = float(pct) if not isinstance(pct, float) else pct
+ if pct_float > 0:
+ total_pct += pct_float
+ portfolio_lines.append(f" • {asset}: {pct_float*100:.0f}%")
+ # Non mostrare USDT:0%
+
+ portfolio_str = "\n".join(portfolio_lines) if portfolio_lines else " • None"
+
+ # Generate ID if not set
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ from .controllers.quantum_grid_allocator import generate_id as qga_generate_id
+ config["id"] = qga_generate_id(config, existing_configs)
+
+ context.user_data["bots_state"] = "qga_wizard_input"
+ context.user_data["qga_wizard_step"] = "final"
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:qga_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:qga_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # Escapa i valori per MarkdownV2
+ escaped_title = escape_markdown_v2(f"⚡ Quantum Grid Allocator - Step 5/5 (Final)")
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_quote = escape_markdown_v2(quote)
+ escaped_position_mode = escape_markdown_v2(position_mode)
+ escaped_interval = escape_markdown_v2(interval)
+
+ config_text = (
+ f"*{escaped_title}*\n\n"
+ f"*Portfolio Allocation:*\n{portfolio_str}\n\n"
+ f"`connector_name={escaped_connector}`\n"
+ f"`quote_asset={escaped_quote}`\n"
+ f"`total_amount_quote={total_amount:.0f}`\n"
+ f"`leverage={leverage}`\n"
+ f"`position_mode={escaped_position_mode}`\n"
+ f"`base_grid_value_pct={config.get('base_grid_value_pct', 0.08)}`\n"
+ f"`max_grid_value_pct={config.get('max_grid_value_pct', 0.15)}`\n"
+ f"`grid_range={config.get('grid_range', 0.002)}`\n"
+ f"`tp_sl_ratio={config.get('tp_sl_ratio', 0.8)}`\n"
+ f"`long_only_threshold={config.get('long_only_threshold', 0.2)}`\n"
+ f"`short_only_threshold={config.get('short_only_threshold', 0.2)}`\n"
+ f"`hedge_ratio={config.get('hedge_ratio', 2)}`\n"
+ f"`min_order_amount={config.get('min_order_amount', 5)}`\n"
+ f"`max_open_orders={config.get('max_open_orders', 2)}`\n"
+ f"`max_deviation={config.get('max_deviation', 0.05)}`\n"
+ f"`bb_length={bb_length}`\n"
+ f"`bb_std_dev={bb_std_dev}`\n"
+ f"`interval={escaped_interval}`\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = msg.message_id
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["qga_wizard_message_id"] = new_msg.message_id
+
+
+async def handle_qga_save(update, context) -> None:
+ """Save Quantum Grid Allocator configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Convert portfolio allocation from dict to string if needed
+ portfolio = config.get("portfolio_allocation", {})
+ if isinstance(portfolio, dict):
+ import json
+ config["portfolio_allocation"] = json.dumps(portfolio)
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["qga_wizard_step", "qga_wizard_message_id", "qga_wizard_chat_id",
+ "qga_current_price", "qga_candles", "qga_candles_interval"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_quantum_grid_allocator")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"QGA save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:qga_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+# Back handlers
+async def handle_qga_back_to_connector(update, context) -> None:
+ context.user_data["qga_wizard_step"] = "connector_name"
+ await _qga_show_connector_step(update, context)
+
+async def handle_qga_back_to_quote(update, context) -> None:
+ """Torna allo step quote asset e resetta le allocazioni"""
+ query = update.callback_query
+ await query.answer()
+
+ config = get_controller_config(context)
+
+ # Resetta le allocazioni
+ config["portfolio_allocation"] = {}
+ set_controller_config(context, config)
+
+ # Resetta lo step
+ context.user_data["qga_wizard_step"] = "quote_asset"
+
+ # Mostra lo step quote asset
+ await _qga_show_quote_asset_step(update, context)
+
+async def handle_qga_back_to_portfolio(update, context) -> None:
+ """Torna allo step di portfolio allocation"""
+ query = update.callback_query
+ await query.answer()
+
+ # Reset dello stato
+ context.user_data["bots_state"] = "qga_wizard_input"
+
+ # Torna allo step portfolio
+ await _qga_show_portfolio_step(update, context)
+
+
+async def handle_qga_back_to_grid_params(update, context) -> None:
+ context.user_data["qga_wizard_step"] = "grid_params"
+ await _qga_show_grid_params_step(update, context)
+
+
+async def handle_qga_back_to_amount(update, context) -> None:
+ context.user_data["qga_wizard_step"] = "total_amount_quote"
+ await _qga_show_amount_step(update, context)
+
+async def handle_qga_add_prompt(update, context) -> None:
+
+ query = update.callback_query
+ await query.answer()
+ context.user_data["bots_state"] = "qga_waiting_for_asset"
+ current_msg_id = context.user_data.get("qga_wizard_message_id")
+ wizard_chat_id = context.user_data.get("qga_wizard_chat_id", update.effective_chat.id)
+ escaped_title = escape_markdown_v2("⚡ Add Asset")
+
+ message_text = (
+ f"*{escaped_title}*\n\n"
+ "Type the asset and percentage in the format:\n"
+ "`ASSET:percentage`\n\n"
+ "*Examples:*\n"
+ "• `SOL:0\\.5` \\(50%\\)\n"
+ "• `BTC:0\\.3` \\(30%\\)\n\n"
+ "_Total cannot exceed 100%_"
+ )
+
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:qga_back_to_portfolio")
+ ]]),
+ )
+
+async def process_qga_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Quantum Grid Allocator wizard"""
+ step = context.user_data.get("qga_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("qga_wizard_message_id")
+ wizard_chat_id = context.user_data.get("qga_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "portfolio_allocation":
+ # Gestisci input diretto per aggiungere asset
+ user_input = user_input.strip().upper()
+ if ":" in user_input:
+ asset, pct = user_input.split(":", 1)
+ try:
+ pct_float = float(pct.replace("%", ""))
+ if pct_float > 1:
+ pct_float = pct_float / 100
+
+ if pct_float <= 0:
+ await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text="❌ Percentage must be positive."
+ )
+ await _qga_show_portfolio_step(update, context, target_message_id=message_id)
+ return
+
+ portfolio = config.get("portfolio_allocation", {})
+ if isinstance(portfolio, str):
+ import json
+ try:
+ portfolio = json.loads(portfolio)
+ except:
+ portfolio = {}
+
+ current_total = sum(portfolio.values())
+ if current_total + pct_float > 1.0:
+ remaining = 1.0 - current_total
+ await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text=f"❌ Cannot add {pct_float*100:.0f}%. Only {remaining*100:.0f}% remaining."
+ )
+ await _qga_show_portfolio_step(update, context, target_message_id=message_id)
+ return
+
+ portfolio[asset] = pct_float
+ config["portfolio_allocation"] = portfolio
+ set_controller_config(context, config)
+
+ await _qga_show_portfolio_step(update, context, target_message_id=message_id)
+
+ except ValueError:
+ await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text="❌ Invalid format. Use ASSET:percentage (e.g., SOL:0.5)"
+ )
+ await _qga_show_portfolio_step(update, context, target_message_id=message_id)
+ else:
+ await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text="❌ Invalid format. Use ASSET:percentage (e.g., SOL:0.5)"
+ )
+ await _qga_show_portfolio_step(update, context, target_message_id=message_id)
+ return
+
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["qga_wizard_step"] = "final"
+ await _qga_show_final_step(update, context)
+ except ValueError:
+ await context.bot.send_message(
+ chat_id=wizard_chat_id,
+ text="❌ Invalid amount. Please enter a number (e.g., 5000)"
+ )
+
+ except Exception as e:
+ logger.error(f"QGA wizard input error: {e}", exc_info=True)
+
+
+
+# ============================================
+# STAT ARB V2 WIZARD (versione con asset base + quote assets)
+# ============================================
+
+async def show_new_stat_arb_v2_form(update, context) -> None:
+ """Start the Stat Arb V2 wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Clear cached data
+ for key in ["stat_arb_current_price_dom", "stat_arb_current_price_hedge",
+ "stat_arb_candles_dom", "stat_arb_candles_hedge", "stat_arb_candles_interval",
+ "stat_arb_analysis"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "stat_arb_v2")
+ context.user_data["bots_state"] = "stat_arb_wizard"
+ context.user_data["stat_arb_wizard_step"] = "connector_name"
+ context.user_data["stat_arb_wizard_message_id"] = query.message.message_id
+ context.user_data["stat_arb_wizard_chat_id"] = query.message.chat_id
+
+ await _stat_arb_show_connector_step(update, context)
+
+async def _stat_arb_show_pair_dom_step(update, context) -> None:
+ """Step 2: Dominant trading pair (legacy, non più usato)"""
+ # Reindirizza al nuovo step
+ await _stat_arb_show_base_asset_step(update, context)
+
+
+async def _stat_arb_show_pair_hedge_step(update, context) -> None:
+ """Step 3: Hedge trading pair (legacy, non più usato)"""
+ # Reindirizza al nuovo step
+ await _stat_arb_show_quote_asset_1_step(update, context)
+
+
+async def _stat_arb_show_connector_step(update, context) -> None:
+ """Step 1: Select Connector (unico exchange)"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ try:
+ client, server_name = await get_bots_client(chat_id, context.user_data)
+ cex_connectors = await get_available_cex_connectors(
+ context.user_data, client, server_name=server_name
+ )
+
+ if not cex_connectors:
+ keyboard = [
+ [InlineKeyboardButton("🔑 Configure API Keys", callback_data="config_api_keys")],
+ [InlineKeyboardButton("« Back", callback_data="bots:main_menu")],
+ ]
+ await query.message.edit_text(
+ r"*📊 Stat Arb V2 \- New Config*" + "\n\n"
+ r"⚠️ No CEX connectors available\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ keyboard = []
+ row = []
+ for connector in cex_connectors:
+ row.append(InlineKeyboardButton(f"🏦 {connector}", callback_data=f"bots:stat_arb_connector:{connector}"))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*📊 Stat Arb V2*" + "\n\n"
+ r"Statistical arbitrage between the same base asset quoted in two different currencies\." + "\n\n"
+ r"Example: KCS quoted in USDT vs KCS quoted in BTC" + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select Exchange*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"Stat Arb connector step error: {e}", exc_info=True)
+ keyboard = [[InlineKeyboardButton("Back", callback_data="bots:main_menu")]]
+ await query.message.edit_text(
+ format_error_message(f"Error: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_stat_arb_wizard_connector(update, context, connector: str) -> None:
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "base_asset"
+ await _stat_arb_show_base_asset_step(update, context)
+
+
+async def _stat_arb_show_base_asset_step(update, context) -> None:
+ """Step 2: Select Base Asset (the common token) - input diretto in chat"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ context.user_data["bots_state"] = "stat_arb_wizard_input"
+ context.user_data["stat_arb_wizard_step"] = "base_asset"
+
+ # Recent base assets from existing configs
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ recent_assets = []
+ seen = set()
+ for cfg in reversed(existing_configs):
+ asset = cfg.get("base_asset", "")
+ if asset and asset not in seen:
+ seen.add(asset)
+ recent_assets.append(asset)
+ if len(recent_assets) >= 6:
+ break
+
+ keyboard = []
+
+ # Common base assets suggestions
+ common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"]
+ row = []
+ for asset in common_assets:
+ row.append(InlineKeyboardButton(asset, callback_data=f"bots:stat_arb_base_asset:{asset}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ if recent_assets:
+ keyboard.append([InlineKeyboardButton("— Recent —", callback_data="bots:noop")])
+ row = []
+ for asset in recent_assets[:6]:
+ row.append(InlineKeyboardButton(asset, callback_data=f"bots:stat_arb_base_asset:{asset}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ # Nessun pulsante "Type custom" - l'utente scrive direttamente in chat
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 2
+
+ await query.message.edit_text(
+ rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🪙 *Base Asset*" + "\n\n"
+ r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n"
+ r"*Select a button or type the asset name in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_stat_arb_base_asset(update, context, asset: str) -> None:
+ """Handle base asset selection - verifica subito le quote disponibili"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+
+ config["base_asset"] = asset.upper()
+ set_controller_config(context, config)
+
+ # Mostra messaggio di caricamento
+ await query.message.edit_text(
+ rf"*📊 Stat Arb V2*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(asset.upper())}`" + "\n\n"
+ r"⏳ *Checking available quote assets\.\.\.*" + "\n\n"
+ r"_Please wait, verifying which pairs have candle data_",
+ parse_mode="MarkdownV2",
+ )
+
+ # Ottieni e verifica le quote disponibili
+ valid_quotes = []
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ trading_rules = await get_trading_rules(context.user_data, client, connector)
+
+ prefix = f"{asset.upper()}-"
+ all_quotes = []
+
+ # Raccogli tutte le quote disponibili
+ for pair, rules in trading_rules.items():
+ if pair.startswith(prefix):
+ quote = pair.replace(prefix, "")
+ all_quotes.append(quote)
+
+ logger.info(f"All quotes for {asset} on {connector}: {all_quotes}")
+
+ # Verifica quali hanno dati candela
+ if all_quotes:
+ for quote in all_quotes:
+ pair = f"{asset.upper()}-{quote}"
+ try:
+ try:
+ test_candles = await asyncio.wait_for(
+ fetch_candles(client, connector, pair, interval="5m", max_records=420),
+ timeout=15.0
+ )
+ except (asyncio.TimeoutError, Exception):
+ test_candles = None
+ if test_candles:
+ valid_quotes.append(quote)
+ logger.info(f"✓ {pair} has candle data")
+ else:
+ logger.warning(f"✗ {pair} has NO candle data")
+ except Exception as e:
+ logger.warning(f"✗ {pair} error: {e}")
+
+ valid_quotes.sort()
+
+ # CONTROLLO: deve avere almeno 2 quote valide
+ if len(valid_quotes) < 2:
+ await query.message.edit_text(
+ text=rf"*📊 Stat Arb V2 \- Error*" + "\n\n"
+ f"❌ `{asset.upper()}` has only {len(valid_quotes)} valid quote(s)\\.\n\n"
+ r"Please choose a different base asset with at least 2 trading pairs\\.\n\n"
+ r"*Examples:* BTC, ETH, SOL, KCS",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset")
+ ]]),
+ )
+ return
+
+ # Salva le quote valide nel context per usarle dopo
+ context.user_data["stat_arb_valid_quotes"] = valid_quotes
+ logger.info(f"Valid quotes for {asset}: {valid_quotes}")
+
+ except Exception as e:
+ logger.error(f"Error checking quotes: {e}")
+ valid_quotes = ["USDT", "USDC"] # fallback
+
+ # Se nessuna quota valida, usa fallback
+ if not valid_quotes:
+ valid_quotes = ["USDT", "USDC"]
+
+ # CONTROLLO PER IL FALLBACK
+ if len(valid_quotes) < 2:
+ await query.message.edit_text(
+ text=rf"*📊 Stat Arb V2 \- Error*" + "\n\n"
+ f"❌ `{asset.upper()}` has only {len(valid_quotes)} valid quote(s)\\.\n\n"
+ r"Please choose a different base asset with at least 2 trading pairs\\.\n\n"
+ r"*Examples:* BTC, ETH, SOL, KCS",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset")
+ ]]),
+ )
+ return
+
+ # Vai allo step successivo
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_1"
+ await _stat_arb_show_quote_asset_1_step(update, context)
+
+async def _stat_arb_show_quote_asset_1_step(update, context) -> None:
+ """Step 3: First Quote Asset"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ base_asset = config.get("base_asset", "")
+
+ # Recupera le quote già verificate
+ valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"])
+
+ context.user_data["bots_state"] = "stat_arb_wizard_input"
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_1"
+
+ keyboard = []
+ row = []
+ for quote in valid_quotes[:12]:
+ row.append(InlineKeyboardButton(quote, callback_data=f"bots:stat_arb_quote_1:{quote}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ # Nessun pulsante "Type custom" - l'utente scrive direttamente in chat
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 3
+
+ await query.message.edit_text(
+ rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ f"📈 *First Quote Asset*" + "\n\n"
+ rf"The quote currency for `{base_asset}-XXX`:" + "\n\n"
+ rf"*Available quotes with candle data:* {len(valid_quotes)} found" + "\n\n"
+ r"*Select a button or type the quote asset in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_stat_arb_quote_asset_1(update, context, quote: str) -> None:
+ """Handle first quote asset selection"""
+ config = get_controller_config(context)
+ base_asset = config.get("base_asset", "")
+ config["first_quote_asset"] = quote.upper()
+ config["trading_pair_dominant"] = f"{base_asset}-{quote.upper()}"
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_2"
+ await _stat_arb_show_quote_asset_2_step(update, context)
+
+async def _stat_arb_show_quote_asset_2_step(update, context) -> None:
+ """Step 4: Second Quote Asset"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ base_asset = config.get("base_asset", "")
+ first_quote = config.get("first_quote_asset", "")
+
+ valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"])
+ remaining_quotes = [q for q in valid_quotes if q != first_quote]
+
+ context.user_data["bots_state"] = "stat_arb_wizard_input"
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_2"
+
+ keyboard = []
+ row = []
+ for quote in remaining_quotes[:12]:
+ row.append(InlineKeyboardButton(quote, callback_data=f"bots:stat_arb_quote_2:{quote}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ # Nessun pulsante "Type custom" - l'utente scrive direttamente in chat
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 4
+
+ await query.message.edit_text(
+ rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ rf"*First quote selected:* `{first_quote}`" + "\n\n"
+ f"📉 *Second Quote Asset*" + "\n\n"
+ rf"The quote currency for `{base_asset}-XXX` \(different from {first_quote}\):" + "\n\n"
+ rf"*Available quotes:* {len(remaining_quotes)} found" + "\n\n"
+ r"*Select a button or type the quote asset in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_stat_arb_quote_asset_2(update, context, quote: str) -> None:
+ """Handle second quote asset selection"""
+ config = get_controller_config(context)
+ base_asset = config.get("base_asset", "")
+ config["second_quote_asset"] = quote.upper()
+ config["trading_pair_hedge"] = f"{base_asset}-{quote.upper()}"
+ set_controller_config(context, config)
+
+ connector = config.get("connector_name", "")
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+
+ if is_perp:
+ context.user_data["stat_arb_wizard_step"] = "leverage"
+ await _stat_arb_show_leverage_step(update, context)
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "HEDGE"
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "total_amount_quote"
+ await _stat_arb_show_amount_step(update, context)
+
+async def _stat_arb_show_leverage_step(update, context) -> None:
+ """Step 5 (perp only): Leverage"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+
+ context.user_data["bots_state"] = "stat_arb_wizard_input"
+ context.user_data["stat_arb_wizard_step"] = "leverage"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:stat_arb_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:stat_arb_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:stat_arb_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:stat_arb_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:stat_arb_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:stat_arb_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 5 if is_perp else 5
+
+ await query.message.edit_text(
+ rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(config.get('base_asset', ''))}`" + "\n\n"
+ f"📈 *First pair:*`{escape_markdown_v2(dom_pair)}`" + "\n"
+ f"📉 *Second pair:*`{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+async def handle_stat_arb_wizard_leverage(update, context, leverage: int) -> None:
+ config = get_controller_config(context)
+ config["leverage"] = leverage
+ config["position_mode"] = "HEDGE"
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "total_amount_quote"
+ await _stat_arb_show_amount_step(update, context)
+
+
+async def _stat_arb_show_amount_step(update, context) -> None:
+ """Step 6: Total Amount Quote"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+ leverage = config.get("leverage", 1)
+
+ context.user_data["bots_state"] = "stat_arb_wizard_input"
+ context.user_data["stat_arb_wizard_step"] = "total_amount_quote"
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 6 if is_perp else 5
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for Stat Arb amount step: {e}")
+
+ # CORREZIONE: back corretto per spot
+ back_callback = "bots:stat_arb_back_to_leverage" if is_perp else "bots:stat_arb_back_to_quote_2"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(config.get('base_asset', ''))}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x`"
+
+ message_text = (
+ rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + balance_text + "\n\n"
+ f"📈 `{escape_markdown_v2(dom_pair)}` vs 📉 `{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"_Select or type an amount \(will be split between legs via hedge ratio\):_"
+ )
+
+ await query.message.edit_text(
+ message_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["stat_arb_wizard_message_id"] = query.message.message_id
+
+
+async def handle_stat_arb_wizard_amount(update, context, amount: float) -> None:
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ await query.message.edit_text(
+ r"*📊 Stat Arb V2 \- New Config*" + "\n\n"
+ r"⏳ *Loading cointegration analysis\.\.\.*" + "\n\n"
+ r"_Fetching market data and computing spread\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["stat_arb_wizard_step"] = "final"
+ await _stat_arb_show_final_step(update, context)
+
+async def _stat_arb_show_final_step(update, context, interval: str = None) -> None:
+ """Final Step: Show analysis + config summary with suggested parameters"""
+ import asyncio
+ from .controllers.stat_arb_v2 import StatArbV2Controller
+ from .controllers.stat_arb_v2.config import generate_id as stat_arb_generate_id
+ from .controllers.stat_arb_v2.analysis import analyze_candles_for_stat_arb, format_stat_arb_summary
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ leverage = config.get("leverage", 1)
+
+ if interval is None:
+ interval = context.user_data.get("stat_arb_chart_interval", config.get("interval", "5m"))
+ context.user_data["stat_arb_chart_interval"] = interval
+ config["interval"] = interval
+ set_controller_config(context, config)
+
+ # Mostra messaggio di caricamento
+ try:
+ await msg.edit_text(
+ r"*📊 Stat Arb V2 \- New Config*" + "\n\n"
+ f"⏳ Fetching market data for `{escape_markdown_v2(dom_pair)}` and `{escape_markdown_v2(hedge_pair)}`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+
+ # Inizializza variabili per i dati
+ dom_list = []
+ hedge_list = []
+ analysis = {}
+ combined_data = []
+
+ # Fetch candles for both pairs
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+
+ if not candles_connector or len(candles_connector) < 3:
+ candles_connector = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ if not candles_connector:
+ candles_connector = "kucoin"
+
+ logger.info(f"Stat Arb: Trading on {connector}, using candles from {candles_connector}")
+
+ # Prova entrambi i formati per la dominant pair
+ dom_variants = [dom_pair, dom_pair.replace("-", "/")]
+ candles_dom = None
+ for variant in dom_variants:
+ try:
+ logger.info(f"Trying to fetch candles for {variant} from {candles_connector}")
+ candles_dom = await asyncio.wait_for(
+ fetch_candles(client, candles_connector, variant, interval=interval, max_records=420),
+ timeout=15.0
+ )
+ if candles_dom:
+ candles_data = candles_dom.get("data", []) if isinstance(candles_dom, dict) else (candles_dom or [])
+ if candles_data and len(candles_data) > 0:
+ logger.info(f"Successfully fetched {len(candles_data)} candles for {variant}")
+ break
+ else:
+ candles_dom = None
+ else:
+ candles_dom = None
+ except asyncio.TimeoutError:
+ logger.warning(f"Timeout fetching {variant}")
+ candles_dom = None
+ except Exception as e:
+ logger.warning(f"Failed to fetch {variant}: {e}")
+ candles_dom = None
+
+ # Prova entrambi i formati per la hedge pair
+ hedge_variants = [hedge_pair, hedge_pair.replace("-", "/")]
+ candles_hedge = None
+ for variant in hedge_variants:
+ try:
+ logger.info(f"Trying to fetch candles for {variant} from {candles_connector}")
+ candles_hedge = await asyncio.wait_for(
+ fetch_candles(client, candles_connector, variant, interval=interval, max_records=420),
+ timeout=15.0
+ )
+ if candles_hedge:
+ candles_data = candles_hedge.get("data", []) if isinstance(candles_hedge, dict) else (candles_hedge or [])
+ if candles_data and len(candles_data) > 0:
+ logger.info(f"Successfully fetched {len(candles_data)} candles for {variant}")
+ break
+ else:
+ candles_hedge = None
+ else:
+ candles_hedge = None
+ except asyncio.TimeoutError:
+ logger.warning(f"Timeout fetching {variant}")
+ candles_hedge = None
+ except Exception as e:
+ logger.warning(f"Failed to fetch {variant}: {e}")
+ candles_hedge = None
+
+ if not candles_dom or not candles_hedge:
+ missing = []
+ if not candles_dom:
+ missing.append(f"{dom_pair}")
+ if not candles_hedge:
+ missing.append(f"{hedge_pair}")
+ logger.warning(f"Could not fetch candles for: {', '.join(missing)}")
+ analysis = {"error": f"Could not fetch candles for: {', '.join(missing)}"}
+ else:
+ dom_list = candles_dom.get("data", []) if isinstance(candles_dom, dict) else (candles_dom or [])
+ hedge_list = candles_hedge.get("data", []) if isinstance(candles_hedge, dict) else (candles_hedge or [])
+
+ logger.info(f"Stat Arb: Loaded {len(dom_list)} candles for {dom_pair}, {len(hedge_list)} candles for {hedge_pair}")
+
+ # COMBINA I DATI PER IL CHART
+ # Allinea le due serie per timestamp
+ dom_by_ts = {c.get('timestamp'): c for c in dom_list}
+ hedge_by_ts = {c.get('timestamp'): c for c in hedge_list}
+
+ # Timestamp comuni
+ common_timestamps = set(dom_by_ts.keys()) & set(hedge_by_ts.keys())
+ common_timestamps = sorted(list(common_timestamps))
+ # Se l'intervallo è 15m, limita a 24 ore (96 candele)
+ current_interval = config.get("interval", "5m")
+ max_candles = None
+ if current_interval == "15m":
+ max_candles = 96 # 24 ore * 4 candele/ora = 96
+ elif current_interval == "1h":
+ max_candles = 168 # 7 giorni (opzionale)
+ # Aggiungi altri limiti se necessario
+
+ if max_candles and len(common_timestamps) > max_candles:
+ # Prendi le ultime N candele
+ common_timestamps = common_timestamps[-max_candles:]
+ logger.info(f"Limited chart to last {max_candles} candles ({len(common_timestamps)}) for interval {current_interval}")
+ for ts in common_timestamps:
+ dom_candle = dom_by_ts[ts]
+ hedge_candle = hedge_by_ts[ts]
+ combined_data.append({
+ 'timestamp': ts,
+ 'close_dom': float(dom_candle.get('close', 0)),
+ 'close_hedge': float(hedge_candle.get('close', 0)),
+ 'open_dom': float(dom_candle.get('open', 0)),
+ 'open_hedge': float(hedge_candle.get('open', 0)),
+ 'high_dom': float(dom_candle.get('high', 0)),
+ 'high_hedge': float(hedge_candle.get('high', 0)),
+ 'low_dom': float(dom_candle.get('low', 0)),
+ 'low_hedge': float(hedge_candle.get('low', 0)),
+ })
+
+ logger.info(f"Combined {len(combined_data)} candles for chart")
+ if len(combined_data) == 0:
+ logger.warning(f"No common timestamps found! dom_list samples: {len(dom_list)}, hedge_list samples: {len(hedge_list)}")
+ if dom_list:
+ logger.info(f"First dom timestamp: {dom_list[0].get('timestamp')}")
+ if hedge_list:
+ logger.info(f"First hedge timestamp: {hedge_list[0].get('timestamp')}")
+ # Run analysis
+ # Calcola lookback dinamico (usa il minimo tra lookback configurato e dati disponibili)
+ max_lookback = config.get("lookback_period", 100)
+ actual_lookback = min(max_lookback, len(dom_list), len(hedge_list))
+ # Assicura almeno 30 candele (se possibile)
+ if actual_lookback < 30 and len(dom_list) >= 30 and len(hedge_list) >= 30:
+ actual_lookback = 30
+
+ logger.info(f"Stat Arb: Using lookback={actual_lookback} candles (config={max_lookback}, dom={len(dom_list)}, hedge={len(hedge_list)})")
+
+ # Run analysis
+ analysis = analyze_candles_for_stat_arb(dom_list, hedge_list, lookback=actual_lookback)
+
+ if "error" not in analysis:
+ # Auto‑suggest parameters if not already set
+ if config.get("entry_threshold", 2.0) == 2.0:
+ config["entry_threshold"] = analysis.get("suggested_entry_threshold", 2.0)
+ if config.get("take_profit", 0.0008) == 0.0008:
+ config["take_profit"] = analysis.get("suggested_take_profit", 0.0008)
+
+ set_controller_config(context, config)
+
+ # Generate config ID if not set
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = stat_arb_generate_id(config, existing_configs)
+
+ context.user_data["stat_arb_analysis"] = analysis
+
+ except Exception as e:
+ logger.error(f"Stat Arb analysis failed: {e}", exc_info=True)
+ analysis = {"error": str(e)}
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ final_step = 7 if is_perp else 6
+
+ # Build interval buttons
+ interval_options = ["15m", "1h", "4h", "1d"]
+ interval_row = [
+ InlineKeyboardButton(
+ f"✓ {opt}" if opt == interval else opt,
+ callback_data=f"bots:stat_arb_interval:{opt}"
+ )
+ for opt in interval_options
+ ]
+
+ keyboard = [
+ interval_row,
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:stat_arb_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # Build config text (copyable)
+ config_block = (
+ f"id: {config.get('id', '')}\n"
+ f"connector_name: {connector}\n"
+ f"trading_pair_dominant: {dom_pair}\n"
+ f"trading_pair_hedge: {hedge_pair}\n"
+ f"leverage: {leverage}\n"
+ f"total_amount_quote: {total_amount:.0f}\n"
+ f"interval: {interval}\n"
+ f"lookback_period: {config.get('lookback_period', 300)}\n"
+ f"entry_threshold: {config.get('entry_threshold', 2.0)}\n"
+ f"take_profit: {config.get('take_profit', 0.0008)}\n"
+ f"tp_global: {config.get('tp_global', 0.01)}\n"
+ f"sl_global: {config.get('sl_global', 0.05)}\n"
+ f"min_amount_quote: {config.get('min_amount_quote', 10)}\n"
+ f"quoter_spread: {config.get('quoter_spread', 0.0001)}\n"
+ f"quoter_cooldown: {config.get('quoter_cooldown', 30)}\n"
+ f"quoter_refresh: {config.get('quoter_refresh', 10)}\n"
+ f"max_orders_placed_per_side: {config.get('max_orders_placed_per_side', 2)}\n"
+ f"max_orders_filled_per_side: {config.get('max_orders_filled_per_side', 2)}\n"
+ f"max_position_deviation: {config.get('max_position_deviation', 0.1)}\n"
+ f"use_dynamic_hedge_ratio: {config.get('use_dynamic_hedge_ratio', True)}\n"
+ f"pos_hedge_ratio: {config.get('pos_hedge_ratio', 1.0)}\n"
+ f"max_dynamic_hedge_ratio: {config.get('max_dynamic_hedge_ratio', 3.0)}\n"
+ f"min_dynamic_hedge_ratio: {config.get('min_dynamic_hedge_ratio', 0.2)}\n"
+ f"min_r_squared: {config.get('min_r_squared', 0.70)}\n"
+ f"adf_pvalue_threshold: {config.get('adf_pvalue_threshold', 0.05)}"
+ )
+
+ # Add analysis summary
+ analysis_text = format_stat_arb_summary(analysis) if "error" not in analysis else f"⚠️ Analysis error: {analysis.get('error', 'Unknown error')}"
+
+ escaped_pair = escape_markdown_v2(dom_pair)
+ config_text = (
+ rf"*📊 Stat Arb V2 \- Step {final_step}/{final_step} \(Final\)*" + "\n\n"
+ f"*{escaped_pair}* vs *{escape_markdown_v2(hedge_pair)}*\n\n"
+ f"```\n{config_block}\n```\n\n"
+ f"```\n{analysis_text}\n```\n\n"
+ r"_Edit: `field=value`_"
+ )
+
+ # Genera il chart con i dati combinati
+ try:
+ from .controllers.stat_arb_v2.chart import generate_chart as stat_arb_chart
+
+ # Usa i dati combinati se disponibili, altrimenti usa dom_list
+ chart_data = combined_data if combined_data else (dom_list if dom_list else [])
+
+ if chart_data and len(chart_data) > 0:
+ logger.info(f"Generating chart with {len(chart_data)} candles")
+ chart_bytes = stat_arb_chart(config, chart_data, current_price=0)
+ if chart_bytes:
+ try:
+ await msg.delete()
+ except Exception:
+ pass
+ new_msg = await context.bot.send_photo(
+ chat_id=chat_id,
+ photo=chart_bytes,
+ caption=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["stat_arb_wizard_message_id"] = new_msg.message_id
+ context.user_data["stat_arb_wizard_chat_id"] = chat_id
+ return
+ except Exception as e:
+ logger.warning(f"Chart generation failed: {e}")
+
+ # Fallback: send text only
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["stat_arb_wizard_message_id"] = msg.message_id
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["stat_arb_wizard_message_id"] = new_msg.message_id
+
+
+async def handle_stat_arb_save(update, context) -> None:
+ """Save Stat Arb V2 config"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ # Clean up temporary fields
+ config.pop("candles_config", None)
+ config.pop("manual_kill_switch", None)
+ config["position_mode"] = "HEDGE"
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ # Clean wizard state
+ for key in ["stat_arb_wizard_step", "stat_arb_wizard_message_id", "stat_arb_wizard_chat_id",
+ "stat_arb_current_price_dom", "stat_arb_current_price_hedge",
+ "stat_arb_candles_dom", "stat_arb_candles_hedge", "stat_arb_candles_interval",
+ "stat_arb_analysis"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_stat_arb_v2")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"Stat Arb save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:stat_arb_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_stat_arb_back_to_connector(update, context) -> None:
+ context.user_data["stat_arb_wizard_step"] = "connector_name"
+ await _stat_arb_show_connector_step(update, context)
+
+
+async def handle_stat_arb_back_to_pair_dom(update, context) -> None:
+ context.user_data["stat_arb_wizard_step"] = "trading_pair_dominant"
+ await _stat_arb_show_pair_dom_step(update, context)
+
+async def handle_stat_arb_back_to_leverage(update, context) -> None:
+ context.user_data["stat_arb_wizard_step"] = "leverage"
+ await _stat_arb_show_leverage_step(update, context)
+
+
+async def handle_stat_arb_back_to_amount(update, context) -> None:
+ context.user_data["stat_arb_wizard_step"] = "total_amount_quote"
+ await _stat_arb_show_amount_step(update, context)
+
+async def handle_stat_arb_back_to_base_asset(update, context) -> None:
+ context.user_data["stat_arb_wizard_step"] = "base_asset"
+ await _stat_arb_show_base_asset_step(update, context)
+
+async def handle_stat_arb_interval_change(update, context, interval: str) -> None:
+ """Change chart interval and refresh analysis"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Aggiorna l'intervallo
+ context.user_data["stat_arb_candles_dom"] = None
+ context.user_data["stat_arb_candles_hedge"] = None
+ context.user_data["stat_arb_candles_interval"] = interval
+ context.user_data["stat_arb_chart_interval"] = interval
+
+ # Non passare update, usa query per editare il messaggio corrente
+ # Crea un nuovo update fittizio con il messaggio corrente
+ fake_update = type('FakeUpdate', (), {
+ 'callback_query': query,
+ 'effective_chat': update.effective_chat
+ })()
+
+ await _stat_arb_show_final_step(fake_update, context, interval=interval)
+
+async def handle_stat_arb_back_to_quote_1(update, context) -> None:
+ """Go back to first quote asset step"""
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_1"
+ await _stat_arb_show_quote_asset_1_step(update, context)
+
+
+async def handle_stat_arb_back_to_quote_2(update, context) -> None:
+ """Go back to second quote asset step"""
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_2"
+ await _stat_arb_show_quote_asset_2_step(update, context)
+
+# Helper for pair suggestions
+async def _show_stat_arb_pair_suggestions(
+ update: Update,
+ context: ContextTypes.DEFAULT_TYPE,
+ input_pair: str,
+ error_msg: str,
+ suggestions: list,
+ connector: str,
+ role: str, # "dom" or "hedge"
+) -> None:
+ message_id = context.user_data.get("stat_arb_wizard_message_id")
+ chat_id = context.user_data.get("stat_arb_wizard_chat_id")
+ wizard_chat_id = context.user_data.get("stat_arb_wizard_chat_id", update.effective_chat.id)
+
+ help_text = f"❌ *{escape_markdown_v2(error_msg)}*\n\n"
+ if suggestions:
+ help_text += "💡 *Did you mean:*\n"
+ else:
+ help_text += "_No similar pairs found\\._\n"
+
+ keyboard = []
+ for pair in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(f"📈 {pair}", callback_data=f"bots:stat_arb_pair_{role}_select:{pair}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data=f"bots:stat_arb_back_to_pair_{role}")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ if message_id and chat_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=message_id,
+ text=help_text,
+ parse_mode="MarkdownV2",
+ reply_markup=reply_markup,
+ )
+ except Exception as e:
+ logger.debug(f"Could not update stat arb wizard message: {e}")
+ else:
+ await update.effective_chat.send_message(
+ help_text, parse_mode="MarkdownV2", reply_markup=reply_markup
+ )
+
+async def _show_stat_arb_pair_format_error(update, context, connector: str, role: str) -> None:
+ """Mostra errore per formato coppia non valido"""
+ message_id = context.user_data.get("stat_arb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("stat_arb_wizard_chat_id", update.effective_chat.id)
+
+ keyboard = [
+ [InlineKeyboardButton("⬅️ Back", callback_data=f"bots:stat_arb_back_to_pair_{role}")]
+ ]
+
+ role_label = "dominant" if role == "dom" else "hedge"
+
+ err_text = (
+ r"*📊 Stat Arb V2 \- Format Error*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`\n\n"
+ r"⚠️ *Invalid format\.* Use `BASE\-QUOTE` \(e\.g\. `BTC\-USDT`\)\n\n"
+ f"Please type the {role_label} pair again:"
+ )
+
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=err_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except Exception as e:
+ logger.error(f"Error editing message in format error: {e}")
+ # Fallback: invia nuovo messaggio
+ msg = await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ text=err_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["stat_arb_wizard_message_id"] = msg.message_id
+ else:
+ msg = await context.bot.send_message(
+ chat_id=update.effective_chat.id,
+ text=err_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["stat_arb_wizard_message_id"] = msg.message_id
+
+async def process_stat_arb_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during Stat Arb V2 wizard"""
+ step = context.user_data.get("stat_arb_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("stat_arb_wizard_message_id")
+ wizard_chat_id = context.user_data.get("stat_arb_wizard_chat_id", chat_id)
+
+ try:
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ # ========== GESTISCI INPUT MANUALE PER BASE ASSET ==========
+ if step == "base_asset":
+ asset = user_input.upper().strip()
+ if not asset:
+ # Ricostruisci lo step base_asset direttamente
+ connector = config.get("connector_name", "")
+ keyboard = []
+ common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"]
+ row = []
+ for a in common_assets:
+ row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step 2/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🪙 *Base Asset*" + "\n\n"
+ r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n"
+ r"*Select a button or type the asset name in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Validazione base: deve essere un token valido (solo lettere)
+ if not asset.isalpha():
+ connector = config.get("connector_name", "")
+ keyboard = []
+ common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"]
+ row = []
+ for a in common_assets:
+ row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step 2/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🪙 *Base Asset*" + "\n\n"
+ r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n"
+ r"*Select a button or type the asset name in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ connector = config.get("connector_name", "")
+
+ # Mostra messaggio di caricamento
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(asset)}`" + "\n\n"
+ r"⏳ *Checking available quote assets\.\.\.*" + "\n\n"
+ r"_Please wait, verifying which pairs have candle data_",
+ parse_mode="MarkdownV2",
+ )
+
+ # Salva il base asset
+ config["base_asset"] = asset
+ set_controller_config(context, config)
+
+ # Verifica le quote disponibili
+ valid_quotes = []
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ trading_rules = await get_trading_rules(context.user_data, client, connector)
+
+ prefix = f"{asset}-"
+ all_quotes = []
+
+ for pair, rules in trading_rules.items():
+ if pair.startswith(prefix):
+ quote = pair.replace(prefix, "")
+ all_quotes.append(quote)
+
+ logger.info(f"All quotes for {asset} on {connector}: {all_quotes}")
+
+ if all_quotes:
+ for quote in all_quotes[:20]:
+ pair = f"{asset}-{quote}"
+ try:
+ found = False
+ for test_interval in ["1d", "4h", "1h"]:
+ try:
+ test_candles = await asyncio.wait_for(
+ fetch_candles(client, connector, pair, interval=test_interval, max_records=5),
+ timeout=15.0
+ )
+ if test_candles:
+ candles_data = test_candles.get("data", []) if isinstance(test_candles, dict) else (test_candles or [])
+ # Richiede almeno 3 candele per considerare la coppia valida
+ if len(candles_data) >= 3:
+ valid_quotes.append(quote)
+ logger.info(f"✓ {pair} has candle data at {test_interval}")
+ found = True
+ break
+ except Exception as e:
+ logger.warning(f"✗ {pair} failed at {test_interval}: {e}")
+
+ if not found:
+ logger.warning(f"✗ {pair} has NO candle data at any interval")
+ if test_candles:
+ valid_quotes.append(quote)
+ logger.info(f"✓ {pair} has candle data")
+ else:
+ logger.warning(f"✗ {pair} has NO candle data")
+ except Exception as e:
+ logger.warning(f"✗ {pair} error: {e}")
+
+
+ valid_quotes.sort()
+ context.user_data["stat_arb_valid_quotes"] = valid_quotes
+ logger.info(f"Valid quotes for {asset}: {valid_quotes}")
+ # CONTROLLO: deve avere almeno 2 quote valide
+ if len(valid_quotes) < 2:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Error*" + "\n\n"
+ f"❌ `{asset}` has only {len(valid_quotes)} valid quote(s)\\.\n\n"
+ r"Please choose a different base asset with at least 2 trading pairs\\.\n\n"
+ r"*Examples:* BTC, ETH, SOL, KCS",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset")
+ ]]),
+ )
+ return
+
+ except Exception as e:
+ logger.error(f"Error checking quotes: {e}")
+ valid_quotes = ["USDT", "USDC"]
+ context.user_data["stat_arb_valid_quotes"] = valid_quotes
+
+ if not valid_quotes:
+ valid_quotes = ["USDT", "USDC"]
+ context.user_data["stat_arb_valid_quotes"] = valid_quotes
+
+ # CONTROLLO PER IL FALLBACK
+ if len(valid_quotes) < 2:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Error*" + "\n\n"
+ f"❌ `{asset}` has only {len(valid_quotes)} valid quote(s)\\.\n\n"
+ r"Please choose a different base asset with at least 2 trading pairs\\.\n\n"
+ r"*Examples:* BTC, ETH, SOL, KCS",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset")
+ ]]),
+ )
+ return
+
+ if not valid_quotes:
+ valid_quotes = ["USDT", "USDC"]
+ context.user_data["stat_arb_valid_quotes"] = valid_quotes
+
+ # Vai allo step successivo - costruisci direttamente
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_1"
+
+ base_asset = config.get("base_asset", "")
+ valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"])
+
+ keyboard = []
+ row = []
+ for quote in valid_quotes[:12]:
+ row.append(InlineKeyboardButton(quote, callback_data=f"bots:stat_arb_quote_1:{quote}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 3
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ f"📈 *First Quote Asset*" + "\n\n"
+ rf"The quote currency for `{base_asset}-XXX`:" + "\n\n"
+ rf"*Available quotes with candle data:* {len(valid_quotes)} found" + "\n\n"
+ r"*Select a button or type the quote asset in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT MANUALE PER QUOTE ASSET 1 ==========
+ elif step == "quote_asset_1":
+ quote = user_input.upper().strip()
+ base_asset = config.get("base_asset", "")
+ if not base_asset:
+ # Ricostruisci base_asset
+ connector = config.get("connector_name", "")
+ keyboard = []
+ common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"]
+ row = []
+ for a in common_assets:
+ row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step 2/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🪙 *Base Asset*" + "\n\n"
+ r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n"
+ r"*Select a button or type the asset name in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Costruisci la coppia
+ pair = f"{base_asset}-{quote}"
+
+ # Validazione della coppia sull'exchange
+ connector = config.get("connector_name", "")
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:stat_arb_quote_1:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_base_asset")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step 3*" + "\n\n"
+ f"❌ `{pair}` not found on `{connector}`\\.\n\n"
+ r"*Did you mean?*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["first_quote_asset"] = quote
+ config["trading_pair_dominant"] = pair
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "quote_asset_2"
+
+ # Ricostruisci quote_asset_2
+ first_quote = config.get("first_quote_asset", "")
+ valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"])
+ remaining_quotes = [q for q in valid_quotes if q != first_quote]
+
+ keyboard = []
+ row = []
+ for q in remaining_quotes[:12]:
+ row.append(InlineKeyboardButton(q, callback_data=f"bots:stat_arb_quote_2:{q}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 4
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ rf"*First quote selected:* `{first_quote}`" + "\n\n"
+ f"📉 *Second Quote Asset*" + "\n\n"
+ rf"The quote currency for `{base_asset}-XXX` \(different from {first_quote}\):" + "\n\n"
+ rf"*Available quotes:* {len(remaining_quotes)} found" + "\n\n"
+ r"*Select a button or type the quote asset in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT MANUALE PER QUOTE ASSET 2 ==========
+ elif step == "quote_asset_2":
+ quote = user_input.upper().strip()
+ base_asset = config.get("base_asset", "")
+ first_quote = config.get("first_quote_asset", "")
+
+ if not base_asset:
+ # Ricostruisci base_asset (come sopra)
+ connector = config.get("connector_name", "")
+ keyboard = []
+ common_assets = ["BTC", "ETH", "SOL", "KCS", "BNB", "XRP"]
+ row = []
+ for a in common_assets:
+ row.append(InlineKeyboardButton(a, callback_data=f"bots:stat_arb_base_asset:{a}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step 2/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}`" + "\n\n"
+ r"🪙 *Base Asset*" + "\n\n"
+ r"The common token traded on both pairs \(e\.g\. KCS, BTC, ETH\):" + "\n\n"
+ r"*Select a button or type the asset name in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Verifica che non sia uguale alla prima quote
+ if quote == first_quote:
+ # Ricostruisci quote_asset_2
+ valid_quotes = context.user_data.get("stat_arb_valid_quotes", ["USDT", "USDC"])
+ remaining_quotes = [q for q in valid_quotes if q != first_quote]
+ connector = config.get("connector_name", "")
+ keyboard = []
+ row = []
+ for q in remaining_quotes[:12]:
+ row.append(InlineKeyboardButton(q, callback_data=f"bots:stat_arb_quote_2:{q}"))
+ if len(row) == 3:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ total_steps = 7 if is_perp else 6
+ current_step = 4
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ rf"*First quote selected:* `{first_quote}`" + "\n\n"
+ f"📉 *Second Quote Asset*" + "\n\n"
+ rf"The quote currency for `{base_asset}-XXX` \(different from {first_quote}\):" + "\n\n"
+ rf"*Available quotes:* {len(remaining_quotes)} found" + "\n\n"
+ r"*Select a button or type the quote asset in chat:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ # Costruisci la coppia
+ pair = f"{base_asset}-{quote}"
+
+ # Validazione della coppia sull'exchange
+ connector = config.get("connector_name", "")
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ is_valid, error_msg, suggestions, correct_pair = await validate_trading_pair(
+ context.user_data, client, connector, pair
+ )
+
+ if not is_valid:
+ keyboard = []
+ for sugg in suggestions[:4]:
+ keyboard.append([InlineKeyboardButton(sugg, callback_data=f"bots:stat_arb_quote_2:{sugg}")])
+ keyboard.append([InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_1")])
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step 4*" + "\n\n"
+ f"❌ `{pair}` not found on `{connector}`\\.\n\n"
+ r"*Did you mean?*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ return
+
+ if correct_pair:
+ pair = correct_pair
+
+ config["second_quote_asset"] = quote
+ config["trading_pair_hedge"] = pair
+ set_controller_config(context, config)
+
+ # Vai a leverage o amount
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ if is_perp:
+ context.user_data["stat_arb_wizard_step"] = "leverage"
+
+ # Ricostruisci leverage step
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+ base_asset = config.get("base_asset", "")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:stat_arb_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:stat_arb_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:stat_arb_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:stat_arb_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:stat_arb_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:stat_arb_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ current_step = 5
+ total_steps = 7 if is_perp else 6
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ f"📈 `{escape_markdown_v2(dom_pair)}` vs 📉 `{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ else:
+ config["leverage"] = 1
+ config["position_mode"] = "HEDGE"
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "total_amount_quote"
+
+ # Ricostruisci amount step
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+ base_asset = config.get("base_asset", "")
+ leverage = config.get("leverage", 1)
+
+ back_callback = "bots:stat_arb_back_to_quote_2"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ current_step = 5 if is_perp else 5
+ total_steps = 7 if is_perp else 6
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`"
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + "\n\n"
+ f"📈 `{escape_markdown_v2(dom_pair)}` vs 📉 `{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"_Select or type an amount \(will be split between legs via hedge ratio\):_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT PER LEVERAGE ==========
+ elif step == "leverage":
+ try:
+ clean_input = user_input.strip().lower().replace("x", "")
+ val = int(float(clean_input))
+ if val < 1:
+ raise ValueError("Leverage must be at least 1")
+ config["leverage"] = val
+ config["position_mode"] = "HEDGE"
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "total_amount_quote"
+
+ # Ricostruisci amount step
+ connector = config.get("connector_name", "")
+ base_asset = config.get("base_asset", "")
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+ leverage = config.get("leverage", 1)
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ back_callback = "bots:stat_arb_back_to_leverage" if is_perp else "bots:stat_arb_back_to_quote_2"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ current_step = 6 if is_perp else 5
+ total_steps = 7 if is_perp else 6
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x`"
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + "\n\n"
+ f"📈 `{escape_markdown_v2(dom_pair)}` vs 📉 `{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"_Select or type an amount \(will be split between legs via hedge ratio\):_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ except ValueError:
+ # Ricostruisci leverage step
+ connector = config.get("connector_name", "")
+ base_asset = config.get("base_asset", "")
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("1x", callback_data="bots:stat_arb_leverage:1"),
+ InlineKeyboardButton("5x", callback_data="bots:stat_arb_leverage:5"),
+ InlineKeyboardButton("10x", callback_data="bots:stat_arb_leverage:10"),
+ ],
+ [
+ InlineKeyboardButton("20x", callback_data="bots:stat_arb_leverage:20"),
+ InlineKeyboardButton("50x", callback_data="bots:stat_arb_leverage:50"),
+ InlineKeyboardButton("75x", callback_data="bots:stat_arb_leverage:75"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:stat_arb_back_to_quote_2"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ current_step = 5 if is_perp else 5
+ total_steps = 7 if is_perp else 6
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`" + "\n\n"
+ f"📈 `{escape_markdown_v2(dom_pair)}` vs 📉 `{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"⚡ *Select Leverage*" + "\n"
+ r"_Or type a value \(e\.g\. 20\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT PER TOTAL_AMOUNT_QUOTE ==========
+ elif step == "total_amount_quote":
+ try:
+ clean_input = user_input.strip().replace("$", "").replace(",", "").replace(" ", "")
+ amount = float(clean_input)
+ if amount <= 0:
+ raise ValueError("Amount must be positive")
+
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["stat_arb_wizard_step"] = "final"
+
+ dom_pair = config.get("trading_pair_dominant", "")
+ if message_id:
+ try:
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- New Config*" + "\n\n"
+ rf"⏳ Loading cointegration analysis for `{escape_markdown_v2(dom_pair)}`\.\.\.",
+ parse_mode="MarkdownV2",
+ )
+ except Exception:
+ pass
+
+ await _stat_arb_show_final_step(update, context)
+
+ except ValueError:
+ # Ricostruisci amount step
+ connector = config.get("connector_name", "")
+ base_asset = config.get("base_asset", "")
+ dom_pair = config.get("trading_pair_dominant", "")
+ hedge_pair = config.get("trading_pair_hedge", "")
+ leverage = config.get("leverage", 1)
+
+ is_perp = connector.endswith("_perpetual") or "_margin" in connector.lower()
+ back_callback = "bots:stat_arb_back_to_leverage" if is_perp else "bots:stat_arb_back_to_quote_2"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:stat_arb_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:stat_arb_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:stat_arb_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:stat_arb_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:stat_arb_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:stat_arb_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data=back_callback),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ current_step = 6 if is_perp else 5
+ total_steps = 7 if is_perp else 6
+
+ header_info = f"🏦 `{escape_markdown_v2(connector)}` \\| 🪙 `{escape_markdown_v2(base_asset)}`"
+ if is_perp:
+ header_info += f" \\| ⚡ `{leverage}x`"
+
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 Stat Arb V2 \- Step {current_step}/{total_steps}*" + "\n\n"
+ + header_info + "\n\n"
+ f"📈 `{escape_markdown_v2(dom_pair)}` vs 📉 `{escape_markdown_v2(hedge_pair)}`" + "\n\n"
+ r"💰 *Total Amount \(Quote\)*" + "\n"
+ r"_Select or type an amount \(will be split between legs via hedge ratio\):_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ # ========== GESTISCI INPUT PER FINAL (EDIT CAMPI) ==========
+ elif step == "final":
+ if "=" in user_input:
+ for line in user_input.strip().split("\n"):
+ if "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+ try:
+ if field in ("total_amount_quote", "entry_threshold", "take_profit", "tp_global", "sl_global",
+ "quoter_spread", "max_position_deviation", "pos_hedge_ratio",
+ "max_dynamic_hedge_ratio", "min_dynamic_hedge_ratio", "min_r_squared",
+ "adf_pvalue_threshold"):
+ config[field] = float(value)
+ elif field in ("leverage", "lookback_period", "min_amount_quote", "quoter_cooldown",
+ "quoter_refresh", "max_orders_placed_per_side", "max_orders_filled_per_side"):
+ config[field] = int(float(value))
+ elif field == "use_dynamic_hedge_ratio":
+ config[field] = value.lower() in ("true", "yes", "1")
+ elif field == "interval":
+ config["interval"] = value
+ context.user_data.pop("stat_arb_candles_dom", None)
+ context.user_data.pop("stat_arb_candles_hedge", None)
+ context.user_data["stat_arb_chart_interval"] = value
+ else:
+ config[field] = value
+ except Exception:
+ pass
+ set_controller_config(context, config)
+ await _stat_arb_show_final_step(update, context)
+
+ except Exception as e:
+ logger.error(f"Stat Arb wizard input error: {e}", exc_info=True)
+
+# ============================================
+# LM MULTI PAIR DEX WIZARD (lm_multi_pair_dex)
+# ============================================
+# Steps: connector → markets → token → amount → allocation → final
+# Prefisso handler: lmp_
+
+async def show_new_lm_multi_pair_dex_form(update, context) -> None:
+ """Start the LM Multi Pair DEX wizard - Step 1: Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Clear cached data
+ for key in ["lmp_current_prices", "lmp_candles", "lmp_liquidity_analysis"]:
+ context.user_data.pop(key, None)
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ configs = await client.controllers.list_controller_configs()
+ context.user_data["controller_configs_list"] = configs
+ except Exception as e:
+ logger.warning(f"Could not fetch existing configs: {e}")
+
+ init_new_controller_config(context, "lm_multi_pair_dex")
+ context.user_data["bots_state"] = "lmp_wizard"
+ context.user_data["lmp_wizard_step"] = "connector_name"
+ context.user_data["lmp_wizard_message_id"] = query.message.message_id
+ context.user_data["lmp_wizard_chat_id"] = query.message.chat_id
+
+ await _lmp_show_connector_step(update, context)
+
+
+async def _lmp_show_connector_step(update, context) -> None:
+ """LMP Step 1: Select DEX Connector"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+
+ # Available DEX connectors
+ dex_connectors = [
+ ("xrpl", "🔵 XRPL DEX", "Fee ~0.000012 XRP, self-custody, 3-5s latency"),
+ ("hyperliquid", "🟣 Hyperliquid", "Maker rebate -0.01%, 0.2ms latency"),
+ ]
+
+ keyboard = []
+ for conn_id, label, desc in dex_connectors:
+ keyboard.append([
+ InlineKeyboardButton(
+ f"{label}",
+ callback_data=f"bots:lmp_connector:{conn_id}"
+ )
+ ])
+
+ keyboard.append([InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu")])
+
+ await query.message.edit_text(
+ r"*📊 LM Multi Pair DEX \- New Config*" + "\n\n"
+ r"Market making multi\-coppia ottimizzato per DEX con order book\." + "\n\n"
+ r"─────────────────────────" + "\n\n"
+ r"*Step 1: Select DEX*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_lmp_wizard_connector(update, context, connector: str) -> None:
+ """Handle connector selection"""
+ config = get_controller_config(context)
+ config["connector_name"] = connector
+
+ if connector == "xrpl":
+ config["token"] = "XRP"
+ config["buy_spreads"] = [0.005, 0.01, 0.02]
+ config["sell_spreads"] = [0.005, 0.01, 0.02]
+ config["order_refresh_time"] = 60
+ config["cooldown_time"] = 30
+ config["order_refresh_tolerance_pct"] = 0.01
+ default_markets = ["XRP-RLUSD", "BTC-XRP", "ETH-RLUSD"]
+ else: # hyperliquid
+ config["token"] = "USDC"
+ config["buy_spreads"] = [0.002, 0.004, 0.006]
+ config["sell_spreads"] = [0.002, 0.004, 0.006]
+ config["order_refresh_time"] = 30
+ config["cooldown_time"] = 15
+ config["order_refresh_tolerance_pct"] = 0.005
+ default_markets = ["SOL-USDC", "ETH-USDC", "BTC-USDC"]
+
+ config["markets"] = default_markets
+ set_controller_config(context, config)
+ context.user_data["lmp_wizard_step"] = "markets"
+ await _lmp_show_markets_step(update, context)
+
+
+async def _lmp_show_markets_step(update, context) -> None:
+ """LMP Step 2: Configure Trading Pairs"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ token = config.get("token", "USDC")
+ current_markets = config.get("markets", [])
+
+ context.user_data["bots_state"] = "lmp_wizard_input"
+ context.user_data["lmp_wizard_step"] = "markets"
+
+ # Suggest pairs based on connector
+ if connector == "xrpl":
+ suggested = ["XRP-RLUSD", "BTC-XRP", "ETH-RLUSD", "XRP-USD"]
+ else:
+ suggested = ["SOL-USDC", "ETH-USDC", "BTC-USDC", "ARB-USDC", "OP-USDC"]
+
+ keyboard = []
+ row = []
+ for pair in suggested:
+ is_selected = pair in current_markets
+ checkbox = "✅ " if is_selected else "➕ "
+ row.append(InlineKeyboardButton(
+ f"{checkbox}{pair}",
+ callback_data=f"bots:lmp_toggle_pair:{pair}"
+ ))
+ if len(row) == 2:
+ keyboard.append(row)
+ row = []
+ if row:
+ keyboard.append(row)
+
+ # Next button
+ if current_markets:
+ keyboard.append([
+ InlineKeyboardButton("✅ Next", callback_data="bots:lmp_next_markets")
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_connector"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_token = escape_markdown_v2(token)
+ markets_str = ", ".join(current_markets) if current_markets else "None"
+
+ await query.message.edit_text(
+ rf"*📊 LM Multi Pair DEX \- Step 2/6*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_token}`" + "\n\n"
+ r"🔗 *Trading Pairs*" + "\n\n"
+ f"Selected: `{markets_str}`" + "\n\n"
+ r"*Tap buttons to add/remove:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_lmp_toggle_pair(update, context, pair: str) -> None:
+ """Toggle pair selection"""
+ config = get_controller_config(context)
+ markets = config.get("markets", [])
+
+ if pair in markets:
+ markets.remove(pair)
+ else:
+ markets.append(pair)
+
+ config["markets"] = markets
+ set_controller_config(context, config)
+
+ await _lmp_show_markets_step(update, context)
+
+
+async def handle_lmp_next_markets(update, context) -> None:
+ """Proceed to token step"""
+ config = get_controller_config(context)
+ markets = config.get("markets", [])
+
+ if not markets:
+ query = update.callback_query
+ await query.answer("Please select at least one trading pair", show_alert=True)
+ return
+
+ context.user_data["lmp_wizard_step"] = "token"
+ await _lmp_show_token_step(update, context)
+
+
+async def _lmp_show_token_step(update, context) -> None:
+ """LMP Step 3: Select/Confirm Unified Token"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ current_token = config.get("token", "")
+
+ context.user_data["bots_state"] = "lmp_wizard_input"
+ context.user_data["lmp_wizard_step"] = "token"
+
+ # Suggest token based on connector
+ if connector == "xrpl":
+ suggestions = ["XRP", "RLUSD"]
+ hint = "XRP for lower fees, RLUSD for stablecoin pairs"
+ else:
+ suggestions = ["USDC"]
+ hint = "USDC recommended for best fee structure"
+
+ keyboard = []
+ for token in suggestions:
+ marker = "✓ " if token == current_token else ""
+ keyboard.append([
+ InlineKeyboardButton(
+ f"{marker}{token}",
+ callback_data=f"bots:lmp_token:{token}"
+ )
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_markets"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ])
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_current = escape_markdown_v2(current_token) if current_token else "Not set"
+
+ await query.message.edit_text(
+ rf"*📊 LM Multi Pair DEX \- Step 3/6*" + "\n\n"
+ f"🏦 `{escaped_connector}`" + "\n\n"
+ r"💰 *Unified Token*" + "\n\n"
+ f"Current: `{escaped_current}`" + "\n\n"
+ f"*{escape_markdown_v2(hint)}*" + "\n\n"
+ r"*Select a token:*",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_lmp_token(update, context, token: str) -> None:
+ """Handle token selection"""
+ config = get_controller_config(context)
+ config["token"] = token
+ set_controller_config(context, config)
+ context.user_data["lmp_wizard_step"] = "allocation"
+ await _lmp_show_allocation_step(update, context)
+
+
+async def _lmp_show_allocation_step(update, context) -> None:
+ """LMP Step 4: Portfolio Allocation %"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ current_allocation = config.get("portfolio_allocation", 0.10) * 100
+
+ context.user_data["bots_state"] = "lmp_wizard_input"
+ context.user_data["lmp_wizard_step"] = "allocation"
+
+ keyboard = [
+ [
+ InlineKeyboardButton("5%", callback_data="bots:lmp_allocation:0.05"),
+ InlineKeyboardButton("10%", callback_data="bots:lmp_allocation:0.10"),
+ InlineKeyboardButton("15%", callback_data="bots:lmp_allocation:0.15"),
+ ],
+ [
+ InlineKeyboardButton("20%", callback_data="bots:lmp_allocation:0.20"),
+ InlineKeyboardButton("25%", callback_data="bots:lmp_allocation:0.25"),
+ InlineKeyboardButton("30%", callback_data="bots:lmp_allocation:0.30"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_token"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_token = escape_markdown_v2(config.get("token", ""))
+
+ await query.message.edit_text(
+ rf"*📊 LM Multi Pair DEX \- Step 4/6*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_token}`" + "\n\n"
+ r"💰 *Portfolio Allocation*" + "\n\n"
+ f"Current: `{current_allocation:.0f}%` of capital" + "\n\n"
+ r"_Or type a custom value \(e\.g\. 12%\)_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_lmp_allocation(update, context, allocation: float) -> None:
+ """Handle allocation selection"""
+ config = get_controller_config(context)
+ config["portfolio_allocation"] = allocation
+ set_controller_config(context, config)
+ context.user_data["lmp_wizard_step"] = "total_amount_quote"
+ await _lmp_show_amount_step(update, context)
+
+
+async def _lmp_show_amount_step(update, context) -> None:
+ """LMP Step 5: Total Amount"""
+ query = update.callback_query
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ connector = config.get("connector_name", "")
+ token = config.get("token", "")
+ allocation = config.get("portfolio_allocation", 0.10) * 100
+
+ context.user_data["bots_state"] = "lmp_wizard_input"
+ context.user_data["lmp_wizard_step"] = "total_amount_quote"
+
+ # Fetch balance
+ balance_text = ""
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ balances = await get_cex_balances(
+ context.user_data, client, "master_account", ttl=30
+ )
+
+ # Flexible matching come in Grid Strike
+ connector_balances = []
+ connector_lower = connector.lower()
+ connector_base = connector_lower.replace("_perpetual", "").replace("_spot", "")
+
+ for bal_connector, bal_list in balances.items():
+ bal_lower = bal_connector.lower()
+ bal_base = bal_lower.replace("_perpetual", "").replace("_spot", "")
+ if bal_lower == connector_lower or bal_base == connector_base:
+ connector_balances = bal_list
+ break
+
+ if connector_balances:
+ relevant_balances = []
+ quote = pair.split("-")[1] if "-" in pair else "USDT"
+ for bal in connector_balances:
+ token = bal.get("token", bal.get("asset", ""))
+ available = bal.get("units", bal.get("available_balance", bal.get("free", 0)))
+ if token and token.upper() == quote.upper():
+ try:
+ available_float = float(available)
+ if available_float > 0:
+ balance_text = f"\n\n💰 Available `{escape_markdown_v2(quote)}`: `{available_float:,.2f}`"
+ break
+ except (ValueError, TypeError):
+ continue
+ except Exception as e:
+ logger.warning(f"Could not fetch balances for Liquidity Multi Pair amount step: {e}")
+
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:lmp_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:lmp_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:lmp_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:lmp_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:lmp_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:lmp_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_allocation"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_token = escape_markdown_v2(token)
+
+ await query.message.edit_text(
+ rf"*📊 LM Multi Pair DEX \- Step 5/6*" + "\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_token}`" + "\n\n"
+ f"💰 Portfolio Allocation: `{allocation:.0f}%`" + balance_text + "\n\n"
+ r"💰 *Total Capital*" + "\n"
+ r"_Select or type an amount:_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+async def handle_lmp_wizard_amount(update, context, amount: float) -> None:
+ """Handle amount selection"""
+ query = update.callback_query
+ config = get_controller_config(context)
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+
+ await query.message.edit_text(
+ r"*📊 LM Multi Pair DEX \- New Config*" + "\n\n"
+ r"⏳ *Generating configuration\.\.\.*" + "\n\n"
+ r"_Analyzing liquidity and building grid\.\.\._",
+ parse_mode="MarkdownV2",
+ )
+
+ context.user_data["lmp_wizard_step"] = "final"
+ await _lmp_show_final_step(update, context)
+
+
+async def _lmp_show_final_step(update, context, interval: str = None) -> None:
+ """LMP Final Step: Show config summary (MarkdownV2)"""
+ query = update.callback_query
+ msg = query.message if query else update.message
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+
+ connector = config.get("connector_name", "")
+ markets = config.get("markets", [])
+ token = config.get("token", "")
+ total_amount = config.get("total_amount_quote", 1000)
+ allocation = config.get("portfolio_allocation", 0.10)
+ buy_spreads = config.get("buy_spreads", [0.005, 0.01, 0.02])
+ sell_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02])
+ order_refresh_time = config.get("order_refresh_time", 45)
+ cooldown_time = config.get("cooldown_time", 20)
+ tolerance = config.get("order_refresh_tolerance_pct", 0.01)
+ target_base = config.get("target_base_pct", 0.5) * 100
+ min_base = config.get("min_base_pct", 0.3) * 100
+ max_base = config.get("max_base_pct", 0.7) * 100
+ max_skew = config.get("max_skew", 0.2) * 100
+
+ if not config.get("id"):
+ existing_configs = context.user_data.get("controller_configs_list", [])
+ config["id"] = generate_id(config, existing_configs)
+
+ is_hyperliquid = connector == "hyperliquid"
+ fee_note = "💰 Maker rebate: -0.01% (TI PAGANO)" if is_hyperliquid else "💰 Fee per ordine: ~0.000012 XRP (quasi zero)"
+
+ context.user_data["bots_state"] = "lmp_wizard_input"
+ context.user_data["lmp_wizard_step"] = "final"
+
+ keyboard = [
+ [InlineKeyboardButton("💾 Save Config", callback_data="bots:lmp_save")],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_amount"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+
+ # Escapiamo i campi dinamici per MarkdownV2
+ escaped_connector = escape_markdown_v2(connector)
+ escaped_token = escape_markdown_v2(token)
+ escaped_markets = escape_markdown_v2(", ".join(markets))
+ escaped_fee_note = escape_markdown_v2(fee_note)
+ # Il config_block è in un blocco di codice, non va escapato
+ config_block = (
+ f"id: {config.get('id', '')}\n"
+ f"connector_name: {connector}\n"
+ f"markets: {markets}\n"
+ f"token: {token}\n"
+ f"total_amount_quote: {total_amount:.0f}\n"
+ f"portfolio_allocation: {allocation}\n"
+ f"buy_spreads: {buy_spreads}\n"
+ f"sell_spreads: {sell_spreads}\n"
+ f"order_refresh_time: {order_refresh_time}\n"
+ f"cooldown_time: {cooldown_time}\n"
+ f"order_refresh_tolerance_pct: {tolerance}\n"
+ f"target_base_pct: {target_base/100:.2f}\n"
+ f"min_base_pct: {min_base/100:.2f}\n"
+ f"max_base_pct: {max_base/100:.2f}\n"
+ f"max_skew: {max_skew/100:.2f}"
+ )
+
+ # Costruiamo il testo con MarkdownV2, escapando i caratteri speciali delle parti statiche
+ # Nota: i punti statici vanno escapati con \., i due punti con \:, ecc.
+ # Ma possiamo usare escape_markdown_v2 sull'intero testo esclusi i blocchi di codice.
+ # Per semplicità, costruiamo il testo con le parti già escaped.
+ config_text = (
+ f"*📊 LM Multi Pair DEX \\- Step 6/6 \\(Final\\)*\n\n"
+ f"🏦 `{escaped_connector}` \\| 💰 `{escaped_token}`\n\n"
+ f"{escaped_fee_note}\n\n"
+ f"🔗 *Markets:* `{escaped_markets}`\n\n"
+ f"```\n{config_block}\n```\n\n"
+ r"_Edit\: `field\=value`_"
+ )
+
+ try:
+ await msg.edit_text(
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["lmp_wizard_message_id"] = msg.message_id
+ except Exception:
+ new_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text=config_text,
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+ context.user_data["lmp_wizard_message_id"] = new_msg.message_id
+async def handle_lmp_save(update, context) -> None:
+ """Save LM Multi Pair DEX configuration"""
+ query = update.callback_query
+ config = get_controller_config(context)
+
+ config_id = config.get("id", "")
+ chat_id = query.message.chat_id
+
+ try:
+ await query.message.delete()
+ except Exception:
+ pass
+
+ status_msg = await context.bot.send_message(
+ chat_id=chat_id,
+ text="Saving `" + escape_markdown_v2(config_id) + "`\\.\\.\\.",
+ parse_mode="MarkdownV2",
+ )
+
+ try:
+ client, _ = await get_bots_client(chat_id, context.user_data)
+ await client.controllers.create_or_update_controller_config(config_id, config)
+
+ for key in ["lmp_wizard_step", "lmp_wizard_message_id", "lmp_wizard_chat_id",
+ "lmp_current_prices", "lmp_candles", "lmp_liquidity_analysis"]:
+ context.user_data.pop(key, None)
+ context.user_data.pop("bots_state", None)
+
+ keyboard = [
+ [InlineKeyboardButton("Create Another", callback_data="bots:new_lm_multi_pair_dex")],
+ [InlineKeyboardButton("Back to Configs", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ "*Config Saved\\!*\n\n"
+ "Controller `" + escape_markdown_v2(config_id) + "` saved successfully\\.",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ except Exception as e:
+ logger.error(f"LMP save error: {e}", exc_info=True)
+ keyboard = [
+ [InlineKeyboardButton("Try Again", callback_data="bots:lmp_save")],
+ [InlineKeyboardButton("Back", callback_data="bots:controller_configs")],
+ ]
+ await status_msg.edit_text(
+ format_error_message(f"Failed to save: {str(e)}"),
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+
+# Back handlers
+async def handle_lmp_back_to_connector(update, context) -> None:
+ context.user_data["lmp_wizard_step"] = "connector_name"
+ await _lmp_show_connector_step(update, context)
+
+
+async def handle_lmp_back_to_markets(update, context) -> None:
+ context.user_data["lmp_wizard_step"] = "markets"
+ await _lmp_show_markets_step(update, context)
+
+
+async def handle_lmp_back_to_token(update, context) -> None:
+ context.user_data["lmp_wizard_step"] = "token"
+ await _lmp_show_token_step(update, context)
+
+
+async def handle_lmp_back_to_allocation(update, context) -> None:
+ context.user_data["lmp_wizard_step"] = "allocation"
+ await _lmp_show_allocation_step(update, context)
+
+
+async def handle_lmp_back_to_amount(update, context) -> None:
+ context.user_data["lmp_wizard_step"] = "total_amount_quote"
+ await _lmp_show_amount_step(update, context)
+
+
+async def process_lmp_wizard_input(update, context, user_input: str) -> None:
+ """Process text input during LM Multi Pair DEX wizard"""
+ step = context.user_data.get("lmp_wizard_step")
+ chat_id = update.effective_chat.id
+ config = get_controller_config(context)
+ message_id = context.user_data.get("lmp_wizard_message_id")
+ wizard_chat_id = context.user_data.get("lmp_wizard_chat_id", chat_id)
+
+ try:
+ await update.message.delete()
+ except Exception:
+ pass
+
+ if step == "allocation":
+ try:
+ clean_input = user_input.strip().replace("%", "")
+ val = float(clean_input)
+ if val > 1: # User entered percentage like "10"
+ val = val / 100
+ if val <= 0 or val > 1:
+ raise ValueError("Allocation must be between 0 and 1")
+ config["portfolio_allocation"] = val
+ set_controller_config(context, config)
+ context.user_data["lmp_wizard_step"] = "total_amount_quote"
+ await _lmp_show_amount_step(update, context)
+ except ValueError:
+ connector = config.get("connector_name", "")
+ token = config.get("token", "")
+ keyboard = [
+ [
+ InlineKeyboardButton("5%", callback_data="bots:lmp_allocation:0.05"),
+ InlineKeyboardButton("10%", callback_data="bots:lmp_allocation:0.10"),
+ InlineKeyboardButton("15%", callback_data="bots:lmp_allocation:0.15"),
+ ],
+ [
+ InlineKeyboardButton("20%", callback_data="bots:lmp_allocation:0.20"),
+ InlineKeyboardButton("25%", callback_data="bots:lmp_allocation:0.25"),
+ InlineKeyboardButton("30%", callback_data="bots:lmp_allocation:0.30"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_token"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 LM Multi Pair DEX \- Step 4/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 💰 `{escape_markdown_v2(token)}`" + "\n\n"
+ r"💰 *Portfolio Allocation*" + "\n\n"
+ r"⚠️ *Invalid value\. Enter a number between 1 and 30 \(e\.g\. 10\)*" + "\n\n"
+ r"_Select or type a value:_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "total_amount_quote":
+ try:
+ amount = float(user_input.strip().replace("$", "").replace(",", ""))
+ config["total_amount_quote"] = amount
+ set_controller_config(context, config)
+ context.user_data["lmp_wizard_step"] = "final"
+ await _lmp_show_final_step(update, context)
+ except ValueError:
+ connector = config.get("connector_name", "")
+ token = config.get("token", "")
+ allocation = config.get("portfolio_allocation", 0.10) * 100
+ keyboard = [
+ [
+ InlineKeyboardButton("$100", callback_data="bots:lmp_amount:100"),
+ InlineKeyboardButton("$500", callback_data="bots:lmp_amount:500"),
+ InlineKeyboardButton("$1000", callback_data="bots:lmp_amount:1000"),
+ ],
+ [
+ InlineKeyboardButton("$2000", callback_data="bots:lmp_amount:2000"),
+ InlineKeyboardButton("$5000", callback_data="bots:lmp_amount:5000"),
+ InlineKeyboardButton("$10000", callback_data="bots:lmp_amount:10000"),
+ ],
+ [
+ InlineKeyboardButton("⬅️ Back", callback_data="bots:lmp_back_to_allocation"),
+ InlineKeyboardButton("❌ Cancel", callback_data="bots:main_menu"),
+ ],
+ ]
+ await context.bot.edit_message_text(
+ chat_id=wizard_chat_id,
+ message_id=message_id,
+ text=rf"*📊 LM Multi Pair DEX \- Step 5/6*" + "\n\n"
+ f"🏦 `{escape_markdown_v2(connector)}` \\| 💰 `{escape_markdown_v2(token)}`" + "\n\n"
+ f"💰 Portfolio Allocation: `{allocation:.0f}%`" + "\n\n"
+ r"⚠️ *Invalid amount*" + "\n\n"
+ r"_Select or type an amount:_",
+ parse_mode="MarkdownV2",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ elif step == "final":
+ if "=" in user_input:
+ # Handle field=value edits
+ supported_fields = [
+ "total_amount_quote", "portfolio_allocation", "order_refresh_time",
+ "cooldown_time", "order_refresh_tolerance_pct", "target_base_pct",
+ "min_base_pct", "max_base_pct", "max_skew", "min_liquidity_score"
+ ]
+
+ for line in user_input.strip().split("\n"):
+ if "=" not in line:
+ continue
+ field, value = line.split("=", 1)
+ field = field.strip().lower()
+ value = value.strip()
+
+ if field not in supported_fields:
+ continue
+
+ try:
+ if field in ("total_amount_quote", "portfolio_allocation",
+ "order_refresh_tolerance_pct", "target_base_pct",
+ "min_base_pct", "max_base_pct", "max_skew",
+ "min_liquidity_score"):
+ config[field] = float(value)
+ elif field in ("order_refresh_time", "cooldown_time"):
+ config[field] = int(float(value))
+ else:
+ config[field] = value
+ except Exception:
+ pass
+
+ set_controller_config(context, config)
+ await _lmp_show_final_step(update, context)
+
+
+async def handle_lmp_pair_select(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, trading_pair: str
+) -> None:
+ """Handle selection of a suggested trading pair in LM wizard"""
+ config = get_controller_config(context)
+ markets = config.get("markets", [])
+ if trading_pair not in markets:
+ markets.append(trading_pair)
+ config["markets"] = markets
+ set_controller_config(context, config)
+ await _lmp_show_markets_step(update, context)
# ============================================
# CUSTOM CONFIG UPLOAD
diff --git a/handlers/bots/controllers/__init__.py b/handlers/bots/controllers/__init__.py
index 3c58561a..29f5c5d7 100644
--- a/handlers/bots/controllers/__init__.py
+++ b/handlers/bots/controllers/__init__.py
@@ -15,12 +15,36 @@
from .grid_strike import GridStrikeController
from .pmm_mister import PmmMisterController
from .pmm_v1 import PmmV1Controller
-
+from .arbitrage_controller import ArbitrageControllerController
+from .dman_v3 import DManV3Controller
+from .multi_grid_strike import MultiGridStrikeController
+from .xemm_multiple_levels import XEMMMultipleLevelsController
+from .macd_bb_v1 import MacdBbV1Controller
+from .supertrend_v1 import SuperTrendV1Controller
+from .anti_folla_v1 import AntiFollaV1Controller
+from .funding_rate_arb import FundingRateArbController
+from .delta_neutral_mm import DeltaNeutralMMController
+from .bollingrid import BollinGridController
+from .quantum_grid_allocator import QuantumGridAllocatorController
+from .stat_arb_v2 import StatArbV2Controller
+from .lm_multi_pair_dex import LMMultiPairDEXController
# Registry of controller types
_CONTROLLER_REGISTRY: Dict[str, Type[BaseController]] = {
"grid_strike": GridStrikeController,
"pmm_mister": PmmMisterController,
"pmm_v1": PmmV1Controller,
+ "dman_v3": DManV3Controller,
+ "arbitrage_controller": ArbitrageControllerController,
+ "xemm_multiple_levels": XEMMMultipleLevelsController,
+ "macd_bb_v1": MacdBbV1Controller,
+ "supertrend_v1": SuperTrendV1Controller,
+ "anti_folla_v1": AntiFollaV1Controller,
+ "funding_rate_arb": FundingRateArbController,
+ "delta_neutral_mm": DeltaNeutralMMController,
+ "bollingrid": BollinGridController,
+ "quantum_grid_allocator": QuantumGridAllocatorController,
+ "stat_arb_v2": LMMultiPairDEXController,
+ "lm_multi_pair_dex": LMMultiPairDEXController,
}
@@ -102,6 +126,18 @@ def get_controller_info() -> Dict[str, Dict[str, str]]:
"GridStrikeController",
"PmmMisterController",
"PmmV1Controller",
+ "DManV3Controller",
+ "ArbitrageControllerController",
+ "XEMMMultipleLevelsController",
+ "MacdBbV1Controller",
+ "SuperTrendV1Controller",
+ "AntiFollaV1Controller",
+ "FundingRateArbController",
+ "DeltaNeutralMMController",
+ "BollinGridController",
+ "QuantumGridAllocatorController",
+ "StatArbV2Controller",
+ "LMMultiPairDEXController",
# Backwards compatibility
"SUPPORTED_CONTROLLERS",
]
diff --git a/handlers/bots/controllers/anti_folla_v1/__init__.py b/handlers/bots/controllers/anti_folla_v1/__init__.py
new file mode 100644
index 00000000..c1fa01de
--- /dev/null
+++ b/handlers/bots/controllers/anti_folla_v1/__init__.py
@@ -0,0 +1,42 @@
+"""Anti-Folla V1 Controller Module - Directional trading with crowd-contrarian indicators."""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class AntiFollaV1Controller(BaseController):
+ controller_type = "anti_folla_v1"
+ display_name = "Anti-Folla V1"
+ description = "Crowd-contrarian directional trading: VWAP, Donchian, OBV, OBI, Volume Spike, Trade Flow, Funding Rate"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["AntiFollaV1Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
diff --git a/handlers/bots/controllers/anti_folla_v1/analysis.py b/handlers/bots/controllers/anti_folla_v1/analysis.py
new file mode 100644
index 00000000..4ee969f3
--- /dev/null
+++ b/handlers/bots/controllers/anti_folla_v1/analysis.py
@@ -0,0 +1,532 @@
+"""
+Anti-Folla V1 analysis utilities.
+
+Pure-Python implementation of crowd-contrarian indicators
+(no pandas_ta dependency — usable directly from Condor/UI layer):
+
+- Rolling VWAP
+- Donchian Channel (with shift to exclude current candle)
+- OBV + divergence detection
+- Volume spike detection
+- Trade flow analysis (buy/sell pressure from OHLCV)
+- Composite score calculation
+- Parameter suggestion helpers
+"""
+
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+
+# ---------------------------------------------------------------------------
+# LOW-LEVEL CALCULATIONS
+# ---------------------------------------------------------------------------
+
+def calculate_rolling_vwap(
+ candles: List[Dict[str, Any]],
+ period: int = 20,
+) -> List[float]:
+ """
+ Rolling VWAP = sum(close * volume, N) / sum(volume, N).
+ Returns a list aligned with candles (NaN-padded as None for first period-1 items).
+ """
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+ volumes = [float(c.get("volume") or c.get("v") or 0) for c in candles]
+ result: List[Optional[float]] = [None] * len(closes)
+
+ for i in range(period - 1, len(closes)):
+ pv = sum(closes[j] * volumes[j] for j in range(i - period + 1, i + 1))
+ vol = sum(volumes[j] for j in range(i - period + 1, i + 1))
+ result[i] = pv / vol if vol > 0 else closes[i]
+
+ return [v for v in result if v is not None]
+
+
+def calculate_donchian(
+ candles: List[Dict[str, Any]],
+ period: int = 20,
+) -> Tuple[List[float], List[float]]:
+ """
+ Donchian Channel with shift(1) — excludes the current candle.
+ Returns (upper_series, lower_series) aligned with candles from index `period`.
+ """
+ highs = [float(c.get("high") or 0) for c in candles]
+ lows = [float(c.get("low") or 0) for c in candles]
+
+ uppers: List[float] = []
+ lowers: List[float] = []
+
+ # shift(1): window ends at i-1, so range from i-period to i-1
+ for i in range(period, len(candles)):
+ window_h = highs[i - period: i] # shifted: excludes current
+ window_l = lows[i - period: i]
+ uppers.append(max(window_h))
+ lowers.append(min(window_l))
+
+ return uppers, lowers
+
+
+def calculate_obv(candles: List[Dict[str, Any]]) -> List[float]:
+ """Calculate On-Balance Volume."""
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+ volumes = [float(c.get("volume") or c.get("v") or 0) for c in candles]
+
+ obv = [0.0]
+ for i in range(1, len(closes)):
+ if closes[i] > closes[i - 1]:
+ obv.append(obv[-1] + volumes[i])
+ elif closes[i] < closes[i - 1]:
+ obv.append(obv[-1] - volumes[i])
+ else:
+ obv.append(obv[-1])
+ return obv
+
+
+def detect_obv_divergence(
+ candles: List[Dict[str, Any]],
+ obv_series: List[float],
+ lookback: int = 10,
+) -> str:
+ """
+ Detect divergence between OBV and price.
+
+ Returns:
+ 'bullish' – price falls, OBV rises (accumulation)
+ 'bearish' – price rises, OBV falls (distribution)
+ 'none' – no divergence
+ """
+ if len(candles) < lookback or len(obv_series) < lookback:
+ return "none"
+
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+ price_trend = closes[-1] - closes[-lookback]
+ obv_trend = obv_series[-1] - obv_series[-lookback]
+
+ if price_trend < 0 and obv_trend > 0:
+ return "bullish"
+ if price_trend > 0 and obv_trend < 0:
+ return "bearish"
+ return "none"
+
+
+def detect_volume_spike(
+ candles: List[Dict[str, Any]],
+ threshold: float = 2.5,
+) -> Tuple[bool, float]:
+ """Return (is_spike, multiplier). Uses last 20 candles as baseline."""
+ volumes = [float(c.get("volume") or c.get("v") or 0) for c in candles]
+ if len(volumes) < 22:
+ return False, 1.0
+
+ avg_vol = sum(volumes[-21:-1]) / 20
+ if avg_vol == 0:
+ return False, 1.0
+
+ multiplier = volumes[-1] / avg_vol
+ return multiplier >= threshold, round(multiplier, 2)
+
+
+def analyze_trade_flow(
+ candles: List[Dict[str, Any]],
+ lookback: int = 10,
+) -> Dict[str, Any]:
+ """
+ Estimate buy/sell pressure and whale activity from OHLCV.
+ Bullish candles (close > open) = buy pressure, weighted by volume.
+ Whale proxy: last candle with volume > 3× avg AND body > avg body.
+ """
+ if len(candles) < lookback + 1:
+ return {"whale_buying": False, "whale_selling": False, "retail_fomo": False, "buy_pressure": 0.5}
+
+ recent = candles[-lookback:]
+ closes = [float(c.get("close") or 0) for c in recent]
+ opens_ = [float(c.get("open") or 0) for c in recent]
+ volumes = [float(c.get("volume") or 0) for c in recent]
+
+ bull_vol = sum(volumes[i] for i in range(len(recent)) if closes[i] > opens_[i])
+ bear_vol = sum(volumes[i] for i in range(len(recent)) if closes[i] <= opens_[i])
+ total_vol = bull_vol + bear_vol
+ buy_pressure = bull_vol / total_vol if total_vol > 0 else 0.5
+
+ avg_vol = sum(volumes) / len(volumes) if volumes else 0
+ bodies = [abs(closes[i] - opens_[i]) for i in range(len(recent))]
+ avg_body = sum(bodies) / len(bodies) if bodies else 0
+
+ last = candles[-1]
+ last_close = float(last.get("close") or 0)
+ last_open = float(last.get("open") or 0)
+ last_vol = float(last.get("volume") or 0)
+ last_body = abs(last_close - last_open)
+
+ whale_buying = last_vol > avg_vol * 3.0 and last_close > last_open and last_body > avg_body
+ whale_selling = last_vol > avg_vol * 3.0 and last_close < last_open and last_body > avg_body
+
+ # Retail FOMO proxy
+ all_closes = [float(c.get("close") or 0) for c in candles]
+ price_change_pct = (all_closes[-1] - all_closes[-lookback]) / all_closes[-lookback] if all_closes[-lookback] > 0 else 0
+ retail_fomo = bool(price_change_pct > 0.03 and buy_pressure > 0.7 and not whale_buying)
+
+ return {
+ "whale_buying": bool(whale_buying),
+ "whale_selling": bool(whale_selling),
+ "retail_fomo": retail_fomo,
+ "buy_pressure": round(buy_pressure, 3),
+ }
+
+
+def calculate_composite_score(
+ signals: Dict[str, Any],
+ weight_vwap: float = 15,
+ weight_donchian: float = 10,
+ weight_obv: float = 15,
+ weight_obi: float = 20,
+ weight_volume_spike: float = 10,
+ weight_trade_flow: float = 15,
+ weight_funding: float = 15,
+ obi_buy_threshold: float = 1.5,
+ obi_sell_threshold: float = 0.67,
+) -> float:
+ """
+ Compute weighted composite score from -100 (strong sell) to +100 (strong buy).
+ Only activated components contribute to total_weight, then score is normalised.
+ """
+ score = 0.0
+ total_weight = 0.0
+
+ # VWAP
+ if signals.get("vwap_above"):
+ score += weight_vwap
+ total_weight += weight_vwap
+ elif signals.get("vwap_below"):
+ score -= weight_vwap
+ total_weight += weight_vwap
+
+ # Donchian breakout
+ if signals.get("donchian_breakout_up"):
+ score += weight_donchian
+ total_weight += weight_donchian
+ elif signals.get("donchian_breakout_down"):
+ score -= weight_donchian
+ total_weight += weight_donchian
+
+ # OBV divergence
+ obv_div = signals.get("obv_divergence", "none")
+ if obv_div == "bullish":
+ score += weight_obv
+ total_weight += weight_obv
+ elif obv_div == "bearish":
+ score -= weight_obv
+ total_weight += weight_obv
+
+ # OBI
+ obi = signals.get("obi")
+ if obi is not None:
+ if obi >= obi_buy_threshold:
+ score += weight_obi
+ total_weight += weight_obi
+ elif obi <= obi_sell_threshold:
+ score -= weight_obi
+ total_weight += weight_obi
+
+ # Volume spike (directed by price trend)
+ if signals.get("volume_spike"):
+ price_trend = signals.get("price_trend", 0)
+ if price_trend > 0:
+ score += weight_volume_spike
+ elif price_trend < 0:
+ score -= weight_volume_spike
+ total_weight += weight_volume_spike
+
+ # Whale activity
+ if signals.get("whale_buying"):
+ score += weight_trade_flow
+ total_weight += weight_trade_flow
+ elif signals.get("whale_selling"):
+ score -= weight_trade_flow
+ total_weight += weight_trade_flow
+
+ # Funding rate contrarian (futures only)
+ funding_rate = signals.get("funding_rate")
+ if funding_rate is not None:
+ if funding_rate > 0.05: # too many longs → contrarian short
+ score -= weight_funding
+ total_weight += weight_funding
+ elif funding_rate < -0.05: # too many shorts → contrarian long
+ score += weight_funding
+ total_weight += weight_funding
+
+ if total_weight > 0:
+ score = (score / total_weight) * 100
+
+ return round(score, 2)
+
+
+# ---------------------------------------------------------------------------
+# FULL ANALYSIS (for Condor wizard / analysis endpoint)
+# ---------------------------------------------------------------------------
+
+def analyze_candles_for_anti_folla(
+ candles: List[Dict[str, Any]],
+ vwap_period: int = 20,
+ donchian_period: int = 20,
+ atr_period: int = 14,
+ obv_divergence_lookback: int = 10,
+ volume_spike_threshold: float = 2.5,
+ obi_buy_threshold: float = 1.5,
+ obi_sell_threshold: float = 0.67,
+ score_buy_threshold: float = 50.0,
+ score_sell_threshold: float = -50.0,
+ weight_vwap: float = 15,
+ weight_donchian: float = 10,
+ weight_obv: float = 15,
+ weight_obi: float = 20,
+ weight_volume_spike: float = 10,
+ weight_trade_flow: float = 15,
+ weight_funding: float = 15,
+) -> Dict[str, Any]:
+ """
+ Full Anti-Folla analysis from candle data.
+
+ Returns a dict with:
+ - current_signal: 1 (BUY), -1 (SELL), 0 (NEUTRAL)
+ - composite_score: float -100..+100
+ - vwap_current, donchian_upper_current, donchian_lower_current
+ - obv_divergence: 'bullish' | 'bearish' | 'none'
+ - volume_spike, volume_spike_multiplier
+ - whale_buying, whale_selling, retail_fomo, buy_pressure
+ - price_trend: pct change over last 20 candles
+ - suggested_score_buy_threshold, suggested_score_sell_threshold
+ - signal_count_long, signal_count_short (historical)
+ - analysis_candles
+ """
+ result = {
+ "current_signal": 0,
+ "composite_score": 0.0,
+ "vwap_current": None,
+ "donchian_upper_current": None,
+ "donchian_lower_current": None,
+ "obv_divergence": "none",
+ "volume_spike": False,
+ "volume_spike_multiplier": 1.0,
+ "whale_buying": False,
+ "whale_selling": False,
+ "retail_fomo": False,
+ "buy_pressure": 0.5,
+ "price_trend": 0.0,
+ "suggested_score_buy_threshold": score_buy_threshold,
+ "suggested_score_sell_threshold": score_sell_threshold,
+ "signal_count_long": 0,
+ "signal_count_short": 0,
+ "analysis_candles": len(candles),
+ }
+
+ min_required = max(vwap_period, donchian_period, atr_period, obv_divergence_lookback * 2, 50)
+ if not candles or len(candles) < min_required:
+ return result
+
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+
+ # VWAP
+ vwap_series = calculate_rolling_vwap(candles, vwap_period)
+ current_price = closes[-1]
+ current_vwap = vwap_series[-1] if vwap_series else current_price
+
+ # Donchian
+ donchian_upper, donchian_lower = calculate_donchian(candles, donchian_period)
+ current_upper = donchian_upper[-1] if donchian_upper else current_price
+ current_lower = donchian_lower[-1] if donchian_lower else current_price
+
+ # OBV
+ obv_series = calculate_obv(candles)
+ obv_divergence = detect_obv_divergence(candles, obv_series, obv_divergence_lookback)
+
+ # Volume spike
+ is_spike, spike_mult = detect_volume_spike(candles, volume_spike_threshold)
+
+ # Trade flow
+ trade_flow = analyze_trade_flow(candles)
+
+ # Price trend
+ lookback_pt = min(20, len(closes) - 1)
+ price_trend = (closes[-1] - closes[-lookback_pt - 1]) / closes[-lookback_pt - 1] if closes[-lookback_pt - 1] > 0 else 0.0
+
+ signals: Dict[str, Any] = {
+ "vwap_above": current_price > current_vwap,
+ "vwap_below": current_price < current_vwap,
+ "donchian_breakout_up": current_price > current_upper,
+ "donchian_breakout_down": current_price < current_lower,
+ "obv_divergence": obv_divergence,
+ "obi": None, # OBI requires live order book, not available from candles
+ "volume_spike": is_spike,
+ "price_trend": price_trend,
+ "funding_rate": None, # Requires live connector
+ **trade_flow,
+ }
+
+ score = calculate_composite_score(
+ signals,
+ weight_vwap=weight_vwap,
+ weight_donchian=weight_donchian,
+ weight_obv=weight_obv,
+ weight_obi=weight_obi,
+ weight_volume_spike=weight_volume_spike,
+ weight_trade_flow=weight_trade_flow,
+ weight_funding=weight_funding,
+ obi_buy_threshold=obi_buy_threshold,
+ obi_sell_threshold=obi_sell_threshold,
+ )
+
+ if score >= score_buy_threshold:
+ current_signal = 1
+ elif score <= score_sell_threshold:
+ current_signal = -1
+ else:
+ current_signal = 0
+
+ # Historical signal count (rolling, no OBI/funding since those need live data)
+ long_count = 0
+ short_count = 0
+ for i in range(min_required, len(candles)):
+ sub = candles[:i + 1]
+ sub_closes = [float(c.get("close") or 0) for c in sub]
+ sub_vwap = calculate_rolling_vwap(sub, vwap_period)
+ sub_dup, sub_dlo = calculate_donchian(sub, donchian_period)
+ sub_obv = calculate_obv(sub)
+ sub_div = detect_obv_divergence(sub, sub_obv, obv_divergence_lookback)
+ sub_spike, _ = detect_volume_spike(sub, volume_spike_threshold)
+ sub_flow = analyze_trade_flow(sub)
+ sub_pt = (sub_closes[-1] - sub_closes[-min(20, len(sub_closes)-1)-1]) / sub_closes[-min(20, len(sub_closes)-1)-1] if len(sub_closes) > 1 else 0
+ sub_price = sub_closes[-1]
+ sub_signals = {
+ "vwap_above": sub_price > (sub_vwap[-1] if sub_vwap else sub_price),
+ "vwap_below": sub_price < (sub_vwap[-1] if sub_vwap else sub_price),
+ "donchian_breakout_up": sub_price > (sub_dup[-1] if sub_dup else sub_price),
+ "donchian_breakout_down": sub_price < (sub_dlo[-1] if sub_dlo else sub_price),
+ "obv_divergence": sub_div,
+ "obi": None,
+ "volume_spike": sub_spike,
+ "price_trend": sub_pt,
+ "funding_rate": None,
+ **sub_flow,
+ }
+ s = calculate_composite_score(sub_signals, weight_vwap, weight_donchian, weight_obv,
+ weight_obi, weight_volume_spike, weight_trade_flow, weight_funding,
+ obi_buy_threshold, obi_sell_threshold)
+ if s >= score_buy_threshold:
+ long_count += 1
+ elif s <= score_sell_threshold:
+ short_count += 1
+
+ result.update({
+ "current_signal": current_signal,
+ "composite_score": score,
+ "vwap_current": round(current_vwap, 6),
+ "donchian_upper_current": round(current_upper, 6),
+ "donchian_lower_current": round(current_lower, 6),
+ "obv_divergence": obv_divergence,
+ "volume_spike": is_spike,
+ "volume_spike_multiplier": spike_mult,
+ "price_trend": round(price_trend * 100, 3),
+ "signal_count_long": long_count,
+ "signal_count_short": short_count,
+ **trade_flow,
+ })
+
+ return result
+
+
+def format_anti_folla_analysis(analysis: Dict[str, Any]) -> str:
+ """Format analysis results for display in wizard final step."""
+ lines = []
+ n = analysis.get("analysis_candles", 0)
+ score = analysis.get("composite_score", 0.0)
+ signal = analysis.get("current_signal", 0)
+ signal_str = "🟢 BUY" if signal == 1 else ("🔴 SELL" if signal == -1 else "⚪ NEUTRAL")
+
+ lines.append(f"Anti-Folla analysis ({n} candles):")
+ lines.append(f" Signal now: {signal_str} | Score: {score:.1f}")
+ vwap = analysis.get("vwap_current")
+ dup = analysis.get("donchian_upper_current")
+ dlo = analysis.get("donchian_lower_current")
+ if vwap:
+ lines.append(f" VWAP: {vwap:.6g}")
+ if dup and dlo:
+ lines.append(f" Donchian: Upper={dup:.6g} Lower={dlo:.6g}")
+ lines.append(f" OBV divergence: {analysis.get('obv_divergence', 'none')}")
+ spike = analysis.get("volume_spike", False)
+ mult = analysis.get("volume_spike_multiplier", 1.0)
+ lines.append(f" Volume spike: {'YES' if spike else 'no'} ({mult:.1f}×)")
+ lines.append(f" Whale buying: {analysis.get('whale_buying', False)} | Whale selling: {analysis.get('whale_selling', False)}")
+ lines.append(f" Retail FOMO: {analysis.get('retail_fomo', False)} | Buy pressure: {analysis.get('buy_pressure', 0.5):.1%}")
+ lines.append(f" Price trend (20c): {analysis.get('price_trend', 0.0):+.2f}%")
+ lines.append(f" Signals (history): LONG={analysis.get('signal_count_long', 0)} SHORT={analysis.get('signal_count_short', 0)}")
+
+ return "\n".join(lines)
+
+def get_af_strategy_suggestions(analysis: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+ """
+ Ritorna suggerimenti per Anti-Folla V1 basati sull'analisi storica.
+ Modifica soglie di score e pesi per adattare la sensibilità del segnale.
+ """
+
+ # Suggerisci soglie basate sull'analisi storica
+ suggested_buy = analysis.get("suggested_score_buy_threshold", 50.0)
+ suggested_sell = analysis.get("suggested_score_sell_threshold", -50.0)
+
+ # Valori base TP/SL (usati dal controller base)
+ base_tp = 0.03
+ base_sl = 0.05
+ base_ts_activation = 0.015
+ base_ts_delta = 0.005
+
+ return {
+ "aggressive": {
+ "label": "Target: Aggressivo (Entrate anticipate)",
+ "score_buy_threshold": 30.0, # Soglia più bassa → più BUY
+ "score_sell_threshold": -30.0, # Soglia più alta → più SELL
+ # Pesi per lo score (più bilanciati, meno peso a funding)
+ "weight_vwap": 15,
+ "weight_donchian": 15, # Aumentato per più segnali breakout
+ "weight_obv": 15,
+ "weight_obi": 20,
+ "weight_volume_spike": 10,
+ "weight_trade_flow": 15,
+ "weight_funding": 10, # Ridotto (meno impatto funding)
+ "take_profit": round(base_tp * 0.7, 4), # TP più stretto
+ "stop_loss": round(base_sl * 0.8, 4), # SL più stretto
+ "trailing_stop_activation": round(base_ts_activation * 0.8, 4),
+ "trailing_stop_delta": round(base_ts_delta * 0.8, 4),
+ },
+ "balanced": {
+ "label": "Target: Bilanciato (Standard)",
+ "score_buy_threshold": suggested_buy,
+ "score_sell_threshold": suggested_sell,
+ "weight_vwap": 15,
+ "weight_donchian": 10,
+ "weight_obv": 15,
+ "weight_obi": 20,
+ "weight_volume_spike": 10,
+ "weight_trade_flow": 15,
+ "weight_funding": 15,
+ "take_profit": base_tp,
+ "stop_loss": base_sl,
+ "trailing_stop_activation": base_ts_activation,
+ "trailing_stop_delta": base_ts_delta,
+ },
+ "conservative": {
+ "label": "Target: Conservativo (Filtro stretto)",
+ "score_buy_threshold": 70.0, # Soglia più alta → meno BUY (solo segnali forti)
+ "score_sell_threshold": -70.0, # Soglia più bassa → meno SELL
+ # Pesi per lo score (più peso a segnali confermati)
+ "weight_vwap": 20, # Più peso al trend VWAP
+ "weight_donchian": 5, # Meno peso breakout (più falsi)
+ "weight_obv": 20, # Più peso divergenze OBV
+ "weight_obi": 25, # Più peso OBI
+ "weight_volume_spike": 5, # Meno peso spike
+ "weight_trade_flow": 20, # Più peso whale
+ "weight_funding": 5, # Poco peso funding
+ "take_profit": round(base_tp * 1.3, 4), # TP più largo
+ "stop_loss": round(base_sl * 1.2, 4), # SL più largo
+ "trailing_stop_activation": round(base_ts_activation * 1.2, 4),
+ "trailing_stop_delta": round(base_ts_delta * 1.2, 4),
+ }
+ }
diff --git a/handlers/bots/controllers/anti_folla_v1/chart.py b/handlers/bots/controllers/anti_folla_v1/chart.py
new file mode 100644
index 00000000..332b0fad
--- /dev/null
+++ b/handlers/bots/controllers/anti_folla_v1/chart.py
@@ -0,0 +1,499 @@
+"""
+Anti-Folla V1 chart generation.
+
+4 panels:
+ 1. Price – candlesticks + Rolling VWAP + Donchian Channel (upper/lower)
+ 2. Volume – colored bars; volume-spike candles highlighted in yellow
+ 3. OBV – On-Balance Volume; bullish/bearish divergence shaded
+ 4. Score – rolling composite score (-100…+100) computed from candle-only
+ signals (VWAP, Donchian, OBV divergence, Volume Spike, Trade
+ Flow). OBI and Funding Rate are excluded (require live data).
+ score_buy_threshold and score_sell_threshold shown as dashed
+ lines; BUY/SELL zones shaded.
+
+Signal logic:
+ BUY when composite_score >= score_buy_threshold
+ SELL when composite_score <= score_sell_threshold
+"""
+
+import io
+import time
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+from matplotlib.patches import Rectangle
+
+
+# ── PUBLIC API ───────────────────────────────────────────────────────
+def generate_chart(config, candles_data, current_price=None, **kwargs):
+ if not candles_data or len(candles_data) < 10:
+ return _generate_simple_chart(candles_data, current_price)
+
+ df = _prepare_dataframe(candles_data)
+ full_df = df.copy()
+ MAX_VISIBLE_CANDLES = 96
+ for col in ['open', 'high', 'low', 'close', 'volume']:
+ full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0)
+
+ # ── CONFIG ───────────────────────────────────────────────────────
+ vwap_period = int(config.get('vwap_period', 20))
+ donchian_period = int(config.get('donchian_period', 20))
+ vol_spike_thr = float(config.get('volume_spike_threshold', 2.5))
+ score_buy_thr = float(config.get('score_buy_threshold', 50.0))
+ score_sell_thr = float(config.get('score_sell_threshold', -50.0))
+ w_vwap = float(config.get('weight_vwap', 15))
+ w_donchian = float(config.get('weight_donchian', 10))
+ w_obv = float(config.get('weight_obv', 15))
+ w_vol_spike = float(config.get('weight_volume_spike', 10))
+ w_trade_flow = float(config.get('weight_trade_flow', 15))
+ obv_lookback = int(config.get('obv_divergence_lookback', 10))
+
+ # ── INDICATORI ───────────────────────────────────────────────────
+ # Rolling VWAP
+ pv = full_df['close'] * full_df['volume']
+ full_df['vwap'] = (pv.rolling(vwap_period).sum() / full_df['volume'].rolling(vwap_period).sum())
+
+ # Donchian Channel (shift=1 → excludes current candle)
+ full_df['don_upper'] = full_df['high'].shift(1).rolling(donchian_period).max()
+ full_df['don_lower'] = full_df['low'].shift(1).rolling(donchian_period).min()
+
+ # OBV
+ full_df['obv'] = _calc_obv(full_df)
+
+ # Volume spike: current vol vs 20-candle rolling avg (shifted by 1)
+ full_df['vol_avg'] = (
+ full_df['volume']
+ .shift(1)
+ .rolling(20)
+ .mean()
+ )
+
+ full_df['vol_ratio'] = (
+ full_df['volume'] /
+ full_df['vol_avg'].replace(0, np.nan)
+ ).fillna(1.0)
+ full_df['is_spike'] = full_df['vol_ratio'] >= vol_spike_thr
+
+ # Rolling composite score (candle-only signals)
+ full_df['score'] = _calc_rolling_score(
+ full_df, vwap_period, donchian_period, obv_lookback,
+ vol_spike_thr, w_vwap, w_donchian, w_obv, w_vol_spike, w_trade_flow
+ )
+ # SOLO DOPO TAGLI
+ df = full_df.tail(MAX_VISIBLE_CANDLES).copy()
+ vol_avg = df['vol_avg']
+ # ── FIGURA ───────────────────────────────────────────────────────
+ fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(22, 14), sharex=True, gridspec_kw={
+ 'height_ratios': [4.5, 1.2, 1.3, 1.5]
+ }
+ )
+
+ fig.patch.set_facecolor('#111111')
+ for ax in [ax1, ax2, ax3, ax4]:
+ ax.set_facecolor('#111111')
+ ax.tick_params(colors='white')
+ ax.yaxis.label.set_color('white')
+ ax.spines['bottom'].set_color('#444')
+ ax.spines['top'].set_color('#444')
+ ax.spines['left'].set_color('#444')
+ ax.spines['right'].set_color('#444')
+ dates = mdates.date2num(df['datetime'])
+
+ if len(dates) > 1:
+
+ interval = config.get('interval', '5m')
+
+ if interval in ['1m', '5m']:
+ width_factor = 0.65
+ else:
+ width_factor = 0.85
+
+ candle_width = (dates[1] - dates[0]) * width_factor
+ volume_width = (dates[1] - dates[0]) * width_factor
+ else:
+ candle_width = volume_width = 0.0005
+
+ # ── PANNELLO 1: PREZZO + VWAP + DONCHIAN ─────────────────────────
+ for i in range(len(df)):
+ o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']]
+ color = '#2ecc71' if c >= o else '#e74c3c'
+ ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1)
+ ax1.add_patch(Rectangle(
+ (dates[i] - candle_width / 2, min(o, c)),
+ candle_width, abs(c - o) or 1e-8, color=color
+ ))
+
+ # Donchian band
+ valid_don = df['don_upper'].notna() & df['don_lower'].notna()
+ ax1.fill_between(df['datetime'], df['don_lower'], df['don_upper'], where=valid_don, alpha=0.14, color='#3498db', label='Donchian')
+ ax1.plot(df['datetime'], df['don_upper'], linewidth=0.8, color='#3498db', linestyle='--', alpha=0.7)
+ ax1.plot(df['datetime'], df['don_lower'], linewidth=0.8, color='#3498db', linestyle='--', alpha=0.7)
+
+ # VWAP
+ ax1.plot(df['datetime'], df['vwap'], linewidth=1.5, color='#f39c12', label=f'VWAP({vwap_period})')
+
+ if current_price is not None:
+ ax1.axhline(y=current_price, linestyle='--', alpha=0.6, color='gold')
+
+ legend1 = ax1.legend(loc='upper left',fontsize=9,ncol=3,framealpha=0)
+ for text in legend1.get_texts():
+ text.set_color('white')
+ ax1.set_ylabel('Price')
+ ax1.set_xlim(df['datetime'].min(), df['datetime'].max())
+
+ # ── PANNELLO 2: VOLUME (spike in giallo) ─────────────────────────
+ vol_colors = np.where(
+ df['is_spike'],
+ np.where(
+ df['close'] >= df['open'],
+ '#7DFFB3', # spike bullish
+ '#FF9B9B' # spike bearish
+ ),
+ np.where(
+ df['close'] >= df['open'],
+ '#2ecc71',
+ '#e74c3c'
+ )
+ )
+
+ ax2.bar(
+ dates,
+ df['volume'],
+ width=volume_width,
+ color=vol_colors,
+ alpha=0.8
+ )
+
+ # Linea media volume (riferimento spike)
+ ax2.plot(df['datetime'], vol_avg, linewidth=1, color='white', linestyle=':', alpha=0.6, label='Avg vol')
+ ax2.plot(df['datetime'], vol_avg * vol_spike_thr, linewidth=1, color='#f39c12', linestyle='--', alpha=0.7,
+ label=f'Spike ×{vol_spike_thr}')
+
+ legend2 = ax2.legend(loc='upper left', fontsize=9, framealpha=0)
+ for text in legend2.get_texts():
+ text.set_color('white')
+ ax2.set_ylabel('Volume')
+
+ # ── PANNELLO 3: OBV ──────────────────────────────────────────────
+ # colora il fill per evidenziare divergenze OBV/prezzo
+ ax3.plot(df['datetime'], df['obv'], linewidth=1.3,
+ color='#9b59b6', label='OBV')
+
+ # Divergenza rolling semplice: se prezzo sale e OBV scende → bearish (rosso)
+ price_trend = df['close'].diff(obv_lookback)
+ obv_trend = df['obv'].diff(obv_lookback)
+ bull_div = ((price_trend < 0) & (obv_trend > 0) & df['obv'].notna())
+ bear_div = ((price_trend > 0) & (obv_trend < 0) & df['obv'].notna())
+
+ ax3.fill_between(df['datetime'], df['obv'],
+ where=bull_div, alpha=0.25, color='#2ecc71',
+ label='Bullish div')
+ ax3.fill_between(df['datetime'], df['obv'],
+ where=bear_div, alpha=0.25, color='#e74c3c',
+ label='Bearish div')
+
+ legend3 = ax3.legend(loc='upper left', fontsize=9, ncol=3, framealpha=0)
+ for text in legend3.get_texts():
+ text.set_color('white')
+ ax3.set_ylabel('OBV')
+
+ # ── PANNELLO 4: SCORE COMPOSITO ──────────────────────────────────
+ score_vals = df['score'].values
+
+ # Colora la linea: verde se > 0, rosso se < 0
+ for i in range(1, len(df)):
+ if np.isnan(score_vals[i]) or np.isnan(score_vals[i - 1]):
+ continue
+ seg_color = '#2ecc71' if score_vals[i] >= 0 else '#e74c3c'
+ ax4.plot(
+ [df['datetime'].iloc[i - 1], df['datetime'].iloc[i]],
+ [score_vals[i - 1], score_vals[i]],
+ color=seg_color, linewidth=1.3
+ )
+
+ # Soglie e zone
+ ax4.axhline(score_buy_thr, linestyle='--', color='#2ecc71', linewidth=1.2,
+ label=f'Buy ≥{score_buy_thr:.0f}')
+ ax4.axhline(score_sell_thr, linestyle='--', color='#e74c3c', linewidth=1.2,
+ label=f'Sell ≤{score_sell_thr:.0f}')
+ ax4.axhline(0, linestyle=':', color='gray', alpha=0.5)
+ ax4.fill_between(
+ df['datetime'],
+ 0,
+ score_vals,
+ where=score_vals >= 0,
+ alpha=0.08,
+ color='#2ecc71'
+ )
+
+ ax4.fill_between(
+ df['datetime'],
+ 0,
+ score_vals,
+ where=score_vals < 0,
+ alpha=0.08,
+ color='#e74c3c'
+ )
+
+ ax4.set_ylim(-105, 105)
+ ax4.set_ylabel('Score')
+ legend4 = ax4.legend(loc='upper left', fontsize=9, ncol=4, framealpha=0)
+ for text in legend4.get_texts():
+ text.set_color('white')
+
+# ── FIX ASSE X BASATO SUL TIMEFRAME ───────────────────────────────
+
+ interval = config.get('interval', '5m')
+
+ if interval == '1m':
+ locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(interval=5)
+
+ elif interval == '5m':
+ locator = mdates.HourLocator(interval=1)
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+
+ elif interval == '15m':
+
+ locator = mdates.HourLocator(interval=3)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=1)
+
+ elif interval == '1h':
+
+ locator = mdates.HourLocator(interval=12)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=3)
+
+ elif interval == '8h':
+
+ locator = mdates.DayLocator(interval=4)
+ formatter = mdates.DateFormatter('%b%d')
+ minor_locator = mdates.DayLocator(interval=1)
+
+ else:
+
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ formatter = mdates.ConciseDateFormatter(locator)
+ minor_locator = None
+
+ # ── APPLICA A TUTTI GLI ASSI ──────────────────────────────────────
+
+ ax1.tick_params(labelbottom=False)
+ ax2.tick_params(labelbottom=False)
+ ax3.tick_params(labelbottom=False)
+
+ for ax in [ax1, ax2, ax3, ax4]:
+
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+
+ if minor_locator and interval not in ['1m', '5m']:
+ ax.xaxis.set_minor_locator(minor_locator)
+
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
+
+ # grid verticale
+ ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15)
+
+ # grid orizzontale
+ ax.grid(True, which='major', axis='y', alpha=0.25)
+
+ # minor grid
+ if interval not in ['1m', '5m']:
+ ax.grid(True, which='minor', axis='x', linestyle=':', alpha=0.05)
+
+# ── TITOLO ───────────────────────────────────────────────────────
+ interval = config.get('interval', '5m')
+ fig.suptitle(
+ f"{config.get('trading_pair', 'Unknown')} - Anti-Folla V1 "
+ f"(VWAP{vwap_period} | Don{donchian_period} | {interval})",
+ fontsize=13
+ )
+
+ plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06)
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config, candles_data, current_price=None, **kwargs):
+ return generate_chart(config, candles_data, current_price)
+
+
+# ── HELPERS ──────────────────────────────────────────────────────────
+def _prepare_dataframe(candles, timezone=None):
+ if timezone is None:
+ # Prende il fuso orario del sistema
+ timezone = time.tzname[0]
+ df = pd.DataFrame(candles)
+
+ # Cerca colonna timestamp
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ # Converti timestamp
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ # Determina se è millisecondi o secondi
+ if sample > 10**12: # nanosecondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ns', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ elif sample > 10**10: # millisecondi (dopo il 1970)
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ms', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else: # secondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='s', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else:
+ df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None))
+ else:
+ # Fallback: crea date sequenziali usando l'intervallo dalla config
+ # NOTA: questo è un fallback, idealmente dovresti avere timestamp reali
+ freq = config.get('interval', '5m') if 'config' in locals() else '5m'
+ df['datetime'] = pd.date_range(
+ end=pd.Timestamp.now(),
+ periods=len(df),
+ freq=freq
+ )
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+def _calc_obv(df: pd.DataFrame) -> pd.Series:
+ """On-Balance Volume."""
+ direction = np.sign(df['close'].diff().fillna(0))
+ return (direction * df['volume']).cumsum()
+
+
+def _calc_rolling_score(
+ df: pd.DataFrame,
+ vwap_period: int,
+ donchian_period: int,
+ obv_lookback: int,
+ vol_spike_thr: float,
+ w_vwap: float,
+ w_donchian: float,
+ w_obv: float,
+ w_vol_spike: float,
+ w_trade_flow: float,
+) -> pd.Series:
+ """
+ Vectorised rolling composite score (candle-only signals).
+ OBI and Funding Rate are excluded (require live data).
+ Active weights are re-normalised to 100 each bar.
+
+ Components:
+ VWAP → +w if close > vwap, -w if close < vwap
+ Donchian → +w if close > don_upper (breakout up),
+ -w if close < don_lower (breakout down)
+ OBV div → +w if bullish div, -w if bearish div
+ Volume spike→ +w if spike AND bullish candle, -w if spike AND bearish
+ Trade flow → ±w scaled by buy_pressure centred on 0.5
+ """
+ close = df['close']
+ open_ = df['open']
+ vwap = df['vwap']
+ don_upper = df['don_upper']
+ don_lower = df['don_lower']
+ obv = df['obv']
+ volume = df['volume']
+ is_spike = df['is_spike']
+
+ n = len(df)
+ scores = np.full(n, np.nan)
+
+ # Pre-compute rolling buy_pressure (10-bar window)
+ tf_window = 10
+ bull_mask = close >= open_
+
+ bull_vol = (volume.where(bull_mask, 0).rolling(tf_window + 1).sum())
+ bear_vol = (volume.where(~bull_mask, 0).rolling(tf_window + 1).sum())
+ total_vol = (bull_vol + bear_vol).replace(0, np.nan)
+ buy_pressure = (bull_vol / total_vol).fillna(0.5)
+
+ # OBV trend
+ price_trend = close.diff(obv_lookback)
+ obv_trend = obv.diff(obv_lookback)
+
+ start = max(vwap_period, donchian_period, obv_lookback) - 1
+
+ for i in range(start, n):
+ score = 0.0
+ active = 0.0
+
+ # VWAP signal
+ if not (np.isnan(vwap.iloc[i])):
+ active += w_vwap
+ score += w_vwap if close.iloc[i] > vwap.iloc[i] else -w_vwap
+
+ # Donchian breakout
+ if not (np.isnan(don_upper.iloc[i]) or np.isnan(don_lower.iloc[i])):
+ if close.iloc[i] > don_upper.iloc[i]:
+ score += w_donchian
+ active += w_donchian
+ elif close.iloc[i] < don_lower.iloc[i]:
+ score -= w_donchian
+ active += w_donchian
+
+ # OBV divergence
+ if not (np.isnan(price_trend.iloc[i]) or np.isnan(obv_trend.iloc[i])):
+ if price_trend.iloc[i] < 0 and obv_trend.iloc[i] > 0:
+ score += w_obv # bullish divergence
+ active += w_obv
+ elif price_trend.iloc[i] > 0 and obv_trend.iloc[i] < 0:
+ score -= w_obv # bearish divergence
+ active += w_obv
+
+ # Volume spike
+ if is_spike.iloc[i]:
+ bull_candle = close.iloc[i] >= open_.iloc[i]
+ score += w_vol_spike if bull_candle else -w_vol_spike
+ active += w_vol_spike
+
+ # Trade flow (buy pressure centred on 0.5, scaled to ±1)
+ bp = buy_pressure.iloc[i]
+ tf_contribution = (bp - 0.5) * 2 * w_trade_flow # range: -w … +w
+ score += tf_contribution
+ active += w_trade_flow
+
+ # Normalise to -100…+100 based on active weights
+ if active > 0:
+ scores[i] = round((score / active) * 100, 2)
+
+ return pd.Series(scores, index=df.index)
+
+def _generate_simple_chart(candles_data, current_price):
+ if not candles_data:
+ return io.BytesIO()
+ df = _prepare_dataframe(candles_data)
+ MAX_VISIBLE_CANDLES = 96
+ if len(df) > MAX_VISIBLE_CANDLES:
+ df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+ fig, ax = plt.subplots(figsize=(10, 6))
+ ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series(dtype=float)), errors='coerce'))
+ if current_price is not None:
+ ax.axhline(y=current_price, linestyle='--')
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
diff --git a/handlers/bots/controllers/anti_folla_v1/config.py b/handlers/bots/controllers/anti_folla_v1/config.py
new file mode 100644
index 00000000..1f9109eb
--- /dev/null
+++ b/handlers/bots/controllers/anti_folla_v1/config.py
@@ -0,0 +1,202 @@
+"""
+Anti-Folla V1 controller configuration.
+
+Crowd-contrarian directional trading strategy using real flow parameters:
+- LONG when composite score >= score_buy_threshold
+- SHORT when composite score <= score_sell_threshold
+
+Score is a weighted composite of:
+ VWAP position, Donchian breakout, OBV divergence, Order Book Imbalance,
+ Volume Spike, Trade Flow (whale activity), Funding Rate (futures only).
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "anti_folla_v1",
+ "controller_type": "directional_trading",
+ "id": "",
+ # Base fields
+ "manual_kill_switch": None,
+ "candles_config": [],
+ # Connector
+ "connector_name": "",
+ "trading_pair": "",
+ "total_amount_quote": 1000,
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ # DirectionalTradingControllerConfigBase fields
+ "max_executors_per_side": 1,
+ "cooldown_time": 60,
+ "stop_loss": 0.05,
+ "take_profit": 0.03,
+ "take_profit_order_type": 2,
+ "time_limit": None,
+ # Trailing stop
+ "trailing_stop": {
+ "activation_price": 0.015,
+ "trailing_delta": 0.005,
+ },
+ # Candles config
+ "candles_connector": "",
+ "candles_trading_pair": "",
+ "interval": "5m",
+ # Futures flag
+ "is_perpetual": False,
+ # Anti-Folla parameters
+ "vwap_period": 20,
+ "donchian_period": 20,
+ "atr_period": 14,
+ "obv_divergence_lookback": 10,
+ "volume_spike_threshold": 2.5,
+ # Order Book Imbalance
+ "enable_order_book_imbalance": True,
+ "obi_depth_percentage": 0.02,
+ "obi_buy_threshold": 1.5,
+ "obi_sell_threshold": 0.67,
+ # Score thresholds
+ "score_buy_threshold": 50.0,
+ "score_sell_threshold": -50.0,
+ # Weights (must sum to 100)
+ "weight_vwap": 15,
+ "weight_donchian": 10,
+ "weight_obv": 15,
+ "weight_obi": 20,
+ "weight_volume_spike": 10,
+ "weight_trade_flow": 15,
+ "weight_funding": 15,
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(name="id", label="Config ID", type="str", required=True, hint="Auto-generated"),
+ "connector_name": ControllerField(name="connector_name", label="Connector", type="str", required=True, hint="Exchange connector"),
+ "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True, hint="e.g. BTC-USDT"),
+ "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=True, hint="e.g. 1, 5, 10", default=1),
+ "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, hint="HEDGE or ONEWAY", default="HEDGE"),
+ "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (Quote)", type="float", required=True, hint="e.g. 1000 USDT"),
+ "max_executors_per_side": ControllerField(name="max_executors_per_side", label="Max Executors/Side", type="int", required=False, hint="Max concurrent positions per side", default=1),
+ "cooldown_time": ControllerField(name="cooldown_time", label="Cooldown Time (s)", type="int", required=False, hint="Seconds between new executors", default=60),
+ "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, hint="e.g. 0.05 = 5%", default=0.05),
+ "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, hint="e.g. 0.03 = 3%", default=0.03),
+ "take_profit_order_type": ControllerField(name="take_profit_order_type", label="TP Order Type", type="int", required=False, hint="1=Market, 2=Limit, 3=Limit Maker", default=2),
+ "time_limit": ControllerField(name="time_limit", label="Time Limit (s)", type="int", required=False, hint="Max executor lifetime (None = no limit)", default=None),
+ "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False, hint="Leave empty to use same as connector", default=""),
+ "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False, hint="Leave empty to use same as trading pair", default=""),
+ "interval": ControllerField(name="interval", label="Candle Interval", type="str", required=True, hint="e.g. 1m, 5m, 1h, 8h", default="5m"),
+ "is_perpetual": ControllerField(name="is_perpetual", label="Is Perpetual/Futures", type="bool", required=False, hint="Enable funding rate signal (True for perp/futures)", default=False),
+ # Anti-Folla parameters
+ "vwap_period": ControllerField(name="vwap_period", label="VWAP Period", type="int", required=False, hint="Rolling VWAP window", default=20),
+ "donchian_period": ControllerField(name="donchian_period", label="Donchian Period", type="int", required=False, hint="Donchian Channel period (with shift)", default=20),
+ "atr_period": ControllerField(name="atr_period", label="ATR Period", type="int", required=False, hint="ATR period", default=14),
+ "obv_divergence_lookback": ControllerField(name="obv_divergence_lookback", label="OBV Lookback", type="int", required=False, hint="Lookback for OBV divergence detection", default=10),
+ "volume_spike_threshold": ControllerField(name="volume_spike_threshold", label="Volume Spike Threshold", type="float", required=False, hint="Volume multiplier to detect spike (e.g. 2.5 = 2.5x avg)", default=2.5),
+ "enable_order_book_imbalance": ControllerField(name="enable_order_book_imbalance", label="Enable OBI", type="bool", required=False, hint="Enable Order Book Imbalance analysis", default=True),
+ "obi_depth_percentage": ControllerField(name="obi_depth_percentage", label="OBI Depth %", type="float", required=False, hint="Price depth from best bid for OBI (e.g. 0.02 = 2%)", default=0.02),
+ "obi_buy_threshold": ControllerField(name="obi_buy_threshold", label="OBI Buy Threshold", type="float", required=False, hint="OBI ratio >= this → buy pressure (default 1.5)", default=1.5),
+ "obi_sell_threshold": ControllerField(name="obi_sell_threshold", label="OBI Sell Threshold", type="float", required=False, hint="OBI ratio <= this → sell pressure (default 0.67)", default=0.67),
+ "score_buy_threshold": ControllerField(name="score_buy_threshold", label="Score BUY Threshold", type="float", required=False, hint="Min composite score to trigger BUY (default 50)", default=50.0),
+ "score_sell_threshold": ControllerField(name="score_sell_threshold", label="Score SELL Threshold", type="float", required=False, hint="Max composite score to trigger SELL (default -50)", default=-50.0),
+ # Weights
+ "weight_vwap": ControllerField(name="weight_vwap", label="Weight VWAP", type="float", required=False, hint="Weight for VWAP signal (all weights must sum to 100)", default=15),
+ "weight_donchian": ControllerField(name="weight_donchian", label="Weight Donchian", type="float", required=False, hint="Weight for Donchian breakout signal", default=10),
+ "weight_obv": ControllerField(name="weight_obv", label="Weight OBV", type="float", required=False, hint="Weight for OBV divergence signal", default=15),
+ "weight_obi": ControllerField(name="weight_obi", label="Weight OBI", type="float", required=False, hint="Weight for Order Book Imbalance signal", default=20),
+ "weight_volume_spike": ControllerField(name="weight_volume_spike", label="Weight Volume Spike", type="float", required=False, hint="Weight for volume spike signal", default=10),
+ "weight_trade_flow": ControllerField(name="weight_trade_flow", label="Weight Trade Flow", type="float", required=False, hint="Weight for whale trade flow signal", default=15),
+ "weight_funding": ControllerField(name="weight_funding", label="Weight Funding Rate", type="float", required=False, hint="Weight for funding rate contrarian signal (futures only)", default=15),
+ "manual_kill_switch": ControllerField(name="manual_kill_switch", label="Kill Switch", type="bool", required=False, hint="Manual kill switch", default=None),
+}
+
+FIELD_ORDER: List[str] = [
+ "id", "connector_name", "trading_pair", "leverage", "position_mode",
+ "total_amount_quote", "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type", "time_limit",
+ "candles_connector", "candles_trading_pair", "interval",
+ "is_perpetual",
+ "vwap_period", "donchian_period", "atr_period",
+ "obv_divergence_lookback", "volume_spike_threshold",
+ "enable_order_book_imbalance", "obi_depth_percentage",
+ "obi_buy_threshold", "obi_sell_threshold",
+ "score_buy_threshold", "score_sell_threshold",
+ "weight_vwap", "weight_donchian", "weight_obv", "weight_obi",
+ "weight_volume_spike", "weight_trade_flow", "weight_funding",
+ "manual_kill_switch",
+]
+
+EDITABLE_FIELDS: List[str] = [
+ "connector_name", "trading_pair", "total_amount_quote", "leverage",
+ "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type",
+ "trailing_stop_activation", "trailing_stop_delta",
+ "candles_connector", "candles_trading_pair", "interval",
+ "is_perpetual",
+ "vwap_period", "donchian_period", "atr_period",
+ "obv_divergence_lookback", "volume_spike_threshold",
+ "enable_order_book_imbalance", "obi_depth_percentage",
+ "obi_buy_threshold", "obi_sell_threshold",
+ "score_buy_threshold", "score_sell_threshold",
+ "weight_vwap", "weight_donchian", "weight_obv", "weight_obi",
+ "weight_volume_spike", "weight_trade_flow", "weight_funding",
+]
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Estrae i campi in formato piatto per l'editing, gestendo trailing_stop."""
+ trailing = config.get("trailing_stop", {})
+ # Copia i campi esistenti (che sono già piatti)
+ flat = dict(config)
+ # Aggiungi i due campi virtuali
+ flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015)
+ flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005)
+ # Rimuovi il dizionario originale per non mostrarlo come campo separato
+ flat.pop("trailing_stop", None)
+ return flat
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None:
+ """Applica gli aggiornamenti, riconvertendo trailing_stop_activation/delta."""
+ for key, value in updates.items():
+ if key == "trailing_stop_activation":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["activation_price"] = value
+ elif key == "trailing_stop_delta":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["trailing_delta"] = value
+ else:
+ config[key] = value
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ required = ["connector_name", "trading_pair", "total_amount_quote"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ weights = [
+ config.get("weight_vwap", 15),
+ config.get("weight_donchian", 10),
+ config.get("weight_obv", 15),
+ config.get("weight_obi", 20),
+ config.get("weight_volume_spike", 10),
+ config.get("weight_trade_flow", 15),
+ config.get("weight_funding", 15),
+ ]
+ total = sum(weights)
+ if abs(total - 100.0) > 0.01:
+ return False, f"Weights must sum to 100, current total: {total:.2f}"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ max_num = 0
+ for cfg in existing_configs:
+ parts = cfg.get("id", "").split("_", 1)
+ if parts and parts[0].isdigit():
+ max_num = max(max_num, int(parts[0]))
+ seq = str(max_num + 1).zfill(3)
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+ return f"{seq}_antifolla_{connector}_{pair}"
diff --git a/handlers/bots/controllers/arbitrage_controller/__init__.py b/handlers/bots/controllers/arbitrage_controller/__init__.py
new file mode 100644
index 00000000..01b0c7d4
--- /dev/null
+++ b/handlers/bots/controllers/arbitrage_controller/__init__.py
@@ -0,0 +1,88 @@
+"""Arbitrage Controller Module - CEX/DEX arbitrage with historical analysis."""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import (
+ DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS,
+ generate_id, validate_config
+)
+from .analysis import analyze_historical_spread, ArbitrageAnalyzer
+
+
+class ArbitrageControllerController(BaseController):
+ controller_type = "arbitrage_controller"
+ display_name = "Arbitrage"
+ description = "CEX/DEX arbitrage with historical spread analysis"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate configuration synchronously."""
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(
+ cls,
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+ grid_analysis: Optional[Dict[str, Any]] = None
+ ) -> io.BytesIO:
+ """Generate chart with optional grid analysis overlay."""
+ return generate_chart(config, candles_data, current_price, grid_analysis)
+
+ @classmethod
+ def generate_preview_chart(
+ cls,
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None
+ ) -> io.BytesIO:
+ """Generate preview chart."""
+ return generate_preview_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate unique ID for the configuration."""
+ return generate_id(config, existing_configs)
+
+ @classmethod
+ async def analyze_historical_spread(
+ cls,
+ candles1: List[Dict[str, Any]],
+ candles2: List[Dict[str, Any]],
+ config: Dict[str, Any],
+ fee_1: Optional[float] = None,
+ fee_2: Optional[float] = None
+ ) -> Dict[str, Any]:
+ """Run historical spread analysis for fee and profitability assessment."""
+ return await analyze_historical_spread(candles1, candles2, config, fee_1, fee_2)
+
+
+__all__ = [
+ "ArbitrageControllerController",
+ "DEFAULTS",
+ "FIELDS",
+ "FIELD_ORDER",
+ "EDITABLE_FIELDS",
+ "validate_config",
+ "generate_id",
+ "generate_chart",
+ "generate_preview_chart",
+ "analyze_historical_spread",
+ "ArbitrageAnalyzer"
+]
diff --git a/handlers/bots/controllers/arbitrage_controller/analysis.py b/handlers/bots/controllers/arbitrage_controller/analysis.py
new file mode 100644
index 00000000..44326b3c
--- /dev/null
+++ b/handlers/bots/controllers/arbitrage_controller/analysis.py
@@ -0,0 +1,121 @@
+"""
+Analysis for arbitrage controller - historical spread analysis and fee assessment.
+"""
+
+import logging
+from typing import Any, Dict, List, Optional
+
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+class ArbitrageAnalyzer:
+ """
+ Analyzes historical spread data to suggest optimal parameters.
+ """
+
+ def __init__(self, config: Dict[str, Any]):
+ self.config = config
+ self.spread_values = []
+ self.zscore_values = []
+
+ def load_spread_data(self, spread_data: List[Dict]) -> None:
+ """Load spread data from historical analysis."""
+ self.spread_values = [s["spread"] for s in spread_data if not np.isnan(s["spread"])]
+ self.zscore_values = [s["zscore"] for s in spread_data if not np.isnan(s["zscore"])]
+
+ def suggest_min_profitability(self, fee_total: float = 0.002, percentile: float = 75) -> float:
+ if not self.spread_values:
+ return max(fee_total, 0.005)
+
+ spread_sorted = sorted(self.spread_values)
+
+ # Se P75 è negativo, usa un percentile più alto (es. P90)
+ p75_idx = int(len(spread_sorted) * 75 / 100)
+ p75_val = spread_sorted[p75_idx]
+
+ if p75_val <= 0:
+ # Usa P90 invece di P75
+ p90_idx = int(len(spread_sorted) * 90 / 100)
+ suggested_pct = spread_sorted[p90_idx]
+ logger.info(f"P75 negativo ({p75_val:.4f}%), usando P90 = {suggested_pct:.4f}%")
+ else:
+ suggested_pct = p75_val
+
+ suggested_decimal = suggested_pct / 100
+ suggested_decimal = max(suggested_decimal, fee_total * 1.1)
+
+ return suggested_decimal
+
+ def get_spread_statistics(self) -> Dict[str, float]:
+ """Get comprehensive spread statistics."""
+ if not self.spread_values:
+ return {}
+
+ return {
+ "min": min(self.spread_values),
+ "max": max(self.spread_values),
+ "mean": np.mean(self.spread_values),
+ "median": np.median(self.spread_values),
+ "std": np.std(self.spread_values),
+ "p25": np.percentile(self.spread_values, 25),
+ "p50": np.percentile(self.spread_values, 50),
+ "p75": np.percentile(self.spread_values, 75),
+ "p90": np.percentile(self.spread_values, 90),
+ "p95": np.percentile(self.spread_values, 95),
+ }
+
+
+async def analyze_historical_spread(
+ candles1: List[Dict],
+ candles2: List[Dict],
+ config: Dict[str, Any],
+ fee_1: float = None,
+ fee_2: float = None
+) -> Dict[str, Any]:
+ """
+ Run historical spread analysis and return statistics.
+
+ Args:
+ candles1: Historical candles for exchange 1
+ candles2: Historical candles for exchange 2
+ config: Configuration dict
+ fee_1: Fee rate for exchange 1 (optional)
+ fee_2: Fee rate for exchange 2 (optional)
+
+ Returns:
+ Dict with statistics and suggestions
+ """
+ from .chart import calculate_spread_series
+
+ # Calcola spread storico
+ spread_data = calculate_spread_series(candles1, candles2)
+
+ if not spread_data:
+ return {"error": "No spread data available"}
+
+ # Fee totali per round trip
+ total_fee = (fee_1 or 0.001) + (fee_2 or 0.001)
+
+ # Analizza
+ analyzer = ArbitrageAnalyzer(config)
+ analyzer.load_spread_data(spread_data)
+
+ statistics = analyzer.get_spread_statistics()
+ suggested_min_profitability = analyzer.suggest_min_profitability(
+ fee_total=total_fee,
+ percentile=75
+ )
+
+ # Calcola se la coppia è arbitraggiabile (P75 > fees)
+ p75 = statistics.get('p75', 0)
+ is_arbitrageable = (p75 / 100) > total_fee if p75 > 0 else False
+
+ return {
+ "statistics": statistics,
+ "suggested_min_profitability": suggested_min_profitability,
+ "total_fees_percent": total_fee * 100,
+ "is_arbitrageable": is_arbitrageable,
+ "total_samples": len(analyzer.spread_values)
+ }
diff --git a/handlers/bots/controllers/arbitrage_controller/chart.py b/handlers/bots/controllers/arbitrage_controller/chart.py
new file mode 100644
index 00000000..506dff62
--- /dev/null
+++ b/handlers/bots/controllers/arbitrage_controller/chart.py
@@ -0,0 +1,535 @@
+"""Arbitrage Controller chart - shows both exchange prices, spread % and Z-score with profit simulation."""
+
+import io
+from typing import Any, Dict, List, Optional
+import logging
+import numpy as np
+import pandas as pd
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+logger = logging.getLogger(__name__)
+
+def _normalize_candles(candles):
+ """Convert candles to standard list of dicts."""
+ if not candles:
+ return []
+ if isinstance(candles[0], dict) and 'timestamp' in candles[0]:
+ return candles
+ normalized = []
+ for item in candles:
+ if len(item) >= 6:
+ ts, o, h, l, c, v = item[:6]
+ normalized.append({
+ 'timestamp': ts,
+ 'open': float(o),
+ 'high': float(h),
+ 'low': float(l),
+ 'close': float(c),
+ 'volume': float(v) if v else 0
+ })
+ elif len(item) == 5:
+ ts, o, h, l, c = item
+ normalized.append({
+ 'timestamp': ts,
+ 'open': float(o),
+ 'high': float(h),
+ 'low': float(l),
+ 'close': float(c),
+ 'volume': 0
+ })
+ return normalized
+
+
+def _convert_timestamps_to_datetime(candles):
+ """Convert numeric timestamps to datetime objects."""
+ if not candles:
+ return candles
+ for c in candles:
+ ts = c['timestamp']
+ if isinstance(ts, (int, float)):
+ if ts > 1e12:
+ ts = ts / 1000.0
+ c['timestamp'] = pd.to_datetime(ts, unit='s')
+ return candles
+
+def calculate_spread_series(candles1, candles2):
+ """
+ Align two candlestick series and compute:
+ - spread %
+ - rolling mean
+ - z-score
+ """
+
+ # ==========================================
+ # NORMALIZE INPUT
+ # ==========================================
+
+ df1 = pd.DataFrame(_normalize_candles(candles1))
+ df2 = pd.DataFrame(_normalize_candles(candles2))
+
+ if df1.empty or df2.empty:
+ return []
+
+ # ==========================================
+ # TIMESTAMP HANDLING
+ # ==========================================
+
+ df1["timestamp"] = pd.to_datetime(df1["timestamp"])
+ df2["timestamp"] = pd.to_datetime(df2["timestamp"])
+
+ df1 = df1.sort_values("timestamp")
+ df2 = df2.sort_values("timestamp")
+
+ # ==========================================
+ # ALIGN CANDLES
+ # ==========================================
+
+ df = pd.merge_asof(
+ df1,
+ df2,
+ on="timestamp",
+ suffixes=("_1", "_2"),
+ direction="nearest",
+ tolerance=pd.Timedelta("2min")
+ )
+
+ # Remove rows without valid aligned candles
+ df = df.dropna(subset=["close_1", "close_2"])
+
+ if df.empty:
+ return []
+
+ # ==========================================
+ # NUMERIC CONVERSION
+ # ==========================================
+
+ df["close_1"] = pd.to_numeric(df["close_1"], errors="coerce")
+ df["close_2"] = pd.to_numeric(df["close_2"], errors="coerce")
+
+ df = df.dropna(subset=["close_1", "close_2"])
+
+ if df.empty:
+ return []
+
+ # ==========================================
+ # SPREAD CALCULATION
+ # ==========================================
+
+ # Log spread = more statistically stable
+ spread_pct = np.log(df["close_2"] / df["close_1"]) * 100
+
+ # ==========================================
+ # ROLLING STATISTICS
+ # ==========================================
+
+ window = 20
+
+ rolling_mean = spread_pct.rolling(window=window).mean()
+ rolling_std = spread_pct.rolling(window=window).std()
+
+ # Avoid division by zero
+ zscores = (spread_pct - rolling_mean) / (rolling_std + 1e-9)
+
+ # ==========================================
+ # BUILD OUTPUT
+ # ==========================================
+
+ spreads = []
+
+ for i in range(len(df)):
+
+ spreads.append({
+ "time": df["timestamp"].iloc[i],
+
+ "spread": (
+ float(spread_pct.iloc[i])
+ if not np.isnan(spread_pct.iloc[i])
+ else 0.0
+ ),
+
+ "mean": (
+ float(rolling_mean.iloc[i])
+ if not np.isnan(rolling_mean.iloc[i])
+ else 0.0
+ ),
+
+ "zscore": (
+ float(zscores.iloc[i])
+ if not np.isnan(zscores.iloc[i])
+ else 0.0
+ ),
+ })
+
+ return spreads
+
+def generate_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+ grid_analysis: Optional[Dict[str, Any]] = None,
+) -> io.BytesIO:
+ """Generate chart with 4 panels: Prices, Spread, Z-Score, Cumulative Profit."""
+
+ # Normalizza e converti timestamp
+ candles1 = _convert_timestamps_to_datetime(_normalize_candles(candles_data))
+ candles2 = _convert_timestamps_to_datetime(_normalize_candles(config.get("candles_exchange_2", [])))
+
+ # Limita a 80 candele
+ MAX_CANDLES = 240
+ if len(candles1) > MAX_CANDLES:
+ candles1 = candles1[-MAX_CANDLES:]
+ if len(candles2) > MAX_CANDLES:
+ candles2 = candles2[-MAX_CANDLES:]
+
+ if not candles1:
+ buf = io.BytesIO()
+ fig = go.Figure()
+ fig.add_annotation(text="No candle data available", x=0.5, y=0.5, showarrow=False)
+ fig.write_image(buf, format="png")
+ buf.seek(0)
+ return buf
+
+ # Extract configuration
+ ep1 = config.get("exchange_pair_1", {})
+ ep2 = config.get("exchange_pair_2", {})
+ connector1 = ep1.get("connector_name", "Exchange 1")
+ connector2 = ep2.get("connector_name", "Exchange 2")
+ pair1 = ep1.get("trading_pair", "Unknown")
+ pair2 = ep2.get("trading_pair", "Unknown")
+ min_profit = float(config.get("min_profitability", 0.005))
+
+ # Parametri per il calcolo del profitto
+ capital = float(config.get("total_amount_quote", 1000))
+ fee_rate_1 = float(config.get("fee_rate_exchange_1", 0.0005))
+ fee_rate_2 = float(config.get("fee_rate_exchange_2", 0.0005))
+ slippage = float(config.get("slippage", 0.0005))
+
+ title = (f"🔬 BACKTEST: {connector1} {pair1} ↔ {connector2} {pair2} | "
+ f"min profit: {min_profit*100:.2f}% | capital: ${capital:,.0f} | "
+ f"fees: {fee_rate_1*100:.2f}%/{fee_rate_2*100:.2f}%")
+
+ # Calcola lo spread
+ spread_data = calculate_spread_series(candles1, candles2)
+ if spread_data:
+ spreads = [s["spread"] for s in spread_data]
+ logger.info(f"Spread min: {min(spreads):.4f}%, max: {max(spreads):.4f}%")
+ logger.info(f"Z-Score min: {min([s['zscore'] for s in spread_data]):.2f}, max: {max([s['zscore'] for s in spread_data]):.2f}")
+
+
+
+ if not spread_data:
+ buf = io.BytesIO()
+ fig = go.Figure()
+ fig.add_annotation(text="No spread data available", x=0.5, y=0.5, showarrow=False)
+ fig.write_image(buf, format="png")
+ buf.seek(0)
+ return buf
+
+ # TROVA IL PRIMO INDICE DOVE ZSCORE È VALIDO
+ start_idx = 0
+ for i, s in enumerate(spread_data):
+ if not np.isnan(s["zscore"]):
+ start_idx = i
+ break
+
+ # Se non trovato o troppo vicino all'inizio, usa un offset minimo di 10
+ if start_idx < 10:
+ start_idx = 10
+
+ # TAGLIA TUTTI I DATI DALLO STESSO PUNTO DI PARTENZA
+ spread_data = spread_data[start_idx:]
+
+ # Allinea anche le candele dei prezzi
+ if len(candles1) > start_idx:
+ candles1 = candles1[start_idx:]
+ if len(candles2) > start_idx:
+ candles2 = candles2[start_idx:]
+
+ # Create 3 subplots
+ fig = make_subplots(
+ rows=3,
+ cols=1,
+ shared_xaxes=True,
+ vertical_spacing=0.08,
+ row_heights=[0.45, 0.30, 0.25],
+ subplot_titles=(
+ f"Prices: {connector1} (cyan) vs {connector2} (orange)",
+
+ f"Spread % | Entry Thresholds ±{min_profit*100:.2f}%",
+
+ f"Cumulative Profit "
+ f"(capital: ${capital:,.0f} | fees: {fee_rate_1*100:.2f}% + {fee_rate_2*100:.2f}%)"
+ )
+ )
+
+ # ==========================================
+ # ROW 1: Price chart
+ # ==========================================
+ if candles1:
+ df1 = pd.DataFrame(candles1).sort_values("timestamp")
+ fig.add_trace(
+ go.Scatter(
+ x=df1["timestamp"],
+ y=df1["close"],
+ mode="lines",
+ name=f"{connector1} {pair1}",
+ line=dict(color="#00d4ff", width=2.5),
+ ),
+ row=1, col=1
+ )
+
+ if candles2:
+ df2 = pd.DataFrame(candles2).sort_values("timestamp")
+ fig.add_trace(
+ go.Scatter(
+ x=df2["timestamp"],
+ y=df2["close"],
+ mode="lines",
+ name=f"{connector2} {pair2}",
+ line=dict(color="orange", width=2.5),
+ ),
+ row=1, col=1
+ )
+
+ if candles1 and len(candles1) == len(candles2):
+ df1_aligned = pd.DataFrame(candles1).sort_values("timestamp")
+ fig.add_trace(
+ go.Scatter(
+ x=df1_aligned["timestamp"],
+ y=df2["close"],
+ mode='lines',
+ fill='tonexty',
+ name="Differenza",
+ line=dict(width=0),
+ fillcolor='rgba(255,165,0,0.15)',
+ showlegend=False
+ ),
+ row=1, col=1
+ )
+
+ fig.add_annotation(
+ xref="x domain", yref="y domain", x=0.02, y=0.98,
+ xanchor="left", yanchor="top",
+ text=f"{connector1} ● | {connector2} ●",
+ showarrow=False, font=dict(size=11, color="white"),
+ bgcolor='rgba(0,0,0,0.5)', borderpad=6, borderwidth=1, bordercolor='#444',
+ row=1, col=1
+ )
+
+ # ==========================================
+ # ROW 2: Spread %
+ # ==========================================
+ spread_vals = [s["spread"] for s in spread_data if s["spread"] != 0]
+ if spread_vals:
+ min_spread, max_spread = min(spread_vals), max(spread_vals)
+ margin = (max_spread - min_spread) * 0.15 if max_spread != min_spread else 0.2
+
+ fig.add_trace(
+ go.Scatter(x=[s["time"] for s in spread_data], y=[s["spread"] for s in spread_data],
+ mode="lines", name="Spread %",
+ line=dict(color="cyan", width=2)),
+ row=2, col=1
+ )
+ fig.add_trace(
+ go.Scatter(x=[s["time"] for s in spread_data], y=[s["mean"] for s in spread_data],
+ mode="lines", name="Mean (20)",
+ line=dict(color="orange", width=1.5, dash="dash")),
+ row=2, col=1
+ )
+
+ fig.update_yaxes(range=[min_spread - margin, max_spread + margin], row=2, col=1)
+
+ fig.add_hline(y=min_profit * 100, line_dash="dot", line_color="green",
+ row=2, col=1, annotation_text=f"+{min_profit*100:.2f}%")
+ fig.add_hline(y=-min_profit * 100, line_dash="dot", line_color="red",
+ row=2, col=1, annotation_text=f"-{min_profit*100:.2f}%")
+
+ fig.add_annotation(
+ xref="x domain", yref="y domain", x=0.02, y=0.98,
+ xanchor="left", yanchor="top",
+ text="Spread % ● | Mean (20) --- | Thresholds + -",
+ showarrow=False, font=dict(size=11, color="white"),
+ bgcolor='rgba(0,0,0,0.5)', borderpad=6, borderwidth=1, bordercolor='#444',
+ row=2, col=1
+ )
+
+ # ==========================================
+ # ROW 3: REALIZED CUMULATIVE PROFIT
+ # ==========================================
+
+ realized_profit = 0.0
+ position = None
+ entry_spread = None
+
+ total_fee_pct = (
+ fee_rate_1 +
+ fee_rate_2 +
+ (slippage * 2)
+ ) * 100
+
+ # Calcola soglia dinamica basata sullo spread MASSIMO (non sulla media)
+ if spread_vals:
+ max_spread = max(spread_vals)
+ # Entra quando lo spread supera l'80% del massimo storico
+ ENTRY_SPREAD = max_spread * 0.8
+ # Soglia minima per evitare rumore (0.01%)
+ MIN_THRESHOLD = 0.01
+ ENTRY_SPREAD = max(ENTRY_SPREAD, MIN_THRESHOLD)
+ logger.info(f"MAX_SPREAD: {max_spread:.4f}%, ENTRY_SPREAD: {ENTRY_SPREAD:.4f}%")
+ else:
+ ENTRY_SPREAD = min_profit * 100
+
+ EXIT_SPREAD = ENTRY_SPREAD * 0.25
+
+ equity = []
+ profit_times = []
+
+ for s in spread_data:
+ spread = s["spread"]
+
+ # ======================================
+ # ENTRY LOGIC
+ # ======================================
+ if position is None:
+ if spread < -ENTRY_SPREAD:
+ position = "long"
+ entry_spread = spread
+ logger.info(f"LONG ENTRY at spread: {spread:.4f}%")
+ elif spread > ENTRY_SPREAD:
+ position = "short"
+ entry_spread = spread
+ logger.info(f"SHORT ENTRY at spread: {spread:.4f}%")
+
+ # ======================================
+ # EXIT LONG
+ # ======================================
+ elif position == "long":
+ if spread >= -EXIT_SPREAD:
+ spread_move = spread - entry_spread
+ trade_profit_pct = spread_move - total_fee_pct
+ #trade_profit_pct = max(trade_profit_pct, 0)
+ realized_profit += (trade_profit_pct / 100) * capital
+ logger.info(f"LONG EXIT at spread: {spread:.4f}%, profit: ${realized_profit:.2f}")
+ position = None
+ entry_spread = None
+
+ # ======================================
+ # EXIT SHORT
+ # ======================================
+ elif position == "short":
+ if spread <= EXIT_SPREAD:
+ spread_move = entry_spread - spread
+ trade_profit_pct = spread_move - total_fee_pct
+ #trade_profit_pct = max(trade_profit_pct, 0)
+ realized_profit += (trade_profit_pct / 100) * capital
+ logger.info(f"SHORT EXIT at spread: {spread:.4f}%, profit: ${realized_profit:.2f}")
+ position = None
+ entry_spread = None
+
+ equity.append(realized_profit)
+ profit_times.append(s["time"])
+ # ==========================================
+ # SAFETY CHECK
+ # ==========================================
+ if len(equity) < 2:
+ equity = [0, 0]
+ profit_times = [spread_data[0]["time"], spread_data[-1]["time"]]
+
+ final_profit_usd = equity[-1]
+
+ line_color = "#00ff88" if final_profit_usd >= 0 else "#ff4444"
+ area_color = "rgba(0,255,136,0.15)" if final_profit_usd >= 0 else "rgba(255,68,68,0.15)"
+
+ # ==========================================
+ # PLOT
+ # ==========================================
+ fig.add_trace(
+ go.Scatter(
+ x=profit_times,
+ y=equity,
+ mode="lines",
+ name="Equity Curve",
+ line=dict(color=line_color, width=2),
+ fill="tozeroy",
+ fillcolor=area_color
+ ),
+ row=3,
+ col=1
+ )
+
+ fig.add_hline(
+ y=0,
+ line_dash="solid",
+ line_color="gray",
+ row=3,
+ col=1,
+ opacity=0.5
+ )
+
+ fig.update_yaxes(
+ title_text="Profit (USD)",
+ row=3,
+ col=1,
+ autorange=True
+ )
+
+ profit_color = "#00ff88" if final_profit_usd >= 0 else "#ff4444"
+
+ # In ROW 3, modifica l'annotazione del profit
+ fig.add_annotation(
+ xref="x domain", yref="y domain", x=0.02, y=0.98, # ← x=0.02 (sinistra)
+ xanchor="left", yanchor="top",
+ text=f"Backtest Final Profit: ${final_profit_usd:.2f} | "
+ f"ROI: {final_profit_usd/capital*100:.2f}%",
+ showarrow=False, font=dict(size = 11, color="white"),
+ bgcolor='rgba(15,18,25,0.82)', borderpad=6, borderwidth=1, bordercolor='#444',
+ row=3, col=1
+ )
+
+ # ==========================================
+ # LAYOUT
+ # ==========================================
+ fig.update_layout(
+ title=dict(text=title, font=dict(size=13, color="white"), x=0.5),
+ template="plotly_dark",
+ height=1050,
+ hovermode="x unified",
+ plot_bgcolor='#0e1117',
+ paper_bgcolor='#0e1117',
+ showlegend=False
+ )
+
+ fig.update_xaxes(
+ gridcolor='#2a2f3a',
+ showgrid=True,
+ gridwidth=0.5,
+ title_font=dict(color="white", size=10),
+ tickfont=dict(color="white", size=9),
+ tickformat="%H:%M\n%d/%m"
+ )
+ fig.update_yaxes(
+ gridcolor='#2a2f3a',
+ showgrid=True,
+ gridwidth=0.5,
+ title_font=dict(color="white", size=10),
+ tickfont=dict(color="white", size=9)
+ )
+
+ fig.update_yaxes(title_text="Price (USD)", row=1, col=1)
+ fig.update_yaxes(title_text="Spread (%)", row=2, col=1)
+ fig.update_yaxes(title_text="Profit (USD)", row=3, col=1)
+ fig.update_xaxes(title_text="Time", row=3, col=1)
+
+ buf = io.BytesIO()
+ fig.write_image(buf, format="png", scale=2)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None
+) -> io.BytesIO:
+ """Preview chart without grid analysis overlays."""
+ return generate_chart(config, candles_data, current_price, grid_analysis=None)
diff --git a/handlers/bots/controllers/arbitrage_controller/config.py b/handlers/bots/controllers/arbitrage_controller/config.py
new file mode 100644
index 00000000..0bc50912
--- /dev/null
+++ b/handlers/bots/controllers/arbitrage_controller/config.py
@@ -0,0 +1,302 @@
+"""
+Arbitrage Controller configuration with dynamic exchange validation and auto-optimization.
+
+CEX/DEX (or CEX/CEX, DEX/DEX) arbitrage strategy that simultaneously
+buys on one exchange and sells on another when profitability exceeds
+the minimum threshold.
+
+Config structure matches ArbitrageControllerConfig in hummingbot-api:
+- exchange_pair_1: {connector_name, trading_pair} (nested object)
+- exchange_pair_2: {connector_name, trading_pair} (nested object)
+- rate_connector: used to fetch conversion rates (gas token, quote conversion)
+- quote_conversion_asset: asset used to normalize profits (usually USDT)
+
+Gas fees for DEX connectors are handled automatically by hummingbot
+via GatewayHttpClient — no manual gas configuration needed.
+"""
+
+import asyncio
+import logging
+from typing import Any, Dict, List, Optional, Tuple
+from decimal import Decimal
+
+import numpy as np
+
+from .._base import ControllerField
+
+logger = logging.getLogger(__name__)
+
+# ============================================
+# DEFAULTS with intelligent values
+# ============================================
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "arbitrage_controller",
+ "controller_type": "generic",
+ "id": "",
+ "total_amount_quote": 1000,
+ # exchange_pair_1 and exchange_pair_2 are nested ConnectorPair objects
+ "exchange_pair_1": {
+ "connector_name": "binance",
+ "trading_pair": "SOL-USDT",
+ },
+ "exchange_pair_2": {
+ "connector_name": "jupiter/router",
+ "trading_pair": "SOL-USDC",
+ },
+ "min_profitability": 0.005, # 0.5% (more realistic)
+ "delay_between_executors": 5, # 5 seconds
+ "max_executors_imbalance": 2,
+ "rate_connector": "binance",
+ "quote_conversion_asset": "USDT",
+ # Base fields from ControllerConfigBase
+ "manual_kill_switch": None,
+ "candles_config": [],
+ "backtest_interval": "5m", # timeframe delle candele (1m, 5m, 15m, 1h, 4h, 1d)
+ "backtest_candles": 500, # numero di candele da fetchare (max 1000)
+}
+
+# ============================================
+# FIELD DEFINITIONS
+# ============================================
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id", label="Config ID", type="str", required=True, hint="Auto-generated"
+ ),
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote", label="Total Amount (Quote)", type="float",
+ required=True, hint="Total capital in quote asset (e.g. 1000 USDT)"
+ ),
+ "exchange_pair_1_connector": ControllerField(
+ name="exchange_pair_1_connector", label="Exchange 1 Connector", type="str",
+ required=True, hint="e.g. binance, kucoin, hyperliquid_perpetual"
+ ),
+ "exchange_pair_1_pair": ControllerField(
+ name="exchange_pair_1_pair", label="Exchange 1 Pair", type="str",
+ required=True, hint="e.g. SOL-USDT"
+ ),
+ "exchange_pair_2_connector": ControllerField(
+ name="exchange_pair_2_connector", label="Exchange 2 Connector", type="str",
+ required=True, hint="e.g. jupiter/router, uniswap/ethereum"
+ ),
+ "exchange_pair_2_pair": ControllerField(
+ name="exchange_pair_2_pair", label="Exchange 2 Pair", type="str",
+ required=True, hint="e.g. SOL-USDC (can differ if quote assets differ)"
+ ),
+ "min_profitability": ControllerField(
+ name="min_profitability", label="Min Profitability", type="float",
+ required=True, hint="Min profit to execute (e.g. 0.01 = 1%)", default=0.005
+ ),
+ "delay_between_executors": ControllerField(
+ name="delay_between_executors", label="Delay Between Exec (s)", type="int",
+ required=False, hint="Seconds between executor creation (default: 5)", default=5
+ ),
+ "max_executors_imbalance": ControllerField(
+ name="max_executors_imbalance", label="Max Imbalance", type="int",
+ required=False, hint="Max buy/sell imbalance before pausing (default: 2)", default=2
+ ),
+ "rate_connector": ControllerField(
+ name="rate_connector", label="Rate Connector", type="str",
+ required=False, hint="CEX for conversion rates, e.g. binance", default="binance"
+ ),
+ "quote_conversion_asset": ControllerField(
+ name="quote_conversion_asset", label="Quote Conversion Asset", type="str",
+ required=False, hint="Asset to normalize profits, e.g. USDT", default="USDT"
+ ),
+ "manual_kill_switch": ControllerField(
+ name="manual_kill_switch", label="Kill Switch", type="bool",
+ required=False, hint="Manual kill switch", default=None
+ ),
+ "backtest_interval": ControllerField(
+ name="backtest_interval", label="Backtest Interval", type="str",
+ required=False, hint="Candle timeframe (1m, 5m, 15m, 1h, 4h, 1d)", default="5m"
+ ),
+ "backtest_candles": ControllerField(
+ name="backtest_candles", label="Backtest Candles", type="int",
+ required=False, hint="Number of candles for backtest (100-1000)", default=500
+ ),
+ "fee_rate_exchange_1": ControllerField(
+ name="fee_rate_exchange_1",
+ label="Fee Rate Exchange 1",
+ type="float",
+ required=False,
+ hint="Trading fee exchange 1 (e.g. 0.001 = 0.1%)",
+ default=0.001
+ ),
+
+ "fee_rate_exchange_2": ControllerField(
+ name="fee_rate_exchange_2",
+ label="Fee Rate Exchange 2",
+ type="float",
+ required=False,
+ hint="Trading fee exchange 2 (e.g. 0.001 = 0.1%)",
+ default=0.001
+ ),
+}
+
+FIELD_ORDER: List[str] = [
+ "id",
+ "delay_between_executors",
+ "exchange_pair_1_connector","exchange_pair_1_pair",
+ "exchange_pair_2_connector", "exchange_pair_2_pair",
+ "fee_rate_exchange_1",
+ "fee_rate_exchange_2",
+ "max_executors_imbalance",
+ "min_profitability",
+ "quote_conversion_asset",
+ "rate_connector",
+ "total_amount_quote"
+]
+
+EDITABLE_FIELDS: List[str] = [
+ "delay_between_executors",
+ "exchange_pair_1_connector", "exchange_pair_1_pair",
+ "exchange_pair_2_connector", "exchange_pair_2_pair",
+ "fee_rate_exchange_1",
+ "fee_rate_exchange_2",
+ "max_executors_imbalance",
+ "min_profitability",
+ "quote_conversion_asset",
+ "rate_connector",
+ "total_amount_quote"
+
+]
+
+# ============================================
+# HELPER FUNCTIONS
+# ============================================
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Extract flat key=value fields for the edit form."""
+ ep1 = config.get("exchange_pair_1", {}) or {}
+ ep2 = config.get("exchange_pair_2", {}) or {}
+
+
+ return {
+ "total_amount_quote": config.get("total_amount_quote", 1000),
+ "exchange_pair_1_connector": ep1.get("connector_name", ""),
+ "exchange_pair_1_pair": ep1.get("trading_pair", ""),
+ "exchange_pair_2_connector": ep2.get("connector_name", ""),
+ "exchange_pair_2_pair": ep2.get("trading_pair", ""),
+ "min_profitability": config.get("min_profitability", 0.005),
+ "delay_between_executors": config.get("delay_between_executors", 5),
+ "max_executors_imbalance": config.get("max_executors_imbalance", 2),
+ "rate_connector": config.get("rate_connector", "binance"),
+ "quote_conversion_asset": config.get("quote_conversion_asset", "USDT"),
+ "fee_rate_exchange_1": config.get("fee_rate_exchange_1", 0.001),
+ "fee_rate_exchange_2": config.get("fee_rate_exchange_2", 0.001),
+ }
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
+ """Apply flat key=value updates back into the nested config structure."""
+ for key, value in updates.items():
+ if key == "exchange_pair_1_connector":
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["connector_name"] = value
+ elif key == "exchange_pair_1_pair":
+ if "exchange_pair_1" not in config:
+ config["exchange_pair_1"] = {}
+ config["exchange_pair_1"]["trading_pair"] = value
+ elif key == "exchange_pair_2_connector":
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["connector_name"] = value
+ elif key == "exchange_pair_2_pair":
+ if "exchange_pair_2" not in config:
+ config["exchange_pair_2"] = {}
+ config["exchange_pair_2"]["trading_pair"] = value
+ else:
+ config[key] = value
+ return config
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate configuration with enhanced business logic."""
+ ep1 = config.get("exchange_pair_1", {}) or {}
+ ep2 = config.get("exchange_pair_2", {}) or {}
+
+ # Basic validation
+ if not ep1.get("connector_name"):
+ return False, "Missing exchange_pair_1 connector_name"
+ if not ep1.get("trading_pair"):
+ return False, "Missing exchange_pair_1 trading_pair"
+ if not ep2.get("connector_name"):
+ return False, "Missing exchange_pair_2 connector_name"
+ if not ep2.get("trading_pair"):
+ return False, "Missing exchange_pair_2 trading_pair"
+
+ total_amount = float(config.get("total_amount_quote", 0))
+ if total_amount <= 0:
+ return False, "total_amount_quote must be positive"
+
+ # Validate base assets match
+ pair1 = ep1.get("trading_pair", "")
+ pair2 = ep2.get("trading_pair", "")
+
+ if "-" in pair1 and "-" in pair2:
+ base1 = pair1.split("-")[0]
+ base2 = pair2.split("-")[0]
+
+ if base1 != base2:
+ return False, f"Base assets must match: {base1} vs {base2}"
+
+ # Validate profitability
+ min_prof = float(config.get("min_profitability", 0))
+ if min_prof <= 0:
+ return False, "min_profitability must be positive"
+ if min_prof > 0.5:
+ return False, "min_profitability too high (>50%)"
+
+ # Validate delay
+ delay = config.get("delay_between_executors", 5)
+ if delay < 1:
+ return False, "delay_between_executors must be at least 1 second"
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate unique ID for the configuration."""
+ max_num = 0
+ for cfg in existing_configs:
+ parts = cfg.get("id", "").split("_", 1)
+ if parts and parts[0].isdigit():
+ max_num = max(max_num, int(parts[0]))
+ seq = str(max_num + 1).zfill(3)
+
+ ep1 = config.get("exchange_pair_1", {}) or {}
+ ep2 = config.get("exchange_pair_2", {}) or {}
+ c1 = ep1.get("connector_name", "ex1").replace("_perpetual", "").replace("_spot", "").replace("/", "-")
+ c2 = ep2.get("connector_name", "ex2").replace("_perpetual", "").replace("_spot", "").replace("/", "-")
+ pair = ep1.get("trading_pair", "UNKNOWN").replace("-", "_")
+ return f"{seq}_arb_{c1}_{c2}_{pair}"
+
+
+# ============================================
+# EXCHANGE DATA FETCHER (internal)
+# ============================================
+
+
+async def _get_current_price(connector_name: str, trading_pair: str, use_mid: bool = True) -> Optional[float]:
+ """Get current price from connector."""
+ cache_key = f"price_{connector_name}_{trading_pair}"
+ cached = _cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ try:
+ from hummingbot.client.hummingbot_application import HummingbotApplication
+ app = HummingbotApplication.main_application()
+
+ if connector_name in app.connectors:
+ connector = app.connectors[connector_name]
+ price = connector.get_price(trading_pair, False)
+ if price:
+ _cache.set(cache_key, float(price))
+ return float(price)
+ except Exception as e:
+ logger.debug(f"Failed to get price: {e}")
+
+ return None
diff --git a/handlers/bots/controllers/bollingrid/__init__.py b/handlers/bots/controllers/bollingrid/__init__.py
new file mode 100644
index 00000000..7172187c
--- /dev/null
+++ b/handlers/bots/controllers/bollingrid/__init__.py
@@ -0,0 +1,46 @@
+"""
+Bollinger Grid Controller Module
+
+Grid trading with Bollinger Bands signal.
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+from .chart import generate_chart # <-- usa il chart specifico
+
+
+class BollinGridController(BaseController):
+ controller_type = "bollingrid"
+ display_name = "Bollinger Grid"
+ description = "Grid trading with Bollinger Bands signals"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ # Usa il chart specifico per Bollinger Grid
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["BollinGridController"]
diff --git a/handlers/bots/controllers/bollingrid/chart.py b/handlers/bots/controllers/bollingrid/chart.py
new file mode 100644
index 00000000..a5c0695b
--- /dev/null
+++ b/handlers/bots/controllers/bollingrid/chart.py
@@ -0,0 +1,408 @@
+"""
+Bollinger Grid chart generation.
+
+3 panels:
+ 1. Price – candlesticks + BB + grid lines (start, end, limit)
+ 2. Volume – colored bars
+ 3. BBP – Bollinger Band Percent with long/short thresholds
+"""
+
+import io
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+import logging
+import matplotlib.dates as mdates
+from matplotlib.patches import Rectangle
+from datetime import datetime
+
+def generate_chart(config, candles_data, current_price=None):
+ if not candles_data or len(candles_data) < 5:
+ return _generate_simple_chart(candles_data, current_price)
+
+ df = _prepare_dataframe(candles_data)
+ if df is None or df.empty:
+ return _generate_simple_chart(candles_data, current_price)
+
+ # 🔥 SAFETY PATCH (QUI)
+ for col in ['open','high','low','close','volume']:
+ if col not in df.columns:
+ df[col] = 0
+
+ full_df = df.copy()
+
+ MAX_VISIBLE_CANDLES = 96
+
+ for col in ['open', 'high', 'low', 'close', 'volume']:
+ full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0)
+
+ logger = logging.getLogger(__name__)
+
+ # ── INDICATORI ──────────────────────────────────────────────────
+ bb_length = int(config.get('bb_length', 100))
+ bb_std_val = float(config.get('bb_std', 2.0))
+
+ # Se non ci sono abbastanza dati, riduci bb_length
+ if len(df) < bb_length:
+ bb_length = max(20, len(df) // 2)
+ logger.info(f"BG Chart: reduced bb_length to {bb_length} due to insufficient data")
+
+ # Calcola BB con min_periods=1 per avere valori dall'inizio
+ rolling = full_df['close'].rolling(window=bb_length, min_periods=1)
+ full_df['bb_mid'] = rolling.mean()
+ bb_std_series = rolling.std(ddof=0)
+ bb_std_series = bb_std_series.replace(0, 0.0001) # evita std=0
+
+ full_df['bb_upper'] = full_df['bb_mid'] + bb_std_val * bb_std_series
+ full_df['bb_lower'] = full_df['bb_mid'] - bb_std_val * bb_std_series
+
+ # ── CALCOLO BBP ────────────────────────────────────────────────
+ denom = full_df['bb_upper'] - full_df['bb_lower']
+ denom = denom.replace(0, np.nan)
+ bbp_values = (full_df['close'] - full_df['bb_lower']) / denom
+ bbp_values = bbp_values.fillna(0.5)
+ bbp_values = bbp_values.clip(-1, 2)
+ full_df['bbp'] = bbp_values
+ df = full_df.tail(MAX_VISIBLE_CANDLES).copy()
+
+ # ── PREZZI DELLA GRIGLIA ────────────────────────────────────────
+ start_price = config.get('start_price', 0)
+ end_price = config.get('end_price', 0)
+ limit_price = config.get('limit_price', 0)
+ if df.empty:
+ current_price = 0
+ elif current_price is None:
+ current_price = float(df['close'].iloc[-1])
+
+ # ── FIGURA ──────────────────────────────────────────────────────
+
+ fig, (ax1, ax2, ax3) = plt.subplots(
+ 3,
+ 1,
+ figsize=(22, 12),
+ sharex=True,
+ gridspec_kw={
+ 'height_ratios': [4.5, 1.2, 1.5]
+ }
+ )
+
+ fig.patch.set_facecolor('#111111')
+
+ for ax in [ax1, ax2, ax3]:
+ ax.set_facecolor('#111111')
+ ax.tick_params(colors='white')
+ ax.yaxis.label.set_color('white')
+ ax.spines['bottom'].set_color('#444')
+ ax.spines['top'].set_color('#444')
+ ax.spines['left'].set_color('#444')
+ ax.spines['right'].set_color('#444')
+
+ dates = mdates.date2num(df['datetime'])
+
+ if len(dates) > 1:
+
+ candle_width = (dates[1] - dates[0]) * 0.85
+ volume_width = (dates[1] - dates[0]) * 0.85
+
+ else:
+
+ candle_width = volume_width = 0.0005
+
+ # ── PANNELLO 1: PREZZO + BOLLINGER BANDS + GRIGLIA ──────────────
+
+ # Candele
+ for i in range(len(df)):
+ o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']]
+ color = '#2ecc71' if c >= o else '#e74c3c'
+ ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1)
+ ax1.add_patch(Rectangle(
+ (dates[i] - candle_width / 2, min(o, c)),
+ candle_width, abs(c - o) or 1e-8,
+ color=color
+ ))
+
+ # Bollinger Bands (colori diversi)
+ ax1.plot(df['datetime'], df['bb_upper'], '--', linewidth=1, color='#1f77b4', alpha=0.8, label='BB Upper')
+ ax1.plot(df['datetime'], df['bb_mid'], ':', linewidth=1, color='#ff7f0e', alpha=0.8, label='BB Mid')
+ ax1.plot(df['datetime'], df['bb_lower'], '--', linewidth=1, color='#2ca02c', alpha=0.8, label='BB Lower')
+
+ # Linee della griglia (sempre mostrate)
+ if start_price > 0:
+ ax1.axhline(y=start_price, linestyle='-', linewidth=1.5, color='green', alpha=0.9, label=f'Start ({start_price:.4f})')
+ if end_price > 0:
+ ax1.axhline(y=end_price, linestyle='-', linewidth=1.5, color='red', alpha=0.9, label=f'End ({end_price:.4f})')
+ if limit_price > 0:
+ ax1.axhline(y=limit_price, linestyle='--', linewidth=1.5, color='orange', alpha=0.9, label=f'Limit ({limit_price:.4f})')
+
+ # Prezzo corrente
+ if current_price:
+ ax1.axhline(y=current_price, linestyle='--', linewidth=1, color='purple', alpha=0.6, label=f'Current ({current_price:.4f})')
+
+ # ── GRID ZONE DINAMICA ─────────────────────────────────────────────
+
+ visible_candles = len(df)
+
+ # intensità fill adattiva
+ if visible_candles <= 32:
+ grid_alpha = 0.06
+ elif visible_candles <= 64:
+ grid_alpha = 0.08
+ elif visible_candles <= 96:
+ grid_alpha = 0.11
+ else:
+ grid_alpha = 0.14
+
+ # colore meno "sporco"
+ grid_color = '#d4c900'
+
+ if start_price > 0 and end_price > 0 and start_price < end_price:
+
+ ax1.axhspan(start_price, end_price, alpha=grid_alpha, color=grid_color, label='Grid Zone', zorder=0)
+
+ # bordi zona più leggibili
+ ax1.axhline(start_price, color='#00ff88', linewidth=1.2, alpha=0.75)
+
+ ax1.axhline(end_price, color='#ff4d6d', linewidth=1.2, alpha=0.75)
+
+ # Calcola i limiti Y includendo le linee della griglia
+ price_min = df['low'].min()
+ price_max = df['high'].max()
+
+ # Includi start_price e limit_price se sono nel range
+ if start_price > 0:
+ price_min = min(price_min, start_price)
+ if limit_price > 0:
+ price_min = min(price_min, limit_price)
+ if end_price > 0:
+ price_max = max(price_max, end_price)
+
+ # Aggiungi un margine del 5%
+ margin = (price_max - price_min) * 0.05
+ y_min = price_min - margin
+ y_max = price_max + margin
+
+ ax1.set_ylim(y_min, y_max)
+
+ ax1.legend(loc='upper left', fontsize=9, ncol=2, framealpha=0)
+ ax1.set_ylabel('Price')
+ ax1.grid(True, alpha=0.3)
+ ax1.set_xlim(df['datetime'].min(), df['datetime'].max())
+
+ # ── PANNELLO 2: VOLUME ──────────────────────────────────────────
+ if 'volume' in df.columns and df['volume'].sum() > 0:
+ vol_colors = [
+ '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c'
+ for i in range(len(df))
+ ]
+ ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7)
+ ax2.set_ylabel('Volume')
+ ax2.grid(True, alpha=0.3)
+
+ # ── PANNELLO 3: BBP ─────────────────────────────────────────────
+
+
+ long_thr = float(config.get('bb_long_threshold', 0.0))
+ short_thr = float(config.get('bb_short_threshold', 1.0))
+ ax3.plot(df['datetime'], df['bbp'], linewidth=1.5, color='#3da5ff')
+ ax3.axhline(long_thr, linestyle='--', color='green', alpha=0.8, label=f'Long ({long_thr})')
+ ax3.axhline(short_thr, linestyle='--', color='red', alpha=0.8, label=f'Short ({short_thr})')
+ ax3.axhline(0, linestyle=':', color='gray', alpha=0.5)
+ ax3.axhline(1, linestyle=':', color='gray', alpha=0.5)
+
+# ── BBP SIGNAL ZONES ──────────────────────────────────────────────
+
+ ax3.fill_between(df['datetime'], -1, long_thr, alpha=0.16, color='#00ff88')
+ ax3.fill_between(df['datetime'], short_thr, 2, alpha=0.16, color='#ff4d6d')
+
+ # Marca i punti di segnale
+ long_signals = df[df['bbp'] < long_thr]
+ short_signals = df[df['bbp'] > short_thr]
+
+ if not long_signals.empty:
+ ax3.scatter(long_signals['datetime'], long_signals['bbp'],
+ color='green', marker='^', s=30, alpha=0.8, label='Long signal')
+ if not short_signals.empty:
+ ax3.scatter(short_signals['datetime'], short_signals['bbp'],
+ color='red', marker='v', s=30, alpha=0.8, label='Short signal')
+
+ ax3.legend(loc='upper left', fontsize=9, framealpha=0)
+ ax3.set_ylabel('BBP')
+ ax3.grid(True, alpha=0.3)
+
+ # ── CONFIGURAZIONE ASSE X (come Grid Strike) ────────────────────
+ interval = config.get('interval', '5m')
+ _setup_x_axis(ax3, df, interval)
+
+ # Imposta i limiti X per tutti i pannelli
+ x_min = df['datetime'].min()
+ x_max = df['datetime'].max()
+
+ # Copia locator e formatter agli altri pannelli
+ ax1.tick_params(labelbottom=False)
+ ax2.tick_params(labelbottom=False)
+ for ax in [ax1, ax2]:
+ ax.set_xlim(x_min, x_max)
+ ax.xaxis.set_minor_locator(ax3.xaxis.get_minor_locator())
+ ax.xaxis.set_major_locator(ax3.xaxis.get_major_locator())
+ ax.xaxis.set_major_formatter(ax3.xaxis.get_major_formatter())
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
+
+
+ # ── TITOLO ──────────────────────────────────────────────────────
+ fig.suptitle(
+ f"{config.get('trading_pair', 'Unknown')} - Bollinger Grid "
+ f"(BB{bb_length} | Grid: {start_price:.4f} → {end_price:.4f} | {interval})",
+ fontsize=13
+ )
+
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+def _setup_x_axis(ax, df, interval):
+ import matplotlib.dates as mdates
+
+ # ── TIMEFRAME CONFIG ──────────────────────────────────────────
+
+ if interval == '1m':
+ locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(interval=5)
+
+ elif interval == '5m':
+ locator = mdates.HourLocator(interval=1)
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+
+ elif interval == '15m':
+ locator = mdates.HourLocator(interval=2)
+ formatter = mdates.DateFormatter('%d %H:%M')
+ minor_locator = mdates.HourLocator(interval=1)
+
+ elif interval == '1h':
+ locator = mdates.HourLocator(interval=4)
+ formatter = mdates.DateFormatter('%d %H:%M')
+ minor_locator = mdates.HourLocator(interval=1)
+
+ elif interval == '4h':
+ locator = mdates.HourLocator(interval=12)
+ formatter = mdates.DateFormatter('%d %H:%M')
+ minor_locator = mdates.HourLocator(interval=4)
+
+ elif interval == '8h':
+ locator = mdates.DayLocator(interval=2)
+ formatter = mdates.DateFormatter('%d %b')
+ minor_locator = mdates.HourLocator(interval=8)
+
+ else:
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ formatter = mdates.ConciseDateFormatter(locator)
+ minor_locator = None
+
+ # ── APPLY ─────────────────────────────────────────────────────
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+
+ if minor_locator:
+ ax.xaxis.set_minor_locator(minor_locator)
+
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center', fontsize=9)
+ # major vertical grid
+ ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15)
+ ax.set_xlim(df['datetime'].min(), df['datetime'].max() )
+
+def _prepare_dataframe(candles, timezone=None):
+ if timezone is None:
+ timezone = datetime.now().astimezone().tzinfo
+ if not candles:
+ return pd.DataFrame({
+ "datetime": pd.date_range(end=pd.Timestamp.now(), periods=1, freq="5min"),
+ "open": [0], "high": [0], "low": [0], "close": [0], "volume": [0]
+ })
+ df = pd.DataFrame(candles)
+
+ ts_col = next(
+ (c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns),
+ None
+ )
+
+ if ts_col:
+ sample = df[ts_col].iloc[0]
+
+ if isinstance(sample, (int, float)):
+
+ if sample > 10**12:
+ # nanoseconds
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ns', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+
+ elif sample > 10**10:
+ # milliseconds
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ms', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+
+ else:
+ # seconds
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='s', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+
+ else:
+
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+
+ else:
+
+ df['datetime'] = pd.date_range(
+ end=pd.Timestamp.now(),
+ periods=len(df),
+ freq='5min'
+ )
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+def _generate_simple_chart(candles_data, current_price):
+ if not candles_data:
+ return io.BytesIO()
+ full_df = _prepare_dataframe(candles_data)
+ MAX_VISIBLE_CANDLES = 96
+ if len(full_df) > MAX_VISIBLE_CANDLES:
+ df = full_df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+ else:
+ df = full_df.copy()
+ fig, ax = plt.subplots(figsize=(12, 6))
+ if 'close' in df.columns:
+ ax.plot(df['datetime'], pd.to_numeric(df['close'], errors='coerce'), linewidth=1.5, color='steelblue')
+ if current_price:
+ ax.axhline(y=current_price, linestyle='--', color='purple', alpha=0.7)
+
+ ax.set_title('Bollinger Grid - Price Chart')
+ ax.set_ylabel('Price')
+ ax.grid(True, alpha=0.3)
+ _setup_x_axis(ax, df, '5m')
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=100)
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config, candles_data, current_price=None):
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/bollingrid/config.py b/handlers/bots/controllers/bollingrid/config.py
new file mode 100644
index 00000000..8f2324e1
--- /dev/null
+++ b/handlers/bots/controllers/bollingrid/config.py
@@ -0,0 +1,101 @@
+"""
+Bollinger Grid controller configuration.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "bollingrid",
+ "controller_type": "directional_trading",
+ "id": "",
+ "connector_name": "",
+ "trading_pair": "",
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ "total_amount_quote": 1000,
+ "candles_connector": None,
+ "candles_trading_pair": None,
+ "interval": "5m",
+ "bb_length": 100,
+ "bb_std": 2.0,
+ "bb_long_threshold": 0.0,
+ "bb_short_threshold": 1.0,
+ "grid_start_price_coefficient": 0.25,
+ "grid_end_price_coefficient": 0.75,
+ "grid_limit_price_coefficient": 0.35,
+ "min_spread_between_orders": 0.005,
+ "order_frequency": 2,
+ "max_orders_per_batch": 1,
+ "min_order_amount_quote": 6,
+ "max_open_orders": 5,
+ "stop_loss": 0.05,
+ "take_profit": 0.03,
+ "trailing_stop_activation": 0.015,
+ "trailing_stop_delta": 0.005,
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(name="id", label="Config ID", type="str", required=True),
+ "connector_name": ControllerField(name="connector_name", label="Exchange", type="str", required=True),
+ "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True),
+ "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=False, default=1),
+ "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, default="HEDGE"),
+ "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (USDT)", type="float", required=True),
+ "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False),
+ "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False),
+ "interval": ControllerField(name="interval", label="Interval", type="str", required=False, default="5m"),
+ "bb_length": ControllerField(name="bb_length", label="BB Length", type="int", required=False, default=100),
+ "bb_std": ControllerField(name="bb_std", label="BB Std Dev", type="float", required=False, default=2.0),
+ "bb_long_threshold": ControllerField(name="bb_long_threshold", label="BB Long Threshold", type="float", required=False, default=0.0),
+ "bb_short_threshold": ControllerField(name="bb_short_threshold", label="BB Short Threshold", type="float", required=False, default=1.0),
+ "grid_start_price_coefficient": ControllerField(name="grid_start_price_coefficient", label="Start Price Coeff", type="float", required=False, default=0.25),
+ "grid_end_price_coefficient": ControllerField(name="grid_end_price_coefficient", label="End Price Coeff", type="float", required=False, default=0.75),
+ "grid_limit_price_coefficient": ControllerField(name="grid_limit_price_coefficient", label="Limit Price Coeff", type="float", required=False, default=0.35),
+ "min_spread_between_orders": ControllerField(name="min_spread_between_orders", label="Min Spread", type="float", required=False, default=0.005),
+ "order_frequency": ControllerField(name="order_frequency", label="Order Frequency (s)", type="int", required=False, default=2),
+ "max_orders_per_batch": ControllerField(name="max_orders_per_batch", label="Max Orders/Batch", type="int", required=False, default=1),
+ "min_order_amount_quote": ControllerField(name="min_order_amount_quote", label="Min Order Amount", type="float", required=False, default=6),
+ "max_open_orders": ControllerField(name="max_open_orders", label="Max Open Orders", type="int", required=False, default=5),
+ "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, default=0.05),
+ "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, default=0.03),
+ "trailing_stop_activation": ControllerField(name="trailing_stop_activation", label="TS Activation", type="float", required=False, default=0.015),
+ "trailing_stop_delta": ControllerField(name="trailing_stop_delta", label="TS Delta", type="float", required=False, default=0.005),
+}
+
+FIELD_ORDER: List[str] = [
+ "id", "connector_name", "trading_pair", "leverage", "position_mode",
+ "total_amount_quote", "candles_connector", "candles_trading_pair", "interval",
+ "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "grid_start_price_coefficient", "grid_end_price_coefficient", "grid_limit_price_coefficient",
+ "min_spread_between_orders", "order_frequency", "max_orders_per_batch",
+ "min_order_amount_quote", "max_open_orders", "stop_loss", "take_profit",
+ "trailing_stop_activation", "trailing_stop_delta",
+]
+
+EDITABLE_FIELDS: List[str] = FIELD_ORDER.copy()
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ if not config.get("connector_name"):
+ return False, "Missing exchange"
+ if not config.get("trading_pair"):
+ return False, "Missing trading pair"
+ if config.get("total_amount_quote", 0) <= 0:
+ return False, "Total amount must be positive"
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ max_num = 0
+ for cfg in existing_configs:
+ cfg_id = cfg.get("id", "")
+ if cfg_id and cfg_id[:3].isdigit():
+ max_num = max(max_num, int(cfg_id[:3]))
+ seq = str(max_num + 1).zfill(3)
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+ return f"{seq}_bg_{connector}_{pair}"
+
+
diff --git a/handlers/bots/controllers/delta_neutral_mm/__init__.py b/handlers/bots/controllers/delta_neutral_mm/__init__.py
new file mode 100644
index 00000000..cb8ae0d4
--- /dev/null
+++ b/handlers/bots/controllers/delta_neutral_mm/__init__.py
@@ -0,0 +1,46 @@
+"""
+Delta Neutral Market Making Controller Module
+
+Market making with delta hedging on perpetual exchange.
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class DeltaNeutralMMController(BaseController):
+ controller_type = "delta_neutral_mm"
+ display_name = "Delta Neutral Market Making"
+ description = "Market making with delta hedging on perpetual exchange"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["DeltaNeutralMMController", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
\ No newline at end of file
diff --git a/handlers/bots/controllers/delta_neutral_mm/chart.py b/handlers/bots/controllers/delta_neutral_mm/chart.py
new file mode 100644
index 00000000..a3e786a1
--- /dev/null
+++ b/handlers/bots/controllers/delta_neutral_mm/chart.py
@@ -0,0 +1,245 @@
+"""
+Delta Neutral Market Making chart generation.
+
+4-panel chart:
+ 1. Price + Reference price (MACD-skewed) + NATR bands
+ 2. Spreads (buy/sell levels in NATR multiples)
+ 3. Net delta + hedge thresholds
+ 4. Combined PnL
+"""
+
+import io
+from typing import Any, Dict, List, Optional
+
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+import numpy as np
+import pandas as pd
+
+
+def generate_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """
+ Generate a 4-panel chart for Delta Neutral MM.
+ """
+ if not candles_data or len(candles_data) < 10:
+ return _generate_simple_chart(config, candles_data, current_price)
+
+ # Prepare dataframe
+ df = _prepare_dataframe(candles_data)
+
+ # Convert numeric columns
+ for col in ['open', 'high', 'low', 'close', 'volume']:
+ if col in df.columns:
+ df[col] = pd.to_numeric(df[col], errors='coerce')
+
+ # Get config values
+ interval = config.get('interval', '3m')
+ trading_pair = config.get('connector_pair_maker_trading_pair', 'Unknown')
+ maker_connector = config.get('connector_pair_maker_connector_name', 'Maker')
+ hedge_connector = config.get('connector_pair_hedge_connector_name', 'Hedge')
+
+ # Simulate reference price (MACD-skewed) and spread multiplier (NATR)
+ # In real implementation, these would come from the bot's processed_data
+ natr = df['close'].pct_change().rolling(14).std().fillna(0.01)
+ spread_mult = natr * 100
+
+ # Calculate reference price (close + small random shift for demo)
+ np.random.seed(42)
+ price_shift = np.random.normal(0, 0.002, len(df))
+ reference_price = df['close'] * (1 + price_shift)
+
+ # Calculate net delta (simulated from price movement)
+ price_change = df['close'].pct_change().fillna(0)
+ net_delta = (price_change.cumsum() * 100).fillna(0)
+
+ # Combined PnL
+ pnl = net_delta * 0.01 # Simulated PnL
+
+ # Hedge thresholds
+ hedge_threshold = config.get('hedge_threshold_quote', 10)
+ max_delta = config.get('max_delta_quote', 50)
+
+ # Spread levels
+ buy_spreads = config.get('buy_spreads', [1.0, 2.0, 3.0])
+ sell_spreads = config.get('sell_spreads', [1.0, 2.0, 3.0])
+ if isinstance(buy_spreads, str):
+ buy_spreads = [float(x.strip()) for x in buy_spreads.split(",")]
+ if isinstance(sell_spreads, str):
+ sell_spreads = [float(x.strip()) for x in sell_spreads.split(",")]
+
+ # Create figure with 4 subplots
+ fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True)
+ ax1, ax2, ax3, ax4 = axes
+
+ # --- Panel 1: Price + Reference Price + NATR bands ---
+ ax1.plot(df['datetime'], df['close'], linewidth=1.5, color='white', label='Close')
+ ax1.plot(df['datetime'], reference_price, linewidth=1.5, color='orange', alpha=0.8, label='Reference (MACD-skewed)')
+
+ # NATR bands (± spread_multiplier)
+ upper_band = reference_price * (1 + spread_mult)
+ lower_band = reference_price * (1 - spread_mult)
+ ax1.fill_between(df['datetime'], lower_band, upper_band, alpha=0.2, color='blue', label='NATR Bands')
+
+ if current_price:
+ ax1.axhline(y=current_price, linestyle='--', color='yellow', alpha=0.7, linewidth=1, label=f'Current: {current_price:.4f}')
+
+ ax1.set_ylabel('Price')
+ ax1.legend(loc='upper left', fontsize=8)
+ ax1.grid(True, alpha=0.3)
+ ax1.set_title(f'{trading_pair} - {maker_connector} (Maker) ↔ {hedge_connector} (Hedge)')
+
+ # --- Panel 2: Spreads (buy/sell levels) ---
+ # Calculate spread values at each point
+ current_spread = spread_mult.iloc[-1] if not spread_mult.empty else 0.01
+
+ # Create spread level lines
+ for i, spread in enumerate(buy_spreads):
+ ax2.axhline(y=-spread * 100, linestyle='--', color='green', alpha=0.5, linewidth=0.8)
+ for i, spread in enumerate(sell_spreads):
+ ax2.axhline(y=spread * 100, linestyle='--', color='red', alpha=0.5, linewidth=0.8)
+
+ # Plot actual spread multiplier over time
+ ax2.plot(df['datetime'], spread_mult * 100, linewidth=1.5, color='blue', label='NATR × 100%')
+
+ # Add text annotations for levels
+ y_min, y_max = ax2.get_ylim()
+ for i, spread in enumerate(buy_spreads):
+ ax2.text(df['datetime'].iloc[-1], -spread * 100, f'Buy L{i+1} ({spread}×NATR)',
+ verticalalignment='center', fontsize=7, color='green')
+ for i, spread in enumerate(sell_spreads):
+ ax2.text(df['datetime'].iloc[-1], spread * 100, f'Sell L{i+1} ({spread}×NATR)',
+ verticalalignment='center', fontsize=7, color='red')
+
+ ax2.set_ylabel('Spread (% of price)')
+ ax2.legend(loc='upper left', fontsize=8)
+ ax2.grid(True, alpha=0.3)
+ ax2.set_title('Order Spread Levels (NATR multiples)')
+
+ # --- Panel 3: Net Delta + Hedge Thresholds ---
+ ax3.plot(df['datetime'], net_delta, linewidth=1.5, color='cyan', label='Net Delta (USDT)')
+
+ # Hedge thresholds
+ ax3.axhline(y=hedge_threshold, linestyle='--', color='orange', alpha=0.7, label=f'Hedge Threshold (±{hedge_threshold})')
+ ax3.axhline(y=-hedge_threshold, linestyle='--', color='orange', alpha=0.7)
+ ax3.axhline(y=max_delta, linestyle='--', color='red', alpha=0.7, label=f'Max Delta (±{max_delta})')
+ ax3.axhline(y=-max_delta, linestyle='--', color='red', alpha=0.7)
+ ax3.axhline(y=0, linestyle='-', color='gray', alpha=0.5)
+
+ # Fill areas beyond thresholds
+ ax3.fill_between(df['datetime'], net_delta, hedge_threshold, where=(net_delta > hedge_threshold), color='orange', alpha=0.3)
+ ax3.fill_between(df['datetime'], net_delta, -hedge_threshold, where=(net_delta < -hedge_threshold), color='orange', alpha=0.3)
+
+ ax3.set_ylabel('Net Delta (USDT)')
+ ax3.legend(loc='upper left', fontsize=8)
+ ax3.grid(True, alpha=0.3)
+ ax3.set_title('Net Unhedged Delta')
+
+ # --- Panel 4: Combined PnL ---
+ # Color based on positive/negative
+ colors = ['green' if x >= 0 else 'red' for x in pnl]
+ ax4.bar(df['datetime'], pnl * 100, width=0.8, color=colors, alpha=0.7, label='PnL %')
+
+ # SL/TP thresholds
+ sl_global = config.get('sl_global', 0.03)
+ tp_global = config.get('tp_global', 0.05)
+ ax4.axhline(y=sl_global * 100, linestyle='--', color='red', alpha=0.7, label=f'Stop Loss ({sl_global*100:.0f}%)')
+ ax4.axhline(y=-sl_global * 100, linestyle='--', color='red', alpha=0.7)
+ ax4.axhline(y=tp_global * 100, linestyle='--', color='green', alpha=0.7, label=f'Take Profit ({tp_global*100:.0f}%)')
+ ax4.axhline(y=-tp_global * 100, linestyle='--', color='green', alpha=0.7)
+ ax4.axhline(y=0, linestyle='-', color='gray', alpha=0.5)
+
+ ax4.set_ylabel('PnL (%)')
+ ax4.set_xlabel('Time')
+ ax4.legend(loc='upper left', fontsize=8)
+ ax4.grid(True, alpha=0.3)
+ ax4.set_title('Combined PnL (Maker + Hedge)')
+
+ # Format x-axis
+ date_range = df['datetime'].max() - df['datetime'].min()
+ if date_range.days >= 1:
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ formatter = mdates.ConciseDateFormatter(locator)
+ else:
+ locator = mdates.HourLocator(interval=max(1, date_range.seconds // 7200))
+ formatter = mdates.DateFormatter('%H:%M')
+
+ for ax in axes:
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')
+
+ fig.suptitle(f'Delta Neutral Market Making - {trading_pair}', fontsize=12)
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def _prepare_dataframe(candles: List[Dict[str, Any]]) -> pd.DataFrame:
+ """Convert candles list to DataFrame with datetime index."""
+ df = pd.DataFrame(candles)
+
+ # Find timestamp column
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ if sample > 10**12: # milliseconds
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='ms')
+ else: # seconds
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='s')
+ else:
+ df['datetime'] = pd.to_datetime(df[ts_col])
+ else:
+ # Fallback: sequential dates
+ df['datetime'] = pd.date_range(end=pd.Timestamp.now(), periods=len(df), freq='1min')
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+
+def _generate_simple_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """Generate simple chart when insufficient data."""
+ fig, ax = plt.subplots(figsize=(10, 6))
+
+ trading_pair = config.get('connector_pair_maker_trading_pair', 'Unknown')
+
+ if candles_data:
+ df = _prepare_dataframe(candles_data)
+ if 'close' in df.columns:
+ ax.plot(df['datetime'], pd.to_numeric(df['close'], errors='coerce'), linewidth=1.5, color='white')
+
+ if current_price:
+ ax.axhline(y=current_price, linestyle='--', color='yellow', alpha=0.7)
+
+ ax.set_title(f'Delta Neutral MM - {trading_pair} (Insufficient Data)')
+ ax.set_ylabel('Price')
+ ax.set_xlabel('Time')
+ ax.grid(True, alpha=0.3)
+
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=100, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """Generate preview chart (smaller dimensions)."""
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/delta_neutral_mm/config.py b/handlers/bots/controllers/delta_neutral_mm/config.py
new file mode 100644
index 00000000..853b8ff0
--- /dev/null
+++ b/handlers/bots/controllers/delta_neutral_mm/config.py
@@ -0,0 +1,365 @@
+"""
+Delta Neutral Market Making controller configuration.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+# Default configuration values
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "delta_neutral_mm",
+ "controller_type": "generic",
+ "id": "",
+ # Exchanges
+ "connector_pair_maker_connector_name": "kucoin",
+ "connector_pair_maker_trading_pair": "SOL-USDT",
+ "connector_pair_hedge_connector_name": "hyperliquid_perpetual",
+ "connector_pair_hedge_trading_pair": "SOL-USDT",
+ # Candles
+ "candles_connector": None,
+ "candles_trading_pair": None,
+ "interval": "3m",
+ # MACD
+ "macd_fast": 21,
+ "macd_slow": 42,
+ "macd_signal": 9,
+ # NATR
+ "natr_length": 14,
+ # Market making levels
+ "buy_spreads": "1.0, 2.0, 3.0",
+ "sell_spreads": "1.0, 2.0, 3.0",
+ "order_amount_quote": 15,
+ "order_refresh_time": 30,
+ # Delta hedging
+ "hedge_threshold_quote": 10,
+ "max_delta_quote": 50,
+ # Hedge settings
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ # Risk
+ "sl_global": 0.03,
+ "tp_global": 0.05,
+ # Timeout
+ "hedge_position_timeout": 3600,
+ # TP multiplier
+ "maker_tp_multiplier": 1.0,
+}
+
+
+# Field definitions
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated",
+ ),
+ # Maker exchange
+ "connector_pair_maker_connector_name": ControllerField(
+ name="connector_pair_maker_connector_name",
+ label="Maker Exchange",
+ type="str",
+ required=True,
+ hint="Exchange for limit orders (spot preferred)",
+ ),
+ "connector_pair_maker_trading_pair": ControllerField(
+ name="connector_pair_maker_trading_pair",
+ label="Maker Pair",
+ type="str",
+ required=True,
+ hint="e.g. SOL-USDT",
+ ),
+ # Hedge exchange
+ "connector_pair_hedge_connector_name": ControllerField(
+ name="connector_pair_hedge_connector_name",
+ label="Hedge Exchange",
+ type="str",
+ required=True,
+ hint="Perpetual exchange for delta hedging",
+ ),
+ "connector_pair_hedge_trading_pair": ControllerField(
+ name="connector_pair_hedge_trading_pair",
+ label="Hedge Pair",
+ type="str",
+ required=True,
+ hint="e.g. SOL-USDT",
+ ),
+ # Candles
+ "candles_connector": ControllerField(
+ name="candles_connector",
+ label="Candles Connector",
+ type="str",
+ required=False,
+ hint="Leave empty to use maker exchange",
+ default=None,
+ ),
+ "candles_trading_pair": ControllerField(
+ name="candles_trading_pair",
+ label="Candles Pair",
+ type="str",
+ required=False,
+ hint="Leave empty to use maker pair",
+ default=None,
+ ),
+ "interval": ControllerField(
+ name="interval",
+ label="Candle Interval",
+ type="str",
+ required=True,
+ hint="e.g. 1m, 3m, 5m, 1h",
+ default="3m",
+ ),
+ # MACD
+ "macd_fast": ControllerField(
+ name="macd_fast",
+ label="MACD Fast",
+ type="int",
+ required=False,
+ hint="Fast EMA period",
+ default=21,
+ ),
+ "macd_slow": ControllerField(
+ name="macd_slow",
+ label="MACD Slow",
+ type="int",
+ required=False,
+ hint="Slow EMA period",
+ default=42,
+ ),
+ "macd_signal": ControllerField(
+ name="macd_signal",
+ label="MACD Signal",
+ type="int",
+ required=False,
+ hint="Signal line period",
+ default=9,
+ ),
+ # NATR
+ "natr_length": ControllerField(
+ name="natr_length",
+ label="NATR Length",
+ type="int",
+ required=False,
+ hint="Normalized ATR period",
+ default=14,
+ ),
+ # Spreads
+ "buy_spreads": ControllerField(
+ name="buy_spreads",
+ label="Buy Spreads",
+ type="str",
+ required=False,
+ hint="Comma-separated NATR multiples (e.g. 1.0,2.0,3.0)",
+ default="1.0,2.0,3.0",
+ ),
+ "sell_spreads": ControllerField(
+ name="sell_spreads",
+ label="Sell Spreads",
+ type="str",
+ required=False,
+ hint="Comma-separated NATR multiples",
+ default="1.0,2.0,3.0",
+ ),
+ "order_amount_quote": ControllerField(
+ name="order_amount_quote",
+ label="Order Amount (USDT)",
+ type="float",
+ required=False,
+ hint="Amount per level in quote currency",
+ default=15,
+ ),
+ "order_refresh_time": ControllerField(
+ name="order_refresh_time",
+ label="Refresh Time (s)",
+ type="int",
+ required=False,
+ hint="Cancel unfilled orders after this many seconds",
+ default=30,
+ ),
+ # Delta hedging
+ "hedge_threshold_quote": ControllerField(
+ name="hedge_threshold_quote",
+ label="Hedge Threshold (USDT)",
+ type="float",
+ required=False,
+ hint="Hedge when delta exceeds this value",
+ default=10,
+ ),
+ "max_delta_quote": ControllerField(
+ name="max_delta_quote",
+ label="Max Delta (USDT)",
+ type="float",
+ required=False,
+ hint="Emergency hedge at this delta",
+ default=50,
+ ),
+ # Leverage
+ "leverage": ControllerField(
+ name="leverage",
+ label="Leverage",
+ type="int",
+ required=False,
+ hint="1x recommended",
+ default=1,
+ ),
+ "position_mode": ControllerField(
+ name="position_mode",
+ label="Position Mode",
+ type="str",
+ required=False,
+ hint="HEDGE or ONEWAY",
+ default="HEDGE",
+ ),
+ # Risk
+ "sl_global": ControllerField(
+ name="sl_global",
+ label="Stop Loss",
+ type="float",
+ required=False,
+ hint="Emergency exit at this loss (e.g. 0.03 = 3%)",
+ default=0.03,
+ ),
+ "tp_global": ControllerField(
+ name="tp_global",
+ label="Take Profit",
+ type="float",
+ required=False,
+ hint="Emergency exit at this profit",
+ default=0.05,
+ ),
+ "hedge_position_timeout": ControllerField(
+ name="hedge_position_timeout",
+ label="Hedge Timeout (s)",
+ type="int",
+ required=False,
+ hint="Close hedge positions after this many seconds (0=disabled)",
+ default=3600,
+ ),
+ "maker_tp_multiplier": ControllerField(
+ name="maker_tp_multiplier",
+ label="Maker TP Multiplier",
+ type="float",
+ required=False,
+ hint="Take profit multiplier for maker orders",
+ default=1.0,
+ ),
+}
+
+
+FIELD_ORDER: List[str] = [
+ "id",
+ "connector_pair_maker_connector_name",
+ "connector_pair_maker_trading_pair",
+ "connector_pair_hedge_connector_name",
+ "connector_pair_hedge_trading_pair",
+ "candles_connector",
+ "candles_trading_pair",
+ "interval",
+ "macd_fast",
+ "macd_slow",
+ "macd_signal",
+ "natr_length",
+ "buy_spreads",
+ "sell_spreads",
+ "order_amount_quote",
+ "order_refresh_time",
+ "hedge_threshold_quote",
+ "max_delta_quote",
+ "leverage",
+ "position_mode",
+ "sl_global",
+ "tp_global",
+ "hedge_position_timeout",
+ "maker_tp_multiplier",
+]
+
+
+EDITABLE_FIELDS: List[str] = [
+ "connector_pair_maker_connector_name",
+ "connector_pair_maker_trading_pair",
+ "connector_pair_hedge_connector_name",
+ "connector_pair_hedge_trading_pair",
+ "candles_connector",
+ "candles_trading_pair",
+ "interval",
+ "buy_spreads",
+ "sell_spreads",
+ "order_amount_quote",
+ "order_refresh_time",
+ "hedge_threshold_quote",
+ "max_delta_quote",
+ "leverage",
+ "sl_global",
+ "tp_global",
+ "hedge_position_timeout",
+ "maker_tp_multiplier",
+]
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Estrae i campi piatti, convertendo eventuali liste di spreads in stringhe."""
+ flat = dict(config)
+ for key in ("buy_spreads", "sell_spreads"):
+ if key in flat and isinstance(flat[key], list):
+ flat[key] = ",".join(str(x) for x in flat[key])
+ return flat
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None:
+ """Applica gli aggiornamenti, mantenendo i spreads come stringhe."""
+ for key, value in updates.items():
+ if key in ("buy_spreads", "sell_spreads") and isinstance(value, list):
+ config[key] = ",".join(str(x) for x in value)
+ else:
+ config[key] = value
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate delta neutral MM configuration."""
+ required = [
+ "connector_pair_maker_connector_name",
+ "connector_pair_maker_trading_pair",
+ "connector_pair_hedge_connector_name",
+ "connector_pair_hedge_trading_pair",
+ ]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ # Validate spreads
+ buy_spreads = config.get("buy_spreads", [])
+ sell_spreads = config.get("sell_spreads", [])
+ if isinstance(buy_spreads, str):
+ buy_spreads = [float(x.strip()) for x in buy_spreads.split(",")]
+ if isinstance(sell_spreads, str):
+ sell_spreads = [float(x.strip()) for x in sell_spreads.split(",")]
+
+ if not buy_spreads or not sell_spreads:
+ return False, "Buy and sell spreads must have at least one level"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate unique config ID."""
+ max_num = 0
+ for cfg in existing_configs:
+ config_id = cfg.get("id", "")
+ if not config_id:
+ continue
+ parts = config_id.split("_", 1)
+ if parts and parts[0].isdigit():
+ num = int(parts[0])
+ max_num = max(max_num, num)
+
+ next_num = max_num + 1
+ seq = str(next_num).zfill(3)
+
+ maker = config.get("connector_pair_maker_connector_name", "unknown")
+ hedge = config.get("connector_pair_hedge_connector_name", "unknown")
+ pair = config.get("connector_pair_maker_trading_pair", "UNKNOWN").upper()
+
+ maker_clean = maker.replace("_perpetual", "").replace("_spot", "")
+ hedge_clean = hedge.replace("_perpetual", "").replace("_spot", "")
+
+ return f"{seq}_dnmm_{maker_clean}_{hedge_clean}_{pair}"
diff --git a/handlers/bots/controllers/dman_v3/__init__.py b/handlers/bots/controllers/dman_v3/__init__.py
new file mode 100644
index 00000000..bc96d77b
--- /dev/null
+++ b/handlers/bots/controllers/dman_v3/__init__.py
@@ -0,0 +1,42 @@
+"""DMan V3 Controller Module - Mean reversion with Bollinger Bands + DCA execution."""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class DManV3Controller(BaseController):
+ controller_type = "dman_v3"
+ display_name = "DMan V3"
+ description = "Mean reversion with Bollinger Bands + DCA"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["DManV3Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
diff --git a/handlers/bots/controllers/dman_v3/analysis.py b/handlers/bots/controllers/dman_v3/analysis.py
new file mode 100644
index 00000000..b6529351
--- /dev/null
+++ b/handlers/bots/controllers/dman_v3/analysis.py
@@ -0,0 +1,315 @@
+"""
+DMan V3 analysis utilities.
+
+Calculates suggested parameters from candle data:
+- bb_long_threshold → based on historical BBP distribution
+- bb_short_threshold → based on historical BBP distribution
+- dca_spreads → based on NATR (volatility-scaled)
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+
+def calculate_bbp_series(
+ candles: List[Dict[str, Any]],
+ bb_length: int = 20,
+ bb_std: float = 2.0,
+) -> List[float]:
+ """
+ Calculate Bollinger Band Percent (BBP) for each candle.
+
+ BBP = (close - lower) / (upper - lower)
+ - BBP = 0.0 → price at lower band
+ - BBP = 0.5 → price at middle (SMA)
+ - BBP = 1.0 → price at upper band
+ - BBP < 0 → price below lower band (oversold)
+ - BBP > 1 → price above upper band (overbought)
+
+ Args:
+ candles: List of candle dicts with 'close' key
+ bb_length: Bollinger Bands period
+ bb_std: Standard deviations
+
+ Returns:
+ List of BBP values (same length as candles, None for initial period)
+ """
+ import math
+
+ closes = []
+ for c in candles:
+ close = c.get("close") or c.get("c")
+ if close is not None:
+ closes.append(float(close))
+
+ if len(closes) < bb_length:
+ return []
+
+ bbp_values = []
+ for i in range(len(closes)):
+ if i < bb_length - 1:
+ continue
+ window = closes[i - bb_length + 1: i + 1]
+ sma = sum(window) / bb_length
+ variance = sum((x - sma) ** 2 for x in window) / bb_length
+ std = math.sqrt(variance)
+ upper = sma + bb_std * std
+ lower = sma - bb_std * std
+ band_width = upper - lower
+ if band_width > 0:
+ bbp = (closes[i] - lower) / band_width
+ else:
+ bbp = 0.5
+ bbp_values.append(bbp)
+
+ return bbp_values
+
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """Calculate Normalized ATR from candles."""
+ if not candles or len(candles) < period + 1:
+ return None
+
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = float(candles[i].get("high", 0) or 0)
+ low = float(candles[i].get("low", 0) or 0)
+ prev_close = float(candles[i - 1].get("close", 0) or 0)
+ if not all([high, low, prev_close]):
+ continue
+ tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return None
+
+ atr = sum(true_ranges[-period:]) / period
+ current_close = float(candles[-1].get("close", 0) or 0)
+ if current_close <= 0:
+ return None
+
+ return atr / current_close
+
+
+def suggest_dca_spreads(natr: float, num_levels: int = 4) -> List[float]:
+ """
+ Suggest DCA spread levels based on NATR.
+
+ Logic:
+ - Level 1 (closest): ~0.5x NATR
+ - Level 2: ~2x NATR
+ - Level 3: ~5x NATR
+ - Level 4 (deepest): ~10x NATR
+
+ These multipliers ensure meaningful distance between orders
+ proportional to the market's typical daily range.
+
+ Args:
+ natr: Normalized ATR as decimal (e.g. 0.005 = 0.5%)
+ num_levels: Number of DCA levels (2-4)
+
+ Returns:
+ List of spread values sorted ascending
+ """
+ multipliers = [0.5, 2.0, 5.0, 10.0][:num_levels]
+ spreads = [round(natr * m, 4) for m in multipliers]
+ # Ensure minimum reasonable spreads
+ spreads = [max(s, 0.0005) for s in spreads]
+ return spreads
+
+
+def suggest_bb_thresholds(
+ bbp_values: List[float],
+ long_percentile: float = 15.0,
+ short_percentile: float = 85.0,
+) -> Tuple[float, float]:
+ """
+ Suggest bb_long_threshold and bb_short_threshold based on
+ historical BBP distribution.
+
+ Logic:
+ - bb_long_threshold: BBP value at the Nth percentile from below
+ → "enter LONG when price is in the bottom X% of its historical range"
+ - bb_short_threshold: BBP value at the Nth percentile from above
+ → "enter SHORT when price is in the top X% of its historical range"
+
+ Default: bottom 15% / top 15% of observations
+ → conservative entry, avoids chasing
+
+ Args:
+ bbp_values: Historical BBP series
+ long_percentile: Enter LONG below this percentile (default 15%)
+ short_percentile: Enter SHORT above this percentile (default 85%)
+
+ Returns:
+ Tuple of (bb_long_threshold, bb_short_threshold)
+ """
+ if not bbp_values:
+ return 0.0, 1.0
+
+ sorted_bbp = sorted(bbp_values)
+ n = len(sorted_bbp)
+
+ long_idx = int(n * long_percentile / 100)
+ short_idx = int(n * short_percentile / 100)
+
+ long_idx = max(0, min(long_idx, n - 1))
+ short_idx = max(0, min(short_idx, n - 1))
+
+ long_threshold = round(sorted_bbp[long_idx], 3)
+ short_threshold = round(sorted_bbp[short_idx], 3)
+
+ return long_threshold, short_threshold
+
+
+def analyze_candles_for_dman(
+ candles: List[Dict[str, Any]],
+ bb_length: int = 20,
+ bb_std: float = 2.0,
+ natr_period: int = 14,
+ dca_levels: int = 4,
+) -> Dict[str, Any]:
+ """
+ Full analysis of candle data for DMan V3 parameter suggestion.
+
+ Returns a dict with:
+ - bbp_current: Current BBP value
+ - bb_upper/middle/lower: Current BB levels
+ - natr: Normalized ATR
+ - suggested_long_threshold: Suggested bb_long_threshold
+ - suggested_short_threshold: Suggested bb_short_threshold
+ - suggested_dca_spreads: Suggested DCA spreads list
+ - pct_below_lower: % of time price was below lower band
+ - pct_above_upper: % of time price was above upper band
+ - analysis_candles: Number of candles used
+ """
+ import math
+
+ result = {
+ "bbp_current": None,
+ "bb_upper": None,
+ "bb_middle": None,
+ "bb_lower": None,
+ "natr": None,
+ "suggested_long_threshold": 0.0,
+ "suggested_short_threshold": 1.0,
+ "suggested_dca_spreads": [0.001, 0.018, 0.15, 0.25],
+ "pct_below_lower": 0.0,
+ "pct_above_upper": 0.0,
+ "analysis_candles": len(candles),
+ }
+
+ if not candles or len(candles) < bb_length + natr_period:
+ return result
+
+ # Calculate BBP series
+ bbp_values = calculate_bbp_series(candles, bb_length, bb_std)
+ if not bbp_values:
+ return result
+
+ result["bbp_current"] = round(bbp_values[-1], 3)
+ result["analysis_candles"] = len(candles)
+
+ # % time outside bands
+ below = sum(1 for v in bbp_values if v < 0)
+ above = sum(1 for v in bbp_values if v > 1)
+ n = len(bbp_values)
+ result["pct_below_lower"] = round(below / n * 100, 1)
+ result["pct_above_upper"] = round(above / n * 100, 1)
+
+ # Current BB levels
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+ if len(closes) >= bb_length:
+ window = closes[-bb_length:]
+ sma = sum(window) / bb_length
+ variance = sum((x - sma) ** 2 for x in window) / bb_length
+ std = math.sqrt(variance)
+ result["bb_upper"] = round(sma + bb_std * std, 6)
+ result["bb_middle"] = round(sma, 6)
+ result["bb_lower"] = round(sma - bb_std * std, 6)
+
+ # NATR
+ natr = calculate_natr(candles, natr_period)
+ result["natr"] = natr
+
+ # Suggested thresholds from BBP distribution
+ long_thr, short_thr = suggest_bb_thresholds(bbp_values)
+ result["suggested_long_threshold"] = long_thr
+ result["suggested_short_threshold"] = short_thr
+
+ # Suggested DCA spreads from NATR
+ if natr and natr > 0:
+ result["suggested_dca_spreads"] = suggest_dca_spreads(natr, dca_levels)
+
+ return result
+
+
+def format_dman_analysis(analysis: Dict[str, Any]) -> str:
+ """
+ Format analysis results for display in wizard final step.
+
+ Returns a string block to append to the config text.
+ """
+ lines = []
+ natr = analysis.get("natr")
+ bbp = analysis.get("bbp_current")
+ bb_upper = analysis.get("bb_upper")
+ bb_middle = analysis.get("bb_middle")
+ bb_lower = analysis.get("bb_lower")
+ pct_below = analysis.get("pct_below_lower", 0)
+ pct_above = analysis.get("pct_above_upper", 0)
+ n_candles = analysis.get("analysis_candles", 0)
+
+ lines.append(f"BB analysis ({n_candles} candles):")
+ if bb_upper and bb_middle and bb_lower:
+ lines.append(f" Upper: {bb_upper:.4f} | Mid: {bb_middle:.4f} | Lower: {bb_lower:.4f}")
+ if bbp is not None:
+ pos = "oversold" if bbp < 0.2 else ("overbought" if bbp > 0.8 else "neutral")
+ lines.append(f" BBP now: {bbp:.3f} ({pos})")
+ if natr:
+ lines.append(f" NATR(14): {natr*100:.3f}%")
+ lines.append(f" % below lower band: {pct_below:.1f}%")
+ lines.append(f" % above upper band: {pct_above:.1f}%")
+
+ lines.append("")
+ lines.append(f" → bb_long_threshold: {analysis['suggested_long_threshold']}")
+ lines.append(f" → bb_short_threshold: {analysis['suggested_short_threshold']}")
+ spreads = analysis.get("suggested_dca_spreads", [])
+ if spreads:
+ lines.append(f" → dca_spreads: {','.join(str(s) for s in spreads)}")
+
+ return "\n".join(lines)
+
+def get_dca_strategy_suggestions(natr: float) -> Dict[str, Dict[str, Any]]:
+ if not natr:
+ natr = 0.01
+
+ # AUTO: Usa la logica suggerita dall'analisi delle candele
+ auto_spreads = suggest_dca_spreads(natr, 4)
+
+ return {
+ "scalping": {
+ "label": "Target: Scalping (Ordini vicini e costanti)",
+ "dca_spreads": [round(natr*0.2, 4), round(natr*0.4, 4), round(natr*0.6, 4), round(natr*0.9, 4)],
+ "dca_amounts_pct": [0.25, 0.25, 0.25, 0.25] # Distribuzione piatta
+ },
+ "martingale": {
+ "label": "Target: Martingala (Raddoppio)",
+ "dca_spreads": [round(natr*0.5, 4), round(natr*1.2, 4), round(natr*2.5, 4), round(natr*5.0, 4)],
+ "dca_amounts_pct": [0.10, 0.20, 0.30, 0.40] # Più capitale sui livelli profondi
+ },
+ "standard": {
+ "label": "Target: Standard (Bilanciato)",
+ "dca_spreads": [round(natr*1.0, 4), round(natr*2.0, 4), round(natr*3.0, 4), round(natr*4.0, 4)],
+ "dca_amounts_pct": [0.20, 0.20, 0.30, 0.30]
+ },
+ "conservative": {
+ "label": "Target: Conservativo (Protezione)",
+ "dca_spreads": [round(natr*2.0, 4), round(natr*5.0, 4), round(natr*10.0, 4), round(natr*15.0, 4)],
+ "dca_amounts_pct": [0.40, 0.30, 0.20, 0.10] # Più capitale vicino, meno se crolla tutto
+ },
+ "auto": {
+ "label": "Target: Auto (Analisi NATR)",
+ "dca_spreads": auto_spreads,
+ "dca_amounts_pct": [0.25, 0.25, 0.25, 0.25]
+ }
+ }
diff --git a/handlers/bots/controllers/dman_v3/chart.py b/handlers/bots/controllers/dman_v3/chart.py
new file mode 100644
index 00000000..f4ce38c4
--- /dev/null
+++ b/handlers/bots/controllers/dman_v3/chart.py
@@ -0,0 +1,311 @@
+"""
+DMan V3 chart generation.
+
+4 panels:
+ 1. Price – candlesticks + BB + MA20/50/EMA9 + DCA lines
+ 2. Volume – colored bars
+ 3. RSI – RSI indicator with overbought/oversold zones
+ 4. BBP – Bollinger Band Percent with long/short thresholds
+"""
+
+import io
+import time
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+from matplotlib.patches import Rectangle
+plt.style.use('dark_background')
+
+def generate_chart(config, candles_data, current_price=None):
+ if not candles_data or len(candles_data) < 5:
+ return _generate_simple_chart(candles_data, current_price)
+
+ df = _prepare_dataframe(candles_data)
+ full_df = df.copy()
+ MAX_VISIBLE_CANDLES = 96
+ for col in ['open', 'high', 'low', 'close', 'volume']:
+ full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0)
+
+ # ── INDICATORI ──────────────────────────────────────────────────
+ full_df['ma20'] = full_df['close'].rolling(20).mean()
+ full_df['ma50'] = full_df['close'].rolling(50).mean()
+ full_df['ema9'] = full_df['close'].ewm(span=9).mean()
+ bb_length = int(config.get('bb_length', 20))
+ bb_std_val = float(config.get('bb_std', 2.0))
+ rolling = full_df['close'].rolling(bb_length)
+ full_df['bb_mid'] = rolling.mean()
+ bb_std_series = rolling.std()
+ full_df['bb_upper'] = (full_df['bb_mid'] + bb_std_val * bb_std_series)
+ full_df['bb_lower'] = (full_df['bb_mid'] - bb_std_val * bb_std_series)
+ denom = (full_df['bb_upper'] - full_df['bb_lower']).replace(0, np.nan)
+ full_df['bbp'] = ((full_df['close'] - full_df['bb_lower']) / denom).clip(-1, 2).fillna(0.5)
+ full_df['rsi'] = _calc_rsi(full_df['close'])
+ # dataset visualizzato
+ df = full_df.tail(MAX_VISIBLE_CANDLES).copy()
+
+ # ── FIGURA ──────────────────────────────────────────────────────
+ fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(22, 14), sharex=True, gridspec_kw={
+ 'height_ratios': [4.5, 1.2, 1.3, 1.5]
+ }
+ )
+
+ fig.patch.set_facecolor('#111111')
+ for ax in [ax1, ax2, ax3, ax4]:
+ ax.set_facecolor('#111111')
+ ax.tick_params(colors='white')
+ ax.yaxis.label.set_color('white')
+ ax.spines['bottom'].set_color('#444')
+ ax.spines['top'].set_color('#444')
+ ax.spines['left'].set_color('#444')
+ ax.spines['right'].set_color('#444')
+ dates = mdates.date2num(df['datetime'])
+
+ if len(dates) > 1:
+ candle_width = (dates[1] - dates[0]) * 0.85
+ volume_width = (dates[1] - dates[0]) * 0.85
+ else:
+ candle_width = volume_width = 0.0005
+
+ # ── PANNELLO 1: PREZZO ──────────────────────────────────────────
+ for i in range(len(df)):
+ o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']]
+ color = '#2ecc71' if c >= o else '#e74c3c'
+ ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1)
+ ax1.add_patch(Rectangle(
+ (dates[i] - candle_width / 2, min(o, c)),
+ candle_width, abs(c - o) or 1e-8,
+ color=color
+ ))
+
+ ax1.plot(df['datetime'], df['ma20'], label='MA20', linewidth=1.4, color='#f39c12')
+ ax1.plot(df['datetime'], df['ma50'], label='MA50', linewidth=1.4, color='#3498db')
+ ax1.plot(df['datetime'], df['ema9'], label='EMA9', linewidth=1.4, color='#9b59b6')
+ ax1.plot(df['datetime'], df['bb_upper'], '--', linewidth=1.1, color='#aaaaaa', alpha=0.95,label='BB Upper')
+ ax1.plot(df['datetime'], df['bb_mid'], ':', linewidth=1.0, color='#aaaaaa', alpha=0.95, label='BB Mid')
+ ax1.plot(df['datetime'], df['bb_lower'], '--', linewidth=1.1, color='#aaaaaa', alpha=0.95, label='BB Lower')
+ ax1.fill_between(df['datetime'], df['bb_lower'], df['bb_upper'], color='#aaaaaa', alpha=0.18)
+
+ if current_price:
+ ax1.axhline(y=current_price, linestyle='--', alpha=0.8, color='gold', linewidth=1.3, label='Price')
+
+ # DCA lines
+ spreads = config.get("dca_spreads", "")
+ if isinstance(spreads, str) and current_price:
+ try:
+ spreads = [float(x.strip()) for x in spreads.split(",")]
+ for s in spreads:
+ ax1.axhline(current_price * (1 - s), linestyle=':', alpha=0.4, color='#7f8c8d')
+ ax1.axhline(current_price * (1 + s), linestyle=':', alpha=0.2, color='#7f8c8d')
+ except Exception:
+ pass
+
+ legend1 = ax1.legend(loc='upper left',fontsize=9,ncol=3,framealpha=0)
+ for text in legend1.get_texts():
+ text.set_color('white')
+ ax1.set_ylabel('Price')
+ ax1.set_xlim(df['datetime'].min(), df['datetime'].max())
+
+ # ── PANNELLO 2: VOLUME ──────────────────────────────────────────
+ vol_colors = [
+ '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c'
+ for i in range(len(df))
+ ]
+ ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7)
+ ax2.set_ylabel('Volume')
+
+ # ── PANNELLO 3: RSI ─────────────────────────────────────────────
+ ax3.plot(df['datetime'], df['rsi'], linewidth=1.5, color='steelblue')
+
+ ax3.axhline(70, linestyle='--', color='red', alpha=0.7, label='Overbought (70)')
+ ax3.axhline(30, linestyle='--', color='green', alpha=0.7, label='Oversold (30)')
+ ax3.axhline(50, linestyle=':', color='#7f8c8d', alpha=0.5)
+
+ ax3.fill_between(df['datetime'], 30, 70, alpha=0.18, color='#aaaaaa')
+ ax3.set_ylim(10, 90)
+ legend3 = ax3.legend(loc='upper left', fontsize=9, framealpha=0)
+ for text in legend3.get_texts():
+ text.set_color('white')
+ ax3.set_ylabel('RSI')
+
+ # ── PANNELLO 4: BBP ─────────────────────────────────────────────
+ ax4.plot(df['datetime'], df['bbp'], linewidth=1.5, color='steelblue')
+ long_thr = float(config.get('bb_long_threshold', 0.0))
+ short_thr = float(config.get('bb_short_threshold', 1.0))
+
+ ax4.axhline(long_thr, linestyle='--', color='green', alpha=0.8, label=f'Long {long_thr}')
+ ax4.axhline(short_thr, linestyle='--', color='red', alpha=0.8, label=f'Short {short_thr}')
+ ax4.axhline(0, linestyle=':', color='#7f8c8d', alpha=0.5)
+ ax4.axhline(1, linestyle=':', color='#7f8c8d', alpha=0.5)
+
+ # evidenzia zone di segnale
+ ax4.fill_between(df['datetime'], -1, long_thr, alpha=0.22, color='#00ff88')
+ ax4.fill_between(df['datetime'], short_thr, 2, alpha=0.22, color='#ff4d6d')
+
+ legend4 = ax4.legend(loc='upper left', fontsize=9, framealpha=0)
+ for text in legend4.get_texts():
+ text.set_color('white')
+ ax4.set_ylabel('BBP')
+
+ # ── FIX ASSE X BASATO SUL TIMEFRAME ───────────────────────────────
+
+ interval = config.get('interval', '5m')
+
+ if interval == '1m':
+ locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(interval=5)
+
+ elif interval == '5m':
+ locator = mdates.HourLocator(interval=1)
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+
+ elif interval == '15m':
+
+ locator = mdates.HourLocator(interval=3)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=1)
+
+ elif interval == '1h':
+
+ locator = mdates.HourLocator(interval=12)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=3)
+
+ elif interval == '8h':
+
+ locator = mdates.DayLocator(interval=4)
+ formatter = mdates.DateFormatter('%b%d')
+ minor_locator = mdates.DayLocator(interval=1)
+
+ else:
+
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ formatter = mdates.ConciseDateFormatter(locator)
+ minor_locator = None
+
+ # ── APPLICA A TUTTI GLI ASSI ──────────────────────────────────────
+
+ ax1.tick_params(labelbottom=False)
+ ax2.tick_params(labelbottom=False)
+ ax3.tick_params(labelbottom=False)
+
+ for ax in [ax1, ax2, ax3, ax4]:
+
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+
+ if minor_locator:
+ ax.xaxis.set_minor_locator(minor_locator)
+
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
+
+ # grid verticale
+ ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15)
+
+ # grid orizzontale
+ ax.grid( True, which='major', axis='y', alpha=0.25)
+
+ # minor grid
+ ax.grid(True, which='minor', axis='y',alpha=0.06)
+ # ── TITOLO ──────────────────────────────────────────────────────
+ interval = config.get('interval', '3m')
+ fig.suptitle(
+ f"{config.get('trading_pair', 'Unknown')} - DMan V3 "
+ f"(BB{bb_length} | RSI14 | {interval})",
+ fontsize=13, color='white'
+ )
+
+ plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06)
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+# ── HELPERS ─────────────────────────────────────────────────────────
+
+def _prepare_dataframe(candles, timezone=None):
+ if timezone is None:
+ # Prende il fuso orario del sistema
+ timezone = time.tzname[0]
+ df = pd.DataFrame(candles)
+
+ # Cerca colonna timestamp
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ # Converti timestamp
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ # Determina se è millisecondi o secondi
+ if sample > 10**12: # nanosecondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ns', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ elif sample > 10**10: # millisecondi (dopo il 1970)
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ms', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else: # secondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='s', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else:
+ df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None))
+ else:
+ # Fallback: crea date sequenziali usando l'intervallo dalla config
+ # NOTA: questo è un fallback, idealmente dovresti avere timestamp reali
+ freq = config.get('interval', '5m') if 'config' in locals() else '5m'
+ df['datetime'] = pd.date_range(
+ end=pd.Timestamp.now(),
+ periods=len(df),
+ freq=freq
+ )
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+
+
+def _calc_rsi(series, period=14):
+ delta = series.diff()
+ gain = (delta.where(delta > 0, 0)).rolling(period).mean()
+ loss = (-delta.where(delta < 0, 0)).rolling(period).mean()
+ rs = gain / loss.replace(0, np.nan)
+ rsi = 100 - (100 / (1 + rs))
+ return rsi.bfill().fillna(50)
+
+
+def _generate_simple_chart(candles_data, current_price):
+ if not candles_data:
+ return io.BytesIO()
+ df = _prepare_dataframe(candles_data)
+ MAX_VISIBLE_CANDLES = 96
+ if len(df) > MAX_VISIBLE_CANDLES:
+ df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+ fig, ax = plt.subplots(figsize=(10, 6))
+ ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series()), errors='coerce'))
+ if current_price:
+ ax.axhline(y=current_price, linestyle='--')
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
+ plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06)
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config, candles_data, current_price=None):
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/dman_v3/config.py b/handlers/bots/controllers/dman_v3/config.py
new file mode 100644
index 00000000..4fa3f2ca
--- /dev/null
+++ b/handlers/bots/controllers/dman_v3/config.py
@@ -0,0 +1,175 @@
+"""
+DMan V3 controller configuration.
+
+Mean reversion strategy using Bollinger Bands to determine direction,
+with DCA execution to enter positions at multiple levels.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "dman_v3",
+ "controller_type": "directional_trading",
+ "id": "",
+ # Base fields from ControllerConfigBase
+ "manual_kill_switch": False,
+ # Connector
+ "connector_name": "",
+ "trading_pair": "",
+ "total_amount_quote": 1000,
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ # DirectionalTradingControllerConfigBase fields
+ "max_executors_per_side": 1,
+ "cooldown_time": 60,
+ "stop_loss": 0.05,
+ "take_profit": 0.03,
+ "take_profit_order_type": 2,
+ "time_limit": None,
+ # Trailing stop as object (matches TrailingStop dataclass)
+ "trailing_stop": {
+ "activation_price": 0.015,
+ "trailing_delta": 0.005,
+ },
+ # Candles config
+ "candles_connector": "",
+ "candles_trading_pair": "",
+ "interval": "5m",
+ # Bollinger Bands
+ "bb_length": 100,
+ "bb_std": 2.0,
+ "bb_long_threshold": 0.0,
+ "bb_short_threshold": 1.0,
+ # DCA
+ "dca_spreads": "0.001,0.018,0.15,0.25",
+ "dca_amounts_pct": "0.25,0.25,0.25,0.25",
+ "dynamic_order_spread": False,
+ "dynamic_target": False,
+ "activation_bounds": None,
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(name="id", label="Config ID", type="str", required=True, hint="Auto-generated"),
+ "connector_name": ControllerField(name="connector_name", label="Connector", type="str", required=True, hint="Exchange connector"),
+ "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True, hint="e.g. BTC-USDT"),
+ "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=True, hint="e.g. 1, 5, 10", default=1),
+ "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, hint="HEDGE or ONEWAY", default="HEDGE"),
+ "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (Quote)", type="float", required=True, hint="e.g. 1000 USDT"),
+ "max_executors_per_side": ControllerField(name="max_executors_per_side", label="Max Executors/Side", type="int", required=False, hint="Max concurrent positions per side (default: 1)", default=1),
+ "cooldown_time": ControllerField(name="cooldown_time", label="Cooldown Time (s)", type="int", required=False, hint="Seconds between new executors (default: 60)", default=60),
+ "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, hint="Stop loss % (e.g. 0.05 = 5%)", default=0.05),
+ "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, hint="Take profit % (e.g. 0.03 = 3%)", default=0.03),
+ "take_profit_order_type": ControllerField(name="take_profit_order_type", label="TP Order Type", type="int", required=False, hint="1=Market, 2=Limit, 3=Limit Maker", default=2),
+ "time_limit": ControllerField(name="time_limit", label="Time Limit (s)", type="int", required=False, hint="Max executor lifetime in seconds (None = no limit)", default=None),
+ "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False, hint="Leave empty to use same as connector", default=""),
+ "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False, hint="Leave empty to use same as trading pair", default=""),
+ "interval": ControllerField(name="interval", label="Candle Interval", type="str", required=True, hint="e.g. 1m, 5m, 1h, 8h", default="5m"),
+ "bb_length": ControllerField(name="bb_length", label="BB Length", type="int", required=False, hint="Bollinger Bands period (default: 100)", default=100),
+ "bb_std": ControllerField(name="bb_std", label="BB Std Dev", type="float", required=False, hint="Standard deviations (default: 2.0)", default=2.0),
+ "bb_long_threshold": ControllerField(name="bb_long_threshold", label="BB Long Threshold", type="float", required=False, hint="BBP below this → LONG signal (default: 0.0)", default=0.0),
+ "bb_short_threshold": ControllerField(name="bb_short_threshold", label="BB Short Threshold", type="float", required=False, hint="BBP above this → SHORT signal (default: 1.0)", default=1.0),
+ "dca_spreads": ControllerField(name="dca_spreads", label="DCA Spreads", type="str", required=True, hint="Comma-separated (e.g. 0.001,0.018,0.15,0.25)", default="0.001,0.018,0.15,0.25"),
+ "dca_amounts_pct": ControllerField(name="dca_amounts_pct", label="DCA Amounts %", type="str", required=False, hint="Comma-separated %, empty = equal distribution", default=""),
+ "dynamic_order_spread": ControllerField(name="dynamic_order_spread", label="Dynamic Spread", type="bool", required=False, hint="Scale spreads with BB width", default=False),
+ "dynamic_target": ControllerField(name="dynamic_target", label="Dynamic Target", type="bool", required=False, hint="Scale TP/SL with BB width", default=False),
+ "activation_bounds": ControllerField(name="activation_bounds", label="Activation Bounds", type="float", required=False, hint="e.g. 0.01 (1%) - None to disable", default=None),
+ "manual_kill_switch": ControllerField(name="manual_kill_switch", label="Kill Switch", type="bool", required=False, hint="Manual kill switch", default=False),
+}
+
+FIELD_ORDER: List[str] = [
+ "id", "connector_name", "trading_pair", "leverage", "position_mode",
+ "total_amount_quote", "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type", "time_limit",
+ "candles_connector", "candles_trading_pair", "interval",
+ "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "dca_spreads", "dca_amounts_pct",
+ "dynamic_order_spread", "dynamic_target",
+ "activation_bounds", "manual_kill_switch",
+]
+
+EDITABLE_FIELDS: List[str] = [
+ "connector_name", "trading_pair", "total_amount_quote", "leverage",
+ "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type",
+ "trailing_stop_activation", "trailing_stop_delta",
+ "candles_connector", "candles_trading_pair", "interval",
+ "bb_length", "bb_std", "bb_long_threshold", "bb_short_threshold",
+ "dca_spreads", "dca_amounts_pct",
+ "dynamic_order_spread", "dynamic_target", "activation_bounds",
+]
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Estrae solo i campi annidati (trailing_stop) in formato piatto."""
+ trailing = config.get("trailing_stop", {})
+ # Partiamo dai campi già presenti nel config (che sono già piatti)
+ flat = dict(config) # copia superficiale
+ # Sostituiamo i due campi annidati con le loro versioni piatte
+ flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015)
+ flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005)
+ # Rimuoviamo il dizionario originale perché non serve nella visualizzazione piatta
+ flat.pop("trailing_stop", None)
+ return flat
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None:
+ """Applica gli aggiornamenti, riconvertendo i due campi nel dizionario trailing_stop."""
+ for key, value in updates.items():
+ if key == "trailing_stop_activation":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["activation_price"] = value
+ elif key == "trailing_stop_delta":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["trailing_delta"] = value
+ else:
+ config[key] = value
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ # Identifica il connettore principale
+ connector = config.get("connector_name", "").lower()
+ is_spot = "spot" in connector or not ("perpetual" in connector or "margin" in connector)
+
+ # --- FIX AUTOMATICO PER IL GRAFICO ---
+ # Se il connettore delle candele non è specificato, lo creiamo pulendo quello principale
+ if not config.get("candles_connector"):
+ # Rimuove i suffissi per puntare allo Spot (es: binance_perpetual -> binance)
+ clean_conn = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ config["candles_connector"] = clean_conn
+
+ # Se la coppia delle candele non è specificata, usa quella di trading
+ if not config.get("candles_trading_pair"):
+ config["candles_trading_pair"] = config.get("trading_pair")
+ # -------------------------------------
+
+ if is_spot:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ else:
+ # Defaults per i Perpetual se non specificati
+ if not config.get("leverage"):
+ config["leverage"] = 1
+ if config.get("position_mode") not in ["HEDGE", "ONEWAY"]:
+ config["position_mode"] = "HEDGE"
+
+ # Validazione campi obbligatori
+ required = ["connector_name", "trading_pair", "total_amount_quote"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ return True, None
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ max_num = 0
+ for cfg in existing_configs:
+ parts = cfg.get("id", "").split("_", 1)
+ if parts and parts[0].isdigit():
+ max_num = max(max_num, int(parts[0]))
+ seq = str(max_num + 1).zfill(3)
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+ return f"{seq}_dman_{connector}_{pair}"
diff --git a/handlers/bots/controllers/funding_rate_arb/__init__.py b/handlers/bots/controllers/funding_rate_arb/__init__.py
new file mode 100644
index 00000000..1bf9b9ae
--- /dev/null
+++ b/handlers/bots/controllers/funding_rate_arb/__init__.py
@@ -0,0 +1,48 @@
+"""
+Funding Rate Arbitrage Controller Module
+
+Perp↔Perp delta neutral and Spot↔Perp cash-and-carry arbitrage.
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class FundingRateArbController(BaseController):
+ controller_type = "funding_rate_arb"
+ display_name = "Funding Rate Arbitrage"
+ description = "Multi-exchange funding rate arbitrage with hourly normalization"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ # Funding rate arb doesn't use candlestick charts
+ # Generate a simple status chart instead
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["FundingRateArbController", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
\ No newline at end of file
diff --git a/handlers/bots/controllers/funding_rate_arb/chart.py b/handlers/bots/controllers/funding_rate_arb/chart.py
new file mode 100644
index 00000000..32893577
--- /dev/null
+++ b/handlers/bots/controllers/funding_rate_arb/chart.py
@@ -0,0 +1,95 @@
+"""
+Funding Rate Arbitrage chart generation.
+
+Simple chart showing funding rate history and net APY.
+"""
+
+import io
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional
+
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+import numpy as np
+
+
+def generate_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """
+ Generate a simple chart for funding rate arbitrage.
+
+ Shows:
+ - Funding rate history for both exchanges (normalized to hourly)
+ - Net rate (difference)
+ - Entry/exit thresholds
+ """
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
+
+ conn_a = config.get("connector_pair_a_connector_name", "Exchange A")
+ conn_b = config.get("connector_pair_b_connector_name", "Exchange B")
+ pair = config.get("connector_pair_a_trading_pair", "Unknown")
+
+ entry_threshold = config.get("entry_threshold", 0.000025)
+ exit_threshold = config.get("exit_threshold", 0.000005)
+
+ # Generate sample data for demonstration
+ # In real implementation, this would use historical funding rate data
+ times = [datetime.now() - timedelta(hours=x) for x in range(24, 0, -1)]
+
+ # Simulate funding rates (replace with real data)
+ np.random.seed(42)
+ rate_a = 0.00002 + np.random.normal(0, 0.000005, 24)
+ rate_b = 0.00001 + np.random.normal(0, 0.000008, 24)
+ net_rate = rate_a - rate_b
+
+ # Plot individual rates
+ ax1.plot(times, rate_a * 100, label=f"{conn_a} (%/h)", linewidth=1.5)
+ ax1.plot(times, rate_b * 100, label=f"{conn_b} (%/h)", linewidth=1.5)
+ ax1.axhline(y=entry_threshold * 100, linestyle='--', color='green', alpha=0.7, label=f'Entry ({entry_threshold*100:.4f}%/h)')
+ ax1.axhline(y=exit_threshold * 100, linestyle='--', color='red', alpha=0.7, label=f'Exit ({exit_threshold*100:.4f}%/h)')
+ ax1.axhline(y=0, linestyle='-', color='gray', alpha=0.3)
+ ax1.set_ylabel('Funding Rate (%/h)')
+ ax1.legend(loc='upper left', fontsize=8)
+ ax1.grid(True, alpha=0.3)
+ ax1.set_title(f'Funding Rates - {pair}')
+
+ # Plot net rate
+ ax2.fill_between(times, 0, net_rate * 100, where=(net_rate > 0), color='green', alpha=0.3, label='Positive (Long A / Short B)')
+ ax2.fill_between(times, 0, net_rate * 100, where=(net_rate < 0), color='red', alpha=0.3, label='Negative (Short A / Long B)')
+ ax2.plot(times, net_rate * 100, color='blue', linewidth=2, label='Net Rate')
+ ax2.axhline(y=entry_threshold * 100, linestyle='--', color='green', alpha=0.7, label=f'Entry')
+ ax2.axhline(y=-entry_threshold * 100, linestyle='--', color='green', alpha=0.7)
+ ax2.axhline(y=exit_threshold * 100, linestyle='--', color='red', alpha=0.7, label=f'Exit')
+ ax2.axhline(y=-exit_threshold * 100, linestyle='--', color='red', alpha=0.7)
+ ax2.axhline(y=0, linestyle='-', color='gray', alpha=0.5)
+ ax2.set_ylabel('Net Rate (%/h)')
+ ax2.set_xlabel('Time')
+ ax2.legend(loc='upper left', fontsize=8)
+ ax2.grid(True, alpha=0.3)
+ ax2.set_title('Net Funding Rate (A - B)')
+
+ # Format x-axis
+ ax2.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
+ ax2.xaxis.set_major_locator(mdates.HourLocator(interval=4))
+ plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45, ha='right')
+
+ fig.suptitle(f'Funding Rate Arbitrage - {conn_a} ↔ {conn_b}', fontsize=12)
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=100, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """Generate preview chart (same as main chart)."""
+ return generate_chart(config, candles_data, current_price)
\ No newline at end of file
diff --git a/handlers/bots/controllers/funding_rate_arb/config.py b/handlers/bots/controllers/funding_rate_arb/config.py
new file mode 100644
index 00000000..fd2ae7c6
--- /dev/null
+++ b/handlers/bots/controllers/funding_rate_arb/config.py
@@ -0,0 +1,292 @@
+"""
+Funding Rate Arbitrage controller configuration.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+# ============================================
+# DEFAULTS values
+# ============================================
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "funding_rate_arb",
+ "controller_type": "generic",
+ "id": "",
+ # Exchange A
+ "connector_pair_a_connector_name": "kucoin_perpetual",
+ "connector_pair_a_trading_pair": "SOL-USDT",
+ # Exchange B
+ "connector_pair_b_connector_name": "hyperliquid_perpetual",
+ "connector_pair_b_trading_pair": "SOL-USDT",
+ # Funding intervals (optional)
+ "funding_interval_a_hours": None,
+ "funding_interval_b_hours": None,
+ # Thresholds
+ "entry_threshold": 0.0002, # se le fees sono 0.1% =0.001 è da 1/5 delle fees
+ "exit_threshold": 0.00003, # 1/5 - 1/10 of entry_threshold
+ # Capital and risk
+ "total_amount_quote": 100,
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ "sl_global": 0.03,
+ "tp_global": 0.05,
+ "funding_check_interval": 300,
+ "executor_refresh_time": 60,
+}
+
+# ============================================
+# FIELD DEFINITIONS
+# ============================================
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated",
+ ),
+ # Exchange A
+ "connector_pair_a_connector_name": ControllerField(
+ name="connector_pair_a_connector_name",
+ label="Exchange A",
+ type="str",
+ required=True,
+ hint="First exchange connector (perp or spot)",
+ ),
+ "connector_pair_a_trading_pair": ControllerField(
+ name="connector_pair_a_trading_pair",
+ label="Pair A",
+ type="str",
+ required=True,
+ hint="e.g. SOL-USDT",
+ ),
+ # Exchange B
+ "connector_pair_b_connector_name": ControllerField(
+ name="connector_pair_b_connector_name",
+ label="Exchange B",
+ type="str",
+ required=True,
+ hint="Second exchange connector",
+ ),
+ "connector_pair_b_trading_pair": ControllerField(
+ name="connector_pair_b_trading_pair",
+ label="Pair B",
+ type="str",
+ required=True,
+ hint="e.g. SOL-USDT",
+ ),
+ # Funding intervals
+ "funding_interval_a_hours": ControllerField(
+ name="funding_interval_a_hours",
+ label="Funding Interval A (hours)",
+ type="int",
+ required=False,
+ hint="Leave empty for auto-detect",
+ default=None,
+ ),
+ "funding_interval_b_hours": ControllerField(
+ name="funding_interval_b_hours",
+ label="Funding Interval B (hours)",
+ type="int",
+ required=False,
+ hint="Leave empty for auto-detect",
+ default=None,
+ ),
+ # Thresholds
+ "entry_threshold": ControllerField(
+ name="entry_threshold",
+ label="Entry Threshold (%/h)",
+ type="float",
+ required=True,
+ hint="Minimum net rate to open (e.g. 0.000025 = 0.0025%/h)",
+ default=0.000025,
+ ),
+ "exit_threshold": ControllerField(
+ name="exit_threshold",
+ label="Exit Threshold (%/h)",
+ type="float",
+ required=True,
+ hint="Close when net rate below this (e.g. 0.000005 = 0.0005%/h)",
+ default=0.000005,
+ ),
+ # Capital
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote",
+ label="Total Amount (USDT)",
+ type="float",
+ required=True,
+ hint="Total capital, split equally between legs",
+ default=100,
+ ),
+ "leverage": ControllerField(
+ name="leverage",
+ label="Leverage",
+ type="int",
+ required=True,
+ hint="1x recommended (no liquidation risk)",
+ default=1,
+ ),
+ "position_mode": ControllerField(
+ name="position_mode",
+ label="Position Mode",
+ type="str",
+ required=False,
+ hint="HEDGE or ONEWAY",
+ default="HEDGE",
+ ),
+ # Risk
+ "sl_global": ControllerField(
+ name="sl_global",
+ label="Global Stop Loss",
+ type="float",
+ required=False,
+ hint="Emergency exit at this loss (e.g. 0.03 = 3%)",
+ default=0.03,
+ ),
+ "tp_global": ControllerField(
+ name="tp_global",
+ label="Global Take Profit",
+ type="float",
+ required=False,
+ hint="Emergency exit at this profit (e.g. 0.05 = 5%)",
+ default=0.05,
+ ),
+ # Intervals
+ "funding_check_interval": ControllerField(
+ name="funding_check_interval",
+ label="Check Interval (s)",
+ type="int",
+ required=False,
+ hint="Seconds between funding rate checks",
+ default=300,
+ ),
+ "executor_refresh_time": ControllerField(
+ name="executor_refresh_time",
+ label="Refresh Time (s)",
+ type="int",
+ required=False,
+ hint="Cancel unfilled orders after this many seconds",
+ default=60,
+ ),
+}
+
+
+FIELD_ORDER: List[str] = [
+ "id",
+ "connector_pair_a_connector_name",
+ "connector_pair_a_trading_pair",
+ "connector_pair_b_connector_name",
+ "connector_pair_b_trading_pair",
+ "funding_interval_a_hours",
+ "funding_interval_b_hours",
+ "entry_threshold",
+ "exit_threshold",
+ "total_amount_quote",
+ "leverage",
+ "position_mode",
+ "sl_global",
+ "tp_global",
+ "funding_check_interval",
+ "executor_refresh_time",
+]
+
+
+EDITABLE_FIELDS: List[str] = [
+ "connector_pair_a_connector_name",
+ "connector_pair_a_trading_pair",
+ "connector_pair_b_connector_name",
+ "connector_pair_b_trading_pair",
+ "funding_interval_a_hours",
+ "funding_interval_b_hours",
+ "entry_threshold",
+ "exit_threshold",
+ "total_amount_quote",
+ "leverage",
+ "sl_global",
+ "tp_global",
+ "funding_check_interval",
+ "executor_refresh_time",
+]
+
+# ============================================
+# HELPER FUNCTIONS
+# ============================================
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Estrae i campi in formato piatto per l'editing."""
+ ep1 = config.get("connector_pair_a", {}) or {}
+ ep2 = config.get("connector_pair_b", {}) or {}
+ flat = {k: v for k, v in config.items() if not isinstance(v, dict)}
+ flat["connector_pair_a_connector_name"] = ep1.get("connector_name", "")
+ flat["connector_pair_a_trading_pair"] = ep1.get("trading_pair", "")
+ flat["connector_pair_b_connector_name"] = ep2.get("connector_name", "")
+ flat["connector_pair_b_trading_pair"] = ep2.get("trading_pair", "")
+ flat.pop("connector_pair_a", None)
+ flat.pop("connector_pair_b", None)
+ return flat
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None:
+ """Applica gli aggiornamenti ai dizionari annidati."""
+ for key, value in updates.items():
+ if key == "connector_pair_a_connector_name":
+ config.setdefault("connector_pair_a", {})["connector_name"] = value
+ elif key == "connector_pair_a_trading_pair":
+ config.setdefault("connector_pair_a", {})["trading_pair"] = value
+ elif key == "connector_pair_b_connector_name":
+ config.setdefault("connector_pair_b", {})["connector_name"] = value
+ elif key == "connector_pair_b_trading_pair":
+ config.setdefault("connector_pair_b", {})["trading_pair"] = value
+ else:
+ config[key] = value
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate funding rate arb configuration."""
+ # Check required fields
+ required = [
+ "connector_pair_a_connector_name",
+ "connector_pair_a_trading_pair",
+ "connector_pair_b_connector_name",
+ "connector_pair_b_trading_pair",
+ "total_amount_quote",
+ ]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ # Validate thresholds
+ entry = config.get("entry_threshold", 0)
+ exit_ = config.get("exit_threshold", 0)
+ if entry <= exit_:
+ return False, f"Entry threshold ({entry}) must be greater than exit threshold ({exit_})"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate unique config ID."""
+ max_num = 0
+ for cfg in existing_configs:
+ config_id = cfg.get("id", "")
+ if not config_id:
+ continue
+ parts = config_id.split("_", 1)
+ if parts and parts[0].isdigit():
+ num = int(parts[0])
+ max_num = max(max_num, num)
+
+ next_num = max_num + 1
+ seq = str(next_num).zfill(3)
+
+ # Get connector names for ID
+ conn_a = config.get("connector_pair_a_connector_name", "unknown")
+ conn_b = config.get("connector_pair_b_connector_name", "unknown")
+ pair = config.get("connector_pair_a_trading_pair", "UNKNOWN").upper()
+
+ conn_a_clean = conn_a.replace("_perpetual", "").replace("_spot", "")
+ conn_b_clean = conn_b.replace("_perpetual", "").replace("_spot", "")
+
+ return f"{seq}_fra_{conn_a_clean}_{conn_b_clean}_{pair}"
diff --git a/handlers/bots/controllers/lm_multi_pair_dex/__init__.py b/handlers/bots/controllers/lm_multi_pair_dex/__init__.py
new file mode 100644
index 00000000..419a99af
--- /dev/null
+++ b/handlers/bots/controllers/lm_multi_pair_dex/__init__.py
@@ -0,0 +1,75 @@
+"""
+LMMultiPairDEX Controller Module for Condor.
+
+Market making multi-coppia ottimizzato per DEX con order book:
+- XRPL DEX (latenza 3-5s, fee ~$0.00001)
+- Hyperliquid (latenza 0.2ms, maker rebate -0.01%)
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, FIELD_ORDER, FIELDS, WIZARD_STEPS, generate_id, validate_config
+from .analysis import analyze_liquidity, format_liquidity_summary
+
+
+class LMMultiPairDEXController(BaseController):
+ controller_type = "lm_multi_pair_dex"
+ display_name = "Liquidity Mining Multi-Pair DEX"
+ description = "Market making multi-coppia per DEX order book (XRPL, Hyperliquid)"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(
+ cls,
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+ ) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+ @classmethod
+ def analyze_liquidity(cls, config: Dict[str, Any], market_data: Dict[str, Any]) -> Dict[str, Any]:
+ """Analizza liquidità per le coppie configurate."""
+ return analyze_liquidity(config, market_data)
+
+ @classmethod
+ def format_analysis(cls, analysis: Dict[str, Any]) -> str:
+ """Formatta l'analisi per visualizzazione."""
+ return format_liquidity_summary(analysis)
+
+
+__all__ = [
+ "LMMultiPairDEXController",
+ "DEFAULTS",
+ "FIELDS",
+ "FIELD_ORDER",
+ "WIZARD_STEPS",
+ "validate_config",
+ "generate_id",
+ "generate_chart",
+ "generate_preview_chart",
+ "analyze_liquidity",
+ "format_liquidity_summary",
+]
diff --git a/handlers/bots/controllers/lm_multi_pair_dex/analysis.py b/handlers/bots/controllers/lm_multi_pair_dex/analysis.py
new file mode 100644
index 00000000..80867870
--- /dev/null
+++ b/handlers/bots/controllers/lm_multi_pair_dex/analysis.py
@@ -0,0 +1,149 @@
+"""
+LMMultiPairDEX analysis utilities for Condor.
+"""
+
+from typing import Any, Dict, List
+
+
+def analyze_liquidity(config: Dict[str, Any], market_data: Dict[str, Any]) -> Dict[str, Any]:
+ """Analizza liquidità per la configurazione."""
+ connector_name = config.get("connector_name", "unknown")
+ markets = config.get("markets", [])
+
+ results = {}
+ total_depth = 0
+ liquid_pairs = 0
+
+ for pair in markets:
+ pair_data = market_data.get(pair, {})
+ metrics = _analyze_pair(connector_name, pair, pair_data)
+ results[pair] = metrics
+
+ if metrics.get("depth_05_usd", 0) > 10000:
+ liquid_pairs += 1
+ total_depth += metrics.get("depth_05_usd", 0)
+
+ # Stima APY
+ user_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02])
+ avg_spread = sum(user_spreads) / len(user_spreads) if user_spreads else 0.01
+ daily_profit = total_depth * 0.1 * avg_spread
+ apy = (daily_profit / config.get("total_amount_quote", 1000) * 365 *
+ config.get("portfolio_allocation", 0.1) * 100)
+
+ dex_type = "hyperliquid" if "hyperliquid" in connector_name.lower() else "xrpl"
+
+ return {
+ "dex_type": dex_type,
+ "pairs": results,
+ "total_depth_usd": round(total_depth, 0),
+ "liquid_pairs": liquid_pairs,
+ "total_pairs": len(markets),
+ "estimated_apy": round(min(50, apy), 2),
+ "warnings": _generate_warnings(results, dex_type),
+ "recommendations": _generate_recommendations(results, dex_type, config),
+ "fee_info": _get_fee_info(dex_type)
+ }
+
+
+def _analyze_pair(connector_name: str, pair: str, data: Dict) -> Dict:
+ """Analizza una singola coppia."""
+ best_bid = data.get("best_bid", 0)
+ best_ask = data.get("best_ask", 0)
+
+ if not best_bid or not best_ask:
+ return {"error": "No order book data", "is_liquid": False}
+
+ mid_price = (best_bid + best_ask) / 2
+ spread_pct = (best_ask - best_bid) / mid_price * 100
+
+ depth = data.get("depth_05_usd", 0)
+ liquidity_score = min(1.0, depth / 50000)
+
+ return {
+ "pair": pair,
+ "mid_price": round(mid_price, 8),
+ "spread_pct": round(spread_pct, 4),
+ "depth_05_usd": round(depth, 0),
+ "liquidity_score": round(liquidity_score, 2),
+ "is_liquid": liquidity_score >= 0.3,
+ }
+
+
+def _generate_warnings(metrics: Dict, dex_type: str) -> List[str]:
+ """Genera warning."""
+ warnings = []
+ for pair, m in metrics.items():
+ if "error" in m:
+ warnings.append(f"⚠️ {pair}: {m['error']}")
+ elif not m.get("is_liquid", False):
+ warnings.append(f"⚠️ {pair}: liquidità bassa ({m.get('depth_05_usd', 0):,.0f} USD)")
+ elif m.get("spread_pct", 0) > 2:
+ warnings.append(f"⚠️ {pair}: spread alto ({m.get('spread_pct', 0):.2f}%)")
+
+ if dex_type == "xrpl" and not warnings:
+ warnings.append("ℹ️ XRPL: fee quasi zero, pazienza necessaria.")
+ elif dex_type == "hyperliquid" and not warnings:
+ warnings.append("💰 Hyperliquid: maker rebate -0.01% attivo!")
+
+ return warnings
+
+
+def _generate_recommendations(metrics: Dict, dex_type: str, config: Dict) -> List[str]:
+ """Genera raccomandazioni."""
+ recs = []
+
+ if dex_type == "xrpl":
+ if config.get("order_refresh_time", 30) < 60:
+ recs.append("⏱️ XRPL: aumenta order_refresh_time a 60+ secondi")
+ if config.get("cooldown_time", 15) < 30:
+ recs.append("⏸️ XRPL: aumenta cooldown_time a 30 secondi")
+ elif dex_type == "hyperliquid":
+ if config.get("order_refresh_time", 45) > 35:
+ recs.append("⚡ Hyperliquid: riduci order_refresh_time a 30 secondi")
+ if config.get("token", "") != "USDC":
+ recs.append("💡 Hyperliquid: usa USDC per fee migliori")
+
+ return recs
+
+
+def _get_fee_info(dex_type: str) -> Dict:
+ """Info fee per DEX."""
+ if dex_type == "hyperliquid":
+ return {"maker_fee": "-0.01%", "note": "Ti PAGANO per fornire liquidità"}
+ else:
+ return {"maker_fee": "~0.000012 XRP", "note": "Fee quasi zero"}
+
+
+def format_liquidity_summary(analysis: Dict[str, Any]) -> str:
+ """Formatta l'analisi."""
+ if "error" in analysis:
+ return f"⚠️ Errore: {analysis['error']}"
+
+ dex_name = "HYPERLIQUID" if analysis.get("dex_type") == "hyperliquid" else "XRPL"
+ lines = [
+ f"📊 LMMultiPairDEX - {dex_name}",
+ "",
+ f"💰 Fee: {analysis.get('fee_info', {}).get('maker_fee', 'N/A')} maker",
+ f" {analysis.get('fee_info', {}).get('note', '')}",
+ "",
+ f"📈 Riepilogo:",
+ f" Liquidità totale: ${analysis.get('total_depth_usd', 0):,.0f}",
+ f" Coppie liquide: {analysis.get('liquid_pairs', 0)}/{analysis.get('total_pairs', 0)}",
+ f" APY stimata: {analysis.get('estimated_apy', 0)}%",
+ ""
+ ]
+
+ for pair, m in analysis.get("pairs", {}).items():
+ if "error" in m:
+ lines.append(f" ❌ {pair}: {m['error']}")
+ else:
+ icon = "✅" if m.get("is_liquid") else "⚠️"
+ lines.append(f" {icon} {pair}: spread={m.get('spread_pct', 0):.2f}% | depth=${m.get('depth_05_usd', 0):,.0f}")
+
+ if analysis.get("warnings"):
+ lines.extend(["", "⚠️ Avvertenze:"] + [f" {w}" for w in analysis["warnings"]])
+
+ if analysis.get("recommendations"):
+ lines.extend(["", "💡 Raccomandazioni:"] + [f" {r}" for r in analysis["recommendations"]])
+
+ return "\n".join(lines)
diff --git a/handlers/bots/controllers/lm_multi_pair_dex/chart.py b/handlers/bots/controllers/lm_multi_pair_dex/chart.py
new file mode 100644
index 00000000..20d8b877
--- /dev/null
+++ b/handlers/bots/controllers/lm_multi_pair_dex/chart.py
@@ -0,0 +1,148 @@
+"""
+LMMultiPairDEX chart generation for Condor.
+"""
+
+import io
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+import numpy as np
+
+
+def generate_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO:
+ """Genera chart per LMMultiPairDEX."""
+ if not candles_data or len(candles_data) < 5:
+ return _generate_simple_chart(config, candles_data)
+
+ connector = config.get("connector_name", "unknown")
+ markets = config.get("markets", ["XRP-RLUSD"])
+
+ fig = plt.figure(figsize=(14, 10))
+
+ # Prezzo
+ ax1 = plt.subplot(2, 1, 1)
+ _plot_price(ax1, candles_data, config, markets[0] if markets else "Unknown")
+
+ # Depth e allocazione
+ ax2 = plt.subplot(2, 1, 2)
+ _plot_depth_and_allocation(ax2, config, markets[0] if markets else "Unknown")
+
+ dex_name = "HYPERLIQUID" if "hyperliquid" in connector.lower() else "XRPL"
+ fig.suptitle(f"{dex_name} | LMMultiPairDEX - {', '.join(markets[:3])}", fontsize=12)
+
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def _plot_price(ax, candles_data, config, pair):
+ """Plot prezzo con livelli spread."""
+ df = _prepare_dataframe(candles_data)
+ if df.empty or 'close' not in df.columns:
+ ax.text(0.5, 0.5, "Waiting for price data...", transform=ax.transAxes, ha='center', va='center')
+ return
+
+ closes = pd.to_numeric(df['close'], errors='coerce').values
+ dates = df['datetime'].values
+
+ ax.plot(dates, closes, linewidth=1.5, color='white', label=pair)
+
+ buy_spreads = config.get("buy_spreads", [0.005, 0.01, 0.02])
+ sell_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02])
+ current_price = closes[-1] if len(closes) > 0 else 0
+
+ for i, s in enumerate(buy_spreads):
+ ax.axhline(y=current_price * (1 - s), linestyle='--', alpha=0.5, color='green', linewidth=0.8)
+ for i, s in enumerate(sell_spreads):
+ ax.axhline(y=current_price * (1 + s), linestyle='--', alpha=0.5, color='red', linewidth=0.8)
+
+ ax.set_ylabel('Price')
+ ax.set_title(f'Price with Spread Levels - {pair}')
+ ax.grid(True, alpha=0.3)
+ ax.legend(loc='upper left')
+
+
+def _plot_depth_and_allocation(ax, config, pair):
+ """Plot depth e allocazione."""
+ buy_spreads = config.get("buy_spreads", [0.005, 0.01, 0.02])
+ sell_spreads = config.get("sell_spreads", [0.005, 0.01, 0.02])
+ markets = config.get("markets", [])
+
+ # Depth
+ amounts = [1000, 2000, 3000]
+ bid_prices = [-s * 100 for s in buy_spreads]
+ ask_prices = [s * 100 for s in sell_spreads]
+
+ ax.barh(bid_prices, amounts, color='green', alpha=0.7, label='Buy orders', height=0.3)
+ ax.barh(ask_prices, amounts, color='red', alpha=0.7, label='Sell orders', height=0.3)
+
+ # Allocazione (testo)
+ target = config.get("target_base_pct", 0.5)
+ ax.text(0.02, 0.95, f"Target base: {target*100:.0f}%", transform=ax.transAxes, fontsize=9, verticalalignment='top')
+ ax.text(0.02, 0.88, f"Coppie: {len(markets)}", transform=ax.transAxes, fontsize=9, verticalalignment='top')
+
+ ax.axhline(y=0, linestyle='-', color='white', alpha=0.5, linewidth=1)
+ ax.set_xlabel('Amount (USD)')
+ ax.set_ylabel('Spread from mid (%)')
+ ax.set_title(f'Order Book Depth - {pair}')
+ ax.legend(loc='upper right')
+ ax.grid(True, alpha=0.3, axis='x')
+
+
+def _prepare_dataframe(candles: list) -> pd.DataFrame:
+ """Prepara DataFrame dalle candele."""
+ if not candles:
+ return pd.DataFrame()
+
+ df = pd.DataFrame(candles)
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ if sample > 10**12:
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='ns')
+ elif sample > 10**10:
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='ms')
+ else:
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='s')
+ else:
+ df['datetime'] = pd.to_datetime(df[ts_col])
+ else:
+ df['datetime'] = pd.date_range(end=pd.Timestamp.now(), periods=len(df), freq='5min')
+
+ for col in ['close', 'open', 'high', 'low']:
+ if col in df.columns:
+ df[col] = pd.to_numeric(df[col], errors='coerce')
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+
+def _generate_simple_chart(config: dict, candles_data: list) -> io.BytesIO:
+ """Chart semplice quando mancano dati."""
+ fig, ax = plt.subplots(figsize=(12, 6))
+ markets = config.get("markets", ["Unknown"])
+
+ if not candles_data:
+ msg = f"Waiting for candle data...\n{', '.join(markets)}"
+ else:
+ msg = f"Not enough data ({len(candles_data)} candles)\nNeed at least 5 candles"
+
+ ax.text(0.5, 0.5, msg, transform=ax.transAxes, ha='center', va='center', fontsize=12)
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
+ ax.axis('off')
+
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=100)
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/lm_multi_pair_dex/config.py b/handlers/bots/controllers/lm_multi_pair_dex/config.py
new file mode 100644
index 00000000..faf70008
--- /dev/null
+++ b/handlers/bots/controllers/lm_multi_pair_dex/config.py
@@ -0,0 +1,246 @@
+"""
+LMMultiPairDEX configuration for Condor.
+
+Supporta:
+- XRPL DEX (latenza 3-5s, fee ~$0.00001)
+- Hyperliquid (latenza 0.2ms, maker rebate -0.01%)
+"""
+from .._base import ControllerField
+from decimal import Decimal
+from typing import Any, Dict, List, Optional, Tuple
+
+
+# Default configuration values
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "lm_multi_pair_dex",
+ "controller_type": "generic",
+ "connector_name": "xrpl",
+ "markets": ["XRP-RLUSD"],
+ "token": "XRP",
+ "total_amount_quote": 1000,
+ "portfolio_allocation": 0.10,
+ "buy_spreads": [0.005, 0.01, 0.02],
+ "sell_spreads": [0.005, 0.01, 0.02],
+ "use_dynamic_spreads": True,
+ "atr_length": 14,
+ "atr_multiplier_min": 0.5,
+ "atr_multiplier_max": 2.0,
+ "order_refresh_time": 45,
+ "cooldown_time": 20,
+ "order_refresh_tolerance_pct": 0.01,
+ "target_base_pct": 0.5,
+ "min_base_pct": 0.3,
+ "max_base_pct": 0.7,
+ "max_skew": 0.2,
+ "leverage": 1,
+ "take_profit": None,
+ "max_spread_multiplier": 3.0,
+ "min_spread_multiplier": 0.3,
+ "min_volume_usd": 10000,
+ "min_liquidity_score": 0.3,
+}
+
+# Field definitions for the Condor wizard
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated with sequence number",
+ ),
+ "connector_name": ControllerField(
+ name="connector_name",
+ label="DEX connector",
+ type="str",
+ required=True,
+ hint="xrpl or hyperliquid",
+ ),
+ "markets": ControllerField(
+ name="markets",
+ label="Trading pairs",
+ type="list",
+ required=True,
+ hint="Comma-separated: XRP-RLUSD, BTC-XRP (XRPL) or SOL-USDC, ETH-USDC (Hyperliquid)",
+ ),
+ "token": ControllerField(
+ name="token",
+ label="Unified token",
+ type="str",
+ required=True,
+ hint="XRPL: XRP or RLUSD | Hyperliquid: USDC",
+ ),
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote",
+ label="Total capital",
+ type="float",
+ required=True,
+ hint="Total amount in unified token",
+ default=1000,
+ ),
+ "portfolio_allocation": ControllerField(
+ name="portfolio_allocation",
+ label="Portfolio allocation",
+ type="float",
+ required=False,
+ hint="Percent of total capital to use (0.1 = 10%)",
+ default=0.10,
+ ),
+ "buy_spreads": ControllerField(
+ name="buy_spreads",
+ label="Buy spreads",
+ type="list",
+ required=False,
+ hint="Spreads for buy orders (e.g., 0.005,0.01 = 0.5%,1.0%)",
+ default=[0.005, 0.01, 0.02],
+ ),
+ "sell_spreads": ControllerField(
+ name="sell_spreads",
+ label="Sell spreads",
+ type="list",
+ required=False,
+ hint="Spreads for sell orders",
+ default=[0.005, 0.01, 0.02],
+ ),
+ "use_dynamic_spreads": ControllerField(
+ name="use_dynamic_spreads",
+ label="Dynamic spreads",
+ type="bool",
+ required=False,
+ hint="Adjust spreads based on ATR volatility",
+ default=True,
+ ),
+ "order_refresh_time": ControllerField(
+ name="order_refresh_time",
+ label="Refresh time (s)",
+ type="int",
+ required=False,
+ hint="Cancel and replace unfilled orders after N seconds",
+ default=45,
+ ),
+ "cooldown_time": ControllerField(
+ name="cooldown_time",
+ label="Cooldown after fill (s)",
+ type="int",
+ required=False,
+ hint="Wait N seconds after a fill before placing new orders",
+ default=20,
+ ),
+ "order_refresh_tolerance_pct": ControllerField(
+ name="order_refresh_tolerance_pct",
+ label="Price tolerance",
+ type="float",
+ required=False,
+ hint="Refresh only if price changed more than this (0.01 = 1%)",
+ default=0.01,
+ ),
+ "target_base_pct": ControllerField(
+ name="target_base_pct",
+ label="Target base %",
+ type="float",
+ required=False,
+ hint="Target percentage of base assets (0.5 = 50%)",
+ default=0.5,
+ ),
+ "min_base_pct": ControllerField(
+ name="min_base_pct",
+ label="Min base %",
+ type="float",
+ required=False,
+ hint="Below this, buy aggressively",
+ default=0.3,
+ ),
+ "max_base_pct": ControllerField(
+ name="max_base_pct",
+ label="Max base %",
+ type="float",
+ required=False,
+ hint="Above this, sell aggressively",
+ default=0.7,
+ ),
+ "max_skew": ControllerField(
+ name="max_skew",
+ label="Max skew",
+ type="float",
+ required=False,
+ hint="Minimum order size multiplier (0.2 = 20% of normal)",
+ default=0.2,
+ ),
+ "min_liquidity_score": ControllerField(
+ name="min_liquidity_score",
+ label="Min liquidity score",
+ type="float",
+ required=False,
+ hint="Skip pairs with liquidity score below this",
+ default=0.3,
+ ),
+}
+
+# Field order in the wizard
+FIELD_ORDER: List[str] = [
+ "id",
+ "connector_name",
+ "markets",
+ "token",
+ "total_amount_quote",
+ "portfolio_allocation",
+ "buy_spreads",
+ "sell_spreads",
+ "use_dynamic_spreads",
+ "order_refresh_time",
+ "cooldown_time",
+ "order_refresh_tolerance_pct",
+ "target_base_pct",
+ "min_base_pct",
+ "max_base_pct",
+ "max_skew",
+ "min_liquidity_score",
+]
+
+# Wizard steps – minimal required for quick setup
+WIZARD_STEPS: List[str] = [
+ "connector_name",
+ "markets",
+ "token",
+ "total_amount_quote",
+ "portfolio_allocation",
+ "review",
+]
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate the configuration."""
+ required = ["connector_name", "markets", "token"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ total = config.get("total_amount_quote", 0)
+ if total <= 0:
+ return False, "total_amount_quote must be > 0"
+
+ alloc = config.get("portfolio_allocation", 0)
+ if alloc <= 0 or alloc > 1:
+ return False, "portfolio_allocation must be between 0 and 1"
+
+ connector = config.get("connector_name", "")
+ if connector not in ["xrpl", "hyperliquid"]:
+ return False, f"connector_name must be 'xrpl' or 'hyperliquid', got '{connector}'"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate unique config ID with sequence number."""
+ max_num = 0
+ for cfg in existing_configs:
+ cid = cfg.get("id", "")
+ if cid and cid.split("_")[0].isdigit():
+ num = int(cid.split("_")[0])
+ max_num = max(max_num, num)
+ next_num = max_num + 1
+ seq = str(next_num).zfill(3)
+
+ connector = config.get("connector_name", "unknown")
+ first_market = config.get("markets", ["UNKNOWN"])[0].split("-")[0]
+ return f"{seq}_lmmulti_{connector}_{first_market}"
diff --git a/handlers/bots/controllers/macd_bb_v1/__init__.py b/handlers/bots/controllers/macd_bb_v1/__init__.py
new file mode 100644
index 00000000..5be59d2e
--- /dev/null
+++ b/handlers/bots/controllers/macd_bb_v1/__init__.py
@@ -0,0 +1,42 @@
+"""MACD BB V1 Controller Module - Directional trading with Bollinger Bands + MACD."""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class MacdBbV1Controller(BaseController):
+ controller_type = "macd_bb_v1"
+ display_name = "MACD BB V1"
+ description = "Directional trading with Bollinger Bands + MACD confirmation"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["MacdBbV1Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
diff --git a/handlers/bots/controllers/macd_bb_v1/analysis.py b/handlers/bots/controllers/macd_bb_v1/analysis.py
new file mode 100644
index 00000000..4070527c
--- /dev/null
+++ b/handlers/bots/controllers/macd_bb_v1/analysis.py
@@ -0,0 +1,337 @@
+"""
+MACD BB V1 analysis utilities.
+Aggiornato con supporto Perpetual e suggerimenti strategie.
+"""
+
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+
+def _ema(values: List[float], period: int) -> List[float]:
+ """Calculate EMA for a list of values."""
+ if len(values) < period:
+ return []
+ k = 2.0 / (period + 1)
+ result = [sum(values[:period]) / period]
+ for v in values[period:]:
+ result.append(v * k + result[-1] * (1 - k))
+ return result
+
+
+def calculate_bbp_series(
+ candles: List[Dict[str, Any]],
+ bb_length: int = 100,
+ bb_std: float = 2.0,
+) -> List[float]:
+ """Calculate Bollinger Band Percent (BBP) series."""
+ closes = []
+ for c in candles:
+ close = c.get("close") or c.get("c")
+ if close is not None:
+ closes.append(float(close))
+
+ if len(closes) < bb_length:
+ return []
+
+ bbp_values = []
+ for i in range(len(closes)):
+ if i < bb_length - 1:
+ continue
+ window = closes[i - bb_length + 1: i + 1]
+ sma = sum(window) / bb_length
+ variance = sum((x - sma) ** 2 for x in window) / bb_length
+ std = math.sqrt(variance)
+ upper = sma + bb_std * std
+ lower = sma - bb_std * std
+ band_width = upper - lower
+ bbp_values.append((closes[i] - lower) / band_width if band_width > 0 else 0.5)
+
+ return bbp_values
+
+
+def calculate_macd_series(
+ candles: List[Dict[str, Any]],
+ fast: int = 21,
+ slow: int = 42,
+ signal: int = 9,
+) -> Tuple[List[float], List[float], List[float]]:
+ """
+ Calculate MACD, Signal line, and Histogram.
+ """
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+ if len(closes) < slow + signal:
+ return [], [], []
+
+ ema_fast = _ema(closes, fast)
+ ema_slow = _ema(closes, slow)
+
+ offset = slow - fast
+ ema_fast_aligned = ema_fast[offset:]
+ macd_line = [f - s for f, s in zip(ema_fast_aligned, ema_slow)]
+
+ signal_line = _ema(macd_line, signal)
+ macd_aligned = macd_line[signal - 1:]
+ histogram = [m - s for m, s in zip(macd_aligned, signal_line)]
+
+ return macd_aligned, signal_line, histogram
+
+
+def suggest_bb_thresholds(
+ bbp_values: List[float],
+ long_percentile: float = 15.0,
+ short_percentile: float = 85.0,
+) -> Tuple[float, float]:
+ """Suggest bb_long_threshold and bb_short_threshold from BBP distribution."""
+ if not bbp_values:
+ return 0.0, 1.0
+
+ sorted_bbp = sorted(bbp_values)
+ n = len(sorted_bbp)
+ long_idx = max(0, min(int(n * long_percentile / 100), n - 1))
+ short_idx = max(0, min(int(n * short_percentile / 100), n - 1))
+
+ return round(sorted_bbp[long_idx], 3), round(sorted_bbp[short_idx], 3)
+
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """Calculate Normalized ATR."""
+ if not candles or len(candles) < period + 1:
+ return None
+
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = float(candles[i].get("high", 0) or 0)
+ low = float(candles[i].get("low", 0) or 0)
+ prev_close = float(candles[i - 1].get("close", 0) or 0)
+ if not all([high, low, prev_close]):
+ continue
+ tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return None
+
+ atr = sum(true_ranges[-period:]) / period
+ current_close = float(candles[-1].get("close", 0) or 0)
+ return atr / current_close if current_close > 0 else None
+
+
+def analyze_candles_for_macd_bb(
+ candles: List[Dict[str, Any]],
+ bb_length: int = 100,
+ bb_std: float = 2.0,
+ macd_fast: int = 21,
+ macd_slow: int = 42,
+ macd_signal: int = 9,
+ natr_period: int = 14,
+) -> Dict[str, Any]:
+ """
+ Analisi completa per MACD BB V1.
+ """
+ result = {
+ "bbp_current": None,
+ "bb_upper": None,
+ "bb_middle": None,
+ "bb_lower": None,
+ "natr": None,
+ "macd_current": None,
+ "macd_histogram_current": None,
+ "suggested_long_threshold": 0.0,
+ "suggested_short_threshold": 1.0,
+ "signal_count_long": 0,
+ "signal_count_short": 0,
+ "pct_below_lower": 0.0,
+ "pct_above_upper": 0.0,
+ "analysis_candles": len(candles),
+ }
+
+ if not candles or len(candles) < max(bb_length, macd_slow + macd_signal) + natr_period:
+ return result
+
+ bbp_values = calculate_bbp_series(candles, bb_length, bb_std)
+ if not bbp_values:
+ return result
+
+ result["bbp_current"] = round(bbp_values[-1], 3)
+ n = len(bbp_values)
+ result["pct_below_lower"] = round(sum(1 for v in bbp_values if v < 0) / n * 100, 1)
+ result["pct_above_upper"] = round(sum(1 for v in bbp_values if v > 1) / n * 100, 1)
+
+ closes = [float(c.get("close") or c.get("c") or 0) for c in candles]
+ if len(closes) >= bb_length:
+ window = closes[-bb_length:]
+ sma = sum(window) / bb_length
+ variance = sum((x - sma) ** 2 for x in window) / bb_length
+ std = math.sqrt(variance)
+ result["bb_upper"] = round(sma + bb_std * std, 6)
+ result["bb_middle"] = round(sma, 6)
+ result["bb_lower"] = round(sma - bb_std * std, 6)
+
+ macd_line, signal_line, histogram = calculate_macd_series(candles, macd_fast, macd_slow, macd_signal)
+ if macd_line: result["macd_current"] = round(macd_line[-1], 6)
+ if histogram: result["macd_histogram_current"] = round(histogram[-1], 6)
+
+ long_thr, short_thr = suggest_bb_thresholds(bbp_values)
+ result["suggested_long_threshold"] = long_thr
+ result["suggested_short_threshold"] = short_thr
+
+ if histogram and bbp_values:
+ h_len = len(histogram)
+ b_aligned = bbp_values[-h_len:]
+ m_aligned = macd_line[-h_len:]
+ result["signal_count_long"] = sum(1 for b, m, h in zip(b_aligned, m_aligned, histogram) if b < long_thr and h > 0 and m < 0)
+ result["signal_count_short"] = sum(1 for b, m, h in zip(b_aligned, m_aligned, histogram) if b > short_thr and h < 0 and m > 0)
+
+ result["natr"] = calculate_natr(candles, natr_period)
+ return result
+
+
+def get_macd_bb_strategy_suggestions(natr: float, analysis: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+ """
+ Ritorna suggerimenti per MACD BB basati sulla volatilità e analisi statistica.
+ I valori TP/SL vengono scalati in base alla volatilità (NATR).
+ """
+ if not natr or natr <= 0:
+ natr = 0.01 # Default 1% volatility
+
+ # Determina il regime di volatilità
+ if natr < 0.005: # <0.5%
+ vol_regime = "very_low"
+ vol_mult = 0.7
+ elif natr < 0.01: # 0.5-1%
+ vol_regime = "low"
+ vol_mult = 1.0
+ elif natr < 0.02: # 1-2%
+ vol_regime = "moderate"
+ vol_mult = 1.3
+ elif natr < 0.03: # 2-3%
+ vol_regime = "high"
+ vol_mult = 1.6
+ else: # >3%
+ vol_regime = "very_high"
+ vol_mult = 2.0
+
+ l_thr = analysis.get("suggested_long_threshold", 0.15)
+ s_thr = analysis.get("suggested_short_threshold", 0.85)
+
+ # TP/SL base scalati con volatilità
+ base_tp = 0.025
+ base_sl = 0.015
+
+ # ========== AGGIUNGI TRAILING STOP ==========
+ # Trailing stop base (activation, delta)
+ base_ts_activation = 0.015 # 1.5%
+ base_ts_delta = 0.005 # 0.5%
+
+ return {
+ "scalping": {
+ "label": "Target: Scalping (Reattivo)",
+ "bb_length": 21,
+ "bb_std": 2.0,
+ "macd_fast": 12,
+ "macd_slow": 26,
+ "macd_signal": 9,
+ "bb_long_threshold": 0.2,
+ "bb_short_threshold": 0.8,
+ "take_profit": round(base_tp * vol_mult * 0.6, 4),
+ "stop_loss": round(base_sl * vol_mult * 0.7, 4),
+ "trailing_stop_activation": round(base_ts_activation * vol_mult * 0.8, 4),
+ "trailing_stop_delta": round(base_ts_delta * vol_mult * 0.8, 4),
+ "volatility_regime": vol_regime
+ },
+ "swing": {
+ "label": "Target: Swing (Filtro stretto)",
+ "bb_length": 100,
+ "bb_std": 2.5,
+ "macd_fast": 21,
+ "macd_slow": 42,
+ "macd_signal": 9,
+ "bb_long_threshold": l_thr,
+ "bb_short_threshold": s_thr,
+ "take_profit": round(base_tp * vol_mult * 1.6, 4),
+ "stop_loss": round(base_sl * vol_mult * 1.3, 4),
+ "trailing_stop_activation": round(base_ts_activation * vol_mult * 1.2, 4),
+ "trailing_stop_delta": round(base_ts_delta * vol_mult * 1.2, 4),
+ "volatility_regime": vol_regime
+ },
+ "auto": {
+ "label": "Target: Auto (Analisi Live)",
+ "bb_length": 50,
+ "bb_std": 2.0,
+ "macd_fast": 21,
+ "macd_slow": 42,
+ "macd_signal": 9,
+ "bb_long_threshold": l_thr,
+ "bb_short_threshold": s_thr,
+ "take_profit": round(base_tp * vol_mult, 4),
+ "stop_loss": round(base_sl * vol_mult, 4),
+ "trailing_stop_activation": round(base_ts_activation * vol_mult, 4),
+ "trailing_stop_delta": round(base_ts_delta * vol_mult, 4),
+ "volatility_regime": vol_regime
+ }
+ }
+
+def format_macd_bb_analysis(analysis: Dict[str, Any]) -> str:
+ """Format analysis results for display in wizard final step."""
+ lines = []
+ n_candles = analysis.get("analysis_candles", 0)
+ natr = analysis.get("natr")
+ bbp = analysis.get("bbp_current")
+ bb_upper = analysis.get("bb_upper")
+ bb_lower = analysis.get("bb_lower")
+ macd = analysis.get("macd_current")
+ hist = analysis.get("macd_histogram_current")
+ l_sig = analysis.get("signal_count_long", 0)
+ s_sig = analysis.get("signal_count_short", 0)
+ pct_below = analysis.get("pct_below_lower", 0)
+ pct_above = analysis.get("pct_above_upper", 0)
+
+ lines.append(f"BB+MACD analysis ({n_candles} candles):")
+ if bb_upper and bb_lower:
+ lines.append(f" Range: {bb_lower:.4f} - {bb_upper:.4f}")
+ if bbp is not None:
+ # Determina posizione BBP
+ if bbp < 0.2:
+ pos = "OVERSOLD"
+ elif bbp > 0.8:
+ pos = "OVERBOUGHT"
+ else:
+ pos = "neutral"
+ lines.append(f" BBP now: {bbp:.3f} ({pos})")
+ if macd is not None and hist is not None:
+ # Determina segnale MACD
+ if hist > 0 and macd < 0:
+ macd_signal = "BULLISH"
+ elif hist < 0 and macd > 0:
+ macd_signal = "BEARISH"
+ else:
+ macd_signal = "neutral"
+ lines.append(f" MACD: {macd:.6f} | Hist: {hist:.6f} ({macd_signal})")
+ if natr:
+ # Valutazione volatilità
+ if natr < 0.005:
+ vol_assessment = "Very Low (<0.5%) → use tighter stops"
+ elif natr < 0.01:
+ vol_assessment = "Low (0.5-1%) → standard stops"
+ elif natr < 0.02:
+ vol_assessment = "Moderate (1-2%) → adjust stops"
+ elif natr < 0.03:
+ vol_assessment = "High (2-3%) → wider stops"
+ else:
+ vol_assessment = "Very High (>3%) → use wider stops"
+ lines.append(f" NATR(14): {natr*100:.3f}% ({vol_assessment})")
+
+ lines.append(f" % below lower band: {pct_below:.1f}%")
+ lines.append(f" % above upper band: {pct_above:.1f}%")
+ lines.append(f" Combined signals: LONG={l_sig} SHORT={s_sig}")
+ lines.append(f" → bb_long_threshold: {analysis['suggested_long_threshold']}")
+ lines.append(f" → bb_short_threshold: {analysis['suggested_short_threshold']}")
+ # Aggiungi raccomandazione basata sui segnali
+ if l_sig > s_sig and l_sig > 0:
+ lines.append(f" → Bias: LONG ({(l_sig/(l_sig+s_sig)*100):.0f}% signals)")
+ elif s_sig > l_sig and s_sig > 0:
+ lines.append(f" → Bias: SHORT ({(s_sig/(l_sig+s_sig)*100):.0f}% signals)")
+ elif l_sig == 0 and s_sig == 0:
+ lines.append(" → Bias: NEUTRAL (no signals detected)")
+
+ return "\n".join(lines)
diff --git a/handlers/bots/controllers/macd_bb_v1/chart.py b/handlers/bots/controllers/macd_bb_v1/chart.py
new file mode 100644
index 00000000..743a8fb9
--- /dev/null
+++ b/handlers/bots/controllers/macd_bb_v1/chart.py
@@ -0,0 +1,328 @@
+"""
+MACD BB V1 chart generation.
+
+4 panels:
+ 1. Price – candlesticks + BB + MA20/50/EMA9
+ 2. Volume – colored bars
+ 3. BBP – Bollinger Band Percent with long/short thresholds
+ 4. MACD – histogram + MACD line + signal line
+
+Signal logic (shown via BBP + MACD panels):
+ LONG when BBP < bb_long_threshold AND hist > 0 AND macd < 0
+ SHORT when BBP > bb_short_threshold AND hist < 0 AND macd > 0
+"""
+import io
+import time
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+from matplotlib.patches import Rectangle
+
+def generate_chart(config, candles_data, current_price=None):
+ if not candles_data or len(candles_data) < 5:
+ return _generate_simple_chart(candles_data, current_price)
+
+ df = _prepare_dataframe(candles_data)
+ full_df = df.copy()
+ MAX_VISIBLE_CANDLES = 96
+ for col in ['open', 'high', 'low', 'close', 'volume']:
+ full_df[col] = pd.to_numeric(full_df.get(col, 0), errors='coerce').fillna(0)
+
+ # ── INDICATORI ──────────────────────────────────────────────────
+ full_df['ma20'] = full_df['close'].rolling(20).mean()
+ full_df['ma50'] = full_df['close'].rolling(50).mean()
+ full_df['ema9'] = full_df['close'].ewm(span=9).mean()
+
+ bb_length = int(config.get('bb_length', 100))
+ bb_std_val = float(config.get('bb_std', 2.0))
+ rolling = full_df['close'].rolling(bb_length)
+ full_df['bb_mid'] = rolling.mean()
+ bb_std_series = rolling.std()
+ full_df['bb_upper'] = full_df['bb_mid'] + bb_std_val * bb_std_series
+ full_df['bb_lower'] = full_df['bb_mid'] - bb_std_val * bb_std_series
+ denom = (full_df['bb_upper'] - full_df['bb_lower']).replace(0, np.nan)
+ full_df['bbp'] = ((full_df['close'] - full_df['bb_lower']) / denom).clip(-1, 2).fillna(0.5)
+ # MACD
+ macd_fast = int(config.get('macd_fast', 21))
+ macd_slow = int(config.get('macd_slow', 42))
+ macd_signal = int(config.get('macd_signal', 9))
+ # MACD SU FULL_DF
+ ema_fast = full_df['close'].ewm(span=macd_fast, adjust=False).mean()
+ ema_slow = full_df['close'].ewm(span=macd_slow, adjust=False).mean()
+
+ full_df['macd'] = ema_fast - ema_slow
+ full_df['macd_signal'] = (full_df['macd'].ewm(span=macd_signal, adjust=False).mean())
+ full_df['macd_hist'] = (full_df['macd'] - full_df['macd_signal'])
+
+ # SOLO DOPO TAGLI
+ df = full_df.tail(MAX_VISIBLE_CANDLES).copy()
+
+ # ── FIGURA ──────────────────────────────────────────────────────
+ fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, figsize=(22, 14), sharex=True, gridspec_kw={
+ 'height_ratios': [4.5, 1.2, 1.3, 1.5]
+ }
+ )
+
+ fig.patch.set_facecolor('#111111')
+ for ax in [ax1, ax2, ax3, ax4]:
+ ax.set_facecolor('#111111')
+ ax.tick_params(colors='white')
+ ax.yaxis.label.set_color('white')
+ ax.spines['bottom'].set_color('#444')
+ ax.spines['top'].set_color('#444')
+ ax.spines['left'].set_color('#444')
+ ax.spines['right'].set_color('#444')
+ dates = mdates.date2num(df['datetime'])
+
+ if len(dates) > 1:
+ candle_width = (dates[1] - dates[0]) * 0.85
+ volume_width = (dates[1] - dates[0]) * 0.85
+ else:
+ candle_width = volume_width = 0.0005
+
+ # ── PANNELLO 1: PREZZO ──────────────────────────────────────────
+ for i in range(len(df)):
+ o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']]
+ color = '#2ecc71' if c >= o else '#e74c3c'
+ ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1)
+ ax1.add_patch(Rectangle(
+ (dates[i] - candle_width / 2, min(o, c)),
+ candle_width, abs(c - o) or 1e-8,
+ color=color
+ ))
+
+ ax1.plot(df['datetime'], df['ma20'], label='MA20', linewidth=1.4, color='#f39c12')
+ ax1.plot(df['datetime'], df['ma50'], label='MA50', linewidth=1.4, color='#3498db')
+ ax1.plot(df['datetime'], df['ema9'], label='EMA9', linewidth=1.4, color='#9b59b6')
+
+ ax1.plot(df['datetime'], df['bb_upper'], '--', linewidth=1.1, color='#ffffff', alpha=0.95,label='BB Upper')
+ ax1.plot(df['datetime'], df['bb_mid'], ':', linewidth=1.1, color='#ffffff', alpha=0.95, label='BB Mid')
+ ax1.plot(df['datetime'], df['bb_lower'], '--', linewidth=1.1, color='#ffffff', alpha=0.95, label='BB Lower')
+ ax1.fill_between(df['datetime'], df['bb_lower'], df['bb_upper'], color='#aaaaaa', alpha=0.24)
+ if current_price:
+ ax1.axhline(y=current_price, linestyle='--', alpha=0.8, color='gold', linewidth=1.3, label='Price')
+
+ legend1 = ax1.legend(loc='upper left',fontsize=9,ncol=3,framealpha=0)
+ for text in legend1.get_texts():
+ text.set_color('white')
+ ax1.set_ylabel('Price')
+ ax1.grid(True, alpha=0.3)
+ ax1.set_xlim(df['datetime'].min(), df['datetime'].max())
+
+ # ── PANNELLO 2: VOLUME ──────────────────────────────────────────
+ vol_colors = [
+ '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c'
+ for i in range(len(df))
+ ]
+ ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7)
+ ax2.set_ylabel('Volume')
+ ax2.grid(True, alpha=0.3)
+
+ # ── PANNELLO 3: BBP ─────────────────────────────────────────────
+ ax3.plot(df['datetime'], df['bbp'], linewidth=1.5, color='steelblue')
+
+ long_thr = float(config.get('bb_long_threshold', 0.0))
+ short_thr = float(config.get('bb_short_threshold', 1.0))
+
+ ax3.axhline(long_thr, linestyle='--', color='green', alpha=0.8, label=f'Long {long_thr}')
+ ax3.axhline(short_thr, linestyle='--', color='red', alpha=0.8, label=f'Short {short_thr}')
+ ax3.axhline(0, linestyle=':', color='gray', alpha=0.5)
+ ax3.axhline(1, linestyle=':', color='gray', alpha=0.5)
+
+ # evidenzia zone di segnale
+ ax3.fill_between(df['datetime'], -1, long_thr, alpha=0.07, color='green')
+ ax3.fill_between(df['datetime'], short_thr, 2, alpha=0.07, color='red')
+
+ legend3 = ax3.legend(loc='upper left', fontsize=9, framealpha=0)
+ for text in legend3.get_texts():
+ text.set_color('white')
+ ax3.set_ylabel('BBP')
+
+ # ── PANNELLO 4: MACD ────────────────────────────────────────────
+ # istogramma colorato: verde se positivo, rosso se negativo
+ hist_colors = ['#2ecc71' if v >= 0 else '#e74c3c' for v in df['macd_hist']]
+ ax4.bar(dates, df['macd_hist'], width=volume_width, color=hist_colors, alpha=0.6, label='Hist')
+ ax4.plot(df['datetime'], df['macd'], linewidth=1.2, color='steelblue', label=f'MACD({macd_fast},{macd_slow})')
+ ax4.plot(df['datetime'], df['macd_signal'], linewidth=1.0, color='orange', label=f'Signal({macd_signal})')
+ ax4.axhline(0, linestyle=':', color='gray', alpha=0.5)
+
+ legend4 = ax4.legend(loc='upper left', fontsize=9, framealpha=0)
+ for text in legend4.get_texts():
+ text.set_color('white')
+ ax4.set_ylabel('MACD')
+
+ # ── FIX ASSE X BASATO SUL TIMEFRAME ───────────────────────────────
+
+ interval = config.get('interval', '5m')
+
+ if interval == '1m':
+ locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(interval=5)
+
+ elif interval == '5m':
+ locator = mdates.HourLocator(interval=1)
+ formatter = mdates.DateFormatter('%H:%M')
+ minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+
+ elif interval == '15m':
+
+ locator = mdates.HourLocator(interval=3)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=1)
+
+ elif interval == '1h':
+
+ locator = mdates.HourLocator(interval=12)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=3)
+
+ elif interval == '8h':
+
+ locator = mdates.DayLocator(interval=4)
+ formatter = mdates.DateFormatter('%b%d')
+ minor_locator = mdates.DayLocator(interval=1)
+
+ else:
+
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ formatter = mdates.ConciseDateFormatter(locator)
+ minor_locator = None
+
+ # Applica a TUTTI gli assi (non solo ax4)
+ ax1.tick_params(labelbottom=False)
+ ax2.tick_params(labelbottom=False)
+ ax3.tick_params(labelbottom=False)
+
+ for ax in [ax1, ax2, ax3, ax4]:
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
+ # grid verticale
+ ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15)
+
+ # grid orizzontale
+ ax.grid( True, which='major', axis='y', alpha=0.25)
+
+ # ── TITOLO ──────────────────────────────────────────────────────
+ interval = config.get('interval', '5m')
+ fig.suptitle(
+ f"{config.get('trading_pair', 'Unknown')} - MACD BB V1 "
+ f"(BB{bb_length} | MACD{macd_fast}/{macd_slow}/{macd_signal} | {interval})",
+ fontsize=13
+ )
+
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+def _setup_x_axis(ax, df, interval):
+ """Configura l'asse X in base al timeframe."""
+ date_range = df['datetime'].max() - df['datetime'].min()
+
+ # Soglie in secondi
+ range_seconds = date_range.total_seconds()
+
+ if interval.endswith('m'):
+ minutes = int(interval[:-1])
+ elif interval.endswith('h'):
+ minutes = int(interval[:-1]) * 60
+ elif interval.endswith('d'):
+ minutes = int(interval[:-1]) * 1440
+ else:
+ minutes = 5 # default
+
+ # Scegli formattatore in base al range totale
+ if range_seconds < 3600: # meno di 1 ora
+ # Mostra ore:minuti
+ locator = mdates.AutoDateLocator(minticks=4, maxticks=8)
+ formatter = mdates.DateFormatter('%H:%M')
+ elif range_seconds < 86400: # meno di 1 giorno
+ # Mostra ore (06:00, 12:00, 18:00)
+ locator = mdates.HourLocator(interval=6) # ogni 6 ore
+ formatter = mdates.DateFormatter('%H:%M')
+ else:
+ # Mostra date (Mar30, Apr1)
+ locator = mdates.DayLocator(interval=max(1, int(range_seconds/86400/5)))
+ formatter = mdates.DateFormatter('%b%d')
+
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=0, ha='center')
+# ── HELPERS ─────────────────────────────────────────────────────────
+
+def _prepare_dataframe(candles, timezone=None):
+ if timezone is None:
+ # Prende il fuso orario del sistema
+ timezone = time.tzname[0]
+ df = pd.DataFrame(candles)
+
+ # Cerca colonna timestamp
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ # Converti timestamp
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ # Determina se è millisecondi o secondi
+ if sample > 10**12: # nanosecondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ns', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ elif sample > 10**10: # millisecondi (dopo il 1970)
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ms', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else: # secondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='s', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else:
+ df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None))
+ else:
+ # Fallback: crea date sequenziali usando l'intervallo dalla config
+ # NOTA: questo è un fallback, idealmente dovresti avere timestamp reali
+ freq = config.get('interval', '5m') if 'config' in locals() else '5m'
+ df['datetime'] = pd.date_range(
+ end=pd.Timestamp.now(),
+ periods=len(df),
+ freq=freq
+ )
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+def _generate_simple_chart(candles_data, current_price):
+ if not candles_data:
+ return io.BytesIO()
+ df = _prepare_dataframe(candles_data)
+ MAX_VISIBLE_CANDLES = 96
+ if len(df) > MAX_VISIBLE_CANDLES:
+ df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+ fig, ax = plt.subplots(figsize=(10, 6))
+ ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series()), errors='coerce'))
+ if current_price:
+ ax.axhline(y=current_price, linestyle='--')
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config, candles_data, current_price=None):
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/macd_bb_v1/config.py b/handlers/bots/controllers/macd_bb_v1/config.py
new file mode 100644
index 00000000..c7cc475b
--- /dev/null
+++ b/handlers/bots/controllers/macd_bb_v1/config.py
@@ -0,0 +1,363 @@
+"""
+MACD BB V1 controller configuration.
+
+Directional trading strategy combining Bollinger Bands and MACD:
+- LONG when BBP < long_threshold AND MACD histogram > 0 AND MACD < 0
+- SHORT when BBP > short_threshold AND MACD histogram < 0 AND MACD > 0
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "macd_bb_v1",
+ "controller_type": "directional_trading",
+ "id": "",
+ # Base fields
+ "candles_config": [],
+ # Connector
+ "connector_name": "",
+ "trading_pair": "",
+ "total_amount_quote": 1000,
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ # DirectionalTradingControllerConfigBase fields
+ "max_executors_per_side": 1,
+ "cooldown_time": 60,
+ "stop_loss": 0.05,
+ "take_profit": 0.03,
+ "take_profit_order_type": 2,
+ "time_limit": None,
+ # Trailing stop as object (matches TrailingStop dataclass)
+ "trailing_stop": {
+ "activation_price": 0.015,
+ "trailing_delta": 0.005,
+ },
+ # Candles config
+ "candles_connector": "",
+ "candles_trading_pair": "",
+ "interval": "5m",
+ # Bollinger Bands
+ "bb_length": 100,
+ "bb_std": 2.0,
+ "bb_long_threshold": 0.0,
+ "bb_short_threshold": 1.0,
+ # MACD
+ "macd_fast": 21,
+ "macd_slow": 42,
+ "macd_signal": 9,
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated"
+ ),
+ "connector_name": ControllerField(
+ name="connector_name",
+ label="Connector",
+ type="str",
+ required=True,
+ hint="Exchange connector"
+ ),
+ "trading_pair": ControllerField(
+ name="trading_pair",
+ label="Trading Pair",
+ type="str",
+ required=True,
+ hint="e.g. BTC-USDT"
+ ),
+ "leverage": ControllerField(
+ name="leverage",
+ label="Leverage",
+ type="int",
+ required=True,
+ hint="e.g. 1, 5, 10",
+ default=1
+ ),
+ "position_mode": ControllerField(
+ name="position_mode",
+ label="Position Mode",
+ type="str",
+ required=False,
+ hint="HEDGE or ONEWAY",
+ default="HEDGE"
+ ),
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote",
+ label="Total Amount (Quote)",
+ type="float",
+ required=True,
+ hint="e.g. 1000 USDT"
+ ),
+ "max_executors_per_side": ControllerField(
+ name="max_executors_per_side",
+ label="Max Executors/Side",
+ type="int",
+ required=False,
+ hint="Max concurrent positions per side (default: 1)",
+ default=1
+ ),
+ "cooldown_time": ControllerField(
+ name="cooldown_time",
+ label="Cooldown Time (s)",
+ type="int",
+ required=False,
+ hint="Seconds between new executors (default: 60)",
+ default=60
+ ),
+ "stop_loss": ControllerField(
+ name="stop_loss",
+ label="Stop Loss",
+ type="float",
+ required=False,
+ hint="Stop loss % (e.g. 0.05 = 5%)",
+ default=0.05
+ ),
+ "take_profit": ControllerField(
+ name="take_profit",
+ label="Take Profit",
+ type="float",
+ required=False,
+ hint="Take profit % (e.g. 0.03 = 3%)",
+ default=0.03
+ ),
+ "take_profit_order_type": ControllerField(
+ name="take_profit_order_type",
+ label="TP Order Type",
+ type="int",
+ required=False,
+ hint="1=Market, 2=Limit, 3=Limit Maker",
+ default=2
+ ),
+ "time_limit": ControllerField(
+ name="time_limit",
+ label="Time Limit (s)",
+ type="int",
+ required=False,
+ hint="Max executor lifetime in seconds (None = no limit)",
+ default=None
+ ),
+ "candles_connector": ControllerField(
+ name="candles_connector",
+ label="Candles Connector",
+ type="str",
+ required=False,
+ hint="Leave empty to use same as connector",
+ default=""
+ ),
+ "candles_trading_pair": ControllerField(
+ name="candles_trading_pair",
+ label="Candles Pair",
+ type="str",
+ required=False,
+ hint="Leave empty to use same as trading pair",
+ default=""
+ ),
+ "interval": ControllerField(
+ name="interval",
+ label="Candle Interval",
+ type="str",
+ required=True,
+ hint="e.g. 1m, 5m, 1h, 8h",
+ default="5m"
+ ),
+ "bb_length": ControllerField(
+ name="bb_length",
+ label="BB Length",
+ type="int",
+ required=False,
+ hint="Bollinger Bands period (default: 100)",
+ default=100
+ ),
+ "bb_std": ControllerField(
+ name="bb_std",
+ label="BB Std Dev",
+ type="float",
+ required=False,
+ hint="Standard deviations (default: 2.0)",
+ default=2.0
+ ),
+ "bb_long_threshold": ControllerField(
+ name="bb_long_threshold",
+ label="BB Long Threshold",
+ type="float",
+ required=False,
+ hint="BBP below this → LONG signal (default: 0.0)",
+ default=0.0
+ ),
+ "bb_short_threshold": ControllerField(
+ name="bb_short_threshold",
+ label="BB Short Threshold",
+ type="float",
+ required=False,
+ hint="BBP above this → SHORT signal (default: 1.0)",
+ default=1.0
+ ),
+ "macd_fast": ControllerField(
+ name="macd_fast",
+ label="MACD Fast",
+ type="int",
+ required=False,
+ hint="Fast EMA period (default: 21)",
+ default=21
+ ),
+ "macd_slow": ControllerField(
+ name="macd_slow",
+ label="MACD Slow",
+ type="int",
+ required=False,
+ hint="Slow EMA period (default: 42)",
+ default=42
+ ),
+ "macd_signal": ControllerField(
+ name="macd_signal",
+ label="MACD Signal",
+ type="int",
+ required=False,
+ hint="Signal line period (default: 9)",
+ default=9
+ ),
+}
+
+FIELD_ORDER: List[str] = [
+ "bb_length",
+ "bb_long_threshold",
+ "bb_short_threshold",
+ "bb_std",
+ "candles_connector",
+ "candles_trading_pair",
+ "connector_name",
+ "cooldown_time",
+ "id",
+ "interval",
+ "leverage",
+ "macd_fast",
+ "macd_signal",
+ "macd_slow",
+ "max_executors_per_side",
+ "position_mode",
+ "stop_loss",
+ "take_profit_order_type",
+ "take_profit",
+ "time_limit",
+ "total_amount_quote",
+ "trading_pair",
+]
+
+EDITABLE_FIELDS: List[str] = [
+ "bb_length",
+ "bb_long_threshold",
+ "bb_short_threshold",
+ "bb_std",
+ "candles_connector",
+ "candles_trading_pair",
+ "connector_name",
+ "cooldown_time",
+ "interval",
+ "leverage",
+ "macd_fast",
+ "macd_signal",
+ "macd_slow",
+ "max_executors_per_side",
+ "stop_loss",
+ "take_profit_order_type",
+ "take_profit",
+ "total_amount_quote",
+ "trading_pair",
+ "trailing_stop_activation",
+ "trailing_stop_delta",
+
+]
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Estrae i campi in formato piatto per l'editing, gestendo trailing_stop."""
+ trailing = config.get("trailing_stop", {})
+ # Copia i campi esistenti (che sono già piatti)
+ flat = dict(config)
+ # Aggiungi i due campi virtuali
+ flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015)
+ flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005)
+ # Rimuovi il dizionario originale per non mostrarlo come campo separato
+ flat.pop("trailing_stop", None)
+ return flat
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None:
+ """Applica gli aggiornamenti, riconvertendo trailing_stop_activation/delta."""
+ for key, value in updates.items():
+ if key == "trailing_stop_activation":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["activation_price"] = value
+ elif key == "trailing_stop_delta":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["trailing_delta"] = value
+ else:
+ config[key] = value
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate and auto-fix MACD BB V1 configuration."""
+
+ # Identifica il connettore principale
+ connector = config.get("connector_name", "").lower()
+ is_spot = "spot" in connector or not ("perpetual" in connector or "margin" in connector)
+
+ # Auto-popola candles_connector se vuoto
+ if not config.get("candles_connector"):
+ # Rimuove i suffissi per puntare allo Spot (es: binance_perpetual -> binance)
+ clean_conn = connector.replace("_perpetual", "").replace("_margin", "").replace("_spot", "")
+ config["candles_connector"] = clean_conn
+
+ # Auto-popola candles_trading_pair se vuoto
+ if not config.get("candles_trading_pair"):
+ config["candles_trading_pair"] = config.get("trading_pair")
+
+ # Gestione spot vs perpetual
+ if is_spot:
+ config["leverage"] = 1
+ config["position_mode"] = "ONEWAY"
+ else:
+ # Defaults per i Perpetual se non specificati
+ if not config.get("leverage"):
+ config["leverage"] = 1
+ if config.get("position_mode") not in ["HEDGE", "ONEWAY"]:
+ config["position_mode"] = "HEDGE"
+
+ # Validazione campi obbligatori
+ required = ["connector_name", "trading_pair", "total_amount_quote"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ # Validazione incrociata periodi
+ bb_length = config.get("bb_length", 100)
+ macd_slow = config.get("macd_slow", 42)
+ if bb_length < macd_slow:
+ return False, f"BB length ({bb_length}) should be >= MACD slow ({macd_slow}) for sufficient data"
+
+ # Validazione thresholds
+ bb_long = config.get("bb_long_threshold", 0.0)
+ bb_short = config.get("bb_short_threshold", 1.0)
+ if bb_long >= bb_short:
+ return False, f"BB long threshold ({bb_long}) must be less than BB short threshold ({bb_short})"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate sequential ID for MACD BB V1 configuration."""
+ max_num = 0
+ for cfg in existing_configs:
+ parts = cfg.get("id", "").split("_", 1)
+ if parts and parts[0].isdigit():
+ max_num = max(max_num, int(parts[0]))
+ seq = str(max_num + 1).zfill(3)
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+ return f"{seq}_macdbb_{connector}_{pair}"
diff --git a/handlers/bots/controllers/multi_grid_strike/__init__.py b/handlers/bots/controllers/multi_grid_strike/__init__.py
new file mode 100644
index 00000000..3fb6bd44
--- /dev/null
+++ b/handlers/bots/controllers/multi_grid_strike/__init__.py
@@ -0,0 +1,129 @@
+"""
+Multi Grid Strike Controller Module
+
+Provides configuration, validation, and visualization for multi grid strike controllers.
+
+MultiGridStrike is a strategy that runs multiple independent grids on the same
+trading pair, each covering a different price range. Each grid:
+- Has its own start_price / end_price / limit_price
+- Allocates a percentage of total_amount_quote (amount_quote_pct)
+- Can be LONG or SHORT independently
+- Is activated only when the market price enters its range
+
+This allows building layered grid strategies (e.g. a tight grid near current price
++ a wider catch grid below/above) with a single bot instance.
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import (
+ DEFAULTS,
+ EDITABLE_FIELDS,
+ FIELD_ORDER,
+ FIELDS,
+ GRID_TYPES,
+ MGS_WIZARD_STEPS,
+ ORDER_TYPE_LABELS,
+ ORDER_TYPE_LIMIT,
+ ORDER_TYPE_LIMIT_MAKER,
+ ORDER_TYPE_MARKET,
+ SIDE_LONG,
+ SIDE_SHORT,
+ WIZARD_STEPS,
+ calculate_auto_prices_for_grid,
+ generate_id,
+ validate_config,
+)
+from .grid_analysis import (
+ analyze_all_grids,
+ calculate_natr,
+ calculate_price_stats,
+ calculate_optimal_multi_grids,
+ format_multi_grid_summary,
+ generate_theoretical_grid,
+ suggest_multi_grid_params,
+)
+
+
+class MultiGridStrikeController(BaseController):
+ """Multi Grid Strike controller implementation."""
+
+ controller_type = "multi_grid_strike"
+ display_name = "Multi Grid Strike"
+ description = "Multiple independent grids on the same pair"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ """Get default configuration values."""
+ defaults = DEFAULTS.copy()
+ if "triple_barrier_config" in defaults:
+ defaults["triple_barrier_config"] = defaults["triple_barrier_config"].copy()
+ defaults["grids"] = []
+ return defaults
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ """Get field definitions."""
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ """Get field display order."""
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate configuration."""
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(
+ cls,
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+ ) -> io.BytesIO:
+ """Generate visualization chart."""
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(
+ cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]
+ ) -> str:
+ """Generate unique ID with sequence number."""
+ return generate_id(config, existing_configs)
+
+
+__all__ = [
+ # Controller class
+ "MultiGridStrikeController",
+ # Config
+ "DEFAULTS",
+ "FIELDS",
+ "FIELD_ORDER",
+ "WIZARD_STEPS",
+ "MGS_WIZARD_STEPS",
+ "EDITABLE_FIELDS",
+ "GRID_TYPES",
+ "SIDE_LONG",
+ "SIDE_SHORT",
+ "ORDER_TYPE_MARKET",
+ "ORDER_TYPE_LIMIT",
+ "ORDER_TYPE_LIMIT_MAKER",
+ "ORDER_TYPE_LABELS",
+ # Functions
+ "validate_config",
+ "calculate_auto_prices_for_grid",
+ "generate_id",
+ "generate_chart",
+ "generate_preview_chart",
+ # Grid analysis
+ "calculate_natr",
+ "calculate_price_stats",
+ "analyze_all_grids",
+ "generate_theoretical_grid",
+ "calculate_optimal_multi_grids",
+]
diff --git a/handlers/bots/controllers/multi_grid_strike/chart.py b/handlers/bots/controllers/multi_grid_strike/chart.py
new file mode 100644
index 00000000..d7f90e22
--- /dev/null
+++ b/handlers/bots/controllers/multi_grid_strike/chart.py
@@ -0,0 +1,123 @@
+"""
+Multi Grid Strike chart generation.
+
+Generates a candlestick chart with multiple grid zones overlaid,
+one per enabled grid in the configuration.
+
+Each grid is shown with:
+- A shaded zone between start_price and end_price (unique color per grid)
+- Start/End price lines (dashed)
+- Limit price line (dotted red) — stop loss level
+- Current price line (orange)
+"""
+
+import io
+from typing import Any, Dict, List, Optional
+
+from handlers.dex.visualizations import DARK_THEME, generate_candlestick_chart
+
+from .config import SIDE_LONG
+
+# Distinct colors for up to 6 grids
+GRID_COLORS = [
+ "#4A9EFF", # blue
+ "#50C878", # green
+ "#FFB347", # orange
+ "#DA70D6", # orchid
+ "#FF6B6B", # red
+ "#87CEEB", # sky blue
+]
+
+
+def generate_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """
+ Generate a candlestick chart with all multi-grid zones overlaid.
+
+ Args:
+ config: MultiGridStrike configuration dict (with 'grids' list)
+ candles_data: OHLCV candle data
+ current_price: Current market price
+
+ Returns:
+ BytesIO containing PNG image
+ """
+ trading_pair = config.get("trading_pair", "Unknown")
+ grids = config.get("grids", [])
+
+ data = (
+ candles_data if isinstance(candles_data, list) else candles_data.get("data", [])
+ )
+
+ total_amount = config.get("total_amount_quote", 0)
+ active_grids = [g for g in grids if g.get("enabled", True)]
+ title = f"{trading_pair} - Multi Grid Strike ({len(active_grids)} grids | ${total_amount:.0f} total)"
+
+ hlines = []
+ hrects = []
+
+ for i, grid in enumerate(active_grids):
+ color = GRID_COLORS[i % len(GRID_COLORS)]
+ grid_id = grid.get("grid_id", f"grid_{i+1}")
+ side = grid.get("side", SIDE_LONG)
+ side_str = "L" if side == SIDE_LONG else "S"
+ start_price = grid.get("start_price")
+ end_price = grid.get("end_price")
+ limit_price = grid.get("limit_price")
+ pct = grid.get("amount_quote_pct", 0)
+ amount = total_amount * pct
+
+ if start_price:
+ hlines.append({
+ "y": start_price,
+ "color": color,
+ "dash": "dash",
+ "label": f"[{grid_id}] Start: {start_price:,.4f}",
+ "label_position": "right",
+ })
+
+ if end_price:
+ hlines.append({
+ "y": end_price,
+ "color": color,
+ "dash": "dash",
+ "label": f"[{grid_id}] End: {end_price:,.4f} ({side_str} ${amount:.0f})",
+ "label_position": "right",
+ })
+
+ if limit_price:
+ hlines.append({
+ "y": limit_price,
+ "color": DARK_THEME["down_color"],
+ "dash": "dot",
+ "label": f"[{grid_id}] Limit: {limit_price:,.4f}",
+ "label_position": "left",
+ })
+
+ if start_price and end_price:
+ hrects.append({
+ "y0": min(start_price, end_price),
+ "y1": max(start_price, end_price),
+ "color": color,
+ "opacity": 0.07,
+ })
+
+ return generate_candlestick_chart(
+ candles=data,
+ title=title,
+ current_price=current_price,
+ hlines=hlines,
+ hrects=hrects,
+ )
+
+
+def generate_preview_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ """Alias for generate_chart — used during wizard preview."""
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/multi_grid_strike/config.py b/handlers/bots/controllers/multi_grid_strike/config.py
new file mode 100644
index 00000000..6e689d9e
--- /dev/null
+++ b/handlers/bots/controllers/multi_grid_strike/config.py
@@ -0,0 +1,377 @@
+"""
+Multi Grid Strike controller configuration.
+
+Contains defaults, field definitions, and validation for multi grid strike controllers.
+
+MultiGridStrike supports multiple grids on the same pair, each with its own
+price range and capital allocation (expressed as a percentage of total_amount_quote).
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+# Side value mapping (same as grid_strike)
+SIDE_LONG = 1
+SIDE_SHORT = 2
+
+# Order type mapping
+ORDER_TYPE_MARKET = 1
+ORDER_TYPE_LIMIT = 2
+ORDER_TYPE_LIMIT_MAKER = 3
+
+ORDER_TYPE_LABELS = {
+ ORDER_TYPE_MARKET: "Market",
+ ORDER_TYPE_LIMIT: "Limit",
+ ORDER_TYPE_LIMIT_MAKER: "Limit Maker",
+}
+
+# Default configuration values
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "multi_grid_strike",
+ "controller_type": "generic",
+ "id": "",
+ "connector_name": "",
+ "trading_pair": "",
+ "leverage": 20,
+ "position_mode": "HEDGE",
+ "total_amount_quote": 1000,
+ "min_order_amount_quote": 5,
+ "min_spread_between_orders": 0.001,
+ "max_open_orders": 2,
+ "max_orders_per_batch": 1,
+ "order_frequency": 3,
+ "activation_bounds": None,
+ "keep_position": False,
+ "triple_barrier_config": {
+ "open_order_type": ORDER_TYPE_LIMIT_MAKER,
+ "take_profit": 0.001,
+ "take_profit_order_type": ORDER_TYPE_LIMIT_MAKER,
+ "stop_loss": None,
+ "stop_loss_order_type": ORDER_TYPE_MARKET,
+ "time_limit": None,
+ "time_limit_order_type": ORDER_TYPE_MARKET,
+ "trailing_stop": None,
+ },
+ # grids is a list of GridConfig dicts - empty by default, user adds them
+ "grids": [],
+ # Fields from ControllerConfigBase
+ "manual_kill_switch": False,
+ "candles_config": [],
+ "initial_positions": [],
+}
+
+# Field definitions for the configuration form
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated with sequence number",
+ ),
+ "connector_name": ControllerField(
+ name="connector_name",
+ label="Connector",
+ type="str",
+ required=True,
+ hint="Select from available exchanges",
+ ),
+ "trading_pair": ControllerField(
+ name="trading_pair",
+ label="Trading Pair",
+ type="str",
+ required=True,
+ hint="e.g. WLD-USDT, BTC-USDT",
+ ),
+ "leverage": ControllerField(
+ name="leverage",
+ label="Leverage",
+ type="int",
+ required=True,
+ hint="e.g. 1, 10, 20",
+ default=20,
+ ),
+ "position_mode": ControllerField(
+ name="position_mode",
+ label="Position Mode",
+ type="str",
+ required=False,
+ hint="HEDGE (recommended for multi-grid) or ONEWAY",
+ default="HEDGE",
+ ),
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote",
+ label="Total Amount (Quote)",
+ type="float",
+ required=True,
+ hint="Total capital in USDT distributed across all grids",
+ ),
+ "max_open_orders": ControllerField(
+ name="max_open_orders",
+ label="Max Open Orders",
+ type="int",
+ required=False,
+ hint="Max open orders per grid (default: 2)",
+ default=2,
+ ),
+ "max_orders_per_batch": ControllerField(
+ name="max_orders_per_batch",
+ label="Max Orders/Batch",
+ type="int",
+ required=False,
+ hint="Default: 1",
+ default=1,
+ ),
+ "min_order_amount_quote": ControllerField(
+ name="min_order_amount_quote",
+ label="Min Order Amount",
+ type="float",
+ required=False,
+ hint="Default: 5",
+ default=5,
+ ),
+ "min_spread_between_orders": ControllerField(
+ name="min_spread_between_orders",
+ label="Min Spread Between Orders",
+ type="float",
+ required=False,
+ hint="Default: 0.001",
+ default=0.001,
+ ),
+ "order_frequency": ControllerField(
+ name="order_frequency",
+ label="Order Frequency (s)",
+ type="int",
+ required=False,
+ hint="Seconds between order placement (default: 3)",
+ default=3,
+ ),
+ "take_profit": ControllerField(
+ name="take_profit",
+ label="Take Profit",
+ type="float",
+ required=False,
+ hint="TP per level (default: 0.001 = 0.1%)",
+ default=0.001,
+ ),
+ "keep_position": ControllerField(
+ name="keep_position",
+ label="Keep Position",
+ type="bool",
+ required=False,
+ hint="Keep position open after grid completion",
+ default=False,
+ ),
+ "activation_bounds": ControllerField(
+ name="activation_bounds",
+ label="Activation Bounds",
+ type="float",
+ required=False,
+ hint="Price distance to activate orders (None = disabled)",
+ default=None,
+ ),
+}
+
+# Field display order
+FIELD_ORDER: List[str] = [
+ "id",
+ "connector_name",
+ "trading_pair",
+ "leverage",
+ "position_mode",
+ "total_amount_quote",
+ "max_open_orders",
+ "max_orders_per_batch",
+ "order_frequency",
+ "min_order_amount_quote",
+ "min_spread_between_orders",
+ "take_profit",
+ "keep_position",
+ "activation_bounds",
+]
+
+# Wizard steps
+WIZARD_STEPS: List[str] = [
+ "connector_name",
+ "trading_pair",
+ "leverage",
+ "total_amount_quote",
+ "take_profit",
+ "review",
+]
+
+# Editable fields shown in edit view
+EDITABLE_FIELDS: List[str] = [
+ "connector_name",
+ "trading_pair",
+ "total_amount_quote",
+ "leverage",
+ "position_mode",
+ "take_profit",
+ "min_spread_between_orders",
+ "min_order_amount_quote",
+ "max_open_orders",
+ "activation_bounds",
+ "keep_position",
+]
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """
+ Validate a multi grid strike configuration.
+
+ Checks:
+ - Required fields are present
+ - Grids list is valid (if provided)
+ - Each grid has correct price ordering based on side
+ - Sum of amount_quote_pct <= 1.0
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ # Check required top-level fields
+ required = ["connector_name", "trading_pair", "total_amount_quote"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ grids = config.get("grids", [])
+
+ if grids:
+ total_pct = 0.0
+ for i, grid in enumerate(grids):
+ grid_id = grid.get("grid_id", f"grid_{i}")
+ side = grid.get("side", SIDE_LONG)
+ start_price = grid.get("start_price", 0)
+ end_price = grid.get("end_price", 0)
+ limit_price = grid.get("limit_price", 0)
+ pct = grid.get("amount_quote_pct", 0)
+
+ if side == SIDE_LONG:
+ if not (limit_price < start_price < end_price):
+ return False, (
+ f"Grid '{grid_id}': Invalid prices for LONG "
+ f"(require limit < start < end). "
+ f"Got: {limit_price} < {start_price} < {end_price}"
+ )
+ else:
+ if not (start_price < end_price < limit_price):
+ return False, (
+ f"Grid '{grid_id}': Invalid prices for SHORT "
+ f"(require start < end < limit). "
+ f"Got: {start_price} < {end_price} < {limit_price}"
+ )
+
+ total_pct += pct
+
+ if total_pct > 1.0 + 1e-9:
+ return False, (
+ f"Sum of amount_quote_pct across grids ({total_pct:.2f}) "
+ f"exceeds 1.0 (100%). Reduce grid allocations."
+ )
+
+ return True, None
+
+
+def calculate_auto_prices_for_grid(
+ current_price: float,
+ side: int,
+ base_pct: float = 0.02,
+ limit_pct: float = 0.03,
+) -> Tuple[float, float, float]:
+ """
+ Calculate start, end, and limit prices for a single grid.
+
+ Uses the same 3:1 ratio logic as grid_strike.
+
+ Returns:
+ Tuple of (start_price, end_price, limit_price)
+ """
+ if side == SIDE_LONG:
+ start_price = current_price * (1 - base_pct)
+ end_price = current_price * (1 + base_pct * 3)
+ limit_price = current_price * (1 - limit_pct)
+ else:
+ start_price = current_price * (1 - base_pct * 3)
+ end_price = current_price * (1 + base_pct)
+ limit_price = current_price * (1 + limit_pct)
+
+ return (round(start_price, 6), round(end_price, 6), round(limit_price, 6))
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """
+ Generate a unique config ID with sequential numbering.
+
+ Format: NNN_mgs_connector_pair
+ Example: 001_mgs_binance_WLD-USDT
+
+ Args:
+ config: The configuration being created
+ existing_configs: List of existing configurations
+
+ Returns:
+ Generated config ID
+ """
+ max_num = 0
+ for cfg in existing_configs:
+ config_id = cfg.get("id", "")
+ if not config_id:
+ continue
+ parts = config_id.split("_", 1)
+ if parts and parts[0].isdigit():
+ num = int(parts[0])
+ max_num = max(max_num, num)
+
+ next_num = max_num + 1
+ seq = str(next_num).zfill(3)
+
+ connector = config.get("connector_name", "unknown")
+ conn_clean = connector.replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+
+ return f"{seq}_mgs_{conn_clean}_{pair}"
+
+
+
+
+
+
+# ============================================
+# Multi-Grid Strategy Types (NEW)
+# ============================================
+GRID_TYPES = {
+ "accumulation_distribution": {
+ "label": "📈 Accumulation + Distribution",
+ "description": "Buy in low & sell in high range",
+ "default_grids": 2,
+ "max_grids": 6,
+ "min_grids": 2,
+ },
+ "range_trading": {
+ "label": "🔄 Range Trading",
+ "description": "Alternating LONG/SHORT grids in a range",
+ "default_grids": 4,
+ "max_grids": 20,
+ "min_grids": 2,
+ },
+ "pyramid": {
+ "label": "🔺 Pyramid DCA",
+ "description": "Gradual weighted accumulation",
+ "default_grids": 4,
+ "max_grids": 8,
+ "min_grids": 2,
+ },
+}
+
+# Wizard steps for MultiGrid Strike
+MGS_WIZARD_STEPS: List[str] = [
+ "connector_name",
+ "trading_pair",
+ "grid_type",
+ "num_grids",
+ "leverage", # only for perpetual
+ "total_amount_quote",
+ "review",
+]
diff --git a/handlers/bots/controllers/multi_grid_strike/grid_analysis.py b/handlers/bots/controllers/multi_grid_strike/grid_analysis.py
new file mode 100644
index 00000000..1b48c1ea
--- /dev/null
+++ b/handlers/bots/controllers/multi_grid_strike/grid_analysis.py
@@ -0,0 +1,579 @@
+"""
+Multi Grid Strike analysis utilities.
+
+Adapts the grid_strike analysis functions for multi-grid configurations.
+
+Provides:
+- NATR calculation (identical to grid_strike)
+- Per-grid parameter suggestions based on volatility
+- Theoretical grid generation for each grid in the config
+- Combined summary across all grids
+- Multi-grid generation based on strategy type
+"""
+
+import logging
+import math
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# ============================================
+# Side constants
+# ============================================
+
+SIDE_LONG = 1
+SIDE_SHORT = 2
+
+
+def side_str(side: int) -> str:
+ """Convert side int to string"""
+ return "LONG" if side == SIDE_LONG else "SHORT"
+
+
+# ============================================
+# NATR & price stats (identical to grid_strike)
+# ============================================
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """
+ Calculate Normalized Average True Range (NATR) from candles.
+
+ NATR = (ATR / Close) * 100, expressed as a decimal (e.g. 0.025 = 2.5%).
+
+ Args:
+ candles: List of candle dicts with high, low, close keys
+ period: ATR period (default 14)
+
+ Returns:
+ NATR as decimal, or None if insufficient data
+ """
+ if not candles or len(candles) < period + 1:
+ return None
+
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = candles[i].get("high", 0)
+ low = candles[i].get("low", 0)
+ prev_close = candles[i - 1].get("close", 0)
+
+ if not all([high, low, prev_close]):
+ continue
+
+ tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return None
+
+ atr = sum(true_ranges[-period:]) / period
+ current_close = candles[-1].get("close", 0)
+ if current_close <= 0:
+ return None
+
+ return atr / current_close
+
+
+def calculate_price_stats(
+ candles: List[Dict[str, Any]], lookback: int = 100
+) -> Dict[str, float]:
+ """
+ Calculate price statistics from candles.
+
+ Returns:
+ Dict with current_price, high_price, low_price, range_pct,
+ avg_candle_range, natr_14, natr_50
+ """
+ if not candles:
+ return {}
+
+ recent = candles[-lookback:] if len(candles) > lookback else candles
+ current_price = recent[-1].get("close", 0)
+ if current_price <= 0:
+ return {}
+
+ highs = [c.get("high", 0) for c in recent if c.get("high")]
+ lows = [c.get("low", 0) for c in recent if c.get("low")]
+
+ high_price = max(highs) if highs else current_price
+ low_price = min(lows) if lows else current_price
+ range_pct = (high_price - low_price) / current_price if current_price > 0 else 0
+
+ candle_ranges = []
+ for c in recent:
+ h, l, close = c.get("high", 0), c.get("low", 0), c.get("close", 0)
+ if h and l and close:
+ candle_ranges.append((h - l) / close)
+ avg_candle_range = sum(candle_ranges) / len(candle_ranges) if candle_ranges else 0
+
+ return {
+ "current_price": current_price,
+ "high_price": high_price,
+ "low_price": low_price,
+ "range_pct": range_pct,
+ "avg_candle_range": avg_candle_range,
+ "natr_14": calculate_natr(candles, 14),
+ "natr_50": calculate_natr(candles, 50) if len(candles) >= 51 else None,
+ }
+
+
+# ============================================
+# Per-grid theoretical generation
+# ============================================
+
+def generate_theoretical_grid(
+ start_price: float,
+ end_price: float,
+ min_spread: float,
+ total_amount: float,
+ min_order_amount: float,
+ current_price: float,
+ side: int,
+ trading_rules: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """
+ Generate theoretical grid levels for a single grid, mirroring the
+ GridExecutor._generate_grid_levels() logic.
+
+ Args:
+ start_price: Grid start price
+ end_price: Grid end price
+ min_spread: Minimum spread between orders (as decimal)
+ total_amount: Quote amount allocated to this grid
+ min_order_amount: Minimum order amount in quote
+ current_price: Current market price
+ side: 1=LONG, 2=SHORT
+ trading_rules: Optional trading rules dict for validation
+
+ Returns:
+ Dict with levels, num_levels, amount_per_level, spread_pct,
+ warnings, valid, etc.
+ """
+ warnings = []
+
+ low_price = min(start_price, end_price)
+ high_price = max(start_price, end_price)
+
+ if low_price <= 0 or high_price <= low_price or current_price <= 0:
+ return {
+ "levels": [],
+ "amount_per_level": 0,
+ "num_levels": 0,
+ "grid_range_pct": 0,
+ "warnings": ["Invalid price range"],
+ "valid": False,
+ }
+
+ grid_range = (high_price - low_price) / low_price
+ grid_range_pct = grid_range * 100
+
+ # Trading rules
+ min_notional = min_order_amount
+ min_price_increment = 0.0001
+ min_base_increment = 0.0001
+
+ if trading_rules:
+ min_notional = max(min_order_amount, trading_rules.get("min_notional_size", 0))
+ min_price_increment = trading_rules.get("min_price_increment", 0.0001) or 0.0001
+ min_base_increment = trading_rules.get("min_base_amount_increment", 0.0001) or 0.0001
+
+ min_notional_with_margin = min_notional * 1.05
+
+ min_base_from_notional = min_notional_with_margin / current_price
+ min_base_from_quantization = min_base_increment * math.ceil(
+ min_notional / (min_base_increment * current_price)
+ )
+ min_base_amount = max(min_base_from_notional, min_base_from_quantization)
+ min_base_amount = math.ceil(min_base_amount / min_base_increment) * min_base_increment
+ min_quote_amount = min_base_amount * current_price
+
+ min_step_size = max(min_spread, min_price_increment / current_price)
+
+ max_possible_levels = int(total_amount / min_quote_amount) if min_quote_amount > 0 else 0
+
+ if max_possible_levels == 0:
+ return {
+ "levels": [],
+ "amount_per_level": 0,
+ "num_levels": 0,
+ "grid_range_pct": grid_range_pct,
+ "warnings": [f"Need ${min_quote_amount:.2f} min, have ${total_amount:.2f}"],
+ "valid": False,
+ }
+
+ max_levels_by_step = int(grid_range / min_step_size) if min_step_size > 0 else max_possible_levels
+ n_levels = min(max_possible_levels, max_levels_by_step)
+
+ if n_levels == 0:
+ n_levels = 1
+ quote_amount_per_level = min_quote_amount
+ else:
+ base_amount_per_level = max(
+ min_base_amount,
+ math.floor(total_amount / (current_price * n_levels) / min_base_increment)
+ * min_base_increment,
+ )
+ quote_amount_per_level = base_amount_per_level * current_price
+ n_levels = min(n_levels, int(total_amount / quote_amount_per_level))
+
+ n_levels = max(1, n_levels)
+
+ # Generate price levels (linear distribution)
+ levels = []
+ if n_levels > 1:
+ for i in range(n_levels):
+ price = low_price + (high_price - low_price) * i / (n_levels - 1)
+ levels.append(round(price, 8))
+ step = grid_range / (n_levels - 1)
+ else:
+ levels.append(round((low_price + high_price) / 2, 8))
+ step = grid_range
+
+ amount_per_level = total_amount / n_levels if n_levels > 0 else 0
+
+ if amount_per_level < min_notional:
+ warnings.append(f"${amount_per_level:.2f}/lvl < ${min_notional:.2f} min")
+
+ if trading_rules:
+ min_order_size = trading_rules.get("min_order_size", 0)
+ if min_order_size and current_price > 0:
+ base_per_level = amount_per_level / current_price
+ if base_per_level < min_order_size:
+ warnings.append(f"Below min size ({min_order_size})")
+
+ if n_levels > 1 and step < min_spread:
+ warnings.append(f"Spread {step*100:.3f}% < min {min_spread*100:.3f}%")
+
+ levels_below = [lv for lv in levels if lv < current_price]
+ levels_above = [lv for lv in levels if lv >= current_price]
+
+ return {
+ "levels": levels,
+ "levels_below_current": len(levels_below),
+ "levels_above_current": len(levels_above),
+ "amount_per_level": round(amount_per_level, 2),
+ "num_levels": n_levels,
+ "grid_range_pct": round(grid_range_pct, 3),
+ "price_step": round(step * low_price, 8) if n_levels > 1 else 0,
+ "spread_pct": round(step * 100, 3) if n_levels > 1 else round(min_spread * 100, 3),
+ "max_levels_by_budget": max_possible_levels,
+ "max_levels_by_spread": max_levels_by_step,
+ "warnings": warnings,
+ "valid": len(warnings) == 0,
+ }
+
+
+# ============================================
+# MULTI-GRID GENERATION FUNCTIONS (NEW)
+# ============================================
+
+def calculate_optimal_multi_grids(
+ current_price: float,
+ natr: float,
+ total_amount: float,
+ min_order_amount: float,
+ num_grids: int = 2,
+ grid_type: str = "accumulation_distribution"
+) -> List[Dict[str, Any]]:
+ """
+ Calculate optimal multi-grid configurations based on NATR.
+ """
+ if not natr or natr <= 0:
+ natr = 0.02 # 2% default fallback
+
+ total_range_pct = natr * 3
+ grids = []
+
+ if grid_type == "accumulation_distribution":
+ # 🔧 FIX: Supporta num_grids > 2
+ if num_grids == 2:
+ # Comportamento originale: 1 buy + 1 sell
+ buy_range_pct = total_range_pct * 0.6
+ sell_range_pct = total_range_pct * 0.4
+
+ buy_start = current_price * (1 - buy_range_pct)
+ buy_end = current_price * (1 - buy_range_pct * 0.2)
+ buy_limit = buy_start * 0.998
+ grids.append({
+ "grid_id": "accumulation",
+ "start_price": round(buy_start, 6),
+ "end_price": round(buy_end, 6),
+ "limit_price": round(buy_limit, 6),
+ "side": SIDE_LONG,
+ "amount_quote_pct": 0.5,
+ "enabled": True,
+ })
+
+ sell_start = current_price * (1 + sell_range_pct * 0.2)
+ sell_end = current_price * (1 + sell_range_pct)
+ sell_limit = sell_end * 1.002
+ grids.append({
+ "grid_id": "distribution",
+ "start_price": round(sell_start, 6),
+ "end_price": round(sell_end, 6),
+ "limit_price": round(sell_limit, 6),
+ "side": SIDE_SHORT,
+ "amount_quote_pct": 0.5,
+ "enabled": True,
+ })
+ else:
+ # 🔧 NUOVO: Multiplo accumulation/distribution
+ # Alterna LONG e SHORT per ogni grid
+ for i in range(num_grids):
+ is_long = (i % 2 == 0)
+ if is_long:
+ # Accumulation (BUY) - range sotto il prezzo
+ range_pct = total_range_pct * (0.3 + (i / num_grids) * 0.3)
+ start = current_price * (1 - range_pct)
+ end = current_price * (1 - range_pct * 0.2)
+ limit = start * 0.998
+ side = SIDE_LONG
+ else:
+ # Distribution (SELL) - range sopra il prezzo
+ range_pct = total_range_pct * (0.2 + (i / num_grids) * 0.3)
+ start = current_price * (1 + range_pct * 0.2)
+ end = current_price * (1 + range_pct)
+ limit = end * 1.002
+ side = SIDE_SHORT
+
+ grids.append({
+ "grid_id": f"grid_{i+1}",
+ "start_price": round(start, 6),
+ "end_price": round(end, 6),
+ "limit_price": round(limit, 6),
+ "side": side,
+ "amount_quote_pct": round(1.0 / num_grids, 4),
+ "enabled": True,
+ })
+
+ elif grid_type == "range_trading":
+ # OK - già funzionante
+ range_low = current_price * (1 - total_range_pct)
+ range_high = current_price * (1 + total_range_pct)
+ step = (range_high - range_low) / num_grids
+ amount_per_grid = 1.0 / num_grids
+
+ for i in range(num_grids):
+ start = range_low + (step * i)
+ end = start + step
+ side = SIDE_LONG if i % 2 == 0 else SIDE_SHORT
+
+ if side == SIDE_LONG:
+ limit = start * 0.998
+ else:
+ limit = end * 1.002
+
+ grids.append({
+ "grid_id": f"grid_{i+1}",
+ "start_price": round(start, 6),
+ "end_price": round(end, 6),
+ "limit_price": round(limit, 6),
+ "side": side,
+ "amount_quote_pct": round(amount_per_grid, 4),
+ "enabled": True,
+ })
+
+ elif grid_type == "pyramid":
+ # 🔧 FIX: Genera dinamicamente in base a num_grids
+ # Distribuzione esponenziale: più vicino al prezzo, più allocazione
+ allocations = []
+ levels = []
+
+ for i in range(num_grids):
+ # Distanza dal prezzo: più vicino per i primi grid
+ distance = 0.01 * (i + 1) # 1%, 2%, 3%, ...
+ # Allocazione decrescente: più lontano = meno capitale
+ weight = 1.0 / (i + 1) # 1, 1/2, 1/3, 1/4, ...
+ levels.append(distance)
+ allocations.append(weight)
+
+ # Normalizza allocazioni
+ total_weight = sum(allocations)
+ allocations = [w / total_weight for w in allocations]
+
+ for i, (dist_pct, alloc) in enumerate(zip(levels, allocations)):
+ price = current_price * (1 - dist_pct)
+ start = price * 0.99
+ end = price
+ limit = price * 0.998
+
+ grids.append({
+ "grid_id": f"dca_{i+1}",
+ "start_price": round(start, 6),
+ "end_price": round(end, 6),
+ "limit_price": round(limit, 6),
+ "side": SIDE_LONG,
+ "amount_quote_pct": round(alloc, 4),
+ "enabled": True,
+ })
+
+ return grids
+
+def suggest_multi_grid_params(
+ current_price: float,
+ natr: float,
+ total_amount: float,
+ min_order_amount: float,
+ num_grids: int = 2,
+ grid_type: str = "accumulation_distribution"
+) -> Dict[str, Any]:
+ """
+ Suggest parameters for multiple grids with validation.
+ """
+ # 🔧 FIX: Usa num_grids (già passato correttamente)
+ grids = calculate_optimal_multi_grids(
+ current_price, natr, total_amount, min_order_amount,
+ num_grids, grid_type # ← num_grids è qui
+ )
+
+ # Validate sum of amount_quote_pct = 1.0
+ total_pct = sum(g["amount_quote_pct"] for g in grids)
+ if abs(total_pct - 1.0) > 0.01:
+ for g in grids:
+ g["amount_quote_pct"] = round(g["amount_quote_pct"] / total_pct, 4)
+
+ return {
+ "grids": grids,
+ "num_grids": len(grids),
+ "total_pct": sum(g["amount_quote_pct"] for g in grids),
+ }
+
+def format_multi_grid_summary(
+ config: Dict[str, Any],
+ current_price: float,
+ natr: Optional[float] = None,
+ trading_rules: Optional[Dict[str, Any]] = None,
+) -> str:
+ """
+ Format a human-readable summary of all grids.
+
+ Example output:
+ Grid accumulation (LONG, $500, 50%):
+ 15 levels (↓4 ↑11) @ $33.33/lvl | step: 0.583%
+ Grid distribution (SHORT, $500, 50%):
+ 8 levels (↓5 ↑3) @ $37.50/lvl | step: 1.102%
+ NATR (14): 1.45% | Total grids: 2 | Capital used: 100%
+
+ Args:
+ config: Full MultiGridStrike config
+ current_price: Current market price
+ natr: Optional pre-calculated NATR
+ trading_rules: Optional trading rules
+
+ Returns:
+ Formatted summary string
+ """
+ grids = config.get("grids", [])
+ total_amount = float(config.get("total_amount_quote", 0))
+ min_spread = float(config.get("min_spread_between_orders", 0.001))
+ min_order_amount = float(config.get("min_order_amount_quote", 5))
+
+ lines = []
+ total_pct = 0.0
+
+ for grid in grids:
+ if not grid.get("enabled", True):
+ continue
+
+ grid_id = grid.get("grid_id", "?")
+ side = grid.get("side", SIDE_LONG)
+ side_str = "LONG" if side == SIDE_LONG else "SHORT"
+ pct = float(grid.get("amount_quote_pct", 0))
+ allocated = total_amount * pct
+ total_pct += pct
+
+ start = float(grid.get("start_price", 0))
+ end = float(grid.get("end_price", 0))
+
+ analysis = generate_theoretical_grid(
+ start_price=start,
+ end_price=end,
+ min_spread=min_spread,
+ total_amount=allocated,
+ min_order_amount=min_order_amount,
+ current_price=current_price,
+ side=side,
+ trading_rules=trading_rules,
+ )
+
+ header = f"Grid {grid_id} ({side_str}, ${allocated:.0f}, {pct*100:.0f}%):"
+ lines.append(header)
+
+ if not analysis.get("valid"):
+ for w in analysis.get("warnings", []):
+ lines.append(f" ⚠ {w}")
+ continue
+
+ n = analysis["num_levels"]
+ below = analysis.get("levels_below_current", 0)
+ above = analysis.get("levels_above_current", 0)
+ amt = analysis["amount_per_level"]
+ spread = analysis.get("spread_pct", 0)
+ lines.append(
+ f" {n} levels (↓{below} ↑{above}) @ ${amt:.2f}/lvl | step: {spread:.3f}%"
+ )
+
+ if lines:
+ footer_parts = []
+ if natr is not None:
+ footer_parts.append(f"NATR (14): {natr*100:.2f}%")
+ footer_parts.append(f"Total grids: {len([g for g in grids if g.get('enabled')])}")
+ footer_parts.append(f"Capital used: {total_pct*100:.0f}%")
+ lines.append(" | ".join(footer_parts))
+
+ return "\n".join(lines)
+
+# ============================================
+# Multi-grid specific: analyze all grids at once (BACKWARD COMPATIBILITY)
+# ============================================
+
+def analyze_all_grids(
+ config: Dict[str, Any],
+ current_price: float,
+ trading_rules: Optional[Dict[str, Any]] = None,
+) -> List[Dict[str, Any]]:
+ """
+ Run generate_theoretical_grid for every enabled grid in a MultiGridStrike config.
+
+ This function is kept for backward compatibility with existing code.
+
+ Args:
+ config: Full MultiGridStrike config dict (with 'grids' list)
+ current_price: Current market price
+ trading_rules: Optional trading rules
+
+ Returns:
+ List of dicts, one per grid, each containing the grid_id and
+ the result of generate_theoretical_grid for that grid.
+ """
+ results = []
+ total_amount = float(config.get("total_amount_quote", 0))
+ min_spread = float(config.get("min_spread_between_orders", 0.001))
+ min_order_amount = float(config.get("min_order_amount_quote", 5))
+
+ for grid in config.get("grids", []):
+ if not grid.get("enabled", True):
+ continue
+
+ grid_id = grid.get("grid_id", "?")
+ pct = float(grid.get("amount_quote_pct", 0))
+ grid_amount = total_amount * pct
+
+ analysis = generate_theoretical_grid(
+ start_price=float(grid.get("start_price", 0)),
+ end_price=float(grid.get("end_price", 0)),
+ min_spread=min_spread,
+ total_amount=grid_amount,
+ min_order_amount=min_order_amount,
+ current_price=current_price,
+ side=int(grid.get("side", 1)),
+ trading_rules=trading_rules,
+ )
+ analysis["grid_id"] = grid_id
+ analysis["amount_allocated"] = round(grid_amount, 2)
+ analysis["amount_pct"] = pct
+ results.append(analysis)
+
+ return results
+
+
diff --git a/handlers/bots/controllers/quantum_grid_allocator/__init__.py b/handlers/bots/controllers/quantum_grid_allocator/__init__.py
new file mode 100644
index 00000000..fb16f675
--- /dev/null
+++ b/handlers/bots/controllers/quantum_grid_allocator/__init__.py
@@ -0,0 +1,47 @@
+"""
+Quantum Grid Allocator Controller Module
+
+Portfolio rebalancing with grid trading on multiple assets.
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class QuantumGridAllocatorController(BaseController):
+ controller_type = "quantum_grid_allocator"
+ display_name = "Quantum Grid Allocator"
+ description = "Portfolio rebalancing with grid trading on multiple assets"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ from handlers.dex.visualizations import generate_candlestick_chart
+ return generate_candlestick_chart(candles_data, title=f"Quantum Grid Allocator - Portfolio")
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["QuantumGridAllocatorController"]
+
+
diff --git a/handlers/bots/controllers/quantum_grid_allocator/config.py b/handlers/bots/controllers/quantum_grid_allocator/config.py
new file mode 100644
index 00000000..102707a7
--- /dev/null
+++ b/handlers/bots/controllers/quantum_grid_allocator/config.py
@@ -0,0 +1,128 @@
+"""
+Quantum Grid Allocator controller configuration.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "quantum_grid_allocator",
+ "controller_type": "generic",
+ "id": "",
+ "connector_name": "binance",
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ "quote_asset": "FDUSD",
+ "fee_asset": "BNB",
+ "portfolio_allocation": {"SOL": 0.50},
+ "long_only_threshold": 0.2,
+ "short_only_threshold": 0.2,
+ "hedge_ratio": 2,
+ "base_grid_value_pct": 0.08,
+ "max_grid_value_pct": 0.15,
+ "grid_range": 0.002,
+ "tp_sl_ratio": 0.8,
+ "min_order_amount": 5,
+ "max_deviation": 0.05,
+ "max_open_orders": 2,
+ "safe_extra_spread": 0.0001,
+ "favorable_order_frequency": 2,
+ "unfavorable_order_frequency": 5,
+ "max_orders_per_batch": 1,
+ "min_spread_between_orders": 0.0001,
+ "grid_tp_multiplier": 0.0001,
+ "limit_price_spread": 0.001,
+ "activation_bounds": 0.0002,
+ "bb_length": 100,
+ "bb_std_dev": 2.0,
+ "interval": "1s",
+ "dynamic_grid_range": False,
+ "show_terminated_details": False,
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(name="id", label="Config ID", type="str", required=True),
+ "connector_name": ControllerField(name="connector_name", label="Exchange", type="str", required=True),
+ "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=False, default=1),
+ "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, default="HEDGE"),
+ "quote_asset": ControllerField(name="quote_asset", label="Quote Asset", type="str", required=False, default="FDUSD"),
+ "fee_asset": ControllerField(name="fee_asset", label="Fee Asset", type="str", required=False, default="BNB"),
+ "portfolio_allocation": ControllerField(name="portfolio_allocation", label="Portfolio Allocation", type="str", required=False, hint="e.g. SOL:0.5,BTC:0.3"),
+ "long_only_threshold": ControllerField(name="long_only_threshold", label="Long Only Threshold", type="float", required=False, default=0.2),
+ "short_only_threshold": ControllerField(name="short_only_threshold", label="Short Only Threshold", type="float", required=False, default=0.2),
+ "hedge_ratio": ControllerField(name="hedge_ratio", label="Hedge Ratio", type="float", required=False, default=2),
+ "base_grid_value_pct": ControllerField(name="base_grid_value_pct", label="Base Grid Value %", type="float", required=False, default=0.08),
+ "max_grid_value_pct": ControllerField(name="max_grid_value_pct", label="Max Grid Value %", type="float", required=False, default=0.15),
+ "grid_range": ControllerField(name="grid_range", label="Grid Range", type="float", required=False, default=0.002),
+ "tp_sl_ratio": ControllerField(name="tp_sl_ratio", label="TP/SL Ratio", type="float", required=False, default=0.8),
+ "min_order_amount": ControllerField(name="min_order_amount", label="Min Order Amount", type="float", required=False, default=5),
+ "max_deviation": ControllerField(name="max_deviation", label="Max Deviation", type="float", required=False, default=0.05),
+ "max_open_orders": ControllerField(name="max_open_orders", label="Max Open Orders", type="int", required=False, default=2),
+ "safe_extra_spread": ControllerField(name="safe_extra_spread", label="Safe Extra Spread", type="float", required=False, default=0.0001),
+ "favorable_order_frequency": ControllerField(name="favorable_order_frequency", label="Favorable Order Freq (s)", type="int", required=False, default=2),
+ "unfavorable_order_frequency": ControllerField(name="unfavorable_order_frequency", label="Unfavorable Order Freq (s)", type="int", required=False, default=5),
+ "max_orders_per_batch": ControllerField(name="max_orders_per_batch", label="Max Orders/Batch", type="int", required=False, default=1),
+ "min_spread_between_orders": ControllerField(name="min_spread_between_orders", label="Min Spread", type="float", required=False, default=0.0001),
+ "grid_tp_multiplier": ControllerField(name="grid_tp_multiplier", label="Grid TP Multiplier", type="float", required=False, default=0.0001),
+ "limit_price_spread": ControllerField(name="limit_price_spread", label="Limit Price Spread", type="float", required=False, default=0.001),
+ "activation_bounds": ControllerField(name="activation_bounds", label="Activation Bounds", type="float", required=False, default=0.0002),
+ "bb_length": ControllerField(name="bb_length", label="BB Length", type="int", required=False, default=100),
+ "bb_std_dev": ControllerField(name="bb_std_dev", label="BB Std Dev", type="float", required=False, default=2.0),
+ "interval": ControllerField(name="interval", label="Interval", type="str", required=False, default="1s"),
+ "dynamic_grid_range": ControllerField(name="dynamic_grid_range", label="Dynamic Grid Range", type="bool", required=False, default=False),
+ "show_terminated_details": ControllerField(name="show_terminated_details", label="Show Terminated", type="bool", required=False, default=False),
+}
+
+FIELD_ORDER: List[str] = [
+ "id", "connector_name", "leverage", "position_mode", "quote_asset", "fee_asset",
+ "portfolio_allocation", "long_only_threshold", "short_only_threshold", "hedge_ratio",
+ "base_grid_value_pct", "max_grid_value_pct", "grid_range", "tp_sl_ratio",
+ "min_order_amount", "max_deviation", "max_open_orders", "safe_extra_spread",
+ "favorable_order_frequency", "unfavorable_order_frequency", "max_orders_per_batch",
+ "min_spread_between_orders", "grid_tp_multiplier", "limit_price_spread",
+ "activation_bounds", "bb_length", "bb_std_dev", "interval", "dynamic_grid_range",
+ "show_terminated_details",
+]
+
+EDITABLE_FIELDS: List[str] = FIELD_ORDER.copy()
+
+
+def _parse_portfolio_allocation(value: str) -> Dict[str, float]:
+ """Parse portfolio allocation string like 'SOL:0.5,BTC:0.3'"""
+ result = {}
+ for part in value.split(","):
+ if ":" in part:
+ asset, pct = part.split(":")
+ result[asset.strip()] = float(pct.strip())
+ return result
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ if not config.get("connector_name"):
+ return False, "Missing exchange"
+
+ # Validate portfolio allocation
+ portfolio = config.get("portfolio_allocation", {})
+ if isinstance(portfolio, str):
+ portfolio = _parse_portfolio_allocation(portfolio)
+
+ total = sum(portfolio.values())
+ if total >= 1.0:
+ return False, f"Total allocation {total*100:.0f}% must be less than 100%"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ max_num = 0
+ for cfg in existing_configs:
+ cfg_id = cfg.get("id", "")
+ if cfg_id and cfg_id[:3].isdigit():
+ max_num = max(max_num, int(cfg_id[:3]))
+ seq = str(max_num + 1).zfill(3)
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ quote = config.get("quote_asset", "FDUSD")
+ return f"{seq}_qga_{connector}_{quote}"
+
+
diff --git a/handlers/bots/controllers/stat_arb_v2/__init__.py b/handlers/bots/controllers/stat_arb_v2/__init__.py
new file mode 100644
index 00000000..fc938107
--- /dev/null
+++ b/handlers/bots/controllers/stat_arb_v2/__init__.py
@@ -0,0 +1,60 @@
+"""
+Statistical Arbitrage V2 Controller Module.
+
+Trades two cointegrated assets on the same exchange.
+"""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, FIELD_ORDER, FIELDS, WIZARD_STEPS, generate_id, validate_config
+
+
+class StatArbV2Controller(BaseController):
+ controller_type = "stat_arb_v2"
+ display_name = "Statistical Arbitrage V2"
+ description = "Trades two cointegrated assets, entering when z-score exceeds threshold"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(
+ cls,
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+ ) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = [
+ "StatArbV2Controller",
+ "DEFAULTS",
+ "FIELDS",
+ "FIELD_ORDER",
+ "WIZARD_STEPS",
+ "validate_config",
+ "generate_id",
+ "generate_chart",
+ "generate_preview_chart",
+]
diff --git a/handlers/bots/controllers/stat_arb_v2/analysis.py b/handlers/bots/controllers/stat_arb_v2/analysis.py
new file mode 100644
index 00000000..88a14df3
--- /dev/null
+++ b/handlers/bots/controllers/stat_arb_v2/analysis.py
@@ -0,0 +1,369 @@
+"""
+Stat Arb V2 analysis utilities.
+
+Pure-Python implementation of cointegration analysis
+(no sklearn/statsmodels dependency — usable directly from Condor/UI layer):
+
+- Linear regression (beta, alpha, R²)
+- ADF test approximation (stationarity)
+- Half-life of mean reversion via OU process
+- Parameter suggestions
+"""
+
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+
+# ---------------------------------------------------------------------------
+# LOW-LEVEL CALCULATIONS
+# ---------------------------------------------------------------------------
+
+def linear_regression(x: List[float], y: List[float]) -> Tuple[float, float, float]:
+ """
+ Calculate linear regression using pure Python (no numpy/sklearn).
+
+ Returns:
+ (slope, intercept, r_squared)
+ """
+ n = len(x)
+ if n == 0:
+ return 0.0, 0.0, 0.0
+
+ sum_x = sum(x)
+ sum_y = sum(y)
+ sum_xy = sum(xi * yi for xi, yi in zip(x, y))
+ sum_x2 = sum(xi * xi for xi in x)
+ sum_y2 = sum(yi * yi for yi in y)
+
+ denominator = n * sum_x2 - sum_x * sum_x
+ if denominator == 0:
+ return 0.0, sum_y / n, 0.0
+
+ slope = (n * sum_xy - sum_x * sum_y) / denominator
+ intercept = (sum_y - slope * sum_x) / n
+
+ # R² calculation
+ y_mean = sum_y / n
+ ss_res = sum((yi - (intercept + slope * xi)) ** 2 for xi, yi in zip(x, y))
+ ss_tot = sum((yi - y_mean) ** 2 for yi in y)
+
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
+
+ return slope, intercept, r_squared
+
+
+def calculate_cumulative_returns(prices: List[float]) -> List[float]:
+ """Calculate cumulative returns normalized to start at 1.0."""
+ if len(prices) < 2:
+ return [1.0]
+
+ returns = [prices[i] / prices[i - 1] - 1 for i in range(1, len(prices))]
+ cum_returns = [1.0]
+ for r in returns:
+ cum_returns.append(cum_returns[-1] * (1 + r))
+ return cum_returns
+
+
+def calculate_adf_approximation(series: List[float]) -> float:
+ """
+ Approximate ADF test p-value using pure Python.
+
+ Returns a p-value (lower = more stationary).
+ This is a heuristic approximation, not a full ADF implementation.
+ """
+ n = len(series)
+ if n < 10:
+ return 0.5
+
+ # Calculate lagged series
+ y = [series[i] - series[i - 1] for i in range(1, n)]
+ x = series[:-1]
+
+ if len(x) < 3:
+ return 0.5
+
+ # Regression y ~ x
+ n_xy = len(x)
+ sum_x = sum(x)
+ sum_y = sum(y)
+ sum_xy = sum(xi * yi for xi, yi in zip(x, y))
+ sum_x2 = sum(xi * xi for xi in x)
+
+ denominator = n_xy * sum_x2 - sum_x * sum_x
+ if denominator == 0:
+ return 0.5
+
+ gamma = (n_xy * sum_xy - sum_x * sum_y) / denominator
+
+ # Critical approximation: gamma < 0 indicates mean reversion
+ if gamma < -0.05:
+ return 0.01 # Very stationary
+ elif gamma < -0.02:
+ return 0.05 # Stationary
+ elif gamma < -0.01:
+ return 0.10 # Moderately stationary
+ elif gamma < 0:
+ return 0.20 # Weakly stationary
+ else:
+ return 0.50 # Non-stationary
+
+
+def calculate_half_life_ou(spread: List[float]) -> Optional[float]:
+ """
+ Calculate half-life of mean reversion using OU process approximation.
+ """
+ if len(spread) < 3:
+ return None
+
+ spread_lag = spread[:-1]
+ delta_spread = [spread[i] - spread[i - 1] for i in range(1, len(spread))]
+
+ n = len(spread_lag)
+ if n < 2:
+ return None
+
+ # Linear regression: delta = lambda * lag + epsilon
+ sum_lag = sum(spread_lag)
+ sum_delta = sum(delta_spread)
+ sum_lag_delta = sum(l * d for l, d in zip(spread_lag, delta_spread))
+ sum_lag2 = sum(l * l for l in spread_lag)
+
+ denominator = n * sum_lag2 - sum_lag * sum_lag
+ if denominator == 0:
+ return None
+
+ lambda_ou = (n * sum_lag_delta - sum_lag * sum_delta) / denominator
+
+ if lambda_ou < 0:
+ return -math.log(2) / lambda_ou
+ return None
+
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """Calculate Normalized ATR from candles."""
+ if not candles or len(candles) < period + 1:
+ return None
+
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = float(candles[i].get("high", 0) or 0)
+ low = float(candles[i].get("low", 0) or 0)
+ prev_close = float(candles[i - 1].get("close", 0) or 0)
+ if not all([high, low, prev_close]):
+ continue
+ tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return None
+
+ atr = sum(true_ranges[-period:]) / period
+ current_close = float(candles[-1].get("close", 0) or 0)
+ if current_close <= 0:
+ return None
+
+ return atr / current_close
+
+
+# ---------------------------------------------------------------------------
+# COINTEGRATION ANALYSIS
+# ---------------------------------------------------------------------------
+
+def analyze_cointegration(
+ dominant_prices: List[float],
+ hedge_prices: List[float],
+) -> Dict[str, Any]:
+ """
+ Calculate cointegration metrics between two price series.
+
+ Returns dict with:
+ - beta: hedge vs dominant slope
+ - r_squared: regression fit quality (0-1)
+ - adf_pvalue: stationarity p-value (lower = more stationary)
+ - half_life: mean reversion half-life in candles
+ - spread_std: standard deviation of spread (%)
+ """
+ if len(dominant_prices) < 10 or len(hedge_prices) < 10:
+ return {"error": "Insufficient price data"}
+
+ # Use minimum length for alignment
+ n = min(len(dominant_prices), len(hedge_prices))
+ dom_prices = dominant_prices[-n:]
+ hedge_prices = hedge_prices[-n:]
+
+ # Calculate cumulative returns
+ dom_cum = calculate_cumulative_returns(dom_prices)
+ hedge_cum = calculate_cumulative_returns(hedge_prices)
+
+ # Linear regression
+ slope, intercept, r_squared = linear_regression(dom_cum, hedge_cum)
+
+ # Spread as % deviation from predicted value
+ y_pred = [intercept + slope * x for x in dom_cum]
+ spread_pct = [(hedge_cum[i] - y_pred[i]) / y_pred[i] * 100 for i in range(len(dom_cum))]
+
+ # Remove NaN/inf values
+ spread_pct = [s for s in spread_pct if math.isfinite(s)]
+
+ if len(spread_pct) < 5:
+ return {"error": "Invalid spread calculation"}
+
+ # Calculate metrics
+ spread_std = math.sqrt(sum((s - sum(spread_pct) / len(spread_pct)) ** 2 for s in spread_pct) / len(spread_pct))
+ half_life = calculate_half_life_ou(spread_pct)
+ adf_pvalue = calculate_adf_approximation(spread_pct)
+
+ return {
+ "beta": round(slope, 4),
+ "r_squared": round(r_squared, 4),
+ "adf_pvalue": round(adf_pvalue, 4),
+ "half_life": round(half_life, 1) if half_life else None,
+ "spread_std_pct": round(spread_std, 4),
+ }
+
+
+# ---------------------------------------------------------------------------
+# PARAMETER SUGGESTIONS
+# ---------------------------------------------------------------------------
+
+def suggest_entry_threshold(spread_std_pct: Optional[float]) -> float:
+ """Suggest entry_threshold based on spread volatility."""
+ if spread_std_pct is not None:
+ # Higher volatility = higher threshold
+ if spread_std_pct > 1.0:
+ return 2.5
+ elif spread_std_pct > 0.5:
+ return 2.0
+ else:
+ return 1.5
+ return 2.0
+
+
+def suggest_take_profit(spread_std_pct: Optional[float]) -> float:
+ """Suggest take_profit based on spread volatility."""
+ if spread_std_pct is not None:
+ # Take profit = 30% of typical spread movement
+ tp = spread_std_pct / 100 * 0.3
+ return max(0.0003, round(tp, 6))
+ return 0.0008
+
+
+def suggest_hedge_ratio_range(beta: float) -> Tuple[float, float]:
+ """Suggest dynamic_hedge_ratio range based on beta."""
+ if beta > 0:
+ central = 1.0 / beta
+ suggested_min = max(0.2, central * 0.5)
+ suggested_max = min(3.0, central * 2.0)
+ else:
+ suggested_min, suggested_max = 0.5, 2.0
+ return suggested_min, suggested_max
+
+
+# ---------------------------------------------------------------------------
+# FULL ANALYSIS (for Condor wizard)
+# ---------------------------------------------------------------------------
+
+def analyze_candles_for_stat_arb(
+ dominant_candles: List[Dict[str, Any]],
+ hedge_candles: List[Dict[str, Any]],
+ lookback: int = 300,
+) -> Dict[str, Any]:
+ """
+ Full analysis for StatArb parameter suggestions.
+
+ Returns dict with:
+ - beta, r_squared, adf_pvalue, half_life, spread_std_pct
+ - suggested_entry_threshold, suggested_take_profit
+ - suggested_hedge_ratio_range (min, max)
+ - natr_dominant, natr_hedge
+ - warnings
+ """
+ result = {
+ "beta": None,
+ "r_squared": None,
+ "adf_pvalue": None,
+ "half_life": None,
+ "spread_std_pct": None,
+ "suggested_entry_threshold": 2.0,
+ "suggested_take_profit": 0.0008,
+ "suggested_hedge_ratio_range": [0.5, 2.0],
+ "natr_dominant": None,
+ "natr_hedge": None,
+ "warnings": [],
+ "analysis_candles": min(len(dominant_candles), len(hedge_candles)),
+ }
+
+ # Extract close prices
+ dom_closes = [float(c.get("close") or c.get("c") or 0) for c in dominant_candles if c.get("close")]
+ hedge_closes = [float(c.get("close") or c.get("c") or 0) for c in hedge_candles if c.get("close")]
+
+ if len(dom_closes) < lookback or len(hedge_closes) < lookback:
+ result["error"] = f"Insufficient data: need {lookback} candles, got dom={len(dom_closes)} hedge={len(hedge_closes)}"
+ return result
+
+ # Use only last 'lookback' candles
+ dom_array = dom_closes[-lookback:]
+ hedge_array = hedge_closes[-lookback:]
+
+ # Cointegration analysis
+ coint = analyze_cointegration(dom_array, hedge_array)
+ if "error" in coint:
+ result["error"] = coint["error"]
+ return result
+
+ result["beta"] = coint["beta"]
+ result["r_squared"] = coint["r_squared"]
+ result["adf_pvalue"] = coint["adf_pvalue"]
+ result["half_life"] = coint["half_life"]
+ result["spread_std_pct"] = coint["spread_std_pct"]
+
+ # Parameter suggestions
+ result["suggested_entry_threshold"] = suggest_entry_threshold(coint["spread_std_pct"])
+ result["suggested_take_profit"] = suggest_take_profit(coint["spread_std_pct"])
+
+ if coint["beta"] is not None:
+ min_r, max_r = suggest_hedge_ratio_range(coint["beta"])
+ result["suggested_hedge_ratio_range"] = [min_r, max_r]
+
+ # NATR for volatility context
+ result["natr_dominant"] = calculate_natr(dominant_candles, 14)
+ result["natr_hedge"] = calculate_natr(hedge_candles, 14)
+
+ # Warnings
+ if coint["r_squared"] is not None and coint["r_squared"] < 0.5:
+ result["warnings"].append(f"Low R² ({coint['r_squared']:.2f}) – relationship may be weak")
+ if coint["adf_pvalue"] is not None and coint["adf_pvalue"] > 0.05:
+ result["warnings"].append(f"Spread not stationary (p={coint['adf_pvalue']:.3f})")
+ if coint["half_life"] is not None and coint["half_life"] > 100:
+ result["warnings"].append(f"Long half-life ({coint['half_life']:.0f} candles) – slow reversion")
+
+ return result
+
+
+def format_stat_arb_summary(analysis: Dict[str, Any]) -> str:
+ """Format analysis results for display in Condor wizard final step."""
+ if "error" in analysis:
+ return f"⚠️ Analysis error: {analysis['error']}"
+
+ lines = []
+ lines.append("📊 Statistical Arbitrage Analysis")
+ lines.append("")
+ lines.append(f"Beta (hedge vs dominant): {analysis.get('beta', 'N/A')}")
+ lines.append(f"R²: {analysis.get('r_squared', 'N/A')}")
+ lines.append(f"ADF p-value (stationarity): {analysis.get('adf_pvalue', 'N/A')}")
+ hl = analysis.get('half_life')
+ lines.append(f"Half-life (candles): {hl if hl else 'N/A'}")
+ lines.append(f"Spread std (%): {analysis.get('spread_std_pct', 'N/A')}")
+ lines.append("")
+ lines.append("💡 Suggested parameters:")
+ lines.append(f" entry_threshold: {analysis.get('suggested_entry_threshold', 2.0)}")
+ lines.append(f" take_profit: {analysis.get('suggested_take_profit', 0.0008)}")
+ hr = analysis.get('suggested_hedge_ratio_range')
+ if hr:
+ lines.append(f" dynamic_hedge_ratio range: [{hr[0]:.2f}, {hr[1]:.2f}]")
+ lines.append("")
+ if analysis.get("warnings"):
+ lines.append("⚠️ Warnings:")
+ for w in analysis["warnings"]:
+ lines.append(f" • {w}")
+ return "\n".join(lines)
diff --git a/handlers/bots/controllers/stat_arb_v2/chart.py b/handlers/bots/controllers/stat_arb_v2/chart.py
new file mode 100644
index 00000000..362c0080
--- /dev/null
+++ b/handlers/bots/controllers/stat_arb_v2/chart.py
@@ -0,0 +1,229 @@
+"""
+Statistical Arbitrage V2 chart generation.
+
+Generates a chart with:
+- Normalized price series of both assets
+- Spread between the two assets (as percentage)
+- Z-score with entry thresholds
+"""
+
+import io
+import math
+
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+import numpy as np
+
+
+def generate_chart(
+ config: dict,
+ candles_data: list,
+ current_price: float = None,
+) -> io.BytesIO:
+ """
+ Generate a chart for Statistical Arbitrage V2.
+
+ Shows:
+ - Normalized price comparison (both assets starting at 1)
+ - Spread between the two assets (percentage)
+ - Z-score with entry thresholds
+ """
+ if not candles_data or len(candles_data) < 10:
+ return _generate_simple_chart(config, candles_data, current_price)
+
+ # Ottieni le coppie
+ dom_pair = config.get("trading_pair_dominant", "Dominant")
+ hedge_pair = config.get("trading_pair_hedge", "Hedge")
+ interval = config.get("interval", "5m")
+ entry_threshold = config.get("entry_threshold", 2.0)
+
+ # Prepara i dati
+ df = _prepare_dataframe(candles_data)
+
+ # Verifica se abbiamo i dati di entrambe le coppie
+ has_dom = 'close_dom' in df.columns or 'close' in df.columns
+ has_hedge = 'close_hedge' in df.columns
+
+ if has_dom and has_hedge:
+ # Dati combinati - usa close_dom e close_hedge
+ dom_closes = pd.to_numeric(df['close_dom'], errors='coerce').fillna(0).values
+ hedge_closes = pd.to_numeric(df['close_hedge'], errors='coerce').fillna(0).values
+ elif 'close' in df.columns:
+ # Solo una coppia - usa simulazione per demo
+ dom_closes = pd.to_numeric(df['close'], errors='coerce').fillna(0).values
+ # Crea una serie fittizia per la hedge (sposta leggermente)
+ hedge_closes = dom_closes * (1 + np.random.randn(len(dom_closes)) * 0.01)
+ # Applica smoothing
+ hedge_closes = pd.Series(hedge_closes).rolling(window=5, min_periods=1).mean().values
+ else:
+ return _generate_simple_chart(config, candles_data, current_price)
+
+ if len(dom_closes) < 10 or len(hedge_closes) < 10:
+ return _generate_simple_chart(config, candles_data, current_price)
+
+ # Normalizza i prezzi (partono da 1)
+ dom_norm = dom_closes / dom_closes[0] if dom_closes[0] > 0 else dom_closes
+ hedge_norm = hedge_closes / hedge_closes[0] if hedge_closes[0] > 0 else hedge_closes
+
+ # Calcola lo spread percentuale
+ # spread = (dominant - hedge) / hedge * 100
+ spread = (dom_norm - hedge_norm) / hedge_norm * 100
+
+ # Calcola z-score
+ mean_spread = np.mean(spread)
+ std_spread = np.std(spread)
+ if std_spread > 0:
+ z_score = (spread - mean_spread) / std_spread
+ else:
+ z_score = np.zeros_like(spread)
+
+ dates = df['datetime'].values
+
+ # Crea la figura con 2 pannelli
+ fig = plt.figure(figsize=(14, 10))
+
+ # PANNELLO 1: Prezzi normalizzati
+ ax1 = plt.subplot(2, 1, 1)
+
+ ax1.plot(dates, dom_norm, label=f"{dom_pair} (normalized)", linewidth=1.5, color='cyan')
+ ax1.plot(dates, hedge_norm, label=f"{hedge_pair} (normalized)", linewidth=1.5, color='orange')
+ ax1.set_ylabel('Normalized Price')
+ ax1.set_title(f'Statistical Arbitrage: {dom_pair} vs {hedge_pair}')
+ ax1.legend(loc='upper left')
+ ax1.grid(True, alpha=0.3)
+ ax1.axhline(y=1.0, linestyle='--', alpha=0.5, color='gray')
+
+ # PANNELLO 2: Spread e Z-score
+ ax2 = plt.subplot(2, 1, 2)
+
+ # Spread come area
+ ax2.fill_between(dates, 0, spread, alpha=0.3, color='blue', label='Spread %')
+ ax2.plot(dates, spread, linewidth=1, color='blue', alpha=0.7)
+
+ # Z-score (secondo asse)
+ ax2_twin = ax2.twinx()
+ ax2_twin.plot(dates, z_score, linewidth=1.5, color='purple', label='Z-Score')
+ ax2_twin.axhline(y=entry_threshold, linestyle='--', alpha=0.7, color='red', linewidth=1, label=f'Entry +{entry_threshold}')
+ ax2_twin.axhline(y=-entry_threshold, linestyle='--', alpha=0.7, color='green', linewidth=1, label=f'Entry -{entry_threshold}')
+ ax2_twin.axhline(y=0, linestyle='-', alpha=0.5, color='gray', linewidth=0.8)
+ ax2_twin.set_ylabel('Z-Score', color='purple')
+ ax2_twin.tick_params(axis='y', labelcolor='purple')
+
+ ax2.set_ylabel('Spread (%)', color='blue')
+ ax2.tick_params(axis='y', labelcolor='blue')
+ ax2.set_xlabel('Time')
+ ax2.grid(True, alpha=0.3)
+
+ # Legenda combinata
+ lines1, labels1 = ax2.get_legend_handles_labels()
+ lines2, labels2 = ax2_twin.get_legend_handles_labels()
+ ax2.legend(lines1 + lines2, labels1 + labels2, loc='upper left', fontsize=9)
+ # Formatta l'asse x
+ if len(dates) > 1:
+ # Converti in Timestamp per il calcolo
+ start_date = pd.Timestamp(dates[0])
+ end_date = pd.Timestamp(dates[-1])
+ date_range = end_date - start_date
+ total_seconds = date_range.total_seconds()
+ days = date_range.days
+ else:
+ total_seconds = 3600
+ days = 0
+
+ if days >= 3:
+ locator = mdates.DayLocator(interval=max(1, days // 6))
+ formatter = mdates.DateFormatter('%b%d')
+ rotation = 0
+ elif total_seconds < 3600 * 2:
+ locator = mdates.MinuteLocator(interval=15)
+ formatter = mdates.DateFormatter('%H:%M')
+ rotation = 45
+ else:
+ locator = mdates.HourLocator(interval=max(1, int(total_seconds / 3600 // 4)))
+ formatter = mdates.DateFormatter('%H:%M')
+ rotation = 45
+
+ for ax in [ax1, ax2]:
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=rotation, ha='right')
+
+ # Titolo con info
+ fig.suptitle(
+ f"{dom_pair} vs {hedge_pair} - Spread Analysis (Z-Score threshold: {entry_threshold}) | {interval}",
+ fontsize=12,
+ y=0.98
+ )
+
+ plt.tight_layout()
+
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO:
+ """Alias for generate_chart."""
+ return generate_chart(config, candles_data, current_price)
+
+
+def _prepare_dataframe(candles: list) -> pd.DataFrame:
+ """Prepara il DataFrame dalle candele."""
+ if not candles:
+ return pd.DataFrame()
+
+ df = pd.DataFrame(candles)
+
+ # Cerca colonna timestamp
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ if sample > 10**12:
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='ns')
+ elif sample > 10**10:
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='ms')
+ else:
+ df['datetime'] = pd.to_datetime(df[ts_col], unit='s')
+ else:
+ df['datetime'] = pd.to_datetime(df[ts_col])
+ else:
+ # Crea date sequenziali
+ df['datetime'] = pd.date_range(end=pd.Timestamp.now(), periods=len(df), freq='5min')
+
+ # Converti colonne numeriche
+ for col in ['close', 'close_dom', 'close_hedge', 'open', 'open_dom', 'open_hedge', 'high', 'low']:
+ if col in df.columns:
+ df[col] = pd.to_numeric(df[col], errors='coerce')
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+
+def _generate_simple_chart(config: dict, candles_data: list, current_price: float = None) -> io.BytesIO:
+ """Genera un chart semplice quando non ci sono abbastanza dati."""
+ fig, ax = plt.subplots(figsize=(12, 6))
+
+ dom_pair = config.get("trading_pair_dominant", "Dominant")
+ hedge_pair = config.get("trading_pair_hedge", "Hedge")
+
+ if not candles_data or len(candles_data) == 0:
+ ax.text(0.5, 0.5, f"Waiting for candle data...\n{dom_pair} vs {hedge_pair}",
+ transform=ax.transAxes, ha='center', va='center', fontsize=12)
+ else:
+ ax.text(0.5, 0.5, f"Not enough data to generate chart for\n{dom_pair} vs {hedge_pair}\n\nNeed at least 10 candles, got {len(candles_data)}",
+ transform=ax.transAxes, ha='center', va='center', fontsize=12)
+
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
+ ax.axis('off')
+
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=100)
+ plt.close(fig)
+ buf.seek(0)
+ return buf
diff --git a/handlers/bots/controllers/stat_arb_v2/config.py b/handlers/bots/controllers/stat_arb_v2/config.py
new file mode 100644
index 00000000..08ce6a45
--- /dev/null
+++ b/handlers/bots/controllers/stat_arb_v2/config.py
@@ -0,0 +1,335 @@
+"""
+StatArb V2 configuration for Condor.
+"""
+
+from decimal import Decimal
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+
+# Default configuration values
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "stat_arb_v2",
+ "controller_type": "generic",
+ "connector_name": "",
+ "trading_pair_dominant": "",
+ "trading_pair_hedge": "",
+ "total_amount_quote": 1000,
+ "leverage": 20,
+ "position_mode": "HEDGE",
+ "interval": "5m",
+ "lookback_period": 100,
+ "entry_threshold": 2.0,
+ "take_profit": 0.0008,
+ "tp_global": 0.01,
+ "sl_global": 0.05,
+ "min_amount_quote": 10,
+ "quoter_spread": 0.0001,
+ "quoter_cooldown": 30,
+ "quoter_refresh": 10,
+ "max_orders_placed_per_side": 2,
+ "max_orders_filled_per_side": 2,
+ "max_position_deviation": 0.1,
+ "use_dynamic_hedge_ratio": True,
+ "pos_hedge_ratio": 1.0,
+ "max_dynamic_hedge_ratio": 3.0,
+ "min_dynamic_hedge_ratio": 0.2,
+ "min_r_squared": 0.70,
+ "adf_pvalue_threshold": 0.05,
+}
+
+# Field definitions for the Condor wizard
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id",
+ label="Config ID",
+ type="str",
+ required=True,
+ hint="Auto-generated with sequence number",
+ ),
+ "connector_name": ControllerField(
+ name="connector_name",
+ label="Exchange connector",
+ type="str",
+ required=True,
+ hint="e.g., binance_perpetual, bybit_perpetual, etc.",
+ ),
+ "trading_pair_dominant": ControllerField(
+ name="trading_pair_dominant",
+ label="Dominant trading pair",
+ type="str",
+ required=True,
+ hint="e.g., SOL-USDT, BTC-USDT",
+ ),
+ "trading_pair_hedge": ControllerField(
+ name="trading_pair_hedge",
+ label="Hedge trading pair",
+ type="str",
+ required=True,
+ hint="e.g., XRP-USDT, ETH-USDT",
+ ),
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote",
+ label="Total capital (quote)",
+ type="float",
+ required=True,
+ hint="Total amount in quote currency (USDT, USDC, etc.)",
+ default=1000,
+ ),
+ "leverage": ControllerField(
+ name="leverage",
+ label="Leverage",
+ type="int",
+ required=True,
+ hint="Leverage for perpetual futures (1 for spot)",
+ default=20,
+ ),
+ "position_mode": ControllerField(
+ name="position_mode",
+ label="Position mode",
+ type="str",
+ required=False,
+ hint="HEDGE or ONEWAY",
+ default="HEDGE",
+ ),
+ "interval": ControllerField(
+ name="interval",
+ label="Candle interval",
+ type="str",
+ required=False,
+ hint="e.g., 1m, 5m, 15m",
+ default="1m",
+ ),
+ "lookback_period": ControllerField(
+ name="lookback_period",
+ label="Lookback candles",
+ type="int",
+ required=False,
+ hint="Number of candles for regression and z-score",
+ default=300,
+ ),
+ "entry_threshold": ControllerField(
+ name="entry_threshold",
+ label="Entry threshold (z-score)",
+ type="float",
+ required=False,
+ hint="Z-score threshold to trigger a trade (2.0 = 95% quantile)",
+ default=2.0,
+ ),
+ "take_profit": ControllerField(
+ name="take_profit",
+ label="Take profit per leg",
+ type="float",
+ required=False,
+ hint="Percent profit to close a leg (e.g., 0.0008 = 0.08%)",
+ default=0.0008,
+ ),
+ "tp_global": ControllerField(
+ name="tp_global",
+ label="Global take profit",
+ type="float",
+ required=False,
+ hint="Pair PnL% to close everything (e.g., 0.01 = 1%)",
+ default=0.01,
+ ),
+ "sl_global": ControllerField(
+ name="sl_global",
+ label="Global stop loss",
+ type="float",
+ required=False,
+ hint="Pair PnL% loss to close everything (e.g., 0.05 = 5%)",
+ default=0.05,
+ ),
+ "min_amount_quote": ControllerField(
+ name="min_amount_quote",
+ label="Min order amount (quote)",
+ type="float",
+ required=False,
+ hint="Minimum notional per order in quote currency",
+ default=10,
+ ),
+ "quoter_spread": ControllerField(
+ name="quoter_spread",
+ label="Quoter spread",
+ type="float",
+ required=False,
+ hint="Offset from mid price for limit orders (e.g., 0.0001 = 0.01%)",
+ default=0.0001,
+ ),
+ "quoter_cooldown": ControllerField(
+ name="quoter_cooldown",
+ label="Cooldown after fill (s)",
+ type="int",
+ required=False,
+ hint="Seconds to wait before removing a filled executor",
+ default=30,
+ ),
+ "quoter_refresh": ControllerField(
+ name="quoter_refresh",
+ label="Refresh time for unfilled (s)",
+ type="int",
+ required=False,
+ hint="Seconds before cancelling and re-pricing an unfilled order",
+ default=10,
+ ),
+ "max_orders_placed_per_side": ControllerField(
+ name="max_orders_placed_per_side",
+ label="Max pending orders per side",
+ type="int",
+ required=False,
+ hint="Maximum number of unfilled orders per leg",
+ default=2,
+ ),
+ "max_orders_filled_per_side": ControllerField(
+ name="max_orders_filled_per_side",
+ label="Max filled orders per side",
+ type="int",
+ required=False,
+ hint="Maximum number of filled (active) positions per leg",
+ default=2,
+ ),
+ "max_position_deviation": ControllerField(
+ name="max_position_deviation",
+ label="Max position deviation",
+ type="float",
+ required=False,
+ hint="Imbalance threshold that blocks one leg (0.1 = 10%)",
+ default=0.1,
+ ),
+ "use_dynamic_hedge_ratio": ControllerField(
+ name="use_dynamic_hedge_ratio",
+ label="Use dynamic hedge ratio",
+ type="bool",
+ required=False,
+ hint="Size hedge leg according to OLS beta",
+ default=True,
+ ),
+ "pos_hedge_ratio": ControllerField(
+ name="pos_hedge_ratio",
+ label="Fixed hedge ratio (if dynamic off)",
+ type="float",
+ required=False,
+ hint="Hedge notional / dominant notional",
+ default=1.0,
+ ),
+ "max_dynamic_hedge_ratio": ControllerField(
+ name="max_dynamic_hedge_ratio",
+ label="Max dynamic ratio",
+ type="float",
+ required=False,
+ hint="Cap for 1/beta",
+ default=3.0,
+ ),
+ "min_dynamic_hedge_ratio": ControllerField(
+ name="min_dynamic_hedge_ratio",
+ label="Min dynamic ratio",
+ type="float",
+ required=False,
+ hint="Floor for 1/beta",
+ default=0.2,
+ ),
+ "min_r_squared": ControllerField(
+ name="min_r_squared",
+ label="Min R² to trade",
+ type="float",
+ required=False,
+ hint="Minimum coefficient of determination to allow signals",
+ default=0.70,
+ ),
+ "adf_pvalue_threshold": ControllerField(
+ name="adf_pvalue_threshold",
+ label="ADF p-value threshold",
+ type="float",
+ required=False,
+ hint="Maximum p-value for stationarity (lower is better)",
+ default=0.05,
+ ),
+}
+
+# Field order in the wizard
+FIELD_ORDER: List[str] = [
+ "id",
+ "connector_name",
+ "trading_pair_dominant",
+ "trading_pair_hedge",
+ "total_amount_quote",
+ "leverage",
+ "position_mode",
+ "interval",
+ "lookback_period",
+ "entry_threshold",
+ "take_profit",
+ "tp_global",
+ "sl_global",
+ "min_amount_quote",
+ "quoter_spread",
+ "quoter_cooldown",
+ "quoter_refresh",
+ "max_orders_placed_per_side",
+ "max_orders_filled_per_side",
+ "max_position_deviation",
+ "use_dynamic_hedge_ratio",
+ "pos_hedge_ratio",
+ "max_dynamic_hedge_ratio",
+ "min_dynamic_hedge_ratio",
+ "min_r_squared",
+ "adf_pvalue_threshold",
+]
+
+# Wizard steps – minimal required for quick setup
+WIZARD_STEPS: List[str] = [
+ "connector_name",
+ "trading_pair_dominant",
+ "trading_pair_hedge",
+ "total_amount_quote",
+ "leverage",
+ "position_mode",
+ "entry_threshold",
+ "take_profit",
+ "tp_global",
+ "review",
+]
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ """Validate the configuration."""
+ # Check required fields
+ required = ["connector_name", "trading_pair_dominant", "trading_pair_hedge"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+
+ # Check that total_amount_quote > 0
+ total = config.get("total_amount_quote", 0)
+ if total <= 0:
+ return False, "total_amount_quote must be > 0"
+
+ # Check entry_threshold positive
+ entry = config.get("entry_threshold", 0)
+ if entry <= 0:
+ return False, "entry_threshold must be positive"
+
+ # Check take_profit positive
+ tp = config.get("take_profit", 0)
+ if tp <= 0:
+ return False, "take_profit must be positive"
+
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ """Generate unique config ID with sequence number."""
+ max_num = 0
+ for cfg in existing_configs:
+ cid = cfg.get("id", "")
+ if cid and cid.split("_")[0].isdigit():
+ num = int(cid.split("_")[0])
+ max_num = max(max_num, num)
+ next_num = max_num + 1
+ seq = str(next_num).zfill(3)
+
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ dom = config.get("trading_pair_dominant", "UNKNOWN").split("-")[0]
+ hedge = config.get("trading_pair_hedge", "UNKNOWN").split("-")[0]
+ return f"{seq}_statarb_{connector}_{dom}_{hedge}"
diff --git a/handlers/bots/controllers/supertrend_v1/__init__.py b/handlers/bots/controllers/supertrend_v1/__init__.py
new file mode 100644
index 00000000..7f9a8722
--- /dev/null
+++ b/handlers/bots/controllers/supertrend_v1/__init__.py
@@ -0,0 +1,42 @@
+"""SuperTrend V1 Controller Module - Directional trading with SuperTrend indicator."""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class SuperTrendV1Controller(BaseController):
+ controller_type = "supertrend_v1"
+ display_name = "SuperTrend V1"
+ description = "Directional trading with SuperTrend indicator (ATR-based trend following)"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["SuperTrendV1Controller", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
diff --git a/handlers/bots/controllers/supertrend_v1/analysis.py b/handlers/bots/controllers/supertrend_v1/analysis.py
new file mode 100644
index 00000000..d22b0cab
--- /dev/null
+++ b/handlers/bots/controllers/supertrend_v1/analysis.py
@@ -0,0 +1,327 @@
+"""
+SuperTrend V1 analysis utilities.
+
+Calculates SuperTrend indicator manually (pure Python, no pandas_ta):
+- SuperTrend line and direction
+- Suggested parameters based on candle data
+- Signal statistics
+"""
+
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+
+def calculate_atr(candles: List[Dict[str, Any]], period: int = 14) -> List[float]:
+ """Calculate ATR series."""
+ if len(candles) < period + 1:
+ return []
+
+ true_ranges = []
+ for i in range(1, len(candles)):
+ high = float(candles[i].get("high", 0) or 0)
+ low = float(candles[i].get("low", 0) or 0)
+ prev_close = float(candles[i - 1].get("close", 0) or 0)
+ if not all([high, low, prev_close]):
+ true_ranges.append(0.0)
+ continue
+ tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
+ true_ranges.append(tr)
+
+ if len(true_ranges) < period:
+ return []
+
+ # Wilder's smoothing (RMA)
+ atr_values = [sum(true_ranges[:period]) / period]
+ for tr in true_ranges[period:]:
+ atr_values.append((atr_values[-1] * (period - 1) + tr) / period)
+
+ return atr_values
+
+
+def calculate_supertrend(
+ candles: List[Dict[str, Any]],
+ length: int = 20,
+ multiplier: float = 4.0,
+) -> List[Dict[str, Any]]:
+ """
+ Calculate SuperTrend indicator.
+
+ Returns list of dicts with:
+ - supertrend: SuperTrend line value
+ - direction: 1 = uptrend (bullish), -1 = downtrend (bearish)
+ - close: candle close price
+ - percentage_distance: distance from close to ST line as %
+ """
+ if len(candles) < length + 1:
+ return []
+
+ atr_values = calculate_atr(candles, length)
+ if not atr_values:
+ return []
+
+ # Align ATR with candles (ATR starts at index `length`)
+ atr_start = length
+
+ results = []
+ prev_upper = None
+ prev_lower = None
+ prev_direction = 1
+
+ for i in range(len(atr_values)):
+ candle_idx = i + atr_start
+ if candle_idx >= len(candles):
+ break
+
+ high = float(candles[candle_idx].get("high", 0) or 0)
+ low = float(candles[candle_idx].get("low", 0) or 0)
+ close = float(candles[candle_idx].get("close", 0) or 0)
+ atr = atr_values[i]
+
+ hl2 = (high + low) / 2
+ basic_upper = hl2 + multiplier * atr
+ basic_lower = hl2 - multiplier * atr
+
+ # Adjust bands
+ if prev_upper is None:
+ upper = basic_upper
+ lower = basic_lower
+ else:
+ upper = basic_upper if basic_upper < prev_upper or float(candles[candle_idx - 1].get("close", 0)) > prev_upper else prev_upper
+ lower = basic_lower if basic_lower > prev_lower or float(candles[candle_idx - 1].get("close", 0)) < prev_lower else prev_lower
+
+ # Determine direction
+ if prev_direction == -1:
+ direction = 1 if close > upper else -1
+ else:
+ direction = -1 if close < lower else 1
+
+ supertrend = lower if direction == 1 else upper
+ pct_distance = abs(close - supertrend) / close if close > 0 else 0
+
+ results.append({
+ "supertrend": supertrend,
+ "direction": direction,
+ "close": close,
+ "percentage_distance": pct_distance,
+ "upper": upper,
+ "lower": lower,
+ })
+
+ prev_upper = upper
+ prev_lower = lower
+ prev_direction = direction
+
+ return results
+
+
+def calculate_natr(candles: List[Dict[str, Any]], period: int = 14) -> Optional[float]:
+ """Calculate Normalized ATR."""
+ if not candles or len(candles) < period + 1:
+ return None
+ atr_values = calculate_atr(candles, period)
+ if not atr_values:
+ return None
+ current_close = float(candles[-1].get("close", 0) or 0)
+ return atr_values[-1] / current_close if current_close > 0 else None
+
+
+def suggest_percentage_threshold(
+ st_results: List[Dict[str, Any]],
+ signal_percentile: float = 75.0,
+) -> float:
+ """
+ Suggest percentage_threshold based on historical distance distribution.
+
+ Logic: use the Nth percentile of historical distances — captures most
+ signal opportunities while filtering out very distant entries.
+ """
+ if not st_results:
+ return 0.01
+
+ distances = [r["percentage_distance"] for r in st_results if r["percentage_distance"] > 0]
+ if not distances:
+ return 0.01
+
+ sorted_d = sorted(distances)
+ idx = max(0, min(int(len(sorted_d) * signal_percentile / 100), len(sorted_d) - 1))
+ return round(sorted_d[idx], 4)
+
+
+def analyze_candles_for_supertrend(
+ candles: List[Dict[str, Any]],
+ length: int = 20,
+ multiplier: float = 4.0,
+ percentage_threshold: float = 0.01,
+ natr_period: int = 14,
+) -> Dict[str, Any]:
+ """
+ Full analysis of candle data for SuperTrend V1.
+
+ Returns:
+ - current_direction: 1 (up) or -1 (down)
+ - current_supertrend: current ST line value
+ - current_pct_distance: current distance from price to ST line
+ - signal_now: True if current candle would trigger a signal
+ - natr: Normalized ATR
+ - suggested_percentage_threshold: auto-suggested threshold
+ - signal_count_long/short: historical signal counts
+ - trend_changes: number of direction flips
+ - pct_time_long/short: % of time in each trend
+ - analysis_candles: candle count used
+ """
+ result = {
+ "current_direction": 0,
+ "current_supertrend": None,
+ "current_pct_distance": None,
+ "signal_now": False,
+ "natr": None,
+ "suggested_percentage_threshold": percentage_threshold,
+ "signal_count_long": 0,
+ "signal_count_short": 0,
+ "trend_changes": 0,
+ "pct_time_long": 0.0,
+ "pct_time_short": 0.0,
+ "analysis_candles": len(candles),
+ }
+
+ if not candles or len(candles) < length + natr_period + 1:
+ return result
+
+ st_results = calculate_supertrend(candles, length, multiplier)
+ if not st_results:
+ return result
+
+ # Current state
+ current = st_results[-1]
+ result["current_direction"] = current["direction"]
+ result["current_supertrend"] = round(current["supertrend"], 6)
+ result["current_pct_distance"] = round(current["percentage_distance"] * 100, 3)
+ result["signal_now"] = current["percentage_distance"] < percentage_threshold
+
+ # Historical stats
+ long_count = sum(1 for r in st_results if r["direction"] == 1 and r["percentage_distance"] < percentage_threshold)
+ short_count = sum(1 for r in st_results if r["direction"] == -1 and r["percentage_distance"] < percentage_threshold)
+ result["signal_count_long"] = long_count
+ result["signal_count_short"] = short_count
+
+ # Trend changes
+ changes = sum(1 for i in range(1, len(st_results)) if st_results[i]["direction"] != st_results[i-1]["direction"])
+ result["trend_changes"] = changes
+
+ # % time in each trend
+ n = len(st_results)
+ long_time = sum(1 for r in st_results if r["direction"] == 1)
+ result["pct_time_long"] = round(long_time / n * 100, 1)
+ result["pct_time_short"] = round((n - long_time) / n * 100, 1)
+
+ # Suggested threshold
+ result["suggested_percentage_threshold"] = suggest_percentage_threshold(st_results)
+
+ # NATR
+ result["natr"] = calculate_natr(candles, natr_period)
+
+ return result
+
+def get_st_strategy_suggestions(natr: float, analysis: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+ """
+ Ritorna suggerimenti per SuperTrend V1 basati sulla volatilità.
+ I valori TP/SL e threshold vengono scalati in base alla volatilità (NATR).
+ """
+ if not natr or natr <= 0:
+ natr = 0.01 # Default 1% volatility
+
+ # Determina il regime di volatilità
+ if natr < 0.005: # <0.5%
+ vol_regime = "very_low"
+ vol_mult = 0.7
+ elif natr < 0.01: # 0.5-1%
+ vol_regime = "low"
+ vol_mult = 1.0
+ elif natr < 0.02: # 1-2%
+ vol_regime = "moderate"
+ vol_mult = 1.3
+ elif natr < 0.03: # 2-3%
+ vol_regime = "high"
+ vol_mult = 1.6
+ else: # >3%
+ vol_regime = "very_high"
+ vol_mult = 2.0
+
+ # Valori base
+ base_length = 20
+ base_multiplier = 4.0
+ base_threshold = 0.01
+ base_tp = 0.03
+ base_sl = 0.05
+ base_ts_activation = 0.015
+ base_ts_delta = 0.005
+
+ return {
+ "scalping": {
+ "label": "Target: Scalping (Reattivo)",
+ "length": 10, # ATR più corto → più reattivo
+ "multiplier": 3.0, # Bande più strette
+ "percentage_threshold": round(base_threshold * 0.8, 4), # Soglia più stretta
+ "take_profit": round(base_tp * vol_mult * 0.6, 4),
+ "stop_loss": round(base_sl * vol_mult * 0.7, 4),
+ "trailing_stop_activation": round(base_ts_activation * vol_mult * 0.8, 4),
+ "trailing_stop_delta": round(base_ts_delta * vol_mult * 0.8, 4),
+ "volatility_regime": vol_regime
+ },
+ "swing": {
+ "label": "Target: Swing (Filtro stretto)",
+ "length": 30, # ATR più lungo → più stabile
+ "multiplier": 5.0, # Bande più larghe
+ "percentage_threshold": round(base_threshold * 1.5, 4), # Soglia più larga
+ "take_profit": round(base_tp * vol_mult * 1.6, 4),
+ "stop_loss": round(base_sl * vol_mult * 1.3, 4),
+ "trailing_stop_activation": round(base_ts_activation * vol_mult * 1.2, 4),
+ "trailing_stop_delta": round(base_ts_delta * vol_mult * 1.2, 4),
+ "volatility_regime": vol_regime
+ },
+ "auto": {
+ "label": "Target: Auto (Analisi Live)",
+ "length": base_length,
+ "multiplier": base_multiplier,
+ "percentage_threshold": analysis.get("suggested_percentage_threshold", base_threshold),
+ "take_profit": round(base_tp * vol_mult, 4),
+ "stop_loss": round(base_sl * vol_mult, 4),
+ "trailing_stop_activation": round(base_ts_activation * vol_mult, 4),
+ "trailing_stop_delta": round(base_ts_delta * vol_mult, 4),
+ "volatility_regime": vol_regime
+ }
+ }
+
+def format_supertrend_analysis(analysis: Dict[str, Any]) -> str:
+ """Format analysis results for display in wizard final step."""
+ lines = []
+ n_candles = analysis.get("analysis_candles", 0)
+ direction = analysis.get("current_direction", 0)
+ st_val = analysis.get("current_supertrend")
+ pct_dist = analysis.get("current_pct_distance")
+ signal_now = analysis.get("signal_now", False)
+ natr = analysis.get("natr")
+ long_signals = analysis.get("signal_count_long", 0)
+ short_signals = analysis.get("signal_count_short", 0)
+ trend_changes = analysis.get("trend_changes", 0)
+ pct_long = analysis.get("pct_time_long", 0)
+ pct_short = analysis.get("pct_time_short", 0)
+ suggested_thr = analysis.get("suggested_percentage_threshold", 0.01)
+
+ dir_str = "📈 UP (Bullish)" if direction == 1 else ("📉 DOWN (Bearish)" if direction == -1 else "—")
+
+ lines.append(f"SuperTrend analysis ({n_candles} candles):")
+ lines.append(f" Direction now: {dir_str}")
+ if st_val is not None:
+ lines.append(f" ST line: {st_val:.6g}")
+ if pct_dist is not None:
+ lines.append(f" Distance: {pct_dist:.3f}% {'✅ signal active' if signal_now else '⚠️ no signal (too far)'}")
+ if natr:
+ lines.append(f" NATR(14): {natr*100:.3f}%")
+ lines.append(f" % time bullish: {pct_long:.1f}% | bearish: {pct_short:.1f}%")
+ lines.append(f" Trend changes: {trend_changes}")
+ lines.append(f" Signals (history): LONG={long_signals} SHORT={short_signals}")
+ lines.append("")
+ lines.append(f" → suggested percentage_threshold: {suggested_thr}")
+
+ return "\n".join(lines)
diff --git a/handlers/bots/controllers/supertrend_v1/chart.py b/handlers/bots/controllers/supertrend_v1/chart.py
new file mode 100644
index 00000000..a8b50116
--- /dev/null
+++ b/handlers/bots/controllers/supertrend_v1/chart.py
@@ -0,0 +1,519 @@
+"""
+SuperTrend V1 chart generation.
+
+4 panels:
+ 1. Price – candlesticks + SuperTrend line (green=UP / red=DOWN)
+ + MA20/MA50/EMA9
+ 2. Volume – colored bars
+ 3. ATR – raw ATR series (Wilder smoothing) showing volatility
+ 4. Distance – % distance between close and ST line,
+ with percentage_threshold shown as a dashed line.
+ Signal fires when distance < threshold.
+
+Signal logic:
+ LONG when direction == UP AND distance < percentage_threshold
+ SHORT when direction == DOWN AND distance < percentage_threshold
+"""
+
+import io
+import time
+import math
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+from matplotlib.patches import Rectangle
+
+
+# ── PUBLIC API ───────────────────────────────────────────────────────
+
+def generate_chart(config, candles_data, current_price=None, **kwargs):
+ if not candles_data or len(candles_data) < 5:
+ return _generate_simple_chart(candles_data, current_price)
+
+ timezone = config.get('timezone', 'Europe/Rome')
+ df = _prepare_dataframe(candles_data, timezone=timezone)
+
+ # ── MOSTRA SOLO ULTIME 96 CANDELE ─────────────────────
+ MAX_VISIBLE_CANDLES = 96
+
+ # mantieni dataset completo per ATR/ST
+ full_df = df.copy()
+
+ # df visualizzato
+ if len(df) > MAX_VISIBLE_CANDLES:
+ df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+
+ for col in ['open', 'high', 'low', 'close', 'volume']:
+ df[col] = pd.to_numeric(df.get(col, 0), errors='coerce').fillna(0)
+
+ length = int(config.get('length', 20))
+ multiplier = float(config.get('multiplier', 4.0))
+ pct_thr = float(config.get('percentage_threshold', 0.01))
+
+ # ── INDICATORI ───────────────────────────────────────────────────
+ full_df['ma20'] = full_df['close'].rolling(20).mean()
+ full_df['ma50'] = full_df['close'].rolling(50).mean()
+ full_df['ema9'] = full_df['close'].ewm(span=9).mean()
+
+ # ── CALCOLI SU DATASET COMPLETO ───────────────────────
+
+ full_df['atr'] = _calc_atr_rma(full_df, length)
+
+ full_df['st_line'], full_df['st_dir'] = _calc_supertrend(
+ full_df,
+ multiplier
+ )
+
+ full_df['st_dist'] = (
+ (full_df['close'] - full_df['st_line']).abs()
+ / full_df['close']
+ ).fillna(np.nan)
+
+ # ── PRENDI SOLO ULTIME 96 CANDELE VISUALI ────────────
+
+ df = full_df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+
+ # ── FIGURA ───────────────────────────────────────────────────────
+ fig, (ax1, ax2, ax3, ax4) = plt.subplots(
+ 4,
+ 1,
+ figsize=(22, 14),
+ sharex=True,
+ gridspec_kw={
+ 'height_ratios': [4.5, 1.2, 1.3, 1.5]
+ }
+ )
+ fig.patch.set_facecolor('#111111')
+ dates = mdates.date2num(df['datetime'])
+ if len(dates) > 1:
+ candle_width = (dates[1] - dates[0]) * 0.85
+ volume_width = (dates[1] - dates[0]) * 0.85
+ else:
+ candle_width = volume_width = 0.0005
+ for ax in [ax1, ax2, ax3, ax4]:
+
+ ax.set_facecolor('#111111')
+
+ ax.tick_params(colors='white')
+
+ ax.yaxis.label.set_color('white')
+
+ ax.spines['bottom'].set_color('#444')
+ ax.spines['top'].set_color('#444')
+ ax.spines['left'].set_color('#444')
+ ax.spines['right'].set_color('#444')
+
+ # ── PANNELLO 1: PREZZO + SUPERTREND ──────────────────────────────
+ for i in range(len(df)):
+ o, h, l, c = df.iloc[i][['open', 'high', 'low', 'close']]
+ color = '#2ecc71' if c >= o else '#e74c3c'
+ ax1.plot([dates[i], dates[i]], [l, h], color=color, linewidth=1.2)
+ ax1.add_patch(Rectangle(
+ (dates[i] - candle_width / 2, min(o, c)),
+ candle_width, abs(c - o) or 1e-8,
+ color=color
+ ))
+
+ ax1.plot(df['datetime'], df['ma20'], label='MA20', linewidth=1.2, color='#f39c12' )
+ ax1.plot(df['datetime'], df['ma50'], label='MA50', linewidth=1.2, color='#3498db')
+ ax1.plot(df['datetime'], df['ema9'], label='EMA9', linewidth=1.2, color='#9b59b6')
+
+ # SuperTrend: segmenti colorati per direzione
+ _plot_supertrend_colored(ax1, df)
+
+ if current_price:
+ ax1.axhline(y=current_price, linestyle='--', alpha=0.6, color='gold', label='Price')
+ handles, labels = ax1.get_legend_handles_labels()
+ legend_map = dict(zip(labels, handles))
+ desired_order = ['MA20', 'ST UP', 'MA50', 'ST DOWN', 'EMA9', 'Price']
+ ordered_handles = []
+ ordered_labels = []
+ for label in desired_order:
+ if label in legend_map:
+ ordered_handles.append(legend_map[label])
+ ordered_labels.append(label)
+ legend1 = ax1.legend(ordered_handles, ordered_labels, loc='upper left', fontsize=9, ncol=3, framealpha=0)
+ for text in legend1.get_texts():
+ text.set_color('white')
+ ax1.set_ylabel('Price')
+ ax1.grid(True, alpha=0.3)
+ ax1.set_xlim(df['datetime'].min(), df['datetime'].max())
+
+ # ── PANNELLO 2: VOLUME ───────────────────────────────────────────
+ vol_colors = [
+ '#2ecc71' if df['close'].iloc[i] >= df['open'].iloc[i] else '#e74c3c'
+ for i in range(len(df))
+ ]
+ ax2.bar(dates, df['volume'], width=volume_width, color=vol_colors, alpha=0.7)
+ ax2.set_ylabel('Volume')
+ ax2.grid(True, alpha=0.3)
+
+ # ── PANNELLO 3: ATR ──────────────────────────────────────────────
+ ax3.plot(df['datetime'], df['atr'], linewidth=1.5, color='steelblue', label=f'ATR({length})')
+ first_valid_atr = df['atr'].notna().to_numpy().argmax()
+
+ if first_valid_atr is not None and first_valid_atr > 0:
+
+ ax3.axvspan(
+ df['datetime'].iloc[0],
+ df['datetime'].iloc[first_valid_atr],
+ color='gray',
+ alpha=0.10,
+ label='ATR warmup'
+ )
+ ax3.set_ylabel(f'ATR({length})')
+ legend3 = ax3.legend(loc='upper left', fontsize=9,framealpha=0)
+ for text in legend3.get_texts():
+ text.set_color('white')
+ ax3.grid(True, alpha=0.3)
+
+ # ── PANNELLO 4: DISTANZA % ───────────────────────────────────────
+ # colora la linea: verde se direction UP, rosso se DOWN
+ dist_vals = df['st_dist'].values
+ dir_vals = df['st_dir'].values
+ dt_vals = df['datetime'].values
+
+ # Plotta segmenti per direzione
+ up_label_added = False
+ down_label_added = False
+
+ # Evidenzia area warmup indicatori
+ first_valid = df['st_dist'].first_valid_index()
+
+ if first_valid is not None and first_valid > 0:
+
+ ax4.axvspan(
+ df['datetime'].iloc[0],
+ df['datetime'].iloc[first_valid],
+ color='gray',
+ alpha=0.10,
+ label='Indicator warmup'
+ )
+ for i in range(1, len(df)):
+
+ if np.isnan(dist_vals[i]) or np.isnan(dist_vals[i - 1]):
+ continue
+
+ is_up = dir_vals[i] == 1
+
+ seg_color = '#2ecc71' if is_up else '#e74c3c'
+
+ label = None
+
+ if is_up and not up_label_added:
+ label = 'Distance UP'
+ up_label_added = True
+
+ elif not is_up and not down_label_added:
+ label = 'Distance DOWN'
+ down_label_added = True
+
+ ax4.plot(
+ [dt_vals[i - 1], dt_vals[i]],
+ [dist_vals[i - 1], dist_vals[i]],
+ color=seg_color,
+ linewidth=1.4,
+ label=label
+ )
+
+ # Soglia: zona verde sotto la linea = signal attivo
+ ax4.axhline(pct_thr, linestyle='--', color='#f1c40f', linewidth=1.8,
+ label=f'Threshold {pct_thr*100:.2f}%')
+ ax4.fill_between(df['datetime'], 0, pct_thr, alpha=0.2, color='green',
+ label='Signal zone')
+
+ ax4.set_ylabel('Distance %')
+ ax4.set_ylim(bottom=0)
+ ax4.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f'{v*100:.2f}%'))
+ legend4 = ax4.legend(loc='upper left', fontsize=9, ncol=2, framealpha=0)
+ for text in legend4.get_texts():
+ text.set_color('white')
+ ax4.grid(True, alpha=0.3)
+
+# ── FIX ASSE X BASATO SUL TIMEFRAME ───────────────────────────────
+
+ interval = config.get('interval', '5m')
+ # Mapping personalizzato timeframe -> tick principali
+ if interval == '1m':
+ # Tick ogni 15 minuti
+ locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+ formatter = mdates.DateFormatter('%H:%M')
+
+ # Tick minori ogni 5 minuti
+ minor_locator = mdates.MinuteLocator(interval=5)
+
+ elif interval == '5m':
+ # Tick ogni ora
+ locator = mdates.HourLocator(interval=1)
+ formatter = mdates.DateFormatter('%H:%M')
+
+ # Tick minori ogni 15 minuti
+ minor_locator = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
+
+ elif interval == '15m':
+
+ locator = mdates.HourLocator(interval=3)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=1)
+
+ elif interval == '1h':
+
+ locator = mdates.HourLocator(interval=12)
+ formatter = mdates.DateFormatter('%d %b - %H:%M')
+ minor_locator = mdates.HourLocator(interval=3)
+
+ elif interval == '8h':
+
+ locator = mdates.DayLocator(interval=4)
+ formatter = mdates.DateFormatter('%b%d')
+ minor_locator = mdates.DayLocator(interval=1)
+
+ else:
+ # fallback intelligente
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ formatter = mdates.ConciseDateFormatter(locator)
+ minor_locator = None
+
+ # Applica a TUTTI gli assi (non solo ax4)
+ ax1.tick_params(labelbottom=False)
+ ax2.tick_params(labelbottom=False)
+ ax3.tick_params(labelbottom=False)
+ for ax in [ax1, ax2, ax3, ax4]:
+ ax.grid(True, axis='x', which='major', linestyle='--', alpha=0.30, linewidth=0.8)
+ ax.grid(False, which='minor', axis='x')
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(formatter)
+
+ if minor_locator:
+ ax.xaxis.set_minor_locator(minor_locator)
+
+ plt.setp(
+ ax.xaxis.get_majorticklabels(),
+ rotation=0,
+ ha='center'
+ )
+ # Grid orizzontale
+ ax.grid(True, which='major', axis='y', alpha=0.30)
+ # Grid verticale tratteggiato
+ ax.grid(True, which='major', axis='x', linestyle='--', alpha=0.15)
+
+ # Minor grid molto leggera
+ ax.grid(True, which='minor', axis='y', alpha=0.05)
+ # ── TITOLO ───────────────────────────────────────────────────────
+ interval = config.get('interval', '5m')
+ fig.suptitle(
+ f"{config.get('trading_pair', 'Unknown')} - SuperTrend V1 "
+ f"(length={length}, mult={multiplier} | {interval})",
+ fontsize=13, color='white'
+ )
+
+ plt.subplots_adjust(hspace=0.05, top=0.94, bottom=0.06)
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png', dpi=120, bbox_inches='tight')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
+
+
+def generate_preview_chart(config, candles_data, current_price=None, **kwargs):
+ return generate_chart(config, candles_data, current_price)
+
+
+# ── HELPERS ──────────────────────────────────────────────────────────
+
+def _prepare_dataframe(candles, timezone=None):
+ if timezone is None:
+ # Prende il fuso orario del sistema
+ timezone = time.tzname[0]
+ df = pd.DataFrame(candles)
+
+ # Cerca colonna timestamp
+ ts_col = next((c for c in ['timestamp', 'time', 'ts', 'datetime'] if c in df.columns), None)
+
+ if ts_col:
+ # Converti timestamp
+ sample = df[ts_col].iloc[0]
+ if isinstance(sample, (int, float)):
+ # Determina se è millisecondi o secondi
+ if sample > 10**12: # nanosecondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ns', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ elif sample > 10**10: # millisecondi (dopo il 1970)
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='ms', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else: # secondi
+ df['datetime'] = (
+ pd.to_datetime(df[ts_col], unit='s', utc=True)
+ .dt.tz_convert(timezone)
+ .dt.tz_localize(None)
+ )
+ else:
+ df['datetime'] = (pd.to_datetime(df[ts_col], utc=True).dt.tz_convert(timezone).dt.tz_localize(None))
+ else:
+ # Fallback: crea date sequenziali usando l'intervallo dalla config
+ # NOTA: questo è un fallback, idealmente dovresti avere timestamp reali
+ freq = config.get('interval', '5m') if 'config' in locals() else '5m'
+ df['datetime'] = pd.date_range(
+ end=pd.Timestamp.now(),
+ periods=len(df),
+ freq=freq
+ )
+
+ return df.sort_values('datetime').reset_index(drop=True)
+
+
+def _calc_atr_rma(df: pd.DataFrame, period: int) -> pd.Series:
+ """ATR with Wilder's RMA smoothing, matching analysis.py logic."""
+ high = df['high']
+ low = df['low']
+ close = df['close']
+
+ prev_close = close.shift(1)
+ tr = pd.concat([
+ high - low,
+ (high - prev_close).abs(),
+ (low - prev_close).abs(),
+ ], axis=1).max(axis=1)
+
+ # RMA (Wilder): seed with simple mean, then smooth
+ atr = tr.copy().astype(float)
+ seed_end = period # first valid ATR at index `period`
+ if len(tr) < period + 1:
+ return pd.Series([np.nan] * len(df), index=df.index)
+
+ seed = tr.iloc[1:period + 1].mean() # skip row 0 (no prev_close)
+ atr.iloc[:period + 1] = np.nan
+ atr.iloc[period] = seed
+ alpha = 1.0 / period
+ for i in range(period + 1, len(atr)):
+ atr.iloc[i] = atr.iloc[i - 1] * (1 - alpha) + tr.iloc[i] * alpha
+
+ return atr
+
+
+def _calc_supertrend(df: pd.DataFrame, multiplier: float):
+ """
+ Compute SuperTrend line and direction series.
+ Returns (st_line: pd.Series, st_dir: pd.Series)
+ direction: 1 = UP (bullish), -1 = DOWN (bearish)
+ """
+ hl2 = (df['high'] + df['low']) / 2
+ atr = df['atr']
+
+ basic_upper = hl2 + multiplier * atr
+ basic_lower = hl2 - multiplier * atr
+
+ n = len(df)
+ upper = np.full(n, np.nan)
+ lower = np.full(n, np.nan)
+ st = np.full(n, np.nan)
+ direc = np.zeros(n, dtype=int)
+
+ close = df['close'].values
+ bu = basic_upper.values
+ bl = basic_lower.values
+
+ # find first valid index (where atr is not nan)
+ start = df['atr'].first_valid_index()
+ if start is None:
+ return pd.Series(st, index=df.index), pd.Series(direc, index=df.index)
+ si = df.index.get_loc(start)
+
+ upper[si] = bu[si]
+ lower[si] = bl[si]
+ direc[si] = 1
+
+ for i in range(si + 1, n):
+ # tighten upper band
+ upper[i] = bu[i] if bu[i] < upper[i - 1] or close[i - 1] > upper[i - 1] else upper[i - 1]
+ # widen lower band
+ lower[i] = bl[i] if bl[i] > lower[i - 1] or close[i - 1] < lower[i - 1] else lower[i - 1]
+
+ if direc[i - 1] == -1:
+ direc[i] = 1 if close[i] > upper[i] else -1
+ else:
+ direc[i] = -1 if close[i] < lower[i] else 1
+
+ # ST line: lower when UP, upper when DOWN
+ for i in range(si, n):
+ st[i] = lower[i] if direc[i] == 1 else upper[i]
+
+ # set pre-start to nan/0
+ upper[:si] = np.nan
+ lower[:si] = np.nan
+ st[:si] = np.nan
+ direc[:si] = 0
+
+ return (
+ pd.Series(st, index=df.index),
+ pd.Series(direc, index=df.index),
+ )
+
+
+def _plot_supertrend_colored(ax, df: pd.DataFrame):
+
+ dt = df['datetime'].values
+ st = df['st_line'].values
+ direc = df['st_dir'].values
+
+ up_added = False
+ down_added = False
+
+ for i in range(1, len(df)):
+
+ if np.isnan(st[i]) or np.isnan(st[i - 1]):
+ continue
+
+ is_up = direc[i] == 1
+
+ color = '#27ae60' if is_up else '#c0392b'
+
+ label = None
+
+ if is_up and not up_added:
+ label = 'ST UP'
+ up_added = True
+
+ elif not is_up and not down_added:
+ label = 'ST DOWN'
+ down_added = True
+
+ ax.plot(
+ [dt[i - 1], dt[i]],
+ [st[i - 1], st[i]],
+ color=color,
+ linewidth=2.4,
+ solid_capstyle='round',
+ label=label
+ )
+
+def _generate_simple_chart(candles_data, current_price):
+ if not candles_data:
+ return io.BytesIO()
+ df = _prepare_dataframe(candles_data, timezone=timezone)
+
+# ── LIMITA CANDELE VISUALIZZATE ────────────────────────────────────
+ MAX_VISIBLE_CANDLES = 96
+ if len(df) > MAX_VISIBLE_CANDLES:
+ df = df.tail(MAX_VISIBLE_CANDLES).reset_index(drop=True)
+
+ fig, ax = plt.subplots(figsize=(10, 6))
+ ax.plot(df['datetime'], pd.to_numeric(df.get('close', pd.Series(dtype=float)), errors='coerce'))
+ if current_price:
+ ax.axhline(y=current_price, linestyle='--')
+ locator = mdates.AutoDateLocator(minticks=6, maxticks=12)
+ ax.xaxis.set_major_locator(locator)
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
+ plt.tight_layout()
+ buf = io.BytesIO()
+ plt.savefig(buf, format='png')
+ plt.close(fig)
+ buf.seek(0)
+ return buf
diff --git a/handlers/bots/controllers/supertrend_v1/config.py b/handlers/bots/controllers/supertrend_v1/config.py
new file mode 100644
index 00000000..8355752d
--- /dev/null
+++ b/handlers/bots/controllers/supertrend_v1/config.py
@@ -0,0 +1,132 @@
+"""
+SuperTrend V1 controller configuration.
+
+Directional trading strategy using SuperTrend indicator:
+- LONG when SuperTrend direction == UP AND price is within percentage_threshold of the line
+- SHORT when SuperTrend direction == DOWN AND price is within percentage_threshold of the line
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "supertrend_v1",
+ "controller_type": "directional_trading",
+ "id": "",
+ # Base fields
+ "manual_kill_switch": None,
+ "candles_config": [],
+ # Connector
+ "connector_name": "",
+ "trading_pair": "",
+ "total_amount_quote": 1000,
+ "leverage": 1,
+ "position_mode": "HEDGE",
+ # DirectionalTradingControllerConfigBase fields
+ "max_executors_per_side": 1,
+ "cooldown_time": 60,
+ "stop_loss": 0.05,
+ "take_profit": 0.03,
+ "take_profit_order_type": 2,
+ "time_limit": None,
+ # Trailing stop
+ "trailing_stop": {
+ "activation_price": 0.015,
+ "trailing_delta": 0.005,
+ },
+ # Candles config
+ "candles_connector": "",
+ "candles_trading_pair": "",
+ "interval": "5m",
+ # SuperTrend parameters
+ "length": 20,
+ "multiplier": 4.0,
+ "percentage_threshold": 0.01,
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(name="id", label="Config ID", type="str", required=True, hint="Auto-generated"),
+ "connector_name": ControllerField(name="connector_name", label="Connector", type="str", required=True, hint="Exchange connector"),
+ "trading_pair": ControllerField(name="trading_pair", label="Trading Pair", type="str", required=True, hint="e.g. BTC-USDT"),
+ "leverage": ControllerField(name="leverage", label="Leverage", type="int", required=True, hint="e.g. 1, 5, 10", default=1),
+ "position_mode": ControllerField(name="position_mode", label="Position Mode", type="str", required=False, hint="HEDGE or ONEWAY", default="HEDGE"),
+ "total_amount_quote": ControllerField(name="total_amount_quote", label="Total Amount (Quote)", type="float", required=True, hint="e.g. 1000 USDT"),
+ "max_executors_per_side": ControllerField(name="max_executors_per_side", label="Max Executors/Side", type="int", required=False, hint="Max concurrent positions per side", default=1),
+ "cooldown_time": ControllerField(name="cooldown_time", label="Cooldown Time (s)", type="int", required=False, hint="Seconds between new executors", default=60),
+ "stop_loss": ControllerField(name="stop_loss", label="Stop Loss", type="float", required=False, hint="e.g. 0.05 = 5%", default=0.05),
+ "take_profit": ControllerField(name="take_profit", label="Take Profit", type="float", required=False, hint="e.g. 0.03 = 3%", default=0.03),
+ "take_profit_order_type": ControllerField(name="take_profit_order_type", label="TP Order Type", type="int", required=False, hint="1=Market, 2=Limit, 3=Limit Maker", default=2),
+ "time_limit": ControllerField(name="time_limit", label="Time Limit (s)", type="int", required=False, hint="Max executor lifetime (None = no limit)", default=None),
+ "candles_connector": ControllerField(name="candles_connector", label="Candles Connector", type="str", required=False, hint="Leave empty to use same as connector", default=""),
+ "candles_trading_pair": ControllerField(name="candles_trading_pair", label="Candles Pair", type="str", required=False, hint="Leave empty to use same as trading pair", default=""),
+ "interval": ControllerField(name="interval", label="Candle Interval", type="str", required=True, hint="e.g. 1m, 5m, 1h, 8h", default="5m"),
+ "length": ControllerField(name="length", label="SuperTrend Length", type="int", required=False, hint="ATR period (default: 20)", default=20),
+ "multiplier": ControllerField(name="multiplier", label="SuperTrend Multiplier", type="float", required=False, hint="ATR multiplier (default: 4.0)", default=4.0),
+ "percentage_threshold": ControllerField(name="percentage_threshold", label="% Threshold", type="float", required=False, hint="Max distance from ST line to signal (e.g. 0.01 = 1%)", default=0.01),
+ "manual_kill_switch": ControllerField(name="manual_kill_switch", label="Kill Switch", type="bool", required=False, hint="Manual kill switch", default=None),
+}
+
+FIELD_ORDER: List[str] = [
+ "id", "connector_name", "trading_pair", "leverage", "position_mode",
+ "total_amount_quote", "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type", "time_limit",
+ "candles_connector", "candles_trading_pair", "interval",
+ "length", "multiplier", "percentage_threshold",
+ "manual_kill_switch",
+]
+
+EDITABLE_FIELDS: List[str] = [
+ "connector_name", "trading_pair", "total_amount_quote", "leverage",
+ "max_executors_per_side", "cooldown_time",
+ "stop_loss", "take_profit", "take_profit_order_type",
+ "trailing_stop_activation", "trailing_stop_delta",
+ "candles_connector", "candles_trading_pair", "interval",
+ "length", "multiplier", "percentage_threshold",
+]
+
+def get_flat_fields(config: Dict[str, Any]) -> Dict[str, Any]:
+ """Estrae i campi in formato piatto per l'editing, gestendo trailing_stop."""
+ trailing = config.get("trailing_stop", {})
+ # Copia i campi esistenti (che sono già piatti)
+ flat = dict(config)
+ # Aggiungi i due campi virtuali
+ flat["trailing_stop_activation"] = trailing.get("activation_price", 0.015)
+ flat["trailing_stop_delta"] = trailing.get("trailing_delta", 0.005)
+ # Rimuovi il dizionario originale per non mostrarlo come campo separato
+ flat.pop("trailing_stop", None)
+ return flat
+
+
+def apply_flat_fields(config: Dict[str, Any], updates: Dict[str, Any]) -> None:
+ """Applica gli aggiornamenti, riconvertendo trailing_stop_activation/delta."""
+ for key, value in updates.items():
+ if key == "trailing_stop_activation":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["activation_price"] = value
+ elif key == "trailing_stop_delta":
+ if "trailing_stop" not in config:
+ config["trailing_stop"] = {}
+ config["trailing_stop"]["trailing_delta"] = value
+ else:
+ config[key] = value
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ required = ["connector_name", "trading_pair", "total_amount_quote"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ max_num = 0
+ for cfg in existing_configs:
+ parts = cfg.get("id", "").split("_", 1)
+ if parts and parts[0].isdigit():
+ max_num = max(max_num, int(parts[0]))
+ seq = str(max_num + 1).zfill(3)
+ connector = config.get("connector_name", "unknown").replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("trading_pair", "UNKNOWN").upper()
+ return f"{seq}_st_{connector}_{pair}"
diff --git a/handlers/bots/controllers/xemm_multiple_levels/__init__.py b/handlers/bots/controllers/xemm_multiple_levels/__init__.py
new file mode 100644
index 00000000..b0695ccd
--- /dev/null
+++ b/handlers/bots/controllers/xemm_multiple_levels/__init__.py
@@ -0,0 +1,42 @@
+"""XEMM Multiple Levels Controller Module - Cross-exchange market making."""
+
+import io
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import BaseController, ControllerField
+from .chart import generate_chart, generate_preview_chart
+from .config import DEFAULTS, EDITABLE_FIELDS, FIELD_ORDER, FIELDS, generate_id, validate_config
+
+
+class XEMMMultipleLevelsController(BaseController):
+ controller_type = "xemm_multiple_levels"
+ display_name = "XEMM Multi Levels"
+ description = "Cross-exchange market making at multiple profitability levels"
+
+ @classmethod
+ def get_defaults(cls) -> Dict[str, Any]:
+ return DEFAULTS.copy()
+
+ @classmethod
+ def get_fields(cls) -> Dict[str, ControllerField]:
+ return FIELDS
+
+ @classmethod
+ def get_field_order(cls) -> List[str]:
+ return FIELD_ORDER
+
+ @classmethod
+ def validate_config(cls, config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ return validate_config(config)
+
+ @classmethod
+ def generate_chart(cls, config: Dict[str, Any], candles_data: List[Dict[str, Any]], current_price: Optional[float] = None) -> io.BytesIO:
+ return generate_chart(config, candles_data, current_price)
+
+ @classmethod
+ def generate_id(cls, config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ return generate_id(config, existing_configs)
+
+
+__all__ = ["XEMMMultipleLevelsController", "DEFAULTS", "FIELDS", "FIELD_ORDER", "EDITABLE_FIELDS",
+ "validate_config", "generate_id", "generate_chart", "generate_preview_chart"]
diff --git a/handlers/bots/controllers/xemm_multiple_levels/chart.py b/handlers/bots/controllers/xemm_multiple_levels/chart.py
new file mode 100644
index 00000000..a1db249f
--- /dev/null
+++ b/handlers/bots/controllers/xemm_multiple_levels/chart.py
@@ -0,0 +1,41 @@
+"""XEMM Multiple Levels chart - simple candlestick of maker pair."""
+
+import io
+from typing import Any, Dict, List, Optional
+
+from handlers.dex.visualizations import generate_candlestick_chart
+
+
+def generate_chart(
+ config: Dict[str, Any],
+ candles_data: List[Dict[str, Any]],
+ current_price: Optional[float] = None,
+) -> io.BytesIO:
+ maker = config.get("maker_connector", "")
+ taker = config.get("taker_connector", "")
+ pair = config.get("maker_trading_pair", "Unknown")
+ title = f"XEMM: {maker} → {taker} | {pair}"
+ data = candles_data if isinstance(candles_data, list) else candles_data.get("data", [])
+
+ # Aggiungi linea del prezzo corrente se disponibile
+ hlines = []
+ if current_price:
+ hlines.append({
+ "price": current_price,
+ "color": "blue",
+ "width": 1,
+ "label": "Current"
+ })
+
+ return generate_candlestick_chart(
+ candles=data,
+ title=title,
+ current_price=current_price,
+ hlines=hlines,
+ hrects=[]
+ )
+
+
+def generate_preview_chart(config, candles_data, current_price=None):
+ """Alias for generate_chart for compatibility"""
+ return generate_chart(config, candles_data, current_price)
diff --git a/handlers/bots/controllers/xemm_multiple_levels/config.py b/handlers/bots/controllers/xemm_multiple_levels/config.py
new file mode 100644
index 00000000..dc8a4473
--- /dev/null
+++ b/handlers/bots/controllers/xemm_multiple_levels/config.py
@@ -0,0 +1,203 @@
+"""
+XEMM Multiple Levels controller configuration.
+
+Cross-exchange market making: places limit orders on a maker exchange
+(less liquid) and hedges them instantly on a taker exchange (more liquid),
+at multiple profitability target levels.
+
+buy_levels_targets_amount / sell_levels_targets_amount format:
+ "target_profit1,amount1-target_profit2,amount2-..."
+ e.g. "0.003,10-0.006,20-0.009,30"
+ - target_profit: target profitability for this level (e.g. 0.003 = 0.3%)
+ - amount: relative weight (proportional, not absolute USDT)
+ Actual order size = (level_weight / total_weight) * (total_amount_quote * 0.5)
+
+min_profitability / max_profitability:
+ Range around each target:
+ - actual min = target - min_profitability
+ - actual max = target + max_profitability
+
+Gas fees for DEX taker connectors are handled automatically by hummingbot.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+from .._base import ControllerField
+
+DEFAULTS: Dict[str, Any] = {
+ "controller_name": "xemm_multiple_levels",
+ "controller_type": "generic",
+ "id": "",
+ "total_amount_quote": 1000,
+ # Maker = less liquid CEX where limit orders are placed
+ "maker_connector": "mexc",
+ "maker_trading_pair": "PEPE-USDT",
+ # Taker = more liquid CEX/DEX where hedge orders are filled
+ "taker_connector": "binance",
+ "taker_trading_pair": "PEPE-USDT",
+ # Levels: "target_profit,weight-target_profit,weight-..."
+ "buy_levels_targets_amount": "0.003,10-0.006,20-0.009,30",
+ "sell_levels_targets_amount": "0.003,10-0.006,20-0.009,30",
+ # Profitability range around each target level
+ "min_profitability": 0.003,
+ "max_profitability": 0.01,
+ "max_executors_imbalance": 1,
+ # Base fields from ControllerConfigBase
+ "manual_kill_switch": None,
+ "candles_config": [],
+}
+
+FIELDS: Dict[str, ControllerField] = {
+ "id": ControllerField(
+ name="id", label="Config ID", type="str", required=True, hint="Auto-generated"
+ ),
+ "total_amount_quote": ControllerField(
+ name="total_amount_quote", label="Total Amount (Quote)", type="float",
+ required=True, hint="Total capital — 50% buy side, 50% sell side"
+ ),
+ "maker_connector": ControllerField(
+ name="maker_connector", label="Maker Exchange", type="str",
+ required=True, hint="Less liquid CEX where limit orders are placed (e.g. mexc)"
+ ),
+ "maker_trading_pair": ControllerField(
+ name="maker_trading_pair", label="Maker Pair", type="str",
+ required=True, hint="e.g. PEPE-USDT"
+ ),
+ "taker_connector": ControllerField(
+ name="taker_connector", label="Taker Exchange", type="str",
+ required=True, hint="More liquid CEX/DEX for hedging (e.g. binance)"
+ ),
+ "taker_trading_pair": ControllerField(
+ name="taker_trading_pair", label="Taker Pair", type="str",
+ required=True, hint="Usually same as maker pair"
+ ),
+ "buy_levels_targets_amount": ControllerField(
+ name="buy_levels_targets_amount", label="Buy Levels", type="str",
+ required=True,
+ hint="Format: profit,weight-profit,weight (e.g. 0.003,10-0.006,20-0.009,30)",
+ default="0.003,10-0.006,20-0.009,30"
+ ),
+ "sell_levels_targets_amount": ControllerField(
+ name="sell_levels_targets_amount", label="Sell Levels", type="str",
+ required=True,
+ hint="Format: profit,weight-profit,weight (e.g. 0.003,10-0.006,20-0.009,30)",
+ default="0.003,10-0.006,20-0.009,30"
+ ),
+ "min_profitability": ControllerField(
+ name="min_profitability", label="Min Profitability", type="float",
+ required=False,
+ hint="Subtracted from each target level (e.g. 0.003 = 0.3%)", default=0.003
+ ),
+ "max_profitability": ControllerField(
+ name="max_profitability", label="Max Profitability", type="float",
+ required=False,
+ hint="Added to each target level (e.g. 0.01 = 1%)", default=0.01
+ ),
+ "max_executors_imbalance": ControllerField(
+ name="max_executors_imbalance", label="Max Imbalance", type="int",
+ required=False, hint="Max buy/sell imbalance before pausing (default: 1)", default=1
+ ),
+ "manual_kill_switch": ControllerField(
+ name="manual_kill_switch", label="Kill Switch", type="bool",
+ required=False, hint="Manual kill switch", default=None
+ ),
+}
+
+FIELD_ORDER: List[str] = [
+ "id", "total_amount_quote",
+ "maker_connector", "maker_trading_pair",
+ "taker_connector", "taker_trading_pair",
+ "buy_levels_targets_amount", "sell_levels_targets_amount",
+ "min_profitability", "max_profitability",
+ "max_executors_imbalance", "manual_kill_switch",
+]
+
+EDITABLE_FIELDS: List[str] = [
+ "total_amount_quote",
+ "maker_connector", "maker_trading_pair",
+ "taker_connector", "taker_trading_pair",
+ "buy_levels_targets_amount", "sell_levels_targets_amount",
+ "min_profitability", "max_profitability",
+ "max_executors_imbalance",
+]
+
+
+def parse_levels(levels_str: str) -> List[List[float]]:
+ """
+ Parse levels string into list of [target_profit, weight] pairs.
+ e.g. "0.003,10-0.006,20" -> [[0.003, 10], [0.006, 20]]
+ """
+ try:
+ result = []
+ for part in str(levels_str).split("-"):
+ values = part.strip().split(",")
+ if len(values) == 2:
+ result.append([float(values[0]), float(values[1])])
+ return result
+ except Exception:
+ return [[0.003, 10], [0.006, 20], [0.009, 30]]
+
+
+def format_levels(levels: List[List[float]]) -> str:
+ """Convert levels list back to string format."""
+ return "-".join(f"{p},{a}" for p, a in levels)
+
+
+def suggest_levels_from_spread(
+ spread_pct: float, total_amount: float, num_levels: int = 3
+) -> str:
+ """
+ Suggest levels based on observed spread between maker and taker.
+ Each level targets a fraction of the spread.
+
+ Args:
+ spread_pct: Current spread as decimal (e.g. 0.005 = 0.5%)
+ total_amount: Total amount per side
+ num_levels: Number of levels to generate
+
+ Returns:
+ Levels string in format "profit,weight-profit,weight-..."
+ """
+ if spread_pct <= 0:
+ return "0.003,10-0.006,20-0.009,30"
+
+ # Generate levels at 30%, 60%, 90% of spread
+ multipliers = [0.3, 0.6, 0.9][:num_levels]
+ weights = [10, 20, 30][:num_levels]
+
+ levels = []
+ for mult, weight in zip(multipliers, weights):
+ target = round(spread_pct * mult, 4)
+ target = max(target, 0.001) # minimum 0.1%
+ levels.append(f"{target},{weight}")
+
+ return "-".join(levels)
+
+
+def validate_config(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
+ required = ["maker_connector", "maker_trading_pair",
+ "taker_connector", "taker_trading_pair", "total_amount_quote"]
+ for field in required:
+ if not config.get(field):
+ return False, f"Missing required field: {field}"
+ for field in ["buy_levels_targets_amount", "sell_levels_targets_amount"]:
+ val = config.get(field, "")
+ if val:
+ try:
+ [list(map(float, x.split(","))) for x in str(val).split("-")]
+ except Exception:
+ return False, f"Invalid format for {field}. Use: profit,weight-profit,weight"
+ return True, None
+
+
+def generate_id(config: Dict[str, Any], existing_configs: List[Dict[str, Any]]) -> str:
+ max_num = 0
+ for cfg in existing_configs:
+ parts = cfg.get("id", "").split("_", 1)
+ if parts and parts[0].isdigit():
+ max_num = max(max_num, int(parts[0]))
+ seq = str(max_num + 1).zfill(3)
+ maker = config.get("maker_connector", "maker").replace("_perpetual", "").replace("_spot", "")
+ taker = config.get("taker_connector", "taker").replace("_perpetual", "").replace("_spot", "")
+ pair = config.get("maker_trading_pair", "UNKNOWN").split("-")[0]
+ return f"{seq}_xemm_{maker}_{taker}_{pair}"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..29ebbe3a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "condor",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}