Upload 37 files
Browse files- .gitattributes +7 -0
- LICENSE +21 -0
- README.md +7 -0
- STRUCTURE.md +54 -0
- SUM3API/LICENSE +21 -0
- SUM3API/MQL5/Experts/ZmqPublisher.mq5 +450 -0
- SUM3API/MQL5/Include/Zmq/Zmq.mqh +144 -0
- SUM3API/MQL5/Libraries/libsodium.dll +3 -0
- SUM3API/MQL5/Libraries/libzmq.dll +3 -0
- SUM3API/Rustmt5-chart/Cargo.lock +0 -0
- SUM3API/Rustmt5-chart/Cargo.toml +15 -0
- SUM3API/Rustmt5-chart/output/History_XAUUSDc_H1_OHLC_ID0002_20260126_135823.csv +3 -0
- SUM3API/Rustmt5-chart/output/History_XAUUSDc_H1_TICKS_ID0001_20260126_140825.csv +0 -0
- SUM3API/Rustmt5-chart/output/Live_XAUUSDc_ID0001_20260126_135811.csv +14 -0
- SUM3API/Rustmt5-chart/src/main.rs +852 -0
- TECHSTACK.md +18 -0
- gmp-terminal/Cargo.lock +0 -0
- gmp-terminal/Cargo.toml +19 -0
- gmp-terminal/src/app.rs +340 -0
- gmp-terminal/src/gmp_engine.rs +406 -0
- gmp-terminal/src/main.rs +46 -0
- gmp-terminal/src/tab_time.rs +207 -0
- gmp-terminal/src/tab_trade.rs +68 -0
- gmp-terminal/src/timeframe.rs +150 -0
- gmp-terminal/src/trading_panel.rs +340 -0
- gmp-terminal/src/zmq_bridge.rs +207 -0
- mBA-GMP.v3/generate_profiles.py +620 -0
- mBA-GMP.v3/generate_profiles.py_output/cmp_profile.csv +11 -0
- mBA-GMP.v3/generate_profiles.py_output/datapoints.csv +11 -0
- mBA-GMP.v3/generate_profiles.py_output/fig_cmp_profile.png +0 -0
- mBA-GMP.v3/generate_profiles.py_output/fig_cmp_vs_gmp.png +3 -0
- mBA-GMP.v3/generate_profiles.py_output/fig_combined_3panel.png +3 -0
- mBA-GMP.v3/generate_profiles.py_output/fig_gmp_profile.png +0 -0
- mBA-GMP.v3/generate_profiles.py_output/fig_price_scatter.png +3 -0
- mBA-GMP.v3/generate_profiles.py_output/fig_updown_footprint.png +3 -0
- mBA-GMP.v3/generate_profiles.py_output/gmp_profile.csv +11 -0
- mBA-GMP.v3/generate_profiles.py_output/raw_draft_of_concept.png +3 -0
- mBA-GMP.v3/generate_profiles.py_output/updown_profile.csv +11 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
mBA-GMP.v3/generate_profiles.py_output/fig_cmp_vs_gmp.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
mBA-GMP.v3/generate_profiles.py_output/fig_combined_3panel.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
mBA-GMP.v3/generate_profiles.py_output/fig_price_scatter.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
mBA-GMP.v3/generate_profiles.py_output/fig_updown_footprint.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
mBA-GMP.v3/generate_profiles.py_output/raw_draft_of_concept.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
SUM3API/MQL5/Libraries/libsodium.dll filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
SUM3API/MQL5/Libraries/libzmq.dll filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Rembrant Oyangoren Albeos
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Simply go to [SUM3API](https://github.com/Continual-Quasars/SUM3API) if you want basics (Complete End-to-End System Architecture, MQL5-ZMQ Wrapper library, and RUST-ZMQ Wrapper library), and [guide](https://github.com/Continual-Quasars/SUM3API/blob/main/A%20GUIDE%20to%20open%20SUM3API%20software%20(trading%20terminal).pdf) how to lunch the app.<br>
|
| 3 |
+
|
| 4 |
+
You may also read the research paper [here](https://ssrn.com/abstract=6143486)<br><br>
|
| 5 |
+
|
| 6 |
+
https://github.com/user-attachments/assets/e227421a-b43e-49c1-85f0-4f9352189788
|
| 7 |
+
|
STRUCTURE.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Project Structure
|
| 2 |
+
|
| 3 |
+
```text
|
| 4 |
+
SUM3-Trading-Terminal/
|
| 5 |
+
βββ gmp-terminal/
|
| 6 |
+
β βββ src/
|
| 7 |
+
β β βββ app.rs
|
| 8 |
+
β β βββ gmp_engine.rs
|
| 9 |
+
β β βββ main.rs
|
| 10 |
+
β β βββ tab_time.rs
|
| 11 |
+
β β βββ tab_trade.rs
|
| 12 |
+
β β βββ timeframe.rs
|
| 13 |
+
β β βββ trading_panel.rs
|
| 14 |
+
β β βββ zmq_bridge.rs
|
| 15 |
+
β βββ Cargo.lock
|
| 16 |
+
β βββ Cargo.toml
|
| 17 |
+
βββ mBA-GMP.v3/
|
| 18 |
+
β βββ generate_profiles.py_output/
|
| 19 |
+
β β βββ cmp_profile.csv
|
| 20 |
+
β β βββ datapoints.csv
|
| 21 |
+
β β βββ fig_cmp_profile.png
|
| 22 |
+
β β βββ fig_cmp_vs_gmp.png
|
| 23 |
+
β β βββ fig_combined_3panel.png
|
| 24 |
+
β β βββ fig_gmp_profile.png
|
| 25 |
+
β β βββ fig_price_scatter.png
|
| 26 |
+
β β βββ fig_updown_footprint.png
|
| 27 |
+
β β βββ gmp_profile.csv
|
| 28 |
+
β β βββ raw_draft_of_concept.png
|
| 29 |
+
β β βββ updown_profile.csv
|
| 30 |
+
β βββ generate_profiles.py
|
| 31 |
+
βββ SUM3API/
|
| 32 |
+
β βββ MQL5/
|
| 33 |
+
β β βββ Experts/
|
| 34 |
+
β β β βββ ZmqPublisher.mq5
|
| 35 |
+
β β βββ Include/
|
| 36 |
+
β β β βββ Zmq/
|
| 37 |
+
β β β βββ Zmq.mqh
|
| 38 |
+
β β βββ Libraries/
|
| 39 |
+
β β βββ libsodium.dll
|
| 40 |
+
β β βββ libzmq.dll
|
| 41 |
+
β βββ Rustmt5-chart/
|
| 42 |
+
β β βββ output/
|
| 43 |
+
β β β βββ History_XAUUSDc_H1_OHLC_ID0002_20260126_135823.csv
|
| 44 |
+
β β β βββ History_XAUUSDc_H1_TICKS_ID0001_20260126_140825.csv
|
| 45 |
+
β β β βββ Live_XAUUSDc_ID0001_20260126_135811.csv
|
| 46 |
+
β β βββ src/
|
| 47 |
+
β β β βββ main.rs
|
| 48 |
+
β β βββ Cargo.lock
|
| 49 |
+
β β βββ Cargo.toml
|
| 50 |
+
β βββ LICENSE
|
| 51 |
+
βββ LICENSE
|
| 52 |
+
βββ README.md
|
| 53 |
+
βββ TECHSTACK.md
|
| 54 |
+
```
|
SUM3API/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Algorembrant
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
SUM3API/MQL5/Experts/ZmqPublisher.mq5
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//+------------------------------------------------------------------+
|
| 2 |
+
//| ZmqPublisher.mq5 |
|
| 3 |
+
//| Copyright 2024, Antigravity |
|
| 4 |
+
//| |
|
| 5 |
+
//+------------------------------------------------------------------+
|
| 6 |
+
#property copyright "Antigravity"
|
| 7 |
+
#property link ""
|
| 8 |
+
#property version "2.00"
|
| 9 |
+
|
| 10 |
+
// Include our ZMQ wrapper
|
| 11 |
+
#include <Zmq/Zmq.mqh>
|
| 12 |
+
|
| 13 |
+
// Include trading functions
|
| 14 |
+
#include <Trade/Trade.mqh>
|
| 15 |
+
|
| 16 |
+
// Input parameters
|
| 17 |
+
input string InpPubAddress = "tcp://0.0.0.0:5555"; // Tick Publisher Address
|
| 18 |
+
input string InpRepAddress = "tcp://0.0.0.0:5556"; // Order Handler Address
|
| 19 |
+
input double InpDefaultSlippage = 10; // Default Slippage (points)
|
| 20 |
+
|
| 21 |
+
CZmq *g_publisher; // PUB socket for tick data
|
| 22 |
+
CZmq *g_responder; // REP socket for order handling
|
| 23 |
+
CTrade g_trade; // Trading helper
|
| 24 |
+
|
| 25 |
+
//+------------------------------------------------------------------+
|
| 26 |
+
//| Expert initialization function |
|
| 27 |
+
//+------------------------------------------------------------------+
|
| 28 |
+
int OnInit()
|
| 29 |
+
{
|
| 30 |
+
Print("Initializing ZmqPublisher v2.0 with Order Support...");
|
| 31 |
+
|
| 32 |
+
// Initialize tick publisher (PUB socket)
|
| 33 |
+
g_publisher = new CZmq();
|
| 34 |
+
if(!g_publisher.Init(ZMQ_PUB)) {
|
| 35 |
+
Print("Failed to initialize ZMQ Publisher");
|
| 36 |
+
return(INIT_FAILED);
|
| 37 |
+
}
|
| 38 |
+
if(!g_publisher.Bind(InpPubAddress)) {
|
| 39 |
+
Print("Failed to bind publisher to ", InpPubAddress);
|
| 40 |
+
return(INIT_FAILED);
|
| 41 |
+
}
|
| 42 |
+
Print("Tick Publisher bound to ", InpPubAddress);
|
| 43 |
+
|
| 44 |
+
// Initialize order responder (REP socket)
|
| 45 |
+
g_responder = new CZmq();
|
| 46 |
+
if(!g_responder.Init(ZMQ_REP)) {
|
| 47 |
+
Print("Failed to initialize ZMQ Responder");
|
| 48 |
+
return(INIT_FAILED);
|
| 49 |
+
}
|
| 50 |
+
if(!g_responder.Bind(InpRepAddress)) {
|
| 51 |
+
Print("Failed to bind responder to ", InpRepAddress);
|
| 52 |
+
return(INIT_FAILED);
|
| 53 |
+
}
|
| 54 |
+
Print("Order Responder bound to ", InpRepAddress);
|
| 55 |
+
|
| 56 |
+
// Configure trade settings
|
| 57 |
+
g_trade.SetDeviationInPoints((ulong)InpDefaultSlippage);
|
| 58 |
+
g_trade.SetTypeFilling(ORDER_FILLING_IOC);
|
| 59 |
+
|
| 60 |
+
return(INIT_SUCCEEDED);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
//+------------------------------------------------------------------+
|
| 64 |
+
//| Expert deinitialization function |
|
| 65 |
+
//+------------------------------------------------------------------+
|
| 66 |
+
void OnDeinit(const int reason)
|
| 67 |
+
{
|
| 68 |
+
Print("Deinitializing ZmqPublisher...");
|
| 69 |
+
if(g_publisher != NULL) {
|
| 70 |
+
g_publisher.Shutdown();
|
| 71 |
+
delete g_publisher;
|
| 72 |
+
g_publisher = NULL;
|
| 73 |
+
}
|
| 74 |
+
if(g_responder != NULL) {
|
| 75 |
+
g_responder.Shutdown();
|
| 76 |
+
delete g_responder;
|
| 77 |
+
g_responder = NULL;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
//+------------------------------------------------------------------+
|
| 82 |
+
//| Process incoming order request |
|
| 83 |
+
//+------------------------------------------------------------------+
|
| 84 |
+
//+------------------------------------------------------------------+
|
| 85 |
+
//| Process incoming order request |
|
| 86 |
+
//+------------------------------------------------------------------+
|
| 87 |
+
string ProcessOrderRequest(string request)
|
| 88 |
+
{
|
| 89 |
+
// Expected JSON format:
|
| 90 |
+
// {"type":"market_buy"|"close_position"|"cancel_order"|...,
|
| 91 |
+
// "symbol":"XAUUSDc", "volume":0.01, "price":2000.0, "ticket":12345}
|
| 92 |
+
|
| 93 |
+
string orderType = ExtractJsonString(request, "type");
|
| 94 |
+
string symbol = ExtractJsonString(request, "symbol");
|
| 95 |
+
double volume = ExtractJsonDouble(request, "volume");
|
| 96 |
+
double price = ExtractJsonDouble(request, "price");
|
| 97 |
+
ulong ticket = (ulong)ExtractJsonDouble(request, "ticket"); // Simple extraction
|
| 98 |
+
|
| 99 |
+
if(symbol == "") symbol = _Symbol;
|
| 100 |
+
if(volume <= 0) volume = 0.01;
|
| 101 |
+
|
| 102 |
+
Print("Order request: type=", orderType, " symbol=", symbol, " vol=", volume, " price=", price, " ticket=", ticket);
|
| 103 |
+
|
| 104 |
+
bool success = false;
|
| 105 |
+
ulong resultTicket = 0;
|
| 106 |
+
string errorMsg = "";
|
| 107 |
+
|
| 108 |
+
// Execute order based on type
|
| 109 |
+
if(orderType == "market_buy") {
|
| 110 |
+
double askPrice = SymbolInfoDouble(symbol, SYMBOL_ASK);
|
| 111 |
+
success = g_trade.Buy(volume, symbol, askPrice, 0, 0, "Rust GUI Order");
|
| 112 |
+
if(success) resultTicket = g_trade.ResultOrder();
|
| 113 |
+
else errorMsg = GetLastErrorDescription();
|
| 114 |
+
}
|
| 115 |
+
else if(orderType == "market_sell") {
|
| 116 |
+
double bidPrice = SymbolInfoDouble(symbol, SYMBOL_BID);
|
| 117 |
+
success = g_trade.Sell(volume, symbol, bidPrice, 0, 0, "Rust GUI Order");
|
| 118 |
+
if(success) resultTicket = g_trade.ResultOrder();
|
| 119 |
+
else errorMsg = GetLastErrorDescription();
|
| 120 |
+
}
|
| 121 |
+
else if(orderType == "limit_buy") {
|
| 122 |
+
success = g_trade.BuyLimit(volume, price, symbol, 0, 0, ORDER_TIME_GTC, 0, "Rust GUI Limit");
|
| 123 |
+
if(success) resultTicket = g_trade.ResultOrder();
|
| 124 |
+
else errorMsg = GetLastErrorDescription();
|
| 125 |
+
}
|
| 126 |
+
else if(orderType == "limit_sell") {
|
| 127 |
+
success = g_trade.SellLimit(volume, price, symbol, 0, 0, ORDER_TIME_GTC, 0, "Rust GUI Limit");
|
| 128 |
+
if(success) resultTicket = g_trade.ResultOrder();
|
| 129 |
+
else errorMsg = GetLastErrorDescription();
|
| 130 |
+
}
|
| 131 |
+
else if(orderType == "stop_buy") {
|
| 132 |
+
success = g_trade.BuyStop(volume, price, symbol, 0, 0, ORDER_TIME_GTC, 0, "Rust GUI Stop");
|
| 133 |
+
if(success) resultTicket = g_trade.ResultOrder();
|
| 134 |
+
else errorMsg = GetLastErrorDescription();
|
| 135 |
+
}
|
| 136 |
+
else if(orderType == "stop_sell") {
|
| 137 |
+
success = g_trade.SellStop(volume, price, symbol, 0, 0, ORDER_TIME_GTC, 0, "Rust GUI Stop");
|
| 138 |
+
if(success) resultTicket = g_trade.ResultOrder();
|
| 139 |
+
else errorMsg = GetLastErrorDescription();
|
| 140 |
+
}
|
| 141 |
+
else if(orderType == "close_position") {
|
| 142 |
+
if(ticket > 0) {
|
| 143 |
+
success = g_trade.PositionClose(ticket);
|
| 144 |
+
if(success) errorMsg = "Position closed";
|
| 145 |
+
else errorMsg = GetLastErrorDescription();
|
| 146 |
+
} else {
|
| 147 |
+
errorMsg = "Invalid ticket for close_position";
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
else if(orderType == "cancel_order") {
|
| 151 |
+
if(ticket > 0) {
|
| 152 |
+
success = g_trade.OrderDelete(ticket);
|
| 153 |
+
if(success) errorMsg = "Order deleted";
|
| 154 |
+
else errorMsg = GetLastErrorDescription();
|
| 155 |
+
} else {
|
| 156 |
+
errorMsg = "Invalid ticket for cancel_order";
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
else if(orderType == "download_history") {
|
| 160 |
+
// Format: {type: "download_history", symbol: "XAUUSD", timeframe: "M1", start: "2024.01.01", end: "2024.01.02", mode: "OHLC"|"TICKS"}
|
| 161 |
+
string tfStr = ExtractJsonString(request, "timeframe");
|
| 162 |
+
string startStr = ExtractJsonString(request, "start");
|
| 163 |
+
string endStr = ExtractJsonString(request, "end");
|
| 164 |
+
string mode = ExtractJsonString(request, "mode");
|
| 165 |
+
|
| 166 |
+
if(mode == "") mode = "OHLC";
|
| 167 |
+
|
| 168 |
+
success = DownloadHistory(symbol, tfStr, startStr, endStr, mode, errorMsg);
|
| 169 |
+
}
|
| 170 |
+
else {
|
| 171 |
+
errorMsg = "Unknown order type: " + orderType;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// Build response JSON
|
| 175 |
+
string response;
|
| 176 |
+
if(success) {
|
| 177 |
+
if(orderType == "download_history") {
|
| 178 |
+
// ensure errorMsg contains the filename if success
|
| 179 |
+
StringConcatenate(response, "{\"success\":true,\"message\":\"", errorMsg, "\"}");
|
| 180 |
+
} else {
|
| 181 |
+
StringConcatenate(response, "{\"success\":true,\"ticket\":", IntegerToString(resultTicket), "}");
|
| 182 |
+
}
|
| 183 |
+
} else {
|
| 184 |
+
StringConcatenate(response, "{\"success\":false,\"error\":\"", errorMsg, "\"}");
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return response;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
//+------------------------------------------------------------------+
|
| 191 |
+
//| Download History - Returns CSV content via ZMQ |
|
| 192 |
+
//+------------------------------------------------------------------+
|
| 193 |
+
bool DownloadHistory(string symbol, string tfStr, string startStr, string endStr, string mode, string &resultMsg)
|
| 194 |
+
{
|
| 195 |
+
datetime start = StringToTime(startStr);
|
| 196 |
+
datetime end = StringToTime(endStr);
|
| 197 |
+
if(start == 0) start = D'2024.01.01 00:00'; // Default fallback
|
| 198 |
+
if(end == 0) end = TimeCurrent();
|
| 199 |
+
|
| 200 |
+
ENUM_TIMEFRAMES tf = PERIOD_M1;
|
| 201 |
+
if(tfStr == "M5") tf = PERIOD_M5;
|
| 202 |
+
else if(tfStr == "M15") tf = PERIOD_M15;
|
| 203 |
+
else if(tfStr == "H1") tf = PERIOD_H1;
|
| 204 |
+
else if(tfStr == "H4") tf = PERIOD_H4;
|
| 205 |
+
else if(tfStr == "D1") tf = PERIOD_D1;
|
| 206 |
+
|
| 207 |
+
string csvContent = "";
|
| 208 |
+
int count = 0;
|
| 209 |
+
|
| 210 |
+
// Use |NL| as line separator (JSON-safe, Rust will convert to real newlines)
|
| 211 |
+
string NL = "|NL|";
|
| 212 |
+
|
| 213 |
+
if(mode == "TICKS") {
|
| 214 |
+
MqlTick ticks[];
|
| 215 |
+
int received = CopyTicksRange(symbol, ticks, COPY_TICKS_ALL, start * 1000, end * 1000);
|
| 216 |
+
|
| 217 |
+
if(received > 0) {
|
| 218 |
+
csvContent = "Time,Bid,Ask,Volume" + NL;
|
| 219 |
+
for(int i=0; i<received && i<50000; i++) { // Limit to 50k rows
|
| 220 |
+
csvContent += TimeToString(ticks[i].time, TIME_DATE|TIME_SECONDS) + "," +
|
| 221 |
+
DoubleToString(ticks[i].bid, _Digits) + "," +
|
| 222 |
+
DoubleToString(ticks[i].ask, _Digits) + "," +
|
| 223 |
+
IntegerToString(ticks[i].volume) + NL;
|
| 224 |
+
}
|
| 225 |
+
count = MathMin(received, 50000);
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
else {
|
| 229 |
+
// OHLC
|
| 230 |
+
MqlRates rates[];
|
| 231 |
+
ArraySetAsSeries(rates, false);
|
| 232 |
+
int received = CopyRates(symbol, tf, start, end, rates);
|
| 233 |
+
|
| 234 |
+
if(received > 0) {
|
| 235 |
+
csvContent = "Time,Open,High,Low,Close,TickVol,Spread" + NL;
|
| 236 |
+
for(int i=0; i<received && i<100000; i++) { // Limit to 100k rows
|
| 237 |
+
csvContent += TimeToString(rates[i].time, TIME_DATE|TIME_MINUTES) + "," +
|
| 238 |
+
DoubleToString(rates[i].open, _Digits) + "," +
|
| 239 |
+
DoubleToString(rates[i].high, _Digits) + "," +
|
| 240 |
+
DoubleToString(rates[i].low, _Digits) + "," +
|
| 241 |
+
DoubleToString(rates[i].close, _Digits) + "," +
|
| 242 |
+
IntegerToString(rates[i].tick_volume) + "," +
|
| 243 |
+
IntegerToString(rates[i].spread) + NL;
|
| 244 |
+
}
|
| 245 |
+
count = MathMin(received, 100000);
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
if(count > 0) {
|
| 250 |
+
// Return CSV content in a special format that Rust can parse
|
| 251 |
+
// We use ||CSV_DATA|| as delimiter to separate count info from actual data
|
| 252 |
+
resultMsg = IntegerToString(count) + " records||CSV_DATA||" + csvContent;
|
| 253 |
+
return true;
|
| 254 |
+
} else {
|
| 255 |
+
resultMsg = "No data found for period";
|
| 256 |
+
return false;
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
//+------------------------------------------------------------------+
|
| 261 |
+
//| Extract string value from JSON |
|
| 262 |
+
//+------------------------------------------------------------------+
|
| 263 |
+
string ExtractJsonString(string json, string key)
|
| 264 |
+
{
|
| 265 |
+
string searchKey = "\"" + key + "\":\"";
|
| 266 |
+
int startPos = StringFind(json, searchKey);
|
| 267 |
+
if(startPos < 0) return "";
|
| 268 |
+
|
| 269 |
+
startPos += StringLen(searchKey);
|
| 270 |
+
int endPos = StringFind(json, "\"", startPos);
|
| 271 |
+
if(endPos < 0) return "";
|
| 272 |
+
|
| 273 |
+
return StringSubstr(json, startPos, endPos - startPos);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
//+------------------------------------------------------------------+
|
| 277 |
+
//| Extract double value from JSON |
|
| 278 |
+
//+------------------------------------------------------------------+
|
| 279 |
+
double ExtractJsonDouble(string json, string key)
|
| 280 |
+
{
|
| 281 |
+
string searchKey = "\"" + key + "\":";
|
| 282 |
+
int startPos = StringFind(json, searchKey);
|
| 283 |
+
if(startPos < 0) return 0.0;
|
| 284 |
+
|
| 285 |
+
startPos += StringLen(searchKey);
|
| 286 |
+
|
| 287 |
+
// Find end of number (comma, }, or end of string)
|
| 288 |
+
int endPos = startPos;
|
| 289 |
+
int len = StringLen(json);
|
| 290 |
+
while(endPos < len) {
|
| 291 |
+
ushort ch = StringGetCharacter(json, endPos);
|
| 292 |
+
if(ch == ',' || ch == '}' || ch == ' ') break;
|
| 293 |
+
endPos++;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
string valueStr = StringSubstr(json, startPos, endPos - startPos);
|
| 297 |
+
return StringToDouble(valueStr);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
//+------------------------------------------------------------------+
|
| 301 |
+
//| Get human-readable error description |
|
| 302 |
+
//+------------------------------------------------------------------+
|
| 303 |
+
string GetLastErrorDescription()
|
| 304 |
+
{
|
| 305 |
+
int err = GetLastError();
|
| 306 |
+
return "Error " + IntegerToString(err) + ": " + ErrorDescription(err);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
//+------------------------------------------------------------------+
|
| 310 |
+
//| Error description helper |
|
| 311 |
+
//+------------------------------------------------------------------+
|
| 312 |
+
string ErrorDescription(int error)
|
| 313 |
+
{
|
| 314 |
+
switch(error) {
|
| 315 |
+
case 0: return "No error";
|
| 316 |
+
case 10004: return "Requote";
|
| 317 |
+
case 10006: return "Request rejected";
|
| 318 |
+
case 10007: return "Request canceled by trader";
|
| 319 |
+
case 10010: return "Request rejected - only part of the request was fulfilled";
|
| 320 |
+
case 10011: return "Request error";
|
| 321 |
+
case 10012: return "Request canceled due to timeout";
|
| 322 |
+
case 10013: return "Invalid request";
|
| 323 |
+
case 10014: return "Invalid volume";
|
| 324 |
+
case 10015: return "Invalid price";
|
| 325 |
+
case 10016: return "Invalid stops";
|
| 326 |
+
case 10017: return "Trade disabled";
|
| 327 |
+
case 10018: return "Market is closed";
|
| 328 |
+
case 10019: return "Not enough money";
|
| 329 |
+
case 10020: return "Prices changed";
|
| 330 |
+
case 10021: return "No quotes to process request";
|
| 331 |
+
case 10022: return "Invalid order expiration date";
|
| 332 |
+
case 10023: return "Order state changed";
|
| 333 |
+
case 10024: return "Too many requests";
|
| 334 |
+
case 10025: return "No changes in request";
|
| 335 |
+
case 10026: return "Autotrading disabled by server";
|
| 336 |
+
case 10027: return "Autotrading disabled by client terminal";
|
| 337 |
+
case 10028: return "Request locked for processing";
|
| 338 |
+
case 10029: return "Long positions only allowed";
|
| 339 |
+
case 10030: return "Maximum position volume exceeded";
|
| 340 |
+
default: return "Unknown error";
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
//+------------------------------------------------------------------+
|
| 345 |
+
//| Expert tick function |
|
| 346 |
+
//+------------------------------------------------------------------+
|
| 347 |
+
void OnTick()
|
| 348 |
+
{
|
| 349 |
+
// Handle order requests (non-blocking)
|
| 350 |
+
if(g_responder != NULL) {
|
| 351 |
+
string request = g_responder.Receive(true);
|
| 352 |
+
if(request != "") {
|
| 353 |
+
Print("Received order request: ", request);
|
| 354 |
+
string response = ProcessOrderRequest(request);
|
| 355 |
+
g_responder.Send(response, false); // Blocking send for REP pattern
|
| 356 |
+
Print("Sent response: ", response);
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// Publish tick data with account info
|
| 361 |
+
if(g_publisher == NULL) return;
|
| 362 |
+
|
| 363 |
+
MqlTick tick;
|
| 364 |
+
if(SymbolInfoTick(_Symbol, tick)) {
|
| 365 |
+
// Get account info
|
| 366 |
+
double balance = AccountInfoDouble(ACCOUNT_BALANCE);
|
| 367 |
+
double equity = AccountInfoDouble(ACCOUNT_EQUITY);
|
| 368 |
+
double margin = AccountInfoDouble(ACCOUNT_MARGIN);
|
| 369 |
+
double freeMargin = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
|
| 370 |
+
|
| 371 |
+
// Get symbol trading constraints
|
| 372 |
+
double minLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
|
| 373 |
+
double maxLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
|
| 374 |
+
double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
|
| 375 |
+
|
| 376 |
+
// Get Active Positions (Only for current symbol to simplify)
|
| 377 |
+
string positionsJson = "[";
|
| 378 |
+
int posCount = PositionsTotal();
|
| 379 |
+
bool firstPos = true;
|
| 380 |
+
for(int i = 0; i < posCount; i++) {
|
| 381 |
+
ulong ticket = PositionGetTicket(i);
|
| 382 |
+
if(PositionSelectByTicket(ticket)) {
|
| 383 |
+
if(PositionGetString(POSITION_SYMBOL) == _Symbol) {
|
| 384 |
+
if(!firstPos) StringAdd(positionsJson, ",");
|
| 385 |
+
|
| 386 |
+
string posType = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? "BUY" : "SELL";
|
| 387 |
+
StringAdd(positionsJson, "{\"ticket\":" + IntegerToString(ticket) +
|
| 388 |
+
",\"type\":\"" + posType + "\"" +
|
| 389 |
+
",\"volume\":" + DoubleToString(PositionGetDouble(POSITION_VOLUME), 2) +
|
| 390 |
+
",\"price\":" + DoubleToString(PositionGetDouble(POSITION_PRICE_OPEN), _Digits) +
|
| 391 |
+
",\"profit\":" + DoubleToString(PositionGetDouble(POSITION_PROFIT), 2) +
|
| 392 |
+
"}");
|
| 393 |
+
firstPos = false;
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
StringAdd(positionsJson, "]");
|
| 398 |
+
|
| 399 |
+
// Get Pending Orders (Only for current symbol)
|
| 400 |
+
string ordersJson = "[";
|
| 401 |
+
int orderCount = OrdersTotal();
|
| 402 |
+
bool firstOrder = true;
|
| 403 |
+
for(int i = 0; i < orderCount; i++) {
|
| 404 |
+
ulong ticket = OrderGetTicket(i);
|
| 405 |
+
if(OrderSelect(ticket)) {
|
| 406 |
+
if(OrderGetString(ORDER_SYMBOL) == _Symbol) {
|
| 407 |
+
if(!firstOrder) StringAdd(ordersJson, ",");
|
| 408 |
+
|
| 409 |
+
ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE);
|
| 410 |
+
string orderTypeStr = "UNKNOWN";
|
| 411 |
+
if(type == ORDER_TYPE_BUY_LIMIT) orderTypeStr = "BUY LIMIT";
|
| 412 |
+
else if(type == ORDER_TYPE_SELL_LIMIT) orderTypeStr = "SELL LIMIT";
|
| 413 |
+
else if(type == ORDER_TYPE_BUY_STOP) orderTypeStr = "BUY STOP";
|
| 414 |
+
else if(type == ORDER_TYPE_SELL_STOP) orderTypeStr = "SELL STOP";
|
| 415 |
+
|
| 416 |
+
StringAdd(ordersJson, "{\"ticket\":" + IntegerToString(ticket) +
|
| 417 |
+
",\"type\":\"" + orderTypeStr + "\"" +
|
| 418 |
+
",\"volume\":" + DoubleToString(OrderGetDouble(ORDER_VOLUME_INITIAL), 2) +
|
| 419 |
+
",\"price\":" + DoubleToString(OrderGetDouble(ORDER_PRICE_OPEN), _Digits) +
|
| 420 |
+
"}");
|
| 421 |
+
firstOrder = false;
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
StringAdd(ordersJson, "]");
|
| 426 |
+
|
| 427 |
+
// Create JSON with tick data + account info + positions + orders
|
| 428 |
+
string json;
|
| 429 |
+
StringConcatenate(json, "{\"symbol\":\"", _Symbol,
|
| 430 |
+
"\",\"bid\":", DoubleToString(tick.bid, _Digits),
|
| 431 |
+
",\"ask\":", DoubleToString(tick.ask, _Digits),
|
| 432 |
+
",\"time\":", IntegerToString(tick.time),
|
| 433 |
+
",\"volume\":", IntegerToString(tick.volume),
|
| 434 |
+
",\"balance\":", DoubleToString(balance, 2),
|
| 435 |
+
",\"equity\":", DoubleToString(equity, 2),
|
| 436 |
+
",\"margin\":", DoubleToString(margin, 2),
|
| 437 |
+
",\"free_margin\":", DoubleToString(freeMargin, 2),
|
| 438 |
+
",\"min_lot\":", DoubleToString(minLot, 2),
|
| 439 |
+
",\"max_lot\":", DoubleToString(maxLot, 2),
|
| 440 |
+
",\"lot_step\":", DoubleToString(lotStep, 2),
|
| 441 |
+
",\"positions\":", positionsJson,
|
| 442 |
+
",\"orders\":", ordersJson,
|
| 443 |
+
"}");
|
| 444 |
+
|
| 445 |
+
g_publisher.Send(json);
|
| 446 |
+
// Print("Published: ", json); // Uncomment for debugging (spammy)
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
//+------------------------------------------------------------------+
|
SUM3API/MQL5/Include/Zmq/Zmq.mqh
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//+------------------------------------------------------------------+
|
| 2 |
+
//| Zmq.mqh |
|
| 3 |
+
//| Copyright 2026, Algorembrant |
|
| 4 |
+
//| |
|
| 5 |
+
//+------------------------------------------------------------------+
|
| 6 |
+
#property copyright "Algorembrant"
|
| 7 |
+
#property link "https://github.com/ContinualQuasars/SUM3API"
|
| 8 |
+
#property version "2.00"
|
| 9 |
+
#property strict
|
| 10 |
+
|
| 11 |
+
// Define ZMQ constants
|
| 12 |
+
#define ZMQ_PUB 1
|
| 13 |
+
#define ZMQ_SUB 2
|
| 14 |
+
#define ZMQ_REQ 3
|
| 15 |
+
#define ZMQ_REP 4
|
| 16 |
+
|
| 17 |
+
#define ZMQ_NOBLOCK 1
|
| 18 |
+
|
| 19 |
+
// Import functions from libzmq.dll
|
| 20 |
+
// NOTE: Make sure libzmq.dll is in your MQL5/Libraries folder
|
| 21 |
+
// Handles are void* (64-bit on x64), so using 'long' works for both 32 (too big but safe) and 64 bit
|
| 22 |
+
#import "libzmq.dll"
|
| 23 |
+
long zmq_ctx_new();
|
| 24 |
+
int zmq_ctx_term(long context);
|
| 25 |
+
long zmq_socket(long context, int type);
|
| 26 |
+
int zmq_close(long socket);
|
| 27 |
+
int zmq_bind(long socket, uchar &endpoint[]);
|
| 28 |
+
int zmq_connect(long socket, uchar &endpoint[]);
|
| 29 |
+
int zmq_send(long socket, uchar &buf[], int len, int flags);
|
| 30 |
+
int zmq_recv(long socket, uchar &buf[], int len, int flags);
|
| 31 |
+
int zmq_errno();
|
| 32 |
+
#import
|
| 33 |
+
|
| 34 |
+
class CZmq {
|
| 35 |
+
private:
|
| 36 |
+
long m_context;
|
| 37 |
+
long m_socket;
|
| 38 |
+
bool m_initialized;
|
| 39 |
+
|
| 40 |
+
public:
|
| 41 |
+
CZmq() {
|
| 42 |
+
m_context = 0;
|
| 43 |
+
m_socket = 0;
|
| 44 |
+
m_initialized = false;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
~CZmq() {
|
| 48 |
+
Shutdown();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
bool Init(int type) {
|
| 52 |
+
if(m_initialized) return true;
|
| 53 |
+
|
| 54 |
+
m_context = zmq_ctx_new();
|
| 55 |
+
if(m_context == 0) {
|
| 56 |
+
Print("ZMQ Init failed: Context creation error");
|
| 57 |
+
return false;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
m_socket = zmq_socket(m_context, type);
|
| 61 |
+
if(m_socket == 0) {
|
| 62 |
+
Print("ZMQ Init failed: Socket creation error");
|
| 63 |
+
return false;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
m_initialized = true;
|
| 67 |
+
return true;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
bool Bind(string endpoint) {
|
| 71 |
+
if(!m_initialized) return false;
|
| 72 |
+
|
| 73 |
+
uchar data[];
|
| 74 |
+
StringToCharArray(endpoint, data, 0, WHOLE_ARRAY, CP_UTF8);
|
| 75 |
+
|
| 76 |
+
int rc = zmq_bind(m_socket, data);
|
| 77 |
+
if(rc != 0) {
|
| 78 |
+
Print("ZMQ Bind failed. Error: ", zmq_errno());
|
| 79 |
+
return false;
|
| 80 |
+
}
|
| 81 |
+
return true;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
bool Connect(string endpoint) {
|
| 85 |
+
if(!m_initialized) return false;
|
| 86 |
+
|
| 87 |
+
uchar data[];
|
| 88 |
+
StringToCharArray(endpoint, data, 0, WHOLE_ARRAY, CP_UTF8);
|
| 89 |
+
|
| 90 |
+
int rc = zmq_connect(m_socket, data);
|
| 91 |
+
if(rc != 0) {
|
| 92 |
+
Print("ZMQ Connect failed. Error: ", zmq_errno());
|
| 93 |
+
return false;
|
| 94 |
+
}
|
| 95 |
+
return true;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
int Send(string message, bool nonBlocking = true) {
|
| 99 |
+
if(!m_initialized) return -1;
|
| 100 |
+
|
| 101 |
+
uchar data[];
|
| 102 |
+
StringToCharArray(message, data, 0, WHOLE_ARRAY, CP_UTF8);
|
| 103 |
+
// StringToCharArray includes null terminator, we might not want to send it
|
| 104 |
+
// ZMQ messages are just bytes.
|
| 105 |
+
// -1 because array size includes null char, usually we check ArraySize(data)
|
| 106 |
+
int len = ArraySize(data) - 1;
|
| 107 |
+
if (len < 0) len = 0;
|
| 108 |
+
|
| 109 |
+
int flags = 0;
|
| 110 |
+
if(nonBlocking) flags = ZMQ_NOBLOCK;
|
| 111 |
+
|
| 112 |
+
int bytesSent = zmq_send(m_socket, data, len, flags);
|
| 113 |
+
return bytesSent;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Non-blocking receive - returns empty string if no message available
|
| 117 |
+
string Receive(bool nonBlocking = true) {
|
| 118 |
+
if(!m_initialized) return "";
|
| 119 |
+
|
| 120 |
+
uchar buffer[4096];
|
| 121 |
+
ArrayInitialize(buffer, 0);
|
| 122 |
+
|
| 123 |
+
int flags = 0;
|
| 124 |
+
if(nonBlocking) flags = ZMQ_NOBLOCK;
|
| 125 |
+
|
| 126 |
+
int bytesReceived = zmq_recv(m_socket, buffer, ArraySize(buffer) - 1, flags);
|
| 127 |
+
|
| 128 |
+
if(bytesReceived <= 0) return "";
|
| 129 |
+
|
| 130 |
+
return CharArrayToString(buffer, 0, bytesReceived, CP_UTF8);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
void Shutdown() {
|
| 134 |
+
if(m_socket != 0) {
|
| 135 |
+
zmq_close(m_socket);
|
| 136 |
+
m_socket = 0;
|
| 137 |
+
}
|
| 138 |
+
if(m_context != 0) {
|
| 139 |
+
zmq_ctx_term(m_context);
|
| 140 |
+
m_context = 0;
|
| 141 |
+
}
|
| 142 |
+
m_initialized = false;
|
| 143 |
+
}
|
| 144 |
+
};
|
SUM3API/MQL5/Libraries/libsodium.dll
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7745aad20b9578c70b0fff48b3ee5f982c840e0f17b3dc7239a23d4fb36b0717
|
| 3 |
+
size 302592
|
SUM3API/MQL5/Libraries/libzmq.dll
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:19567709cb7ef4664249d46a796f23c06abbe6ba91db3c650aeaa372b4fbf989
|
| 3 |
+
size 451072
|
SUM3API/Rustmt5-chart/Cargo.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
SUM3API/Rustmt5-chart/Cargo.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "Rustmt5-chart"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2026"
|
| 5 |
+
|
| 6 |
+
[dependencies]
|
| 7 |
+
eframe = "0.27.1"
|
| 8 |
+
egui = "0.27.1"
|
| 9 |
+
egui_plot = "0.27.1"
|
| 10 |
+
zeromq = "0.5.0-pre"
|
| 11 |
+
serde = { version = "1.0.197", features = ["derive"] }
|
| 12 |
+
serde_json = "1.0.114"
|
| 13 |
+
tokio = { version = "1.36.0", features = ["full"] }
|
| 14 |
+
futures = "0.3.30"
|
| 15 |
+
chrono = "0.4.43"
|
SUM3API/Rustmt5-chart/output/History_XAUUSDc_H1_OHLC_ID0002_20260126_135823.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Time,Open,High,Low,Close,TickVol,Spread
|
| 2 |
+
2026.01.25 23:00,5019.847,5040.296,5003.575,5039.789,30600,160
|
| 3 |
+
2026.01.26 00:00,5039.821,5052.099,5030.537,5041.830,25016,160
|
SUM3API/Rustmt5-chart/output/History_XAUUSDc_H1_TICKS_ID0001_20260126_140825.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
SUM3API/Rustmt5-chart/output/Live_XAUUSDc_ID0001_20260126_135811.csv
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Time,Bid,Ask,Volume
|
| 2 |
+
1769407092,5073.44,5073.6,0
|
| 3 |
+
1769407092,5073.412,5073.572,0
|
| 4 |
+
1769407092,5073.463,5073.623,0
|
| 5 |
+
1769407092,5073.619,5073.779,0
|
| 6 |
+
1769407092,5073.69,5073.85,0
|
| 7 |
+
1769407092,5073.738,5073.898,0
|
| 8 |
+
1769407092,5073.828,5073.988,0
|
| 9 |
+
1769407093,5073.716,5073.876,0
|
| 10 |
+
1769407093,5073.626,5073.786,0
|
| 11 |
+
1769407093,5073.737,5073.897,0
|
| 12 |
+
1769407093,5073.607,5073.767,0
|
| 13 |
+
1769407093,5073.705,5073.865,0
|
| 14 |
+
1769407093,5073.744,5073.904,0
|
SUM3API/Rustmt5-chart/src/main.rs
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//+------------------------------------------------------------------+
|
| 2 |
+
//| main.rs |
|
| 3 |
+
//| Copyright 2026, Algorembrant |
|
| 4 |
+
//| |
|
| 5 |
+
//+------------------------------------------------------------------+
|
| 6 |
+
//property copyright "Algorembrant"
|
| 7 |
+
//property link "https://github.com/ContinualQuasars/SUM3API"
|
| 8 |
+
//property version "2.00"
|
| 9 |
+
//property strict
|
| 10 |
+
|
| 11 |
+
use eframe::egui;
|
| 12 |
+
use egui_plot::{Line, Plot, PlotPoints};
|
| 13 |
+
use serde::{Deserialize, Serialize};
|
| 14 |
+
use tokio::sync::mpsc;
|
| 15 |
+
use zeromq::{Socket, SocketRecv, SocketSend};
|
| 16 |
+
use std::fs::{self, OpenOptions};
|
| 17 |
+
use std::io::Write;
|
| 18 |
+
use std::path::PathBuf;
|
| 19 |
+
|
| 20 |
+
// ============================================================================
|
| 21 |
+
// Data Structures
|
| 22 |
+
// ============================================================================
|
| 23 |
+
|
| 24 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 25 |
+
#[allow(dead_code)]
|
| 26 |
+
struct PositionData {
|
| 27 |
+
ticket: u64,
|
| 28 |
+
#[serde(rename = "type")]
|
| 29 |
+
pos_type: String, // "BUY" or "SELL"
|
| 30 |
+
volume: f64,
|
| 31 |
+
price: f64,
|
| 32 |
+
profit: f64,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 36 |
+
#[allow(dead_code)]
|
| 37 |
+
struct PendingOrderData {
|
| 38 |
+
ticket: u64,
|
| 39 |
+
#[serde(rename = "type")]
|
| 40 |
+
order_type: String, // "BUY LIMIT", "SELL STOP", etc.
|
| 41 |
+
volume: f64,
|
| 42 |
+
price: f64,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 46 |
+
struct TickData {
|
| 47 |
+
symbol: String,
|
| 48 |
+
bid: f64,
|
| 49 |
+
ask: f64,
|
| 50 |
+
time: i64,
|
| 51 |
+
#[serde(default)]
|
| 52 |
+
volume: u64,
|
| 53 |
+
// Account info
|
| 54 |
+
#[serde(default)]
|
| 55 |
+
balance: f64,
|
| 56 |
+
#[serde(default)]
|
| 57 |
+
equity: f64,
|
| 58 |
+
#[serde(default)]
|
| 59 |
+
margin: f64,
|
| 60 |
+
#[serde(default)]
|
| 61 |
+
free_margin: f64,
|
| 62 |
+
// Trading constraints
|
| 63 |
+
#[serde(default)]
|
| 64 |
+
min_lot: f64,
|
| 65 |
+
#[serde(default)]
|
| 66 |
+
max_lot: f64,
|
| 67 |
+
#[serde(default)]
|
| 68 |
+
lot_step: f64,
|
| 69 |
+
|
| 70 |
+
// Active trades
|
| 71 |
+
#[serde(default)]
|
| 72 |
+
positions: Vec<PositionData>,
|
| 73 |
+
#[serde(default)]
|
| 74 |
+
orders: Vec<PendingOrderData>,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
#[derive(Clone, Debug, Serialize)]
|
| 78 |
+
struct OrderRequest {
|
| 79 |
+
#[serde(rename = "type")]
|
| 80 |
+
order_type: String,
|
| 81 |
+
symbol: String,
|
| 82 |
+
volume: f64,
|
| 83 |
+
price: f64,
|
| 84 |
+
#[serde(default)]
|
| 85 |
+
ticket: u64, // For close/cancel
|
| 86 |
+
// History params
|
| 87 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 88 |
+
timeframe: Option<String>,
|
| 89 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 90 |
+
start: Option<String>,
|
| 91 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 92 |
+
end: Option<String>,
|
| 93 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 94 |
+
mode: Option<String>,
|
| 95 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 96 |
+
request_id: Option<u64>, // Unique ID for history downloads
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 100 |
+
struct OrderResponse {
|
| 101 |
+
success: bool,
|
| 102 |
+
ticket: Option<i64>,
|
| 103 |
+
error: Option<String>,
|
| 104 |
+
message: Option<String>,
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// Struct for tracking order execution breaklines on chart
|
| 108 |
+
#[derive(Clone, Debug)]
|
| 109 |
+
struct OrderBreakline {
|
| 110 |
+
index: usize, // Data index where order was executed
|
| 111 |
+
order_type: String, // "BUY" or "SELL" variant
|
| 112 |
+
ticket: i64, // Order ticket number
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// ============================================================================
|
| 116 |
+
// Application State
|
| 117 |
+
// ============================================================================
|
| 118 |
+
|
| 119 |
+
struct Mt5ChartApp {
|
| 120 |
+
// Tick data
|
| 121 |
+
tick_receiver: mpsc::Receiver<TickData>,
|
| 122 |
+
data: Vec<TickData>,
|
| 123 |
+
symbol: String,
|
| 124 |
+
|
| 125 |
+
// Latest account info
|
| 126 |
+
balance: f64,
|
| 127 |
+
equity: f64,
|
| 128 |
+
margin: f64,
|
| 129 |
+
free_margin: f64,
|
| 130 |
+
min_lot: f64,
|
| 131 |
+
max_lot: f64,
|
| 132 |
+
lot_step: f64,
|
| 133 |
+
|
| 134 |
+
// Order handling
|
| 135 |
+
order_sender: mpsc::Sender<OrderRequest>,
|
| 136 |
+
response_receiver: mpsc::Receiver<OrderResponse>,
|
| 137 |
+
|
| 138 |
+
// UI state for order panel
|
| 139 |
+
lot_size: f64,
|
| 140 |
+
lot_size_str: String,
|
| 141 |
+
limit_price: String,
|
| 142 |
+
#[allow(dead_code)]
|
| 143 |
+
stop_price: String,
|
| 144 |
+
last_order_result: Option<String>,
|
| 145 |
+
|
| 146 |
+
// History Download UI
|
| 147 |
+
history_start_date: String,
|
| 148 |
+
history_end_date: String,
|
| 149 |
+
history_tf: String,
|
| 150 |
+
history_mode: String,
|
| 151 |
+
|
| 152 |
+
// Live Recording
|
| 153 |
+
is_recording: bool,
|
| 154 |
+
live_record_file: Option<std::fs::File>,
|
| 155 |
+
|
| 156 |
+
// Live Trade Data
|
| 157 |
+
positions: Vec<PositionData>,
|
| 158 |
+
pending_orders: Vec<PendingOrderData>,
|
| 159 |
+
|
| 160 |
+
// CSV Output Management
|
| 161 |
+
output_dir: PathBuf,
|
| 162 |
+
request_counter: u64,
|
| 163 |
+
|
| 164 |
+
// Order Breaklines for Chart
|
| 165 |
+
order_breaklines: Vec<OrderBreakline>,
|
| 166 |
+
pending_order_type: Option<String>, // Track what type of order is pending
|
| 167 |
+
|
| 168 |
+
// Pending history request info for CSV naming
|
| 169 |
+
pending_history_request: Option<(u64, String, String, String)>, // (id, symbol, tf, mode)
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
impl Mt5ChartApp {
|
| 173 |
+
fn new(
|
| 174 |
+
tick_receiver: mpsc::Receiver<TickData>,
|
| 175 |
+
order_sender: mpsc::Sender<OrderRequest>,
|
| 176 |
+
response_receiver: mpsc::Receiver<OrderResponse>,
|
| 177 |
+
) -> Self {
|
| 178 |
+
// Defaults dates to "yyyy.mm.dd"
|
| 179 |
+
let now = chrono::Local::now();
|
| 180 |
+
let today_str = now.format("%Y.%m.%d").to_string();
|
| 181 |
+
|
| 182 |
+
// Ensure output directory exists
|
| 183 |
+
let output_dir = PathBuf::from("output");
|
| 184 |
+
fs::create_dir_all(&output_dir).ok();
|
| 185 |
+
|
| 186 |
+
Self {
|
| 187 |
+
tick_receiver,
|
| 188 |
+
data: Vec::new(),
|
| 189 |
+
symbol: "Waiting for data...".to_string(),
|
| 190 |
+
balance: 0.0,
|
| 191 |
+
equity: 0.0,
|
| 192 |
+
margin: 0.0,
|
| 193 |
+
free_margin: 0.0,
|
| 194 |
+
min_lot: 0.01,
|
| 195 |
+
max_lot: 100.0,
|
| 196 |
+
lot_step: 0.01,
|
| 197 |
+
order_sender,
|
| 198 |
+
response_receiver,
|
| 199 |
+
lot_size: 0.01,
|
| 200 |
+
lot_size_str: "0.01".to_string(),
|
| 201 |
+
limit_price: "0.0".to_string(),
|
| 202 |
+
stop_price: "0.0".to_string(),
|
| 203 |
+
last_order_result: None,
|
| 204 |
+
|
| 205 |
+
history_start_date: today_str.clone(),
|
| 206 |
+
history_end_date: today_str,
|
| 207 |
+
history_tf: "M1".to_string(),
|
| 208 |
+
history_mode: "OHLC".to_string(),
|
| 209 |
+
|
| 210 |
+
is_recording: false,
|
| 211 |
+
live_record_file: None,
|
| 212 |
+
|
| 213 |
+
positions: Vec::new(),
|
| 214 |
+
pending_orders: Vec::new(),
|
| 215 |
+
|
| 216 |
+
// Initialize new fields
|
| 217 |
+
output_dir,
|
| 218 |
+
request_counter: 0,
|
| 219 |
+
order_breaklines: Vec::new(),
|
| 220 |
+
pending_order_type: None,
|
| 221 |
+
pending_history_request: None,
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
fn send_order(&mut self, order_type: &str, price: Option<f64>, ticket: Option<u64>) {
|
| 226 |
+
let price_val = price.unwrap_or(0.0);
|
| 227 |
+
let ticket_val = ticket.unwrap_or(0);
|
| 228 |
+
|
| 229 |
+
// Track order type for breakline visualization (only for market orders)
|
| 230 |
+
if order_type.contains("market") {
|
| 231 |
+
self.pending_order_type = Some(order_type.to_string());
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
let request = OrderRequest {
|
| 235 |
+
order_type: order_type.to_string(),
|
| 236 |
+
symbol: self.symbol.clone(),
|
| 237 |
+
volume: self.lot_size,
|
| 238 |
+
price: price_val,
|
| 239 |
+
ticket: ticket_val,
|
| 240 |
+
timeframe: None,
|
| 241 |
+
start: None,
|
| 242 |
+
end: None,
|
| 243 |
+
mode: None,
|
| 244 |
+
request_id: None,
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
self.send_request_impl(request);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
fn send_download_request(&mut self) {
|
| 251 |
+
// Increment counter for unique history download ID
|
| 252 |
+
self.request_counter += 1;
|
| 253 |
+
|
| 254 |
+
// Store request info for CSV filename generation when response arrives
|
| 255 |
+
self.pending_history_request = Some((
|
| 256 |
+
self.request_counter,
|
| 257 |
+
self.symbol.replace("/", "-"),
|
| 258 |
+
self.history_tf.clone(),
|
| 259 |
+
self.history_mode.clone(),
|
| 260 |
+
));
|
| 261 |
+
|
| 262 |
+
let request = OrderRequest {
|
| 263 |
+
order_type: "download_history".to_string(),
|
| 264 |
+
symbol: self.symbol.clone(),
|
| 265 |
+
volume: 0.0,
|
| 266 |
+
price: 0.0,
|
| 267 |
+
ticket: 0,
|
| 268 |
+
timeframe: Some(self.history_tf.clone()),
|
| 269 |
+
start: Some(self.history_start_date.clone()),
|
| 270 |
+
end: Some(self.history_end_date.clone()),
|
| 271 |
+
mode: Some(self.history_mode.clone()),
|
| 272 |
+
request_id: Some(self.request_counter),
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
self.send_request_impl(request);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
fn send_request_impl(&mut self, request: OrderRequest) {
|
| 279 |
+
if let Err(e) = self.order_sender.try_send(request) {
|
| 280 |
+
self.last_order_result = Some(format!("Failed to send: {}", e));
|
| 281 |
+
} else {
|
| 282 |
+
self.last_order_result = Some("Request sent...".to_string());
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
fn adjust_lot_size(&mut self, delta: f64) {
|
| 287 |
+
let new_lot = self.lot_size + delta;
|
| 288 |
+
// Round to lot_step
|
| 289 |
+
let steps = (new_lot / self.lot_step).round();
|
| 290 |
+
self.lot_size = (steps * self.lot_step).max(self.min_lot).min(self.max_lot);
|
| 291 |
+
self.lot_size_str = format!("{:.2}", self.lot_size);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
fn toggle_recording(&mut self) {
|
| 295 |
+
self.is_recording = !self.is_recording;
|
| 296 |
+
if self.is_recording {
|
| 297 |
+
// Increment counter for unique ID
|
| 298 |
+
self.request_counter += 1;
|
| 299 |
+
let filename = format!(
|
| 300 |
+
"{}/Live_{}_ID{:04}_{}.csv",
|
| 301 |
+
self.output_dir.display(),
|
| 302 |
+
self.symbol.replace("/", "-"),
|
| 303 |
+
self.request_counter,
|
| 304 |
+
chrono::Local::now().format("%Y%m%d_%H%M%S")
|
| 305 |
+
);
|
| 306 |
+
match OpenOptions::new().create(true).append(true).open(&filename) {
|
| 307 |
+
Ok(mut file) => {
|
| 308 |
+
let _ = writeln!(file, "Time,Bid,Ask,Volume");
|
| 309 |
+
self.live_record_file = Some(file);
|
| 310 |
+
self.last_order_result = Some(format!("Recording to {}", filename));
|
| 311 |
+
}
|
| 312 |
+
Err(e) => {
|
| 313 |
+
self.is_recording = false;
|
| 314 |
+
self.last_order_result = Some(format!("Rec Error: {}", e));
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
} else {
|
| 318 |
+
self.live_record_file = None;
|
| 319 |
+
self.last_order_result = Some("Recording Stopped".to_string());
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
impl eframe::App for Mt5ChartApp {
|
| 325 |
+
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
| 326 |
+
// Receive all available tick data from the channel without blocking
|
| 327 |
+
while let Ok(tick) = self.tick_receiver.try_recv() {
|
| 328 |
+
self.symbol = tick.symbol.clone();
|
| 329 |
+
|
| 330 |
+
// Record if active
|
| 331 |
+
if self.is_recording {
|
| 332 |
+
if let Some(mut file) = self.live_record_file.as_ref() {
|
| 333 |
+
let _ = writeln!(file, "{},{},{},{}", tick.time, tick.bid, tick.ask, tick.volume);
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Update account info from latest tick
|
| 338 |
+
if tick.balance > 0.0 {
|
| 339 |
+
self.balance = tick.balance;
|
| 340 |
+
self.equity = tick.equity;
|
| 341 |
+
self.margin = tick.margin;
|
| 342 |
+
self.free_margin = tick.free_margin;
|
| 343 |
+
self.min_lot = tick.min_lot;
|
| 344 |
+
self.max_lot = tick.max_lot;
|
| 345 |
+
if tick.lot_step > 0.0 {
|
| 346 |
+
self.lot_step = tick.lot_step;
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// Update active trades
|
| 351 |
+
self.positions = tick.positions.clone();
|
| 352 |
+
self.pending_orders = tick.orders.clone();
|
| 353 |
+
|
| 354 |
+
self.data.push(tick);
|
| 355 |
+
// Keep only last 2000 points
|
| 356 |
+
if self.data.len() > 2000 {
|
| 357 |
+
self.data.remove(0);
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// Check for order responses
|
| 362 |
+
while let Ok(response) = self.response_receiver.try_recv() {
|
| 363 |
+
if response.success {
|
| 364 |
+
// Check if this is a history download with CSV data
|
| 365 |
+
if let Some(ref msg) = response.message {
|
| 366 |
+
if msg.contains("||CSV_DATA||") {
|
| 367 |
+
// Parse CSV data from response
|
| 368 |
+
let parts: Vec<&str> = msg.splitn(2, "||CSV_DATA||").collect();
|
| 369 |
+
if parts.len() == 2 {
|
| 370 |
+
let info_part = parts[0];
|
| 371 |
+
let csv_content = parts[1];
|
| 372 |
+
|
| 373 |
+
// Generate filename using pending request info
|
| 374 |
+
if let Some((id, symbol, tf, mode)) = self.pending_history_request.take() {
|
| 375 |
+
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
| 376 |
+
let filename = format!(
|
| 377 |
+
"{}/History_{}_{}_{}_ID{:04}_{}.csv",
|
| 378 |
+
self.output_dir.display(),
|
| 379 |
+
symbol, tf, mode, id, timestamp
|
| 380 |
+
);
|
| 381 |
+
|
| 382 |
+
// Convert |NL| placeholders back to real newlines
|
| 383 |
+
let csv_with_newlines = csv_content.replace("|NL|", "\n");
|
| 384 |
+
|
| 385 |
+
// Save CSV to output folder
|
| 386 |
+
match std::fs::write(&filename, csv_with_newlines) {
|
| 387 |
+
Ok(_) => {
|
| 388 |
+
self.last_order_result = Some(format!(
|
| 389 |
+
"β {} β Saved to {}",
|
| 390 |
+
info_part, filename
|
| 391 |
+
));
|
| 392 |
+
}
|
| 393 |
+
Err(e) => {
|
| 394 |
+
self.last_order_result = Some(format!(
|
| 395 |
+
"β Failed to save CSV: {}",
|
| 396 |
+
e
|
| 397 |
+
));
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
} else {
|
| 401 |
+
self.last_order_result = Some(format!("β {}", info_part));
|
| 402 |
+
}
|
| 403 |
+
} else {
|
| 404 |
+
self.last_order_result = Some(format!("β {}", msg));
|
| 405 |
+
}
|
| 406 |
+
} else {
|
| 407 |
+
self.last_order_result = Some(format!("β {}", msg));
|
| 408 |
+
}
|
| 409 |
+
} else {
|
| 410 |
+
// Add breakline for successful market orders
|
| 411 |
+
if let Some(ref order_type) = self.pending_order_type.take() {
|
| 412 |
+
let breakline = OrderBreakline {
|
| 413 |
+
index: self.data.len().saturating_sub(1),
|
| 414 |
+
order_type: order_type.clone(),
|
| 415 |
+
ticket: response.ticket.unwrap_or(0),
|
| 416 |
+
};
|
| 417 |
+
self.order_breaklines.push(breakline);
|
| 418 |
+
// Keep only last 50 breaklines
|
| 419 |
+
if self.order_breaklines.len() > 50 {
|
| 420 |
+
self.order_breaklines.remove(0);
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
self.last_order_result = Some(format!(
|
| 425 |
+
"β Order executed! Ticket: {}",
|
| 426 |
+
response.ticket.unwrap_or(0)
|
| 427 |
+
));
|
| 428 |
+
}
|
| 429 |
+
} else {
|
| 430 |
+
self.pending_order_type = None; // Clear pending on failure
|
| 431 |
+
self.pending_history_request = None; // Clear pending history request
|
| 432 |
+
self.last_order_result = Some(format!(
|
| 433 |
+
"β Failed: {}",
|
| 434 |
+
response.error.unwrap_or_else(|| "Unknown error".to_string())
|
| 435 |
+
));
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
// ====================================================================
|
| 440 |
+
// Side Panel - Trading Controls
|
| 441 |
+
// ====================================================================
|
| 442 |
+
egui::SidePanel::left("trading_panel")
|
| 443 |
+
.min_width(280.0) // Widen slightly
|
| 444 |
+
.show(ctx, |ui| {
|
| 445 |
+
ui.heading("π Trading Panel");
|
| 446 |
+
ui.separator();
|
| 447 |
+
|
| 448 |
+
// Account Info
|
| 449 |
+
ui.collapsing("π° Account Info", |ui| {
|
| 450 |
+
egui::Grid::new("account_grid")
|
| 451 |
+
.num_columns(2)
|
| 452 |
+
.spacing([10.0, 4.0])
|
| 453 |
+
.show(ui, |ui| {
|
| 454 |
+
ui.label("Balance:");
|
| 455 |
+
ui.colored_label(egui::Color32::from_rgb(100, 200, 100), format!("${:.2}", self.balance));
|
| 456 |
+
ui.end_row();
|
| 457 |
+
ui.label("Equity:");
|
| 458 |
+
ui.colored_label(egui::Color32::from_rgb(100, 180, 255), format!("${:.2}", self.equity));
|
| 459 |
+
ui.end_row();
|
| 460 |
+
ui.label("Margin Used:");
|
| 461 |
+
ui.colored_label(egui::Color32::from_rgb(255, 200, 100), format!("${:.2}", self.margin));
|
| 462 |
+
ui.end_row();
|
| 463 |
+
ui.label("Free Margin:");
|
| 464 |
+
ui.colored_label(egui::Color32::from_rgb(100, 255, 200), format!("${:.2}", self.free_margin));
|
| 465 |
+
ui.end_row();
|
| 466 |
+
});
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
ui.separator();
|
| 470 |
+
|
| 471 |
+
// Historical Data Section
|
| 472 |
+
ui.heading("π Historical Data");
|
| 473 |
+
ui.add_space(5.0);
|
| 474 |
+
|
| 475 |
+
egui::Grid::new("history_grid").num_columns(2).spacing([10.0, 5.0]).show(ui, |ui| {
|
| 476 |
+
ui.label("Start (yyyy.mm.dd):");
|
| 477 |
+
ui.add(egui::TextEdit::singleline(&mut self.history_start_date).desired_width(100.0));
|
| 478 |
+
ui.end_row();
|
| 479 |
+
|
| 480 |
+
ui.label("End (yyyy.mm.dd):");
|
| 481 |
+
ui.add(egui::TextEdit::singleline(&mut self.history_end_date).desired_width(100.0));
|
| 482 |
+
ui.end_row();
|
| 483 |
+
|
| 484 |
+
ui.label("Timeframe:");
|
| 485 |
+
egui::ComboBox::from_id_source("tf_combo")
|
| 486 |
+
.selected_text(&self.history_tf)
|
| 487 |
+
.show_ui(ui, |ui| {
|
| 488 |
+
ui.selectable_value(&mut self.history_tf, "M1".to_string(), "M1");
|
| 489 |
+
ui.selectable_value(&mut self.history_tf, "M5".to_string(), "M5");
|
| 490 |
+
ui.selectable_value(&mut self.history_tf, "M15".to_string(), "M15");
|
| 491 |
+
ui.selectable_value(&mut self.history_tf, "H1".to_string(), "H1");
|
| 492 |
+
ui.selectable_value(&mut self.history_tf, "D1".to_string(), "D1");
|
| 493 |
+
});
|
| 494 |
+
ui.end_row();
|
| 495 |
+
|
| 496 |
+
ui.label("Mode:");
|
| 497 |
+
egui::ComboBox::from_id_source("mode_combo")
|
| 498 |
+
.selected_text(&self.history_mode)
|
| 499 |
+
.show_ui(ui, |ui| {
|
| 500 |
+
ui.selectable_value(&mut self.history_mode, "OHLC".to_string(), "OHLC");
|
| 501 |
+
ui.selectable_value(&mut self.history_mode, "TICKS".to_string(), "TICKS");
|
| 502 |
+
});
|
| 503 |
+
ui.end_row();
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
ui.add_space(5.0);
|
| 507 |
+
if ui.button("β¬ Download History (CSV)").clicked() {
|
| 508 |
+
self.send_download_request();
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
ui.separator();
|
| 512 |
+
|
| 513 |
+
// Live Recording
|
| 514 |
+
ui.heading("π΄ Live Recording");
|
| 515 |
+
ui.horizontal(|ui| {
|
| 516 |
+
ui.label(if self.is_recording { "Recording..." } else { "Idle" });
|
| 517 |
+
if ui.button(if self.is_recording { "Stop" } else { "Start Recording" }).clicked() {
|
| 518 |
+
self.toggle_recording();
|
| 519 |
+
}
|
| 520 |
+
});
|
| 521 |
+
|
| 522 |
+
ui.separator();
|
| 523 |
+
|
| 524 |
+
// Order Controls
|
| 525 |
+
ui.heading("π¦ Trade Controls");
|
| 526 |
+
|
| 527 |
+
// Lot Size
|
| 528 |
+
ui.horizontal(|ui| {
|
| 529 |
+
if ui.button("β").clicked() { self.adjust_lot_size(-self.lot_step); }
|
| 530 |
+
let response = ui.add(egui::TextEdit::singleline(&mut self.lot_size_str).desired_width(60.0));
|
| 531 |
+
if response.lost_focus() {
|
| 532 |
+
if let Ok(parsed) = self.lot_size_str.parse::<f64>() {
|
| 533 |
+
self.lot_size = parsed.max(self.min_lot).min(self.max_lot);
|
| 534 |
+
self.lot_size_str = format!("{:.2}", self.lot_size);
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
if ui.button("+").clicked() { self.adjust_lot_size(self.lot_step); }
|
| 538 |
+
|
| 539 |
+
ui.label(format!("Lots (Max: {:.1})", self.max_lot));
|
| 540 |
+
});
|
| 541 |
+
|
| 542 |
+
ui.add_space(5.0);
|
| 543 |
+
ui.label("Market Orders:");
|
| 544 |
+
ui.horizontal(|ui| {
|
| 545 |
+
if ui.button("BUY").clicked() { self.send_order("market_buy", None, None); }
|
| 546 |
+
if ui.button("SELL").clicked() { self.send_order("market_sell", None, None); }
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
ui.add_space(5.0);
|
| 550 |
+
ui.label("Pending Orders:");
|
| 551 |
+
ui.horizontal(|ui| {
|
| 552 |
+
ui.label("@ Price:");
|
| 553 |
+
ui.add(egui::TextEdit::singleline(&mut self.limit_price).desired_width(70.0));
|
| 554 |
+
});
|
| 555 |
+
ui.horizontal(|ui| {
|
| 556 |
+
let p = self.limit_price.parse().unwrap_or(0.0);
|
| 557 |
+
if ui.small_button("Buy Limit").clicked() { self.send_order("limit_buy", Some(p), None); }
|
| 558 |
+
if ui.small_button("Sell Limit").clicked() { self.send_order("limit_sell", Some(p), None); }
|
| 559 |
+
if ui.small_button("Buy Stop").clicked() { self.send_order("stop_buy", Some(p), None); }
|
| 560 |
+
if ui.small_button("Sell Stop").clicked() { self.send_order("stop_sell", Some(p), None); }
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
ui.separator();
|
| 564 |
+
|
| 565 |
+
// Order result feedback
|
| 566 |
+
if let Some(ref result) = self.last_order_result {
|
| 567 |
+
ui.heading("π¨ Last Message");
|
| 568 |
+
ui.label(result); // Allow wrapping
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
ui.separator();
|
| 572 |
+
|
| 573 |
+
// Active Positions - Close Management
|
| 574 |
+
ui.collapsing("πΌ Active Positions", |ui| {
|
| 575 |
+
if self.positions.is_empty() {
|
| 576 |
+
ui.label("No active positions");
|
| 577 |
+
} else {
|
| 578 |
+
let positions_clone = self.positions.clone();
|
| 579 |
+
for pos in positions_clone {
|
| 580 |
+
ui.horizontal(|ui| {
|
| 581 |
+
let color = if pos.pos_type == "BUY" {
|
| 582 |
+
egui::Color32::from_rgb(100, 200, 100)
|
| 583 |
+
} else {
|
| 584 |
+
egui::Color32::from_rgb(255, 100, 100)
|
| 585 |
+
};
|
| 586 |
+
ui.colored_label(color, format!(
|
| 587 |
+
"#{} {} {:.2}@{:.5} P:{:.2}",
|
| 588 |
+
pos.ticket, pos.pos_type, pos.volume, pos.price, pos.profit
|
| 589 |
+
));
|
| 590 |
+
if ui.small_button("Close").clicked() {
|
| 591 |
+
self.send_order("close_position", Some(pos.price), Some(pos.ticket));
|
| 592 |
+
}
|
| 593 |
+
});
|
| 594 |
+
}
|
| 595 |
+
}
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
// Pending Orders - Cancel Management
|
| 599 |
+
ui.collapsing("β³ Pending Orders", |ui| {
|
| 600 |
+
if self.pending_orders.is_empty() {
|
| 601 |
+
ui.label("No pending orders");
|
| 602 |
+
} else {
|
| 603 |
+
let orders_clone = self.pending_orders.clone();
|
| 604 |
+
for order in orders_clone {
|
| 605 |
+
ui.horizontal(|ui| {
|
| 606 |
+
let color = if order.order_type.contains("BUY") {
|
| 607 |
+
egui::Color32::from_rgb(100, 150, 255)
|
| 608 |
+
} else {
|
| 609 |
+
egui::Color32::from_rgb(255, 150, 100)
|
| 610 |
+
};
|
| 611 |
+
ui.colored_label(color, format!(
|
| 612 |
+
"#{} {} {:.2}@{:.5}",
|
| 613 |
+
order.ticket, order.order_type, order.volume, order.price
|
| 614 |
+
));
|
| 615 |
+
if ui.small_button("Cancel").clicked() {
|
| 616 |
+
self.send_order("cancel_order", Some(order.price), Some(order.ticket));
|
| 617 |
+
}
|
| 618 |
+
});
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
});
|
| 622 |
+
});
|
| 623 |
+
|
| 624 |
+
// ====================================================================
|
| 625 |
+
// Central Panel - Chart
|
| 626 |
+
// ====================================================================
|
| 627 |
+
egui::CentralPanel::default().show(ctx, |ui| {
|
| 628 |
+
ui.heading(format!("π {}", self.symbol));
|
| 629 |
+
|
| 630 |
+
// Header Info
|
| 631 |
+
if let Some(last_tick) = self.data.last() {
|
| 632 |
+
ui.horizontal(|ui| {
|
| 633 |
+
ui.label(format!("{:.5} / {:.5}", last_tick.bid, last_tick.ask));
|
| 634 |
+
});
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
ui.separator();
|
| 638 |
+
|
| 639 |
+
// Price chart - Index-based X Axis
|
| 640 |
+
let time_map: Vec<i64> = self.data.iter().map(|t| t.time).collect();
|
| 641 |
+
|
| 642 |
+
let plot = Plot::new("mt5_price_plot")
|
| 643 |
+
.legend(egui_plot::Legend::default())
|
| 644 |
+
.allow_boxed_zoom(true)
|
| 645 |
+
.allow_drag(true)
|
| 646 |
+
.allow_scroll(true)
|
| 647 |
+
.allow_zoom(true)
|
| 648 |
+
.x_axis_formatter(move |x, _range, _width| {
|
| 649 |
+
let idx = x.value.round() as isize;
|
| 650 |
+
if idx >= 0 && (idx as usize) < time_map.len() {
|
| 651 |
+
let timestamp = time_map[idx as usize];
|
| 652 |
+
let seconds = timestamp % 60;
|
| 653 |
+
let minutes = (timestamp / 60) % 60;
|
| 654 |
+
let hours = (timestamp / 3600) % 24;
|
| 655 |
+
return format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
| 656 |
+
}
|
| 657 |
+
"".to_string()
|
| 658 |
+
});
|
| 659 |
+
|
| 660 |
+
plot.show(ui, |plot_ui| {
|
| 661 |
+
let bid_points: PlotPoints = self.data
|
| 662 |
+
.iter()
|
| 663 |
+
.enumerate()
|
| 664 |
+
.map(|(i, t)| [i as f64, t.bid])
|
| 665 |
+
.collect();
|
| 666 |
+
|
| 667 |
+
let ask_points: PlotPoints = self.data
|
| 668 |
+
.iter()
|
| 669 |
+
.enumerate()
|
| 670 |
+
.map(|(i, t)| [i as f64, t.ask])
|
| 671 |
+
.collect();
|
| 672 |
+
|
| 673 |
+
plot_ui.line(Line::new(bid_points).name("Bid").color(egui::Color32::from_rgb(100, 200, 100)));
|
| 674 |
+
plot_ui.line(Line::new(ask_points).name("Ask").color(egui::Color32::from_rgb(200, 100, 100)));
|
| 675 |
+
|
| 676 |
+
// Draw Active Positions (horizontal lines)
|
| 677 |
+
for pos in &self.positions {
|
| 678 |
+
let color = if pos.pos_type == "BUY" {
|
| 679 |
+
egui::Color32::from_rgb(50, 100, 255)
|
| 680 |
+
} else {
|
| 681 |
+
egui::Color32::from_rgb(255, 50, 50)
|
| 682 |
+
};
|
| 683 |
+
|
| 684 |
+
plot_ui.hline(
|
| 685 |
+
egui_plot::HLine::new(pos.price)
|
| 686 |
+
.color(color)
|
| 687 |
+
.name(format!("{} #{}", pos.pos_type, pos.ticket))
|
| 688 |
+
.style(egui_plot::LineStyle::Dashed { length: 10.0 })
|
| 689 |
+
);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
// Draw Order Breaklines (vertical lines at execution points)
|
| 693 |
+
for breakline in &self.order_breaklines {
|
| 694 |
+
let color = if breakline.order_type.contains("buy") {
|
| 695 |
+
egui::Color32::from_rgb(0, 200, 100) // Bright green for BUY
|
| 696 |
+
} else {
|
| 697 |
+
egui::Color32::from_rgb(255, 80, 80) // Bright red for SELL
|
| 698 |
+
};
|
| 699 |
+
|
| 700 |
+
plot_ui.vline(
|
| 701 |
+
egui_plot::VLine::new(breakline.index as f64)
|
| 702 |
+
.color(color)
|
| 703 |
+
.name(format!("Order #{}", breakline.ticket))
|
| 704 |
+
.width(2.0)
|
| 705 |
+
);
|
| 706 |
+
}
|
| 707 |
+
});
|
| 708 |
+
});
|
| 709 |
+
|
| 710 |
+
// Request a repaint to update the chart continuously
|
| 711 |
+
ctx.request_repaint();
|
| 712 |
+
}
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
// ============================================================================
|
| 716 |
+
// Main Entry Point
|
| 717 |
+
// ============================================================================
|
| 718 |
+
|
| 719 |
+
#[tokio::main]
|
| 720 |
+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
| 721 |
+
// Channels for tick data
|
| 722 |
+
let (tick_tx, tick_rx) = mpsc::channel(100);
|
| 723 |
+
|
| 724 |
+
// Channels for order requests and responses
|
| 725 |
+
let (order_tx, mut order_rx) = mpsc::channel::<OrderRequest>(10);
|
| 726 |
+
let (response_tx, response_rx) = mpsc::channel::<OrderResponse>(10);
|
| 727 |
+
|
| 728 |
+
// ========================================================================
|
| 729 |
+
// Spawn ZMQ Tick Subscriber task
|
| 730 |
+
// ========================================================================
|
| 731 |
+
tokio::spawn(async move {
|
| 732 |
+
let mut socket = zeromq::SubSocket::new();
|
| 733 |
+
match socket.connect("tcp://127.0.0.1:5555").await {
|
| 734 |
+
Ok(_) => println!("Connected to ZMQ Tick Publisher on port 5555"),
|
| 735 |
+
Err(e) => eprintln!("Failed to connect to ZMQ tick publisher: {}", e),
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
let _ = socket.subscribe("").await;
|
| 739 |
+
|
| 740 |
+
loop {
|
| 741 |
+
match socket.recv().await {
|
| 742 |
+
Ok(msg) => {
|
| 743 |
+
if let Some(payload_bytes) = msg.get(0) {
|
| 744 |
+
if let Ok(json_str) = std::str::from_utf8(payload_bytes) {
|
| 745 |
+
match serde_json::from_str::<TickData>(json_str) {
|
| 746 |
+
Ok(tick) => {
|
| 747 |
+
if let Err(e) = tick_tx.send(tick).await {
|
| 748 |
+
eprintln!("Tick channel error: {}", e);
|
| 749 |
+
break;
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
Err(e) => eprintln!("JSON Parse Error: {}. Msg: {}", e, json_str),
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
}
|
| 756 |
+
}
|
| 757 |
+
Err(e) => {
|
| 758 |
+
eprintln!("ZMQ Tick Recv Error: {}", e);
|
| 759 |
+
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
| 760 |
+
}
|
| 761 |
+
}
|
| 762 |
+
}
|
| 763 |
+
});
|
| 764 |
+
|
| 765 |
+
// ========================================================================
|
| 766 |
+
// Spawn ZMQ Order Request/Response task
|
| 767 |
+
// ========================================================================
|
| 768 |
+
tokio::spawn(async move {
|
| 769 |
+
let mut socket = zeromq::ReqSocket::new();
|
| 770 |
+
match socket.connect("tcp://127.0.0.1:5556").await {
|
| 771 |
+
Ok(_) => println!("Connected to ZMQ Order Handler on port 5556"),
|
| 772 |
+
Err(e) => {
|
| 773 |
+
eprintln!("Failed to connect to ZMQ order handler: {}", e);
|
| 774 |
+
return;
|
| 775 |
+
}
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
while let Some(order_request) = order_rx.recv().await {
|
| 779 |
+
// Serialize order request to JSON
|
| 780 |
+
let json_request = match serde_json::to_string(&order_request) {
|
| 781 |
+
Ok(json) => json,
|
| 782 |
+
Err(e) => {
|
| 783 |
+
eprintln!("Failed to serialize order request: {}", e);
|
| 784 |
+
continue;
|
| 785 |
+
}
|
| 786 |
+
};
|
| 787 |
+
|
| 788 |
+
println!("Sending request: {}", json_request);
|
| 789 |
+
|
| 790 |
+
// Send request
|
| 791 |
+
if let Err(e) = socket.send(json_request.into()).await {
|
| 792 |
+
eprintln!("Failed to send: {}", e);
|
| 793 |
+
let _ = response_tx.send(OrderResponse {
|
| 794 |
+
success: false,
|
| 795 |
+
ticket: None,
|
| 796 |
+
error: Some(format!("Send failed: {}", e)),
|
| 797 |
+
message: None,
|
| 798 |
+
}).await;
|
| 799 |
+
continue;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
// Wait for response
|
| 803 |
+
match socket.recv().await {
|
| 804 |
+
Ok(msg) => {
|
| 805 |
+
if let Some(payload_bytes) = msg.get(0) {
|
| 806 |
+
if let Ok(json_str) = std::str::from_utf8(payload_bytes) {
|
| 807 |
+
println!("Received response: {}", json_str);
|
| 808 |
+
match serde_json::from_str::<OrderResponse>(json_str) {
|
| 809 |
+
Ok(response) => {
|
| 810 |
+
let _ = response_tx.send(response).await;
|
| 811 |
+
}
|
| 812 |
+
Err(e) => {
|
| 813 |
+
let _ = response_tx.send(OrderResponse {
|
| 814 |
+
success: false,
|
| 815 |
+
ticket: None,
|
| 816 |
+
error: Some(format!("Parse error: {}", e)),
|
| 817 |
+
message: None,
|
| 818 |
+
}).await;
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
}
|
| 822 |
+
}
|
| 823 |
+
}
|
| 824 |
+
Err(e) => {
|
| 825 |
+
eprintln!("Response recv error: {}", e);
|
| 826 |
+
let _ = response_tx.send(OrderResponse {
|
| 827 |
+
success: false,
|
| 828 |
+
ticket: None,
|
| 829 |
+
error: Some(format!("Recv failed: {}", e)),
|
| 830 |
+
message: None,
|
| 831 |
+
}).await;
|
| 832 |
+
}
|
| 833 |
+
}
|
| 834 |
+
}
|
| 835 |
+
});
|
| 836 |
+
|
| 837 |
+
// ========================================================================
|
| 838 |
+
// Run the egui application
|
| 839 |
+
// ========================================================================
|
| 840 |
+
let options = eframe::NativeOptions {
|
| 841 |
+
viewport: egui::ViewportBuilder::default()
|
| 842 |
+
.with_inner_size([1200.0, 800.0])
|
| 843 |
+
.with_title("Rust + ZMQ + MT5 Trading Chart"),
|
| 844 |
+
..Default::default()
|
| 845 |
+
};
|
| 846 |
+
|
| 847 |
+
eframe::run_native(
|
| 848 |
+
"Rust + ZMQ + MT5 Trading Chart",
|
| 849 |
+
options,
|
| 850 |
+
Box::new(|_cc| Box::new(Mt5ChartApp::new(tick_rx, order_tx, response_rx))),
|
| 851 |
+
).map_err(|e| e.into())
|
| 852 |
+
}
|
TECHSTACK.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Techstack
|
| 2 |
+
|
| 3 |
+
Audit of **SUM3-Trading-Terminal** project files (excluding environment and cache):
|
| 4 |
+
|
| 5 |
+
| File Type | Count | Size (KB) |
|
| 6 |
+
| :--- | :--- | :--- |
|
| 7 |
+
| Rust (.rs) | 9 | 95.8 |
|
| 8 |
+
| CSV (.csv) | 7 | 1,226.9 |
|
| 9 |
+
| PNG Image (.png) | 7 | 998.7 |
|
| 10 |
+
| (no extension) | 2 | 2.1 |
|
| 11 |
+
| DLL Library (.dll) | 2 | 736.0 |
|
| 12 |
+
| Lock File (.lock) | 2 | 235.3 |
|
| 13 |
+
| TOML (.toml) | 2 | 0.7 |
|
| 14 |
+
| Markdown (.md) | 1 | 0.5 |
|
| 15 |
+
| MQL Header (.mqh) | 1 | 4.0 |
|
| 16 |
+
| MQL5 Source (.mq5) | 1 | 18.6 |
|
| 17 |
+
| Python (.py) | 1 | 26.5 |
|
| 18 |
+
| **Total** | **35** | **3,345.2** |
|
gmp-terminal/Cargo.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
gmp-terminal/Cargo.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "gmp-terminal"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
edition = "2021"
|
| 5 |
+
|
| 6 |
+
[dependencies]
|
| 7 |
+
eframe = "0.27.1"
|
| 8 |
+
egui = "0.27.1"
|
| 9 |
+
egui_plot = "0.27.1"
|
| 10 |
+
zeromq = "0.5.0-pre"
|
| 11 |
+
serde = { version = "1.0", features = ["derive"] }
|
| 12 |
+
serde_json = "1.0"
|
| 13 |
+
tokio = { version = "1.36", features = ["full"] }
|
| 14 |
+
chrono = "0.4"
|
| 15 |
+
|
| 16 |
+
[profile.release]
|
| 17 |
+
opt-level = 3
|
| 18 |
+
lto = true
|
| 19 |
+
codegen-units = 1
|
gmp-terminal/src/app.rs
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// app.rs -- Application State & eframe::App Implementation
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Central state struct shared across all UI modules.
|
| 5 |
+
// Tab switching pauses inactive tab computation.
|
| 6 |
+
// ============================================================================
|
| 7 |
+
|
| 8 |
+
use crate::gmp_engine::{IntervalProfile, RawTick};
|
| 9 |
+
use crate::zmq_bridge::{OrderRequest, OrderResponse, PositionData, PendingOrderData, TickData};
|
| 10 |
+
use crate::tab_time;
|
| 11 |
+
use crate::tab_trade;
|
| 12 |
+
use crate::trading_panel;
|
| 13 |
+
use std::io::Write;
|
| 14 |
+
use std::path::PathBuf;
|
| 15 |
+
use tokio::sync::mpsc;
|
| 16 |
+
|
| 17 |
+
// βββ Tab Enum ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
|
| 19 |
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
| 20 |
+
pub enum TabMode {
|
| 21 |
+
TimeBased,
|
| 22 |
+
TradeBased,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// βββ Application State ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
pub struct AppState {
|
| 28 |
+
// ZMQ channels
|
| 29 |
+
pub tick_rx: mpsc::Receiver<TickData>,
|
| 30 |
+
pub order_tx: mpsc::Sender<OrderRequest>,
|
| 31 |
+
pub response_rx: mpsc::Receiver<OrderResponse>,
|
| 32 |
+
|
| 33 |
+
// Raw tick buffer (rolling)
|
| 34 |
+
pub raw_ticks: Vec<RawTick>,
|
| 35 |
+
pub symbol: String,
|
| 36 |
+
|
| 37 |
+
// Account
|
| 38 |
+
pub balance: f64,
|
| 39 |
+
pub equity: f64,
|
| 40 |
+
pub margin: f64,
|
| 41 |
+
pub free_margin: f64,
|
| 42 |
+
pub min_lot: f64,
|
| 43 |
+
pub max_lot: f64,
|
| 44 |
+
pub lot_step: f64,
|
| 45 |
+
|
| 46 |
+
// Positions & orders (from latest tick)
|
| 47 |
+
pub positions: Vec<PositionData>,
|
| 48 |
+
pub pending_orders: Vec<PendingOrderData>,
|
| 49 |
+
|
| 50 |
+
// Trading UI
|
| 51 |
+
pub lot_size: f64,
|
| 52 |
+
pub lot_size_str: String,
|
| 53 |
+
pub limit_price_str: String,
|
| 54 |
+
pub last_status: Option<String>,
|
| 55 |
+
|
| 56 |
+
// Chart config
|
| 57 |
+
pub bin_size: f64,
|
| 58 |
+
pub bin_size_str: String,
|
| 59 |
+
pub active_tab: TabMode,
|
| 60 |
+
|
| 61 |
+
// Time-based tab
|
| 62 |
+
pub time_interval_secs: u64,
|
| 63 |
+
pub time_input_str: String,
|
| 64 |
+
pub time_parse_err: Option<String>,
|
| 65 |
+
pub time_profiles: Vec<IntervalProfile>,
|
| 66 |
+
pub profiles_dirty: bool,
|
| 67 |
+
pub show_footprint: bool,
|
| 68 |
+
|
| 69 |
+
// Trade-based tab
|
| 70 |
+
pub trade_interval: usize,
|
| 71 |
+
pub trade_input_str: String,
|
| 72 |
+
pub trade_profiles: Vec<IntervalProfile>,
|
| 73 |
+
pub profiles_dirty_trade: bool,
|
| 74 |
+
pub show_footprint_trade: bool,
|
| 75 |
+
|
| 76 |
+
// History download
|
| 77 |
+
pub history_start: String,
|
| 78 |
+
pub history_end: String,
|
| 79 |
+
pub history_tf: String,
|
| 80 |
+
pub history_mode: String,
|
| 81 |
+
|
| 82 |
+
// Live recording
|
| 83 |
+
pub is_recording: bool,
|
| 84 |
+
pub live_record_file: Option<std::fs::File>,
|
| 85 |
+
|
| 86 |
+
// Output
|
| 87 |
+
pub output_dir: PathBuf,
|
| 88 |
+
pub request_counter: u64,
|
| 89 |
+
pub pending_history: Option<(u64, String, String, String)>,
|
| 90 |
+
|
| 91 |
+
// Max ticks to keep in memory
|
| 92 |
+
max_ticks: usize,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
impl AppState {
|
| 96 |
+
pub fn new(
|
| 97 |
+
tick_rx: mpsc::Receiver<TickData>,
|
| 98 |
+
order_tx: mpsc::Sender<OrderRequest>,
|
| 99 |
+
response_rx: mpsc::Receiver<OrderResponse>,
|
| 100 |
+
) -> Self {
|
| 101 |
+
let now = chrono::Local::now();
|
| 102 |
+
let today = now.format("%Y.%m.%d").to_string();
|
| 103 |
+
let output_dir = PathBuf::from("output");
|
| 104 |
+
std::fs::create_dir_all(&output_dir).ok();
|
| 105 |
+
|
| 106 |
+
Self {
|
| 107 |
+
tick_rx,
|
| 108 |
+
order_tx,
|
| 109 |
+
response_rx,
|
| 110 |
+
raw_ticks: Vec::new(),
|
| 111 |
+
symbol: "Waiting...".to_string(),
|
| 112 |
+
balance: 0.0,
|
| 113 |
+
equity: 0.0,
|
| 114 |
+
margin: 0.0,
|
| 115 |
+
free_margin: 0.0,
|
| 116 |
+
min_lot: 0.01,
|
| 117 |
+
max_lot: 100.0,
|
| 118 |
+
lot_step: 0.01,
|
| 119 |
+
positions: Vec::new(),
|
| 120 |
+
pending_orders: Vec::new(),
|
| 121 |
+
lot_size: 0.01,
|
| 122 |
+
lot_size_str: "0.01".to_string(),
|
| 123 |
+
limit_price_str: "0.0".to_string(),
|
| 124 |
+
last_status: None,
|
| 125 |
+
bin_size: 1.0,
|
| 126 |
+
bin_size_str: "1".to_string(),
|
| 127 |
+
active_tab: TabMode::TimeBased,
|
| 128 |
+
time_interval_secs: 60,
|
| 129 |
+
time_input_str: "1m".to_string(),
|
| 130 |
+
time_parse_err: None,
|
| 131 |
+
time_profiles: Vec::new(),
|
| 132 |
+
profiles_dirty: true,
|
| 133 |
+
show_footprint: false,
|
| 134 |
+
trade_interval: 50,
|
| 135 |
+
trade_input_str: "50".to_string(),
|
| 136 |
+
trade_profiles: Vec::new(),
|
| 137 |
+
profiles_dirty_trade: true,
|
| 138 |
+
show_footprint_trade: false,
|
| 139 |
+
history_start: today.clone(),
|
| 140 |
+
history_end: today,
|
| 141 |
+
history_tf: "M1".to_string(),
|
| 142 |
+
history_mode: "TICKS".to_string(),
|
| 143 |
+
is_recording: false,
|
| 144 |
+
live_record_file: None,
|
| 145 |
+
output_dir,
|
| 146 |
+
request_counter: 0,
|
| 147 |
+
pending_history: None,
|
| 148 |
+
max_ticks: 50_000,
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// βββ eframe::App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
+
|
| 155 |
+
pub struct GmpTerminalApp {
|
| 156 |
+
pub state: AppState,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
impl GmpTerminalApp {
|
| 160 |
+
pub fn new(
|
| 161 |
+
tick_rx: mpsc::Receiver<TickData>,
|
| 162 |
+
order_tx: mpsc::Sender<OrderRequest>,
|
| 163 |
+
response_rx: mpsc::Receiver<OrderResponse>,
|
| 164 |
+
) -> Self {
|
| 165 |
+
Self {
|
| 166 |
+
state: AppState::new(tick_rx, order_tx, response_rx),
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
impl eframe::App for GmpTerminalApp {
|
| 172 |
+
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
| 173 |
+
let state = &mut self.state;
|
| 174 |
+
|
| 175 |
+
// ββ Drain tick channel ββ
|
| 176 |
+
let mut new_ticks = false;
|
| 177 |
+
while let Ok(tick) = state.tick_rx.try_recv() {
|
| 178 |
+
state.symbol = tick.symbol.clone();
|
| 179 |
+
|
| 180 |
+
// Record if active
|
| 181 |
+
if state.is_recording {
|
| 182 |
+
if let Some(ref mut file) = state.live_record_file {
|
| 183 |
+
let _ = writeln!(
|
| 184 |
+
file, "{},{},{},{}",
|
| 185 |
+
tick.time, tick.bid, tick.ask, tick.volume
|
| 186 |
+
);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Update account info
|
| 191 |
+
if tick.balance > 0.0 {
|
| 192 |
+
state.balance = tick.balance;
|
| 193 |
+
state.equity = tick.equity;
|
| 194 |
+
state.margin = tick.margin;
|
| 195 |
+
state.free_margin = tick.free_margin;
|
| 196 |
+
state.min_lot = tick.min_lot;
|
| 197 |
+
state.max_lot = tick.max_lot;
|
| 198 |
+
if tick.lot_step > 0.0 {
|
| 199 |
+
state.lot_step = tick.lot_step;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Update positions/orders
|
| 204 |
+
state.positions = tick.positions.clone();
|
| 205 |
+
state.pending_orders = tick.orders.clone();
|
| 206 |
+
|
| 207 |
+
// Use mid-price (avg of bid/ask) for GMP computation
|
| 208 |
+
let mid = (tick.bid + tick.ask) * 0.5;
|
| 209 |
+
let idx = state.raw_ticks.len();
|
| 210 |
+
state.raw_ticks.push(RawTick {
|
| 211 |
+
price: mid,
|
| 212 |
+
time: tick.time,
|
| 213 |
+
index: idx,
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
new_ticks = true;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// Trim buffer
|
| 220 |
+
if state.raw_ticks.len() > state.max_ticks {
|
| 221 |
+
let excess = state.raw_ticks.len() - state.max_ticks;
|
| 222 |
+
state.raw_ticks.drain(..excess);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Mark profiles dirty if new ticks arrived
|
| 226 |
+
if new_ticks {
|
| 227 |
+
match state.active_tab {
|
| 228 |
+
TabMode::TimeBased => state.profiles_dirty = true,
|
| 229 |
+
TabMode::TradeBased => state.profiles_dirty_trade = true,
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// ββ Drain order responses ββ
|
| 234 |
+
while let Ok(resp) = state.response_rx.try_recv() {
|
| 235 |
+
if resp.success {
|
| 236 |
+
if let Some(ref msg) = resp.message {
|
| 237 |
+
if msg.contains("||CSV_DATA||") {
|
| 238 |
+
handle_csv_response(state, msg);
|
| 239 |
+
} else {
|
| 240 |
+
state.last_status = Some(format!("OK: {}", msg));
|
| 241 |
+
}
|
| 242 |
+
} else {
|
| 243 |
+
state.last_status = Some(format!(
|
| 244 |
+
"OK: ticket {}",
|
| 245 |
+
resp.ticket.unwrap_or(0)
|
| 246 |
+
));
|
| 247 |
+
}
|
| 248 |
+
} else {
|
| 249 |
+
state.pending_history = None;
|
| 250 |
+
state.last_status = Some(format!(
|
| 251 |
+
"FAIL: {}",
|
| 252 |
+
resp.error.unwrap_or_else(|| "Unknown".to_string())
|
| 253 |
+
));
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// ββ Side Panel ββ
|
| 258 |
+
egui::SidePanel::left("trading_panel")
|
| 259 |
+
.min_width(260.0)
|
| 260 |
+
.max_width(300.0)
|
| 261 |
+
.show(ctx, |ui| {
|
| 262 |
+
egui::ScrollArea::vertical().show(ui, |ui| {
|
| 263 |
+
trading_panel::render_trading_panel(ui, state);
|
| 264 |
+
});
|
| 265 |
+
});
|
| 266 |
+
|
| 267 |
+
// ββ Central Panel ββ
|
| 268 |
+
egui::CentralPanel::default().show(ctx, |ui| {
|
| 269 |
+
// Header
|
| 270 |
+
ui.horizontal(|ui| {
|
| 271 |
+
ui.heading(&state.symbol);
|
| 272 |
+
if let Some(last) = state.raw_ticks.last() {
|
| 273 |
+
let price = last.price;
|
| 274 |
+
ui.label(format!("{:.5}", price));
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
// Tab bar
|
| 279 |
+
ui.horizontal(|ui| {
|
| 280 |
+
if ui
|
| 281 |
+
.selectable_label(state.active_tab == TabMode::TimeBased, "Time-Based")
|
| 282 |
+
.clicked()
|
| 283 |
+
{
|
| 284 |
+
state.active_tab = TabMode::TimeBased;
|
| 285 |
+
state.profiles_dirty = true;
|
| 286 |
+
}
|
| 287 |
+
if ui
|
| 288 |
+
.selectable_label(state.active_tab == TabMode::TradeBased, "Trade-Based")
|
| 289 |
+
.clicked()
|
| 290 |
+
{
|
| 291 |
+
state.active_tab = TabMode::TradeBased;
|
| 292 |
+
state.profiles_dirty_trade = true;
|
| 293 |
+
}
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
ui.separator();
|
| 297 |
+
|
| 298 |
+
// Active tab content -- only the selected tab computes
|
| 299 |
+
match state.active_tab {
|
| 300 |
+
TabMode::TimeBased => tab_time::render(ui, state),
|
| 301 |
+
TabMode::TradeBased => tab_trade::render(ui, state),
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
// Continuous repaint
|
| 306 |
+
ctx.request_repaint();
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// βββ CSV Response Handler ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 311 |
+
|
| 312 |
+
fn handle_csv_response(state: &mut AppState, msg: &str) {
|
| 313 |
+
let parts: Vec<&str> = msg.splitn(2, "||CSV_DATA||").collect();
|
| 314 |
+
if parts.len() == 2 {
|
| 315 |
+
let info = parts[0];
|
| 316 |
+
let csv = parts[1];
|
| 317 |
+
|
| 318 |
+
if let Some((id, sym, tf, mode)) = state.pending_history.take() {
|
| 319 |
+
let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
| 320 |
+
let filename = format!(
|
| 321 |
+
"{}/History_{}_{}_{}_ID{:04}_{}.csv",
|
| 322 |
+
state.output_dir.display(),
|
| 323 |
+
sym, tf, mode, id, ts
|
| 324 |
+
);
|
| 325 |
+
let csv_clean = csv.replace("|NL|", "\n");
|
| 326 |
+
match std::fs::write(&filename, csv_clean) {
|
| 327 |
+
Ok(_) => {
|
| 328 |
+
state.last_status = Some(format!("Saved: {} ({})", filename, info));
|
| 329 |
+
}
|
| 330 |
+
Err(e) => {
|
| 331 |
+
state.last_status = Some(format!("Save error: {}", e));
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
} else {
|
| 335 |
+
state.last_status = Some(format!("OK: {}", info));
|
| 336 |
+
}
|
| 337 |
+
} else {
|
| 338 |
+
state.last_status = Some(format!("OK: {}", msg));
|
| 339 |
+
}
|
| 340 |
+
}
|
gmp-terminal/src/gmp_engine.rs
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// gmp_engine.rs -- Core GMP & Footprint GMP Algorithms
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Direct port of generate_profiles.py (mBA-GMP.v3).
|
| 5 |
+
// All computation is pure; no GUI or IO dependencies.
|
| 6 |
+
// ============================================================================
|
| 7 |
+
|
| 8 |
+
use std::collections::BTreeMap;
|
| 9 |
+
|
| 10 |
+
// βββ Data Structures βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
|
| 12 |
+
/// A single raw tick used as input to the profile builders.
|
| 13 |
+
#[derive(Clone, Debug)]
|
| 14 |
+
pub struct RawTick {
|
| 15 |
+
pub price: f64,
|
| 16 |
+
pub time: i64, // unix timestamp (seconds)
|
| 17 |
+
pub index: usize, // sequential trade index
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/// One bin in a GMP profile.
|
| 21 |
+
#[derive(Clone, Debug, Default)]
|
| 22 |
+
pub struct GmpBin {
|
| 23 |
+
pub count: u32,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/// One bin in a Footprint profile (GMP + directional classification).
|
| 27 |
+
#[derive(Clone, Debug, Default)]
|
| 28 |
+
pub struct FootprintBin {
|
| 29 |
+
pub count: u32,
|
| 30 |
+
pub up: u32,
|
| 31 |
+
pub down: u32,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
impl FootprintBin {
|
| 35 |
+
#[inline]
|
| 36 |
+
pub fn delta(&self) -> i32 {
|
| 37 |
+
self.up as i32 - self.down as i32
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/// A complete GMP profile for one interval.
|
| 42 |
+
#[derive(Clone, Debug)]
|
| 43 |
+
pub struct GmpProfile {
|
| 44 |
+
pub bins: BTreeMap<i64, GmpBin>,
|
| 45 |
+
pub beta: f64,
|
| 46 |
+
pub min_bin: i64,
|
| 47 |
+
pub max_bin: i64,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/// A complete Footprint profile for one interval.
|
| 51 |
+
#[derive(Clone, Debug)]
|
| 52 |
+
pub struct FootprintProfile {
|
| 53 |
+
pub bins: BTreeMap<i64, FootprintBin>,
|
| 54 |
+
pub beta: f64,
|
| 55 |
+
pub min_bin: i64,
|
| 56 |
+
pub max_bin: i64,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// βββ Core Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
|
| 61 |
+
/// Compute bin index: floor(price / beta).
|
| 62 |
+
/// Matches the Python: `int(math.floor(price / beta))`.
|
| 63 |
+
#[inline]
|
| 64 |
+
pub fn bin_index(price: f64, beta: f64) -> i64 {
|
| 65 |
+
(price / beta).floor() as i64
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/// Price range for a given bin: [b*beta, (b+1)*beta).
|
| 69 |
+
#[inline]
|
| 70 |
+
pub fn bin_price_range(b: i64, beta: f64) -> (f64, f64) {
|
| 71 |
+
(b as f64 * beta, (b + 1) as f64 * beta)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// βββ GMP Builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
/// Build a Gap-filled Market Profile from a slice of ticks.
|
| 77 |
+
///
|
| 78 |
+
/// Algorithm (from main.tex Section IV):
|
| 79 |
+
/// 1. CMP placement: each tick fills its own bin.
|
| 80 |
+
/// 2. Gap-fill: for each consecutive pair, fill every bin strictly
|
| 81 |
+
/// between the two endpoints.
|
| 82 |
+
pub fn build_gmp(ticks: &[RawTick], beta: f64) -> GmpProfile {
|
| 83 |
+
let mut bins: BTreeMap<i64, GmpBin> = BTreeMap::new();
|
| 84 |
+
|
| 85 |
+
if ticks.is_empty() {
|
| 86 |
+
return GmpProfile { bins, beta, min_bin: 0, max_bin: 0 };
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Phase 1 -- CMP placement
|
| 90 |
+
for t in ticks {
|
| 91 |
+
let b = bin_index(t.price, beta);
|
| 92 |
+
bins.entry(b).or_default().count += 1;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Phase 2 -- Gap-fill intermediate bins
|
| 96 |
+
for pair in ticks.windows(2) {
|
| 97 |
+
let b_from = bin_index(pair[0].price, beta);
|
| 98 |
+
let b_to = bin_index(pair[1].price, beta);
|
| 99 |
+
|
| 100 |
+
if (b_to - b_from).abs() <= 1 {
|
| 101 |
+
continue; // adjacent or same bin
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
let dir: i64 = if b_to > b_from { 1 } else { -1 };
|
| 105 |
+
let mut b = b_from + dir;
|
| 106 |
+
while b != b_to {
|
| 107 |
+
bins.entry(b).or_default().count += 1;
|
| 108 |
+
b += dir;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
let min_bin = *bins.keys().next().unwrap_or(&0);
|
| 113 |
+
let max_bin = *bins.keys().next_back().unwrap_or(&0);
|
| 114 |
+
|
| 115 |
+
GmpProfile { bins, beta, min_bin, max_bin }
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// βββ Footprint Builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
|
| 120 |
+
/// Build a Footprint (Up/Down-Bin) Profile from a slice of ticks.
|
| 121 |
+
///
|
| 122 |
+
/// Algorithm (from main.tex Section IV, "Up/Down-Bin Footprint Profile"):
|
| 123 |
+
/// 1. Compute GMP group labels (same logic as build_gmp).
|
| 124 |
+
/// 2. For each consecutive pair, classify bins on the path as up or down.
|
| 125 |
+
pub fn build_footprint(ticks: &[RawTick], beta: f64) -> FootprintProfile {
|
| 126 |
+
let mut bins: BTreeMap<i64, FootprintBin> = BTreeMap::new();
|
| 127 |
+
|
| 128 |
+
if ticks.is_empty() {
|
| 129 |
+
return FootprintProfile { bins, beta, min_bin: 0, max_bin: 0 };
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Phase 1 + 2 -- GMP placement (CMP + gap-fill) for the count field
|
| 133 |
+
for t in ticks {
|
| 134 |
+
let b = bin_index(t.price, beta);
|
| 135 |
+
bins.entry(b).or_default().count += 1;
|
| 136 |
+
}
|
| 137 |
+
for pair in ticks.windows(2) {
|
| 138 |
+
let b_from = bin_index(pair[0].price, beta);
|
| 139 |
+
let b_to = bin_index(pair[1].price, beta);
|
| 140 |
+
if (b_to - b_from).abs() <= 1 { continue; }
|
| 141 |
+
let dir: i64 = if b_to > b_from { 1 } else { -1 };
|
| 142 |
+
let mut b = b_from + dir;
|
| 143 |
+
while b != b_to {
|
| 144 |
+
bins.entry(b).or_default().count += 1;
|
| 145 |
+
b += dir;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Phase 3 -- Directional classification
|
| 150 |
+
for pair in ticks.windows(2) {
|
| 151 |
+
let src_price = pair[0].price;
|
| 152 |
+
let dst_price = pair[1].price;
|
| 153 |
+
let b_from = bin_index(src_price, beta);
|
| 154 |
+
let b_to = bin_index(dst_price, beta);
|
| 155 |
+
|
| 156 |
+
if b_from == b_to {
|
| 157 |
+
// Same bin -- classify by micro price movement
|
| 158 |
+
let entry = bins.entry(b_from).or_default();
|
| 159 |
+
if dst_price > src_price {
|
| 160 |
+
entry.up += 1;
|
| 161 |
+
} else if dst_price < src_price {
|
| 162 |
+
entry.down += 1;
|
| 163 |
+
}
|
| 164 |
+
continue;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
let is_up = b_to > b_from;
|
| 168 |
+
let dir: i64 = if is_up { 1 } else { -1 };
|
| 169 |
+
|
| 170 |
+
// Every bin on the path AFTER source (exclusive), up to and
|
| 171 |
+
// including destination, gets a directional count.
|
| 172 |
+
let mut b = b_from + dir;
|
| 173 |
+
loop {
|
| 174 |
+
let entry = bins.entry(b).or_default();
|
| 175 |
+
if is_up { entry.up += 1; } else { entry.down += 1; }
|
| 176 |
+
if b == b_to { break; }
|
| 177 |
+
b += dir;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
let min_bin = *bins.keys().next().unwrap_or(&0);
|
| 182 |
+
let max_bin = *bins.keys().next_back().unwrap_or(&0);
|
| 183 |
+
|
| 184 |
+
FootprintProfile { bins, beta, min_bin, max_bin }
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// βββ Interval Aggregator βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 188 |
+
|
| 189 |
+
/// A computed interval containing both profile types and metadata.
|
| 190 |
+
#[derive(Clone, Debug)]
|
| 191 |
+
pub struct IntervalProfile {
|
| 192 |
+
pub gmp: GmpProfile,
|
| 193 |
+
pub footprint: FootprintProfile,
|
| 194 |
+
/// Label for the x-axis (time string or trade range).
|
| 195 |
+
pub label: String,
|
| 196 |
+
/// Midpoint price of this interval (for alignment).
|
| 197 |
+
pub mid_price: f64,
|
| 198 |
+
/// Start index in the original tick buffer (for scrolling).
|
| 199 |
+
pub start_idx: usize,
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/// Aggregate ticks into time-based intervals and compute profiles.
|
| 203 |
+
///
|
| 204 |
+
/// `interval_secs` -- duration of each interval in seconds.
|
| 205 |
+
pub fn aggregate_by_time(
|
| 206 |
+
ticks: &[RawTick],
|
| 207 |
+
interval_secs: u64,
|
| 208 |
+
beta: f64,
|
| 209 |
+
) -> Vec<IntervalProfile> {
|
| 210 |
+
if ticks.is_empty() || interval_secs == 0 {
|
| 211 |
+
return Vec::new();
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
let mut profiles = Vec::new();
|
| 215 |
+
let mut start = 0usize;
|
| 216 |
+
let base_time = ticks[0].time;
|
| 217 |
+
|
| 218 |
+
while start < ticks.len() {
|
| 219 |
+
let bucket = ((ticks[start].time - base_time) as u64) / interval_secs;
|
| 220 |
+
let bucket_end_time = base_time + ((bucket + 1) * interval_secs) as i64;
|
| 221 |
+
|
| 222 |
+
// Find the end of this bucket
|
| 223 |
+
let mut end = start;
|
| 224 |
+
while end < ticks.len() && ticks[end].time < bucket_end_time {
|
| 225 |
+
end += 1;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
let slice = &ticks[start..end];
|
| 229 |
+
if !slice.is_empty() {
|
| 230 |
+
let gmp = build_gmp(slice, beta);
|
| 231 |
+
let footprint = build_footprint(slice, beta);
|
| 232 |
+
|
| 233 |
+
// Label: time range
|
| 234 |
+
let t0 = slice.first().unwrap().time;
|
| 235 |
+
let t1 = slice.last().unwrap().time;
|
| 236 |
+
let label = format_time_range(t0, t1);
|
| 237 |
+
|
| 238 |
+
// Midpoint price
|
| 239 |
+
let sum: f64 = slice.iter().map(|t| t.price).sum();
|
| 240 |
+
let mid_price = sum / slice.len() as f64;
|
| 241 |
+
|
| 242 |
+
profiles.push(IntervalProfile {
|
| 243 |
+
gmp,
|
| 244 |
+
footprint,
|
| 245 |
+
label,
|
| 246 |
+
mid_price,
|
| 247 |
+
start_idx: start,
|
| 248 |
+
});
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
start = end;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
profiles
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/// Aggregate ticks into trade-count-based intervals and compute profiles.
|
| 258 |
+
///
|
| 259 |
+
/// `trade_interval` -- number of ticks per group.
|
| 260 |
+
pub fn aggregate_by_trades(
|
| 261 |
+
ticks: &[RawTick],
|
| 262 |
+
trade_interval: usize,
|
| 263 |
+
beta: f64,
|
| 264 |
+
) -> Vec<IntervalProfile> {
|
| 265 |
+
if ticks.is_empty() || trade_interval == 0 {
|
| 266 |
+
return Vec::new();
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
let mut profiles = Vec::new();
|
| 270 |
+
|
| 271 |
+
for (chunk_idx, chunk) in ticks.chunks(trade_interval).enumerate() {
|
| 272 |
+
let gmp = build_gmp(chunk, beta);
|
| 273 |
+
let footprint = build_footprint(chunk, beta);
|
| 274 |
+
|
| 275 |
+
let first_idx = chunk_idx * trade_interval;
|
| 276 |
+
let last_idx = first_idx + chunk.len() - 1;
|
| 277 |
+
let label = format!("T{}-T{}", first_idx + 1, last_idx + 1);
|
| 278 |
+
|
| 279 |
+
let sum: f64 = chunk.iter().map(|t| t.price).sum();
|
| 280 |
+
let mid_price = sum / chunk.len() as f64;
|
| 281 |
+
|
| 282 |
+
profiles.push(IntervalProfile {
|
| 283 |
+
gmp,
|
| 284 |
+
footprint,
|
| 285 |
+
label,
|
| 286 |
+
mid_price,
|
| 287 |
+
start_idx: first_idx,
|
| 288 |
+
});
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
profiles
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 295 |
+
|
| 296 |
+
fn format_time_range(t0: i64, t1: i64) -> String {
|
| 297 |
+
let fmt = |t: i64| -> String {
|
| 298 |
+
let h = (t / 3600) % 24;
|
| 299 |
+
let m = (t / 60) % 60;
|
| 300 |
+
let s = t % 60;
|
| 301 |
+
format!("{:02}:{:02}:{:02}", h, m, s)
|
| 302 |
+
};
|
| 303 |
+
if t0 == t1 {
|
| 304 |
+
fmt(t0)
|
| 305 |
+
} else {
|
| 306 |
+
format!("{}-{}", fmt(t0), fmt(t1))
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// βββ Tests (parity with generate_profiles.py) ββββββββββββββββββββββββββββββββ
|
| 311 |
+
|
| 312 |
+
#[cfg(test)]
|
| 313 |
+
mod tests {
|
| 314 |
+
use super::*;
|
| 315 |
+
|
| 316 |
+
/// The exact 10 datapoints from generate_profiles.py.
|
| 317 |
+
fn reference_datapoints() -> Vec<RawTick> {
|
| 318 |
+
vec![
|
| 319 |
+
RawTick { price: 3000.914, time: 1, index: 0 },
|
| 320 |
+
RawTick { price: 3003.837, time: 2, index: 1 },
|
| 321 |
+
RawTick { price: 3002.432, time: 3, index: 2 },
|
| 322 |
+
RawTick { price: 3009.892, time: 4, index: 3 },
|
| 323 |
+
RawTick { price: 3007.698, time: 5, index: 4 },
|
| 324 |
+
RawTick { price: 3009.176, time: 6, index: 5 },
|
| 325 |
+
RawTick { price: 3003.381, time: 7, index: 6 },
|
| 326 |
+
RawTick { price: 3004.283, time: 8, index: 7 },
|
| 327 |
+
RawTick { price: 3003.512, time: 9, index: 8 },
|
| 328 |
+
RawTick { price: 3003.012, time: 10, index: 9 },
|
| 329 |
+
]
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
#[test]
|
| 333 |
+
fn test_bin_index() {
|
| 334 |
+
// beta = 1: floor(3000.914 / 1) = 3000
|
| 335 |
+
assert_eq!(bin_index(3000.914, 1.0), 3000);
|
| 336 |
+
assert_eq!(bin_index(3003.837, 1.0), 3003);
|
| 337 |
+
assert_eq!(bin_index(3009.892, 1.0), 3009);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
#[test]
|
| 341 |
+
fn test_gmp_parity_with_python() {
|
| 342 |
+
// From generate_profiles.py with beta=1, the GMP for the 10
|
| 343 |
+
// datapoints should produce stacks at bins 3000..=3009, and the
|
| 344 |
+
// total stack count should be 25 (10 CMP + 15 gap-fills).
|
| 345 |
+
let ticks = reference_datapoints();
|
| 346 |
+
let profile = build_gmp(&ticks, 1.0);
|
| 347 |
+
|
| 348 |
+
// All 10 bins from 3000 to 3009 should be populated
|
| 349 |
+
for b in 3000..=3009 {
|
| 350 |
+
assert!(
|
| 351 |
+
profile.bins.contains_key(&b),
|
| 352 |
+
"GMP missing bin {}",
|
| 353 |
+
b
|
| 354 |
+
);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// Total stacks = 25 (verified against Python output)
|
| 358 |
+
let total: u32 = profile.bins.values().map(|b| b.count).sum();
|
| 359 |
+
assert_eq!(total, 25, "GMP total stacks mismatch");
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
#[test]
|
| 363 |
+
fn test_footprint_parity_with_python() {
|
| 364 |
+
// The footprint for the same 10 datapoints should have up/down
|
| 365 |
+
// counts that match the Python build_updown_profile output.
|
| 366 |
+
let ticks = reference_datapoints();
|
| 367 |
+
let fp = build_footprint(&ticks, 1.0);
|
| 368 |
+
|
| 369 |
+
// All 10 bins should exist
|
| 370 |
+
for b in 3000..=3009 {
|
| 371 |
+
assert!(
|
| 372 |
+
fp.bins.contains_key(&b),
|
| 373 |
+
"Footprint missing bin {}",
|
| 374 |
+
b
|
| 375 |
+
);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// Total stacks should also be 25
|
| 379 |
+
let total: u32 = fp.bins.values().map(|b| b.count).sum();
|
| 380 |
+
assert_eq!(total, 25, "Footprint total stacks mismatch");
|
| 381 |
+
|
| 382 |
+
// The sum of (up + down) across all bins should be > 0
|
| 383 |
+
let total_up: u32 = fp.bins.values().map(|b| b.up).sum();
|
| 384 |
+
let total_down: u32 = fp.bins.values().map(|b| b.down).sum();
|
| 385 |
+
assert!(total_up > 0, "Expected some up counts");
|
| 386 |
+
assert!(total_down > 0, "Expected some down counts");
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
#[test]
|
| 390 |
+
fn test_aggregate_by_trades() {
|
| 391 |
+
let ticks = reference_datapoints();
|
| 392 |
+
// Group by 5 trades => 2 intervals
|
| 393 |
+
let intervals = aggregate_by_trades(&ticks, 5, 1.0);
|
| 394 |
+
assert_eq!(intervals.len(), 2);
|
| 395 |
+
assert_eq!(intervals[0].label, "T1-T5");
|
| 396 |
+
assert_eq!(intervals[1].label, "T6-T10");
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
#[test]
|
| 400 |
+
fn test_aggregate_by_time() {
|
| 401 |
+
let ticks = reference_datapoints();
|
| 402 |
+
// 5-second intervals, ticks at time 1..10 => 2 intervals
|
| 403 |
+
let intervals = aggregate_by_time(&ticks, 5, 1.0);
|
| 404 |
+
assert_eq!(intervals.len(), 2);
|
| 405 |
+
}
|
| 406 |
+
}
|
gmp-terminal/src/main.rs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// main.rs -- GMP Trading Terminal Entry Point
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Bootstraps the Tokio async runtime, spawns ZMQ tasks, and launches
|
| 5 |
+
// the eframe GUI.
|
| 6 |
+
// ============================================================================
|
| 7 |
+
|
| 8 |
+
mod app;
|
| 9 |
+
mod gmp_engine;
|
| 10 |
+
mod tab_time;
|
| 11 |
+
mod tab_trade;
|
| 12 |
+
mod timeframe;
|
| 13 |
+
mod trading_panel;
|
| 14 |
+
mod zmq_bridge;
|
| 15 |
+
|
| 16 |
+
use egui;
|
| 17 |
+
use tokio::sync::mpsc;
|
| 18 |
+
|
| 19 |
+
#[tokio::main]
|
| 20 |
+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
| 21 |
+
// Tick data channel
|
| 22 |
+
let (tick_tx, tick_rx) = mpsc::channel(200);
|
| 23 |
+
|
| 24 |
+
// Order request / response channels
|
| 25 |
+
let (order_tx, order_rx) = mpsc::channel::<zmq_bridge::OrderRequest>(10);
|
| 26 |
+
let (resp_tx, resp_rx) = mpsc::channel::<zmq_bridge::OrderResponse>(10);
|
| 27 |
+
|
| 28 |
+
// Spawn ZMQ tasks
|
| 29 |
+
zmq_bridge::spawn_tick_subscriber(tick_tx);
|
| 30 |
+
zmq_bridge::spawn_order_handler(order_rx, resp_tx);
|
| 31 |
+
|
| 32 |
+
// Launch GUI
|
| 33 |
+
let options = eframe::NativeOptions {
|
| 34 |
+
viewport: egui::ViewportBuilder::default()
|
| 35 |
+
.with_inner_size([1100.0, 700.0])
|
| 36 |
+
.with_title("GMP Trading Terminal"),
|
| 37 |
+
..Default::default()
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
eframe::run_native(
|
| 41 |
+
"GMP Trading Terminal",
|
| 42 |
+
options,
|
| 43 |
+
Box::new(|_cc| Box::new(app::GmpTerminalApp::new(tick_rx, order_tx, resp_rx))),
|
| 44 |
+
)
|
| 45 |
+
.map_err(|e| e.into())
|
| 46 |
+
}
|
gmp-terminal/src/tab_time.rs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// tab_time.rs -- Time-Based Chart Tab
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Groups ticks by a custom time interval and renders GMP + Footprint
|
| 5 |
+
// profiles as horizontal bar histograms along a time x-axis.
|
| 6 |
+
// ============================================================================
|
| 7 |
+
|
| 8 |
+
use crate::app::AppState;
|
| 9 |
+
use crate::gmp_engine::{self, IntervalProfile};
|
| 10 |
+
use crate::timeframe;
|
| 11 |
+
use egui;
|
| 12 |
+
use egui_plot::{self, Plot, PlotPoints, Line, PlotPoint, Text};
|
| 13 |
+
|
| 14 |
+
/// Render the Time-Based tab UI and chart.
|
| 15 |
+
pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
|
| 16 |
+
// ββ Interval Input ββ
|
| 17 |
+
ui.horizontal(|ui| {
|
| 18 |
+
ui.label("Interval:");
|
| 19 |
+
let resp = ui.add(
|
| 20 |
+
egui::TextEdit::singleline(&mut state.time_input_str)
|
| 21 |
+
.desired_width(80.0)
|
| 22 |
+
.hint_text("e.g. 1:30, 4h30m"),
|
| 23 |
+
);
|
| 24 |
+
if resp.lost_focus() || resp.changed() {
|
| 25 |
+
match timeframe::parse_timeframe(&state.time_input_str) {
|
| 26 |
+
Ok(secs) => {
|
| 27 |
+
state.time_interval_secs = secs;
|
| 28 |
+
state.profiles_dirty = true;
|
| 29 |
+
state.time_parse_err = None;
|
| 30 |
+
}
|
| 31 |
+
Err(e) => {
|
| 32 |
+
state.time_parse_err = Some(e);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
if state.time_interval_secs > 0 {
|
| 37 |
+
ui.label(format!(
|
| 38 |
+
"= {}",
|
| 39 |
+
timeframe::format_seconds(state.time_interval_secs)
|
| 40 |
+
));
|
| 41 |
+
}
|
| 42 |
+
if let Some(ref e) = state.time_parse_err {
|
| 43 |
+
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), e);
|
| 44 |
+
}
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
ui.separator();
|
| 48 |
+
|
| 49 |
+
// ββ Recompute profiles if dirty ββ
|
| 50 |
+
if state.profiles_dirty || state.time_profiles.is_empty() {
|
| 51 |
+
state.time_profiles = gmp_engine::aggregate_by_time(
|
| 52 |
+
&state.raw_ticks,
|
| 53 |
+
state.time_interval_secs,
|
| 54 |
+
state.bin_size,
|
| 55 |
+
);
|
| 56 |
+
state.profiles_dirty = false;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// ββ Render Mode Toggle ββ
|
| 60 |
+
ui.horizontal(|ui| {
|
| 61 |
+
ui.label("View:");
|
| 62 |
+
ui.selectable_value(&mut state.show_footprint, false, "GMP");
|
| 63 |
+
ui.selectable_value(&mut state.show_footprint, true, "Footprint");
|
| 64 |
+
ui.label(format!(
|
| 65 |
+
"| {} intervals | {} ticks",
|
| 66 |
+
state.time_profiles.len(),
|
| 67 |
+
state.raw_ticks.len()
|
| 68 |
+
));
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
ui.separator();
|
| 72 |
+
|
| 73 |
+
// ββ Chart ββ
|
| 74 |
+
render_profile_chart(ui, &state.time_profiles, state.show_footprint, state.bin_size, "time_chart");
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/// Render a series of interval profiles as a horizontal-bar chart.
|
| 78 |
+
///
|
| 79 |
+
/// Each interval occupies one column along the x-axis.
|
| 80 |
+
/// Within each column, horizontal bars extend from the column center,
|
| 81 |
+
/// with length proportional to the stack count.
|
| 82 |
+
pub fn render_profile_chart(
|
| 83 |
+
ui: &mut egui::Ui,
|
| 84 |
+
profiles: &[IntervalProfile],
|
| 85 |
+
show_footprint: bool,
|
| 86 |
+
beta: f64,
|
| 87 |
+
plot_id: &str,
|
| 88 |
+
) {
|
| 89 |
+
if profiles.is_empty() {
|
| 90 |
+
ui.label("No data to display");
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
let plot = Plot::new(plot_id)
|
| 95 |
+
.legend(egui_plot::Legend::default())
|
| 96 |
+
.allow_boxed_zoom(true)
|
| 97 |
+
.allow_drag(true)
|
| 98 |
+
.allow_scroll(true)
|
| 99 |
+
.allow_zoom(true)
|
| 100 |
+
.y_axis_formatter(move |y, _range, _width| {
|
| 101 |
+
// Show price at bin boundaries
|
| 102 |
+
let bin = (y.value / beta).round() as i64;
|
| 103 |
+
format!("{:.2}", bin as f64 * beta)
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
plot.show(ui, |plot_ui| {
|
| 107 |
+
// Determine global min/max bin for y-axis range
|
| 108 |
+
let (global_min, global_max) = profiles.iter().fold(
|
| 109 |
+
(i64::MAX, i64::MIN),
|
| 110 |
+
|(gmin, gmax), p| {
|
| 111 |
+
let pmin = if show_footprint { p.footprint.min_bin } else { p.gmp.min_bin };
|
| 112 |
+
let pmax = if show_footprint { p.footprint.max_bin } else { p.gmp.max_bin };
|
| 113 |
+
(gmin.min(pmin), gmax.max(pmax))
|
| 114 |
+
},
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
// Find max count for normalization (bar width scaling)
|
| 118 |
+
let max_count = profiles.iter().flat_map(|p| {
|
| 119 |
+
if show_footprint {
|
| 120 |
+
p.footprint.bins.values().map(|b| b.count).collect::<Vec<_>>()
|
| 121 |
+
} else {
|
| 122 |
+
p.gmp.bins.values().map(|b| b.count).collect::<Vec<_>>()
|
| 123 |
+
}
|
| 124 |
+
}).max().unwrap_or(1).max(1) as f64;
|
| 125 |
+
|
| 126 |
+
// Column width in x-units
|
| 127 |
+
let col_width = 0.8;
|
| 128 |
+
let bar_scale = col_width / max_count;
|
| 129 |
+
|
| 130 |
+
for (col, profile) in profiles.iter().enumerate() {
|
| 131 |
+
let x_center = col as f64;
|
| 132 |
+
|
| 133 |
+
if show_footprint {
|
| 134 |
+
// Draw footprint bars: green (up) going right, red (down) going left
|
| 135 |
+
for (&bin_idx, bin) in &profile.footprint.bins {
|
| 136 |
+
let y = bin_idx as f64 * beta + beta * 0.5; // mid-bin price
|
| 137 |
+
|
| 138 |
+
if bin.up > 0 {
|
| 139 |
+
let w = bin.up as f64 * bar_scale;
|
| 140 |
+
let points = PlotPoints::new(vec![
|
| 141 |
+
[x_center, y],
|
| 142 |
+
[x_center + w, y],
|
| 143 |
+
]);
|
| 144 |
+
plot_ui.line(
|
| 145 |
+
Line::new(points)
|
| 146 |
+
.color(egui::Color32::from_rgb(50, 200, 100))
|
| 147 |
+
.width(2.0),
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
if bin.down > 0 {
|
| 151 |
+
let w = bin.down as f64 * bar_scale;
|
| 152 |
+
let points = PlotPoints::new(vec![
|
| 153 |
+
[x_center, y],
|
| 154 |
+
[x_center - w, y],
|
| 155 |
+
]);
|
| 156 |
+
plot_ui.line(
|
| 157 |
+
Line::new(points)
|
| 158 |
+
.color(egui::Color32::from_rgb(255, 80, 80))
|
| 159 |
+
.width(2.0),
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Delta annotation
|
| 164 |
+
let delta = bin.delta();
|
| 165 |
+
if delta != 0 {
|
| 166 |
+
let label = if delta > 0 {
|
| 167 |
+
format!("+{}", delta)
|
| 168 |
+
} else {
|
| 169 |
+
format!("{}", delta)
|
| 170 |
+
};
|
| 171 |
+
plot_ui.text(
|
| 172 |
+
Text::new(PlotPoint::new(x_center + col_width * 0.5, y), label)
|
| 173 |
+
.color(if delta > 0 {
|
| 174 |
+
egui::Color32::from_rgb(50, 200, 100)
|
| 175 |
+
} else {
|
| 176 |
+
egui::Color32::from_rgb(255, 80, 80)
|
| 177 |
+
}),
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
} else {
|
| 182 |
+
// Draw GMP bars: single color going right from center
|
| 183 |
+
for (&bin_idx, bin) in &profile.gmp.bins {
|
| 184 |
+
let y = bin_idx as f64 * beta + beta * 0.5;
|
| 185 |
+
let w = bin.count as f64 * bar_scale;
|
| 186 |
+
|
| 187 |
+
let points = PlotPoints::new(vec![
|
| 188 |
+
[x_center - w * 0.5, y],
|
| 189 |
+
[x_center + w * 0.5, y],
|
| 190 |
+
]);
|
| 191 |
+
plot_ui.line(
|
| 192 |
+
Line::new(points)
|
| 193 |
+
.color(egui::Color32::from_rgb(100, 150, 255))
|
| 194 |
+
.width(2.5),
|
| 195 |
+
);
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// Column separator
|
| 200 |
+
plot_ui.vline(
|
| 201 |
+
egui_plot::VLine::new(x_center - 0.5)
|
| 202 |
+
.color(egui::Color32::from_rgba_premultiplied(100, 100, 100, 40))
|
| 203 |
+
.width(0.5),
|
| 204 |
+
);
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
+
}
|
gmp-terminal/src/tab_trade.rs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// tab_trade.rs -- Trade-Based Chart Tab
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Groups ticks by trade count and renders GMP + Footprint profiles.
|
| 5 |
+
// X-axis = trade group index. Reuses the chart renderer from tab_time.
|
| 6 |
+
// ============================================================================
|
| 7 |
+
|
| 8 |
+
use crate::app::AppState;
|
| 9 |
+
use crate::gmp_engine;
|
| 10 |
+
use crate::tab_time;
|
| 11 |
+
use egui;
|
| 12 |
+
|
| 13 |
+
/// Render the Trade-Based tab UI and chart.
|
| 14 |
+
pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
|
| 15 |
+
// ββ Interval Input ββ
|
| 16 |
+
ui.horizontal(|ui| {
|
| 17 |
+
ui.label("Trades per group:");
|
| 18 |
+
let resp = ui.add(
|
| 19 |
+
egui::TextEdit::singleline(&mut state.trade_input_str)
|
| 20 |
+
.desired_width(60.0)
|
| 21 |
+
.hint_text("e.g. 50"),
|
| 22 |
+
);
|
| 23 |
+
if resp.lost_focus() || resp.changed() {
|
| 24 |
+
if let Ok(n) = state.trade_input_str.parse::<usize>() {
|
| 25 |
+
if n > 0 {
|
| 26 |
+
state.trade_interval = n;
|
| 27 |
+
state.profiles_dirty_trade = true;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
ui.label(format!("= {} trades/group", state.trade_interval));
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
ui.separator();
|
| 35 |
+
|
| 36 |
+
// ββ Recompute profiles if dirty ββ
|
| 37 |
+
if state.profiles_dirty_trade || state.trade_profiles.is_empty() {
|
| 38 |
+
state.trade_profiles = gmp_engine::aggregate_by_trades(
|
| 39 |
+
&state.raw_ticks,
|
| 40 |
+
state.trade_interval,
|
| 41 |
+
state.bin_size,
|
| 42 |
+
);
|
| 43 |
+
state.profiles_dirty_trade = false;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// ββ Render Mode Toggle ββ
|
| 47 |
+
ui.horizontal(|ui| {
|
| 48 |
+
ui.label("View:");
|
| 49 |
+
ui.selectable_value(&mut state.show_footprint_trade, false, "GMP");
|
| 50 |
+
ui.selectable_value(&mut state.show_footprint_trade, true, "Footprint");
|
| 51 |
+
ui.label(format!(
|
| 52 |
+
"| {} groups | {} ticks",
|
| 53 |
+
state.trade_profiles.len(),
|
| 54 |
+
state.raw_ticks.len()
|
| 55 |
+
));
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
ui.separator();
|
| 59 |
+
|
| 60 |
+
// ββ Chart (reuse the renderer from tab_time) ββ
|
| 61 |
+
tab_time::render_profile_chart(
|
| 62 |
+
ui,
|
| 63 |
+
&state.trade_profiles,
|
| 64 |
+
state.show_footprint_trade,
|
| 65 |
+
state.bin_size,
|
| 66 |
+
"trade_chart",
|
| 67 |
+
);
|
| 68 |
+
}
|
gmp-terminal/src/timeframe.rs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// timeframe.rs -- Custom Timeframe String Parser
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Parses human-readable timeframe strings into seconds.
|
| 5 |
+
// "1:30" -> 90s
|
| 6 |
+
// "4h30m" -> 16200s
|
| 7 |
+
// "2m" -> 120s
|
| 8 |
+
// "45s" -> 45s
|
| 9 |
+
// "90" -> 90s (plain number = seconds)
|
| 10 |
+
// ============================================================================
|
| 11 |
+
|
| 12 |
+
/// Parse a timeframe string into total seconds.
|
| 13 |
+
///
|
| 14 |
+
/// Supported formats:
|
| 15 |
+
/// - `"MM:SS"` or `"HH:MM:SS"` (colon-separated)
|
| 16 |
+
/// - `"4h30m10s"` (unit suffixes: h, m, s)
|
| 17 |
+
/// - `"90"` (plain integer = seconds)
|
| 18 |
+
pub fn parse_timeframe(input: &str) -> Result<u64, String> {
|
| 19 |
+
let s = input.trim();
|
| 20 |
+
if s.is_empty() {
|
| 21 |
+
return Err("Empty timeframe string".to_string());
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Colon format: "MM:SS" or "HH:MM:SS"
|
| 25 |
+
if s.contains(':') {
|
| 26 |
+
return parse_colon_format(s);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Unit-suffix format: "4h30m10s", "2m", "45s"
|
| 30 |
+
if s.chars().any(|c| matches!(c, 'h' | 'm' | 's')) {
|
| 31 |
+
return parse_unit_format(s);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Plain number: treat as seconds
|
| 35 |
+
s.parse::<u64>()
|
| 36 |
+
.map_err(|_| format!("Invalid timeframe: '{}'", s))
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
fn parse_colon_format(s: &str) -> Result<u64, String> {
|
| 40 |
+
let parts: Vec<&str> = s.split(':').collect();
|
| 41 |
+
match parts.len() {
|
| 42 |
+
2 => {
|
| 43 |
+
// MM:SS
|
| 44 |
+
let mm: u64 = parts[0].parse().map_err(|_| format!("Invalid minutes: '{}'", parts[0]))?;
|
| 45 |
+
let ss: u64 = parts[1].parse().map_err(|_| format!("Invalid seconds: '{}'", parts[1]))?;
|
| 46 |
+
Ok(mm * 60 + ss)
|
| 47 |
+
}
|
| 48 |
+
3 => {
|
| 49 |
+
// HH:MM:SS
|
| 50 |
+
let hh: u64 = parts[0].parse().map_err(|_| format!("Invalid hours: '{}'", parts[0]))?;
|
| 51 |
+
let mm: u64 = parts[1].parse().map_err(|_| format!("Invalid minutes: '{}'", parts[1]))?;
|
| 52 |
+
let ss: u64 = parts[2].parse().map_err(|_| format!("Invalid seconds: '{}'", parts[2]))?;
|
| 53 |
+
Ok(hh * 3600 + mm * 60 + ss)
|
| 54 |
+
}
|
| 55 |
+
_ => Err(format!("Invalid colon format: '{}'", s)),
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
fn parse_unit_format(s: &str) -> Result<u64, String> {
|
| 60 |
+
let mut total: u64 = 0;
|
| 61 |
+
let mut current_num = String::new();
|
| 62 |
+
|
| 63 |
+
for ch in s.chars() {
|
| 64 |
+
if ch.is_ascii_digit() {
|
| 65 |
+
current_num.push(ch);
|
| 66 |
+
} else {
|
| 67 |
+
let n: u64 = if current_num.is_empty() {
|
| 68 |
+
return Err(format!("Missing number before '{}' in '{}'", ch, s));
|
| 69 |
+
} else {
|
| 70 |
+
current_num.parse().map_err(|_| format!("Invalid number in '{}'", s))?
|
| 71 |
+
};
|
| 72 |
+
current_num.clear();
|
| 73 |
+
|
| 74 |
+
match ch {
|
| 75 |
+
'h' | 'H' => total += n * 3600,
|
| 76 |
+
'm' | 'M' => total += n * 60,
|
| 77 |
+
's' | 'S' => total += n,
|
| 78 |
+
_ => return Err(format!("Unknown unit '{}' in '{}'", ch, s)),
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// If there are trailing digits with no unit, treat as seconds
|
| 84 |
+
if !current_num.is_empty() {
|
| 85 |
+
let n: u64 = current_num.parse().map_err(|_| format!("Invalid number in '{}'", s))?;
|
| 86 |
+
total += n;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if total == 0 {
|
| 90 |
+
return Err(format!("Timeframe resolves to 0 seconds: '{}'", s));
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
Ok(total)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/// Format seconds back into a human-readable string for display.
|
| 97 |
+
pub fn format_seconds(secs: u64) -> String {
|
| 98 |
+
if secs == 0 {
|
| 99 |
+
return "0s".to_string();
|
| 100 |
+
}
|
| 101 |
+
let h = secs / 3600;
|
| 102 |
+
let m = (secs % 3600) / 60;
|
| 103 |
+
let s = secs % 60;
|
| 104 |
+
|
| 105 |
+
let mut out = String::new();
|
| 106 |
+
if h > 0 { out.push_str(&format!("{}h", h)); }
|
| 107 |
+
if m > 0 { out.push_str(&format!("{}m", m)); }
|
| 108 |
+
if s > 0 { out.push_str(&format!("{}s", s)); }
|
| 109 |
+
out
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
#[cfg(test)]
|
| 113 |
+
mod tests {
|
| 114 |
+
use super::*;
|
| 115 |
+
|
| 116 |
+
#[test]
|
| 117 |
+
fn test_colon_mm_ss() {
|
| 118 |
+
assert_eq!(parse_timeframe("1:30").unwrap(), 90);
|
| 119 |
+
assert_eq!(parse_timeframe("0:45").unwrap(), 45);
|
| 120 |
+
assert_eq!(parse_timeframe("10:00").unwrap(), 600);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
#[test]
|
| 124 |
+
fn test_colon_hh_mm_ss() {
|
| 125 |
+
assert_eq!(parse_timeframe("1:00:00").unwrap(), 3600);
|
| 126 |
+
assert_eq!(parse_timeframe("4:30:00").unwrap(), 16200);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#[test]
|
| 130 |
+
fn test_unit_format() {
|
| 131 |
+
assert_eq!(parse_timeframe("4h30m").unwrap(), 16200);
|
| 132 |
+
assert_eq!(parse_timeframe("2m").unwrap(), 120);
|
| 133 |
+
assert_eq!(parse_timeframe("45s").unwrap(), 45);
|
| 134 |
+
assert_eq!(parse_timeframe("1h2m3s").unwrap(), 3723);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#[test]
|
| 138 |
+
fn test_plain_number() {
|
| 139 |
+
assert_eq!(parse_timeframe("90").unwrap(), 90);
|
| 140 |
+
assert_eq!(parse_timeframe("3600").unwrap(), 3600);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
#[test]
|
| 144 |
+
fn test_format_seconds() {
|
| 145 |
+
assert_eq!(format_seconds(90), "1m30s");
|
| 146 |
+
assert_eq!(format_seconds(16200), "4h30m");
|
| 147 |
+
assert_eq!(format_seconds(3600), "1h");
|
| 148 |
+
assert_eq!(format_seconds(45), "45s");
|
| 149 |
+
}
|
| 150 |
+
}
|
gmp-terminal/src/trading_panel.rs
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// trading_panel.rs -- Trading Controls Side Panel
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Shared left-panel UI for both Time-Based and Trade-Based tabs.
|
| 5 |
+
// Contains account info, order controls, position management, history
|
| 6 |
+
// download, live recording, and bin-size configuration.
|
| 7 |
+
// ============================================================================
|
| 8 |
+
|
| 9 |
+
use crate::app::AppState;
|
| 10 |
+
use crate::zmq_bridge::OrderRequest;
|
| 11 |
+
use egui;
|
| 12 |
+
use std::fs::OpenOptions;
|
| 13 |
+
use std::io::Write;
|
| 14 |
+
|
| 15 |
+
// βββ Trading Panel Rendering βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
|
| 17 |
+
pub fn render_trading_panel(ui: &mut egui::Ui, state: &mut AppState) {
|
| 18 |
+
ui.heading("Trading Panel");
|
| 19 |
+
ui.separator();
|
| 20 |
+
|
| 21 |
+
// ββ Account Info ββ
|
| 22 |
+
ui.collapsing("Account Info", |ui| {
|
| 23 |
+
egui::Grid::new("account_grid")
|
| 24 |
+
.num_columns(2)
|
| 25 |
+
.spacing([10.0, 4.0])
|
| 26 |
+
.show(ui, |ui| {
|
| 27 |
+
ui.label("Balance:");
|
| 28 |
+
ui.colored_label(
|
| 29 |
+
egui::Color32::from_rgb(100, 200, 100),
|
| 30 |
+
format!("${:.2}", state.balance),
|
| 31 |
+
);
|
| 32 |
+
ui.end_row();
|
| 33 |
+
ui.label("Equity:");
|
| 34 |
+
ui.colored_label(
|
| 35 |
+
egui::Color32::from_rgb(100, 180, 255),
|
| 36 |
+
format!("${:.2}", state.equity),
|
| 37 |
+
);
|
| 38 |
+
ui.end_row();
|
| 39 |
+
ui.label("Margin:");
|
| 40 |
+
ui.colored_label(
|
| 41 |
+
egui::Color32::from_rgb(255, 200, 100),
|
| 42 |
+
format!("${:.2}", state.margin),
|
| 43 |
+
);
|
| 44 |
+
ui.end_row();
|
| 45 |
+
ui.label("Free Margin:");
|
| 46 |
+
ui.colored_label(
|
| 47 |
+
egui::Color32::from_rgb(100, 255, 200),
|
| 48 |
+
format!("${:.2}", state.free_margin),
|
| 49 |
+
);
|
| 50 |
+
ui.end_row();
|
| 51 |
+
});
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
ui.separator();
|
| 55 |
+
|
| 56 |
+
// ββ Chart Settings ββ
|
| 57 |
+
ui.heading("Chart Settings");
|
| 58 |
+
ui.horizontal(|ui| {
|
| 59 |
+
ui.label("Bin Size:");
|
| 60 |
+
let resp = ui.add(
|
| 61 |
+
egui::TextEdit::singleline(&mut state.bin_size_str).desired_width(60.0),
|
| 62 |
+
);
|
| 63 |
+
if resp.lost_focus() {
|
| 64 |
+
if let Ok(v) = state.bin_size_str.parse::<f64>() {
|
| 65 |
+
if v > 0.0 {
|
| 66 |
+
state.bin_size = v;
|
| 67 |
+
state.profiles_dirty = true;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
state.bin_size_str = format!("{}", state.bin_size);
|
| 71 |
+
}
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
ui.separator();
|
| 75 |
+
|
| 76 |
+
// ββ History Download ββ
|
| 77 |
+
ui.heading("Historical Data");
|
| 78 |
+
ui.add_space(3.0);
|
| 79 |
+
egui::Grid::new("history_grid")
|
| 80 |
+
.num_columns(2)
|
| 81 |
+
.spacing([10.0, 4.0])
|
| 82 |
+
.show(ui, |ui| {
|
| 83 |
+
ui.label("Start:");
|
| 84 |
+
ui.add(
|
| 85 |
+
egui::TextEdit::singleline(&mut state.history_start).desired_width(100.0),
|
| 86 |
+
);
|
| 87 |
+
ui.end_row();
|
| 88 |
+
ui.label("End:");
|
| 89 |
+
ui.add(
|
| 90 |
+
egui::TextEdit::singleline(&mut state.history_end).desired_width(100.0),
|
| 91 |
+
);
|
| 92 |
+
ui.end_row();
|
| 93 |
+
ui.label("Timeframe:");
|
| 94 |
+
egui::ComboBox::from_id_source("dl_tf")
|
| 95 |
+
.selected_text(&state.history_tf)
|
| 96 |
+
.show_ui(ui, |ui| {
|
| 97 |
+
for tf in &["M1", "M5", "M15", "H1", "H4", "D1"] {
|
| 98 |
+
ui.selectable_value(&mut state.history_tf, tf.to_string(), *tf);
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
ui.end_row();
|
| 102 |
+
ui.label("Mode:");
|
| 103 |
+
egui::ComboBox::from_id_source("dl_mode")
|
| 104 |
+
.selected_text(&state.history_mode)
|
| 105 |
+
.show_ui(ui, |ui| {
|
| 106 |
+
ui.selectable_value(&mut state.history_mode, "OHLC".to_string(), "OHLC");
|
| 107 |
+
ui.selectable_value(&mut state.history_mode, "TICKS".to_string(), "TICKS");
|
| 108 |
+
});
|
| 109 |
+
ui.end_row();
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
ui.add_space(3.0);
|
| 113 |
+
if ui.button("Download History (CSV)").clicked() {
|
| 114 |
+
send_download_request(state);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
ui.separator();
|
| 118 |
+
|
| 119 |
+
// ββ Live Recording ββ
|
| 120 |
+
ui.heading("Live Recording");
|
| 121 |
+
ui.horizontal(|ui| {
|
| 122 |
+
ui.label(if state.is_recording { "REC" } else { "Idle" });
|
| 123 |
+
if ui
|
| 124 |
+
.button(if state.is_recording { "Stop" } else { "Start" })
|
| 125 |
+
.clicked()
|
| 126 |
+
{
|
| 127 |
+
toggle_recording(state);
|
| 128 |
+
}
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
ui.separator();
|
| 132 |
+
|
| 133 |
+
// ββ Trade Controls ββ
|
| 134 |
+
ui.heading("Trade Controls");
|
| 135 |
+
|
| 136 |
+
// Lot size
|
| 137 |
+
ui.horizontal(|ui| {
|
| 138 |
+
if ui.button("-").clicked() {
|
| 139 |
+
adjust_lot(state, -state.lot_step);
|
| 140 |
+
}
|
| 141 |
+
let resp = ui.add(
|
| 142 |
+
egui::TextEdit::singleline(&mut state.lot_size_str).desired_width(60.0),
|
| 143 |
+
);
|
| 144 |
+
if resp.lost_focus() {
|
| 145 |
+
if let Ok(v) = state.lot_size_str.parse::<f64>() {
|
| 146 |
+
state.lot_size = v.max(state.min_lot).min(state.max_lot);
|
| 147 |
+
state.lot_size_str = format!("{:.2}", state.lot_size);
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
if ui.button("+").clicked() {
|
| 151 |
+
adjust_lot(state, state.lot_step);
|
| 152 |
+
}
|
| 153 |
+
ui.label(format!("Lots (max {:.1})", state.max_lot));
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
ui.add_space(3.0);
|
| 157 |
+
ui.label("Market Orders:");
|
| 158 |
+
ui.horizontal(|ui| {
|
| 159 |
+
if ui.button("BUY").clicked() {
|
| 160 |
+
send_order(state, "market_buy", None, None);
|
| 161 |
+
}
|
| 162 |
+
if ui.button("SELL").clicked() {
|
| 163 |
+
send_order(state, "market_sell", None, None);
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
ui.add_space(3.0);
|
| 168 |
+
ui.label("Pending Orders:");
|
| 169 |
+
ui.horizontal(|ui| {
|
| 170 |
+
ui.label("@ Price:");
|
| 171 |
+
ui.add(
|
| 172 |
+
egui::TextEdit::singleline(&mut state.limit_price_str).desired_width(70.0),
|
| 173 |
+
);
|
| 174 |
+
});
|
| 175 |
+
ui.horizontal(|ui| {
|
| 176 |
+
let p: f64 = state.limit_price_str.parse().unwrap_or(0.0);
|
| 177 |
+
if ui.small_button("Buy Lmt").clicked() {
|
| 178 |
+
send_order(state, "limit_buy", Some(p), None);
|
| 179 |
+
}
|
| 180 |
+
if ui.small_button("Sell Lmt").clicked() {
|
| 181 |
+
send_order(state, "limit_sell", Some(p), None);
|
| 182 |
+
}
|
| 183 |
+
if ui.small_button("Buy Stp").clicked() {
|
| 184 |
+
send_order(state, "stop_buy", Some(p), None);
|
| 185 |
+
}
|
| 186 |
+
if ui.small_button("Sell Stp").clicked() {
|
| 187 |
+
send_order(state, "stop_sell", Some(p), None);
|
| 188 |
+
}
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
ui.separator();
|
| 192 |
+
|
| 193 |
+
// ββ Status ββ
|
| 194 |
+
if let Some(ref msg) = state.last_status {
|
| 195 |
+
ui.heading("Status");
|
| 196 |
+
ui.label(msg);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
ui.separator();
|
| 200 |
+
|
| 201 |
+
// ββ Active Positions ββ
|
| 202 |
+
ui.collapsing("Active Positions", |ui| {
|
| 203 |
+
if state.positions.is_empty() {
|
| 204 |
+
ui.label("No active positions");
|
| 205 |
+
} else {
|
| 206 |
+
let positions = state.positions.clone();
|
| 207 |
+
for pos in positions {
|
| 208 |
+
ui.horizontal(|ui| {
|
| 209 |
+
let color = if pos.pos_type == "BUY" {
|
| 210 |
+
egui::Color32::from_rgb(100, 200, 100)
|
| 211 |
+
} else {
|
| 212 |
+
egui::Color32::from_rgb(255, 100, 100)
|
| 213 |
+
};
|
| 214 |
+
ui.colored_label(
|
| 215 |
+
color,
|
| 216 |
+
format!(
|
| 217 |
+
"#{} {} {:.2}@{:.5} P:{:.2}",
|
| 218 |
+
pos.ticket, pos.pos_type, pos.volume, pos.price, pos.profit
|
| 219 |
+
),
|
| 220 |
+
);
|
| 221 |
+
if ui.small_button("Close").clicked() {
|
| 222 |
+
send_order(state, "close_position", Some(pos.price), Some(pos.ticket));
|
| 223 |
+
}
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// ββ Pending Orders ββ
|
| 230 |
+
ui.collapsing("Pending Orders", |ui| {
|
| 231 |
+
if state.pending_orders.is_empty() {
|
| 232 |
+
ui.label("No pending orders");
|
| 233 |
+
} else {
|
| 234 |
+
let orders = state.pending_orders.clone();
|
| 235 |
+
for ord in orders {
|
| 236 |
+
ui.horizontal(|ui| {
|
| 237 |
+
let color = if ord.order_type.contains("BUY") {
|
| 238 |
+
egui::Color32::from_rgb(100, 150, 255)
|
| 239 |
+
} else {
|
| 240 |
+
egui::Color32::from_rgb(255, 150, 100)
|
| 241 |
+
};
|
| 242 |
+
ui.colored_label(
|
| 243 |
+
color,
|
| 244 |
+
format!(
|
| 245 |
+
"#{} {} {:.2}@{:.5}",
|
| 246 |
+
ord.ticket, ord.order_type, ord.volume, ord.price
|
| 247 |
+
),
|
| 248 |
+
);
|
| 249 |
+
if ui.small_button("Cancel").clicked() {
|
| 250 |
+
send_order(state, "cancel_order", Some(ord.price), Some(ord.ticket));
|
| 251 |
+
}
|
| 252 |
+
});
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 259 |
+
|
| 260 |
+
fn adjust_lot(state: &mut AppState, delta: f64) {
|
| 261 |
+
let new = state.lot_size + delta;
|
| 262 |
+
let steps = (new / state.lot_step).round();
|
| 263 |
+
state.lot_size = (steps * state.lot_step).max(state.min_lot).min(state.max_lot);
|
| 264 |
+
state.lot_size_str = format!("{:.2}", state.lot_size);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
fn send_order(state: &mut AppState, order_type: &str, price: Option<f64>, ticket: Option<u64>) {
|
| 268 |
+
let req = OrderRequest {
|
| 269 |
+
order_type: order_type.to_string(),
|
| 270 |
+
symbol: state.symbol.clone(),
|
| 271 |
+
volume: state.lot_size,
|
| 272 |
+
price: price.unwrap_or(0.0),
|
| 273 |
+
ticket: ticket.unwrap_or(0),
|
| 274 |
+
timeframe: None,
|
| 275 |
+
start: None,
|
| 276 |
+
end: None,
|
| 277 |
+
mode: None,
|
| 278 |
+
request_id: None,
|
| 279 |
+
};
|
| 280 |
+
if let Err(e) = state.order_tx.try_send(req) {
|
| 281 |
+
state.last_status = Some(format!("Send error: {}", e));
|
| 282 |
+
} else {
|
| 283 |
+
state.last_status = Some("Order sent...".to_string());
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
fn send_download_request(state: &mut AppState) {
|
| 288 |
+
state.request_counter += 1;
|
| 289 |
+
state.pending_history = Some((
|
| 290 |
+
state.request_counter,
|
| 291 |
+
state.symbol.replace("/", "-"),
|
| 292 |
+
state.history_tf.clone(),
|
| 293 |
+
state.history_mode.clone(),
|
| 294 |
+
));
|
| 295 |
+
let req = OrderRequest {
|
| 296 |
+
order_type: "download_history".to_string(),
|
| 297 |
+
symbol: state.symbol.clone(),
|
| 298 |
+
volume: 0.0,
|
| 299 |
+
price: 0.0,
|
| 300 |
+
ticket: 0,
|
| 301 |
+
timeframe: Some(state.history_tf.clone()),
|
| 302 |
+
start: Some(state.history_start.clone()),
|
| 303 |
+
end: Some(state.history_end.clone()),
|
| 304 |
+
mode: Some(state.history_mode.clone()),
|
| 305 |
+
request_id: Some(state.request_counter),
|
| 306 |
+
};
|
| 307 |
+
if let Err(e) = state.order_tx.try_send(req) {
|
| 308 |
+
state.last_status = Some(format!("Send error: {}", e));
|
| 309 |
+
} else {
|
| 310 |
+
state.last_status = Some("Download request sent...".to_string());
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
fn toggle_recording(state: &mut AppState) {
|
| 315 |
+
state.is_recording = !state.is_recording;
|
| 316 |
+
if state.is_recording {
|
| 317 |
+
state.request_counter += 1;
|
| 318 |
+
let filename = format!(
|
| 319 |
+
"{}/Live_{}_ID{:04}_{}.csv",
|
| 320 |
+
state.output_dir.display(),
|
| 321 |
+
state.symbol.replace("/", "-"),
|
| 322 |
+
state.request_counter,
|
| 323 |
+
chrono::Local::now().format("%Y%m%d_%H%M%S"),
|
| 324 |
+
);
|
| 325 |
+
match OpenOptions::new().create(true).append(true).open(&filename) {
|
| 326 |
+
Ok(mut file) => {
|
| 327 |
+
let _ = writeln!(file, "Time,Bid,Ask,Volume");
|
| 328 |
+
state.live_record_file = Some(file);
|
| 329 |
+
state.last_status = Some(format!("Recording to {}", filename));
|
| 330 |
+
}
|
| 331 |
+
Err(e) => {
|
| 332 |
+
state.is_recording = false;
|
| 333 |
+
state.last_status = Some(format!("Record error: {}", e));
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
} else {
|
| 337 |
+
state.live_record_file = None;
|
| 338 |
+
state.last_status = Some("Recording stopped".to_string());
|
| 339 |
+
}
|
| 340 |
+
}
|
gmp-terminal/src/zmq_bridge.rs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================================
|
| 2 |
+
// zmq_bridge.rs -- ZeroMQ Integration Layer
|
| 3 |
+
// ============================================================================
|
| 4 |
+
// Reuses the proven SUB/REQ patterns from SUM3API/Rustmt5-chart.
|
| 5 |
+
// Provides two spawnable async tasks:
|
| 6 |
+
// 1. Tick subscriber (PUB/SUB on port 5555)
|
| 7 |
+
// 2. Order handler (REQ/REP on port 5556)
|
| 8 |
+
// ============================================================================
|
| 9 |
+
|
| 10 |
+
use serde::{Deserialize, Serialize};
|
| 11 |
+
use tokio::sync::mpsc;
|
| 12 |
+
use zeromq::{Socket, SocketRecv, SocketSend};
|
| 13 |
+
|
| 14 |
+
// βββ Data Structures βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 17 |
+
pub struct PositionData {
|
| 18 |
+
pub ticket: u64,
|
| 19 |
+
#[serde(rename = "type")]
|
| 20 |
+
pub pos_type: String,
|
| 21 |
+
pub volume: f64,
|
| 22 |
+
pub price: f64,
|
| 23 |
+
pub profit: f64,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 27 |
+
pub struct PendingOrderData {
|
| 28 |
+
pub ticket: u64,
|
| 29 |
+
#[serde(rename = "type")]
|
| 30 |
+
pub order_type: String,
|
| 31 |
+
pub volume: f64,
|
| 32 |
+
pub price: f64,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 36 |
+
pub struct TickData {
|
| 37 |
+
pub symbol: String,
|
| 38 |
+
pub bid: f64,
|
| 39 |
+
pub ask: f64,
|
| 40 |
+
pub time: i64,
|
| 41 |
+
#[serde(default)]
|
| 42 |
+
pub volume: u64,
|
| 43 |
+
// Account info
|
| 44 |
+
#[serde(default)]
|
| 45 |
+
pub balance: f64,
|
| 46 |
+
#[serde(default)]
|
| 47 |
+
pub equity: f64,
|
| 48 |
+
#[serde(default)]
|
| 49 |
+
pub margin: f64,
|
| 50 |
+
#[serde(default)]
|
| 51 |
+
pub free_margin: f64,
|
| 52 |
+
// Trading constraints
|
| 53 |
+
#[serde(default)]
|
| 54 |
+
pub min_lot: f64,
|
| 55 |
+
#[serde(default)]
|
| 56 |
+
pub max_lot: f64,
|
| 57 |
+
#[serde(default)]
|
| 58 |
+
pub lot_step: f64,
|
| 59 |
+
// Active trades
|
| 60 |
+
#[serde(default)]
|
| 61 |
+
pub positions: Vec<PositionData>,
|
| 62 |
+
#[serde(default)]
|
| 63 |
+
pub orders: Vec<PendingOrderData>,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
#[derive(Clone, Debug, Serialize)]
|
| 67 |
+
pub struct OrderRequest {
|
| 68 |
+
#[serde(rename = "type")]
|
| 69 |
+
pub order_type: String,
|
| 70 |
+
pub symbol: String,
|
| 71 |
+
pub volume: f64,
|
| 72 |
+
pub price: f64,
|
| 73 |
+
#[serde(default)]
|
| 74 |
+
pub ticket: u64,
|
| 75 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 76 |
+
pub timeframe: Option<String>,
|
| 77 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 78 |
+
pub start: Option<String>,
|
| 79 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 80 |
+
pub end: Option<String>,
|
| 81 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 82 |
+
pub mode: Option<String>,
|
| 83 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 84 |
+
pub request_id: Option<u64>,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
#[derive(Clone, Debug, Deserialize)]
|
| 88 |
+
pub struct OrderResponse {
|
| 89 |
+
pub success: bool,
|
| 90 |
+
pub ticket: Option<i64>,
|
| 91 |
+
pub error: Option<String>,
|
| 92 |
+
pub message: Option<String>,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// βββ Tick Subscriber Task ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
|
| 97 |
+
pub fn spawn_tick_subscriber(tx: mpsc::Sender<TickData>) {
|
| 98 |
+
tokio::spawn(async move {
|
| 99 |
+
let mut socket = zeromq::SubSocket::new();
|
| 100 |
+
match socket.connect("tcp://127.0.0.1:5555").await {
|
| 101 |
+
Ok(_) => println!("[ZMQ] Connected to Tick Publisher on port 5555"),
|
| 102 |
+
Err(e) => {
|
| 103 |
+
eprintln!("[ZMQ] Failed to connect to tick publisher: {}", e);
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
let _ = socket.subscribe("").await;
|
| 109 |
+
|
| 110 |
+
loop {
|
| 111 |
+
match socket.recv().await {
|
| 112 |
+
Ok(msg) => {
|
| 113 |
+
if let Some(bytes) = msg.get(0) {
|
| 114 |
+
if let Ok(json_str) = std::str::from_utf8(bytes) {
|
| 115 |
+
match serde_json::from_str::<TickData>(json_str) {
|
| 116 |
+
Ok(tick) => {
|
| 117 |
+
if let Err(e) = tx.send(tick).await {
|
| 118 |
+
eprintln!("[ZMQ] Tick channel closed: {}", e);
|
| 119 |
+
break;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
Err(e) => {
|
| 123 |
+
eprintln!("[ZMQ] Tick JSON parse error: {}", e);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
Err(e) => {
|
| 130 |
+
eprintln!("[ZMQ] Tick recv error: {}", e);
|
| 131 |
+
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// βββ Order Handler Task ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
+
|
| 140 |
+
pub fn spawn_order_handler(
|
| 141 |
+
mut rx: mpsc::Receiver<OrderRequest>,
|
| 142 |
+
tx: mpsc::Sender<OrderResponse>,
|
| 143 |
+
) {
|
| 144 |
+
tokio::spawn(async move {
|
| 145 |
+
let mut socket = zeromq::ReqSocket::new();
|
| 146 |
+
match socket.connect("tcp://127.0.0.1:5556").await {
|
| 147 |
+
Ok(_) => println!("[ZMQ] Connected to Order Handler on port 5556"),
|
| 148 |
+
Err(e) => {
|
| 149 |
+
eprintln!("[ZMQ] Failed to connect to order handler: {}", e);
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
while let Some(order) = rx.recv().await {
|
| 155 |
+
let json_req = match serde_json::to_string(&order) {
|
| 156 |
+
Ok(j) => j,
|
| 157 |
+
Err(e) => {
|
| 158 |
+
eprintln!("[ZMQ] Serialize error: {}", e);
|
| 159 |
+
continue;
|
| 160 |
+
}
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
println!("[ZMQ] Sending: {}", json_req);
|
| 164 |
+
|
| 165 |
+
if let Err(e) = socket.send(json_req.into()).await {
|
| 166 |
+
eprintln!("[ZMQ] Send error: {}", e);
|
| 167 |
+
let _ = tx.send(OrderResponse {
|
| 168 |
+
success: false,
|
| 169 |
+
ticket: None,
|
| 170 |
+
error: Some(format!("Send failed: {}", e)),
|
| 171 |
+
message: None,
|
| 172 |
+
}).await;
|
| 173 |
+
continue;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
match socket.recv().await {
|
| 177 |
+
Ok(msg) => {
|
| 178 |
+
if let Some(bytes) = msg.get(0) {
|
| 179 |
+
if let Ok(json_str) = std::str::from_utf8(bytes) {
|
| 180 |
+
println!("[ZMQ] Response: {}", json_str);
|
| 181 |
+
match serde_json::from_str::<OrderResponse>(json_str) {
|
| 182 |
+
Ok(resp) => { let _ = tx.send(resp).await; }
|
| 183 |
+
Err(e) => {
|
| 184 |
+
let _ = tx.send(OrderResponse {
|
| 185 |
+
success: false,
|
| 186 |
+
ticket: None,
|
| 187 |
+
error: Some(format!("Parse error: {}", e)),
|
| 188 |
+
message: None,
|
| 189 |
+
}).await;
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
Err(e) => {
|
| 196 |
+
eprintln!("[ZMQ] Response recv error: {}", e);
|
| 197 |
+
let _ = tx.send(OrderResponse {
|
| 198 |
+
success: false,
|
| 199 |
+
ticket: None,
|
| 200 |
+
error: Some(format!("Recv failed: {}", e)),
|
| 201 |
+
message: None,
|
| 202 |
+
}).await;
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
+
}
|
mBA-GMP.v3/generate_profiles.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
generate_profiles.py β mBA-GMP.v3 Dataframe & Chart Generator
|
| 4 |
+
==============================================================
|
| 5 |
+
Produces CSV files and publication-quality PNG charts demonstrating the
|
| 6 |
+
Conventional Market Profile (CMP), Gap-filled Market Profile (GMP),
|
| 7 |
+
and Up/Down-Bin Footprint Profile using a 10-datapoint XAUUSD example.
|
| 8 |
+
|
| 9 |
+
Outputs:
|
| 10 |
+
CSV: datapoints.csv, cmp_profile.csv, gmp_profile.csv,
|
| 11 |
+
updown_profile.csv
|
| 12 |
+
PNG: fig_price_scatter.png, fig_cmp_profile.png,
|
| 13 |
+
fig_gmp_profile.png, fig_cmp_vs_gmp.png,
|
| 14 |
+
fig_updown_footprint.png
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import math
|
| 18 |
+
import csv
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
# ββ Try to import optional plotting libs βββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
try:
|
| 23 |
+
import matplotlib
|
| 24 |
+
matplotlib.use("Agg") # non-interactive backend
|
| 25 |
+
import matplotlib.pyplot as plt
|
| 26 |
+
import matplotlib.ticker as ticker
|
| 27 |
+
HAS_MPL = True
|
| 28 |
+
except ImportError:
|
| 29 |
+
HAS_MPL = False
|
| 30 |
+
print("[WARN] matplotlib not found β CSV files will still be generated "
|
| 31 |
+
"but PNG charts will be skipped.")
|
| 32 |
+
|
| 33 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
# 1. RAW DATAPOINTS
|
| 35 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
+
|
| 37 |
+
DATAPOINTS = [
|
| 38 |
+
("A", 1, 3000.914),
|
| 39 |
+
("B", 2, 3003.837),
|
| 40 |
+
("C", 3, 3002.432),
|
| 41 |
+
("D", 4, 3009.892),
|
| 42 |
+
("E", 5, 3007.698),
|
| 43 |
+
("F", 6, 3009.176),
|
| 44 |
+
("G", 7, 3003.381),
|
| 45 |
+
("H", 8, 3004.283),
|
| 46 |
+
("I", 9, 3003.512),
|
| 47 |
+
("J", 10, 3003.012),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
BIN_SIZE = 1 # Ξ² = 1 symbol price unit
|
| 51 |
+
|
| 52 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
# 2. HELPER FUNCTIONS
|
| 54 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
def bin_index(price: float, beta: float = BIN_SIZE) -> int:
|
| 57 |
+
"""Return the bin index for a given price: floor(price / Ξ²)."""
|
| 58 |
+
return int(math.floor(price / beta))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def bin_range(price: float, beta: float = BIN_SIZE):
|
| 62 |
+
"""Return (price_from, price_until) for the bin containing *price*."""
|
| 63 |
+
b = bin_index(price, beta)
|
| 64 |
+
return b * beta, (b + 1) * beta
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def make_bin_key(b: int, beta: float = BIN_SIZE):
|
| 68 |
+
"""Return (bin_number_1based, price_from, price_until) for bin index *b*."""
|
| 69 |
+
return (b * beta, (b + 1) * beta)
|
| 70 |
+
|
| 71 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
# 3. CMP CONSTRUCTION
|
| 73 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
def build_cmp(datapoints, beta=BIN_SIZE):
|
| 76 |
+
"""
|
| 77 |
+
Build CMP profile.
|
| 78 |
+
Returns dict: bin_index -> {"labels": [str], "count": int}
|
| 79 |
+
"""
|
| 80 |
+
profile = {}
|
| 81 |
+
for label, _trade, price in datapoints:
|
| 82 |
+
b = bin_index(price, beta)
|
| 83 |
+
if b not in profile:
|
| 84 |
+
profile[b] = {"labels": [], "count": 0}
|
| 85 |
+
profile[b]["labels"].append(label)
|
| 86 |
+
profile[b]["count"] += 1
|
| 87 |
+
return profile
|
| 88 |
+
|
| 89 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
# 4. GMP CONSTRUCTION
|
| 91 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
+
|
| 93 |
+
def build_gmp(datapoints, beta=BIN_SIZE):
|
| 94 |
+
"""
|
| 95 |
+
Build GMP profile (gap-filled).
|
| 96 |
+
|
| 97 |
+
Convention (matches the dataframe approach):
|
| 98 |
+
1. Every datapoint fills its OWN bin with its own label (same as CMP).
|
| 99 |
+
2. For each consecutive pair (i, i+1), the intermediate bins BETWEEN
|
| 100 |
+
b(p_i) and b(p_{i+1}) β exclusive of both endpoints β are filled
|
| 101 |
+
with the SOURCE datapoint's label (datapoint i).
|
| 102 |
+
|
| 103 |
+
Returns dict: bin_index -> {"labels": [str], "count": int}
|
| 104 |
+
"""
|
| 105 |
+
profile = {}
|
| 106 |
+
|
| 107 |
+
def add_to_bin(b, label):
|
| 108 |
+
if b not in profile:
|
| 109 |
+
profile[b] = {"labels": [], "count": 0}
|
| 110 |
+
profile[b]["labels"].append(label)
|
| 111 |
+
profile[b]["count"] += 1
|
| 112 |
+
|
| 113 |
+
# ββ Step 1: CMP-style placement β each datapoint fills its own bin ββ
|
| 114 |
+
for label, _trade, price in datapoints:
|
| 115 |
+
add_to_bin(bin_index(price, beta), label)
|
| 116 |
+
|
| 117 |
+
# ββ Step 2: Gap-fill intermediate bins between consecutive pairs βββββ
|
| 118 |
+
for idx in range(len(datapoints) - 1):
|
| 119 |
+
src_label, _, src_price = datapoints[idx]
|
| 120 |
+
_dst_label, _, dst_price = datapoints[idx + 1]
|
| 121 |
+
|
| 122 |
+
b_from = bin_index(src_price, beta)
|
| 123 |
+
b_to = bin_index(dst_price, beta)
|
| 124 |
+
|
| 125 |
+
if abs(b_to - b_from) <= 1:
|
| 126 |
+
# Adjacent or same bin β no intermediate bins to fill
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
direction = 1 if b_to > b_from else -1
|
| 130 |
+
# Fill bins strictly BETWEEN b_from and b_to (exclusive of both)
|
| 131 |
+
b = b_from + direction
|
| 132 |
+
while b != b_to:
|
| 133 |
+
add_to_bin(b, src_label)
|
| 134 |
+
b += direction
|
| 135 |
+
|
| 136 |
+
return profile
|
| 137 |
+
|
| 138 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
+
# 4b. UP/DOWN-BIN FOOTPRINT PROFILE CONSTRUCTION
|
| 140 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
|
| 142 |
+
def build_updown_profile(datapoints, beta=BIN_SIZE):
|
| 143 |
+
"""
|
| 144 |
+
Build the Up/Down-Bin Footprint Profile.
|
| 145 |
+
|
| 146 |
+
For each consecutive pair of datapoints, every bin on the gap-filled
|
| 147 |
+
path (excluding the source datapoint's own bin) is classified as an
|
| 148 |
+
up-bin or down-bin depending on the direction of the move.
|
| 149 |
+
|
| 150 |
+
The first datapoint (no prior movement) receives 0 up / 0 down.
|
| 151 |
+
|
| 152 |
+
Returns dict: bin_index -> {"labels": [str], "up": int, "down": int}
|
| 153 |
+
"""
|
| 154 |
+
# ββ Collect GMP group labels (reuse from GMP logic) ββββββββββββββββββ
|
| 155 |
+
groups = {} # bin_index -> list of labels
|
| 156 |
+
|
| 157 |
+
def add_label(b, label):
|
| 158 |
+
if b not in groups:
|
| 159 |
+
groups[b] = []
|
| 160 |
+
groups[b].append(label)
|
| 161 |
+
|
| 162 |
+
# CMP placement
|
| 163 |
+
for label, _trade, price in datapoints:
|
| 164 |
+
add_label(bin_index(price, beta), label)
|
| 165 |
+
|
| 166 |
+
# Gap-fill intermediate labels
|
| 167 |
+
for idx in range(len(datapoints) - 1):
|
| 168 |
+
src_label, _, src_price = datapoints[idx]
|
| 169 |
+
_, _, dst_price = datapoints[idx + 1]
|
| 170 |
+
b_from = bin_index(src_price, beta)
|
| 171 |
+
b_to = bin_index(dst_price, beta)
|
| 172 |
+
if abs(b_to - b_from) <= 1:
|
| 173 |
+
continue
|
| 174 |
+
direction = 1 if b_to > b_from else -1
|
| 175 |
+
b = b_from + direction
|
| 176 |
+
while b != b_to:
|
| 177 |
+
add_label(b, src_label)
|
| 178 |
+
b += direction
|
| 179 |
+
|
| 180 |
+
# ββ Now compute up/down counts per bin ββββββββββββββββββββββββββββββββ
|
| 181 |
+
up_counts = {} # bin_index -> int
|
| 182 |
+
down_counts = {} # bin_index -> int
|
| 183 |
+
|
| 184 |
+
for idx in range(len(datapoints) - 1):
|
| 185 |
+
_, _, src_price = datapoints[idx]
|
| 186 |
+
_, _, dst_price = datapoints[idx + 1]
|
| 187 |
+
|
| 188 |
+
b_from = bin_index(src_price, beta)
|
| 189 |
+
b_to = bin_index(dst_price, beta)
|
| 190 |
+
|
| 191 |
+
if b_from == b_to:
|
| 192 |
+
# Same bin, but price might have moved
|
| 193 |
+
if dst_price > src_price:
|
| 194 |
+
up_counts[b_from] = up_counts.get(b_from, 0) + 1
|
| 195 |
+
elif dst_price < src_price:
|
| 196 |
+
down_counts[b_from] = down_counts.get(b_from, 0) + 1
|
| 197 |
+
continue
|
| 198 |
+
|
| 199 |
+
is_up = b_to > b_from
|
| 200 |
+
direction = 1 if is_up else -1
|
| 201 |
+
|
| 202 |
+
# Every bin on the path AFTER the source bin (exclusive of source,
|
| 203 |
+
# inclusive of destination) gets a directional count.
|
| 204 |
+
b = b_from + direction
|
| 205 |
+
while True:
|
| 206 |
+
if is_up:
|
| 207 |
+
up_counts[b] = up_counts.get(b, 0) + 1
|
| 208 |
+
else:
|
| 209 |
+
down_counts[b] = down_counts.get(b, 0) + 1
|
| 210 |
+
if b == b_to:
|
| 211 |
+
break
|
| 212 |
+
b += direction
|
| 213 |
+
|
| 214 |
+
# ββ Merge into result dict βββββββββββββββββββββββββββββββββββββββββββ
|
| 215 |
+
all_bins = set(groups.keys()) | set(up_counts.keys()) | set(down_counts.keys())
|
| 216 |
+
profile = {}
|
| 217 |
+
for b in all_bins:
|
| 218 |
+
profile[b] = {
|
| 219 |
+
"labels": sorted(groups.get(b, [])),
|
| 220 |
+
"up": up_counts.get(b, 0),
|
| 221 |
+
"down": down_counts.get(b, 0),
|
| 222 |
+
}
|
| 223 |
+
return profile
|
| 224 |
+
|
| 225 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββ
|
| 226 |
+
# 5. CSV OUTPUT
|
| 227 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 228 |
+
|
| 229 |
+
def write_datapoints_csv(datapoints, path="datapoints.csv"):
|
| 230 |
+
"""Write the raw datapoints to CSV."""
|
| 231 |
+
with open(path, "w", newline="") as f:
|
| 232 |
+
w = csv.writer(f)
|
| 233 |
+
w.writerow(["datapoint", "x-axis trades (raw trades or time)", "y-axis Price"])
|
| 234 |
+
for label, trade, price in datapoints:
|
| 235 |
+
w.writerow([label, trade, f"{price:.3f}"])
|
| 236 |
+
print(f"[OK] {path}")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def write_profile_csv(profile, beta, path):
|
| 240 |
+
"""Write a profile (CMP or GMP) to CSV, bins numbered 1..N from lowest."""
|
| 241 |
+
if not profile:
|
| 242 |
+
print(f"[WARN] Empty profile, skipping {path}")
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
b_min = min(profile.keys())
|
| 246 |
+
b_max = max(profile.keys())
|
| 247 |
+
|
| 248 |
+
# Include ALL bins from b_min to b_max (even empty ones)
|
| 249 |
+
rows = []
|
| 250 |
+
bin_number = 1
|
| 251 |
+
for b in range(b_min, b_max + 1):
|
| 252 |
+
p_from = b * beta
|
| 253 |
+
p_until = (b + 1) * beta
|
| 254 |
+
info = profile.get(b, {"labels": [], "count": 0})
|
| 255 |
+
group = "".join(sorted(info["labels"]))
|
| 256 |
+
count = info["count"]
|
| 257 |
+
rows.append([bin_number, int(p_from), int(p_until), group, count])
|
| 258 |
+
bin_number += 1
|
| 259 |
+
|
| 260 |
+
with open(path, "w", newline="") as f:
|
| 261 |
+
w = csv.writer(f)
|
| 262 |
+
w.writerow([
|
| 263 |
+
f"bin (with binsize = {beta} symbol's price unit)",
|
| 264 |
+
"price from", "price until", "datapoint group",
|
| 265 |
+
"number of profile's stacks"
|
| 266 |
+
])
|
| 267 |
+
for row in rows:
|
| 268 |
+
w.writerow(row)
|
| 269 |
+
print(f"[OK] {path}")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def write_updown_profile_csv(updown_profile, gmp_groups, beta, path):
|
| 273 |
+
"""Write the Up/Down-Bin Footprint Profile to CSV."""
|
| 274 |
+
if not updown_profile:
|
| 275 |
+
print(f"[WARN] Empty profile, skipping {path}")
|
| 276 |
+
return
|
| 277 |
+
|
| 278 |
+
b_min = min(updown_profile.keys())
|
| 279 |
+
b_max = max(updown_profile.keys())
|
| 280 |
+
|
| 281 |
+
rows = []
|
| 282 |
+
bin_number = 1
|
| 283 |
+
for b in range(b_min, b_max + 1):
|
| 284 |
+
p_from = b * beta
|
| 285 |
+
p_until = (b + 1) * beta
|
| 286 |
+
info = updown_profile.get(b, {"labels": [], "up": 0, "down": 0})
|
| 287 |
+
group = "".join(info["labels"])
|
| 288 |
+
up_val = info["up"]
|
| 289 |
+
down_val = info["down"]
|
| 290 |
+
delta_val = up_val - down_val
|
| 291 |
+
rows.append([bin_number, int(p_from), int(p_until), group,
|
| 292 |
+
down_val, up_val, delta_val])
|
| 293 |
+
bin_number += 1
|
| 294 |
+
|
| 295 |
+
with open(path, "w", newline="") as f:
|
| 296 |
+
w = csv.writer(f)
|
| 297 |
+
w.writerow([
|
| 298 |
+
f"bin (with binsize = {beta} symbol's price unit)",
|
| 299 |
+
"price from", "price until", "datapoint group",
|
| 300 |
+
"down-bin profile's stacks", "up-bin profile's stacks",
|
| 301 |
+
"delta-bin profile's stacks"
|
| 302 |
+
])
|
| 303 |
+
for row in rows:
|
| 304 |
+
w.writerow(row)
|
| 305 |
+
print(f"[OK] {path}")
|
| 306 |
+
|
| 307 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 308 |
+
# 6. CHART GENERATION
|
| 309 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 310 |
+
|
| 311 |
+
# ββ Color palette (white / light theme) ββββββββββββββββββββββββββββββββββββββ
|
| 312 |
+
CLR_BG = "#ffffff"
|
| 313 |
+
CLR_FG = "#1a1a1a"
|
| 314 |
+
CLR_GRID = "#d0d0d0"
|
| 315 |
+
CLR_ACCENT1 = "#1565c0" # deep blue (scatter)
|
| 316 |
+
CLR_ACCENT2 = "#e65100" # deep orange (CMP)
|
| 317 |
+
CLR_ACCENT3 = "#2e7d32" # deep green (GMP)
|
| 318 |
+
CLR_MUTED = "#607d8b"
|
| 319 |
+
CLR_LABEL = "#333333" # label text
|
| 320 |
+
|
| 321 |
+
CHART_DPI = 300
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _apply_style(ax, title=""):
|
| 325 |
+
"""Apply a consistent white/light theme to an axes object."""
|
| 326 |
+
ax.set_facecolor(CLR_BG)
|
| 327 |
+
ax.figure.set_facecolor(CLR_BG)
|
| 328 |
+
ax.tick_params(colors=CLR_FG, labelsize=8)
|
| 329 |
+
ax.xaxis.label.set_color(CLR_FG)
|
| 330 |
+
ax.yaxis.label.set_color(CLR_FG)
|
| 331 |
+
ax.title.set_color(CLR_FG)
|
| 332 |
+
for spine in ax.spines.values():
|
| 333 |
+
spine.set_color(CLR_GRID)
|
| 334 |
+
ax.grid(True, color=CLR_GRID, linewidth=0.5, alpha=0.4)
|
| 335 |
+
if title:
|
| 336 |
+
ax.set_title(title, fontsize=11, fontweight="bold", pad=10)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def chart_price_scatter(datapoints, path="fig_price_scatter.png", ax=None):
|
| 340 |
+
"""Scatter + line plot of price vs trade index, labeled AβJ."""
|
| 341 |
+
labels = [d[0] for d in datapoints]
|
| 342 |
+
trades = [d[1] for d in datapoints]
|
| 343 |
+
prices = [d[2] for d in datapoints]
|
| 344 |
+
|
| 345 |
+
standalone = ax is None
|
| 346 |
+
if standalone:
|
| 347 |
+
fig, ax = plt.subplots(figsize=(7, 4))
|
| 348 |
+
_apply_style(ax, "Price vs. Trade Index (Datapoints AβJ)")
|
| 349 |
+
|
| 350 |
+
ax.plot(trades, prices, color=CLR_ACCENT1, linewidth=1.2, alpha=0.45,
|
| 351 |
+
zorder=1)
|
| 352 |
+
ax.scatter(trades, prices, color=CLR_ACCENT1, s=52, zorder=2,
|
| 353 |
+
edgecolors="white", linewidths=0.6)
|
| 354 |
+
|
| 355 |
+
for lbl, x, y in zip(labels, trades, prices):
|
| 356 |
+
ax.annotate(lbl, (x, y), textcoords="offset points",
|
| 357 |
+
xytext=(0, 10), ha="center", fontsize=8,
|
| 358 |
+
fontweight="bold", color=CLR_LABEL)
|
| 359 |
+
|
| 360 |
+
ax.set_xlabel("Trade Index (raw trades)", fontsize=9)
|
| 361 |
+
ax.set_ylabel("Price (USD)", fontsize=9)
|
| 362 |
+
ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.0f"))
|
| 363 |
+
|
| 364 |
+
if standalone:
|
| 365 |
+
fig.tight_layout()
|
| 366 |
+
fig.savefig(path, dpi=CHART_DPI, bbox_inches="tight")
|
| 367 |
+
plt.close(fig)
|
| 368 |
+
print(f"[OK] {path}")
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _draw_profile(ax, profile, beta, title, bar_color):
|
| 372 |
+
"""Draw a horizontal bar chart for a profile onto *ax*."""
|
| 373 |
+
b_min = min(profile.keys())
|
| 374 |
+
b_max = max(profile.keys())
|
| 375 |
+
|
| 376 |
+
bin_labels = []
|
| 377 |
+
stacks = []
|
| 378 |
+
groups = []
|
| 379 |
+
for b in range(b_min, b_max + 1):
|
| 380 |
+
p_from = b * beta
|
| 381 |
+
p_until = (b + 1) * beta
|
| 382 |
+
bin_labels.append(f"{int(p_from)}β{int(p_until)}")
|
| 383 |
+
info = profile.get(b, {"labels": [], "count": 0})
|
| 384 |
+
stacks.append(info["count"])
|
| 385 |
+
groups.append("".join(sorted(info["labels"])))
|
| 386 |
+
|
| 387 |
+
y_pos = range(len(bin_labels))
|
| 388 |
+
bars = ax.barh(y_pos, stacks, color=bar_color, edgecolor="white",
|
| 389 |
+
linewidth=0.5, height=0.7, alpha=0.85)
|
| 390 |
+
|
| 391 |
+
ax.set_yticks(y_pos)
|
| 392 |
+
ax.set_yticklabels(bin_labels, fontsize=7)
|
| 393 |
+
ax.set_xlabel("Stacks", fontsize=9)
|
| 394 |
+
ax.set_ylabel("Price Bin (USD)", fontsize=9)
|
| 395 |
+
|
| 396 |
+
# Annotate bars with datapoint group letters
|
| 397 |
+
max_s = max(stacks) if stacks else 1
|
| 398 |
+
for i, (bar, grp) in enumerate(zip(bars, groups)):
|
| 399 |
+
if grp:
|
| 400 |
+
ax.text(bar.get_width() + 0.12, bar.get_y() + bar.get_height() / 2,
|
| 401 |
+
grp, va="center", ha="left", fontsize=7, color=CLR_LABEL,
|
| 402 |
+
fontweight="bold")
|
| 403 |
+
|
| 404 |
+
ax.set_xlim(0, max_s + 2)
|
| 405 |
+
_apply_style(ax, title)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def chart_profile(profile, beta, path, title, bar_color):
|
| 409 |
+
"""Standalone horizontal bar chart for a single profile (CMP or GMP)."""
|
| 410 |
+
if not profile:
|
| 411 |
+
return
|
| 412 |
+
fig, ax = plt.subplots(figsize=(6, 5))
|
| 413 |
+
_draw_profile(ax, profile, beta, title, bar_color)
|
| 414 |
+
fig.tight_layout()
|
| 415 |
+
fig.savefig(path, dpi=CHART_DPI, bbox_inches="tight")
|
| 416 |
+
plt.close(fig)
|
| 417 |
+
print(f"[OK] {path}")
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def chart_cmp_vs_gmp(cmp_profile, gmp_profile, beta,
|
| 421 |
+
path="fig_cmp_vs_gmp.png"):
|
| 422 |
+
"""Side-by-side comparison of CMP and GMP profiles (2-panel)."""
|
| 423 |
+
if not cmp_profile or not gmp_profile:
|
| 424 |
+
return
|
| 425 |
+
|
| 426 |
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 5), sharey=True)
|
| 427 |
+
|
| 428 |
+
_draw_profile(ax1, cmp_profile, beta, "CMP Profile", CLR_ACCENT2)
|
| 429 |
+
_draw_profile(ax2, gmp_profile, beta, "GMP Profile", CLR_ACCENT3)
|
| 430 |
+
ax2.set_ylabel("") # avoid duplicate y-label
|
| 431 |
+
|
| 432 |
+
fig.suptitle("CMP vs. GMP β 10-Datapoint Example (Ξ² = 1)",
|
| 433 |
+
fontsize=13, fontweight="bold", color=CLR_FG, y=1.01)
|
| 434 |
+
fig.tight_layout()
|
| 435 |
+
fig.savefig(path, dpi=CHART_DPI, bbox_inches="tight")
|
| 436 |
+
plt.close(fig)
|
| 437 |
+
print(f"[OK] {path}")
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def chart_combined_3panel(datapoints, cmp_profile, gmp_profile, beta,
|
| 441 |
+
path="fig_combined_3panel.png"):
|
| 442 |
+
"""Three-panel chart: Datapoints | CMP with letters | GMP with letters."""
|
| 443 |
+
if not cmp_profile or not gmp_profile:
|
| 444 |
+
return
|
| 445 |
+
|
| 446 |
+
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(16, 5.5),
|
| 447 |
+
gridspec_kw={"width_ratios": [1.1, 1, 1]})
|
| 448 |
+
|
| 449 |
+
# ββ Panel 1: Datapoints scatter with labels ββββββββββββββββββββββββββ
|
| 450 |
+
labels = [d[0] for d in datapoints]
|
| 451 |
+
trades = [d[1] for d in datapoints]
|
| 452 |
+
prices = [d[2] for d in datapoints]
|
| 453 |
+
|
| 454 |
+
_apply_style(ax1, "Datapoints (AβJ)")
|
| 455 |
+
ax1.plot(trades, prices, color=CLR_ACCENT1, linewidth=1.2, alpha=0.4,
|
| 456 |
+
zorder=1)
|
| 457 |
+
ax1.scatter(trades, prices, color=CLR_ACCENT1, s=52, zorder=2,
|
| 458 |
+
edgecolors="white", linewidths=0.6)
|
| 459 |
+
for lbl, x, y in zip(labels, trades, prices):
|
| 460 |
+
ax1.annotate(lbl, (x, y), textcoords="offset points",
|
| 461 |
+
xytext=(0, 10), ha="center", fontsize=9,
|
| 462 |
+
fontweight="bold", color=CLR_LABEL)
|
| 463 |
+
ax1.set_xlabel("Trade Index", fontsize=9)
|
| 464 |
+
ax1.set_ylabel("Price (USD)", fontsize=9)
|
| 465 |
+
ax1.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.0f"))
|
| 466 |
+
|
| 467 |
+
# ββ Panel 2: CMP with group letters ββββββββββββββββββββββββββββββββββ
|
| 468 |
+
_draw_profile(ax2, cmp_profile, beta, "CMP with Letters", CLR_ACCENT2)
|
| 469 |
+
|
| 470 |
+
# ββ Panel 3: GMP with group letters ββββββββββββββββββββββββββββββββββ
|
| 471 |
+
_draw_profile(ax3, gmp_profile, beta, "GMP with Letters", CLR_ACCENT3)
|
| 472 |
+
ax3.set_ylabel("") # avoid duplicate y-label
|
| 473 |
+
|
| 474 |
+
fig.suptitle("Datapoints β CMP β GMP (Ξ² = 1)",
|
| 475 |
+
fontsize=14, fontweight="bold", color=CLR_FG, y=1.02)
|
| 476 |
+
fig.tight_layout()
|
| 477 |
+
fig.savefig(path, dpi=CHART_DPI, bbox_inches="tight")
|
| 478 |
+
plt.close(fig)
|
| 479 |
+
print(f"[OK] {path}")
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def chart_updown_footprint(updown_profile, beta,
|
| 483 |
+
path="fig_updown_footprint.png"):
|
| 484 |
+
"""Dual horizontal bar chart: down-bins (left/red) vs up-bins (right/teal)."""
|
| 485 |
+
if not updown_profile:
|
| 486 |
+
return
|
| 487 |
+
|
| 488 |
+
CLR_UP = "#00897b" # teal
|
| 489 |
+
CLR_DOWN = "#e53935" # red
|
| 490 |
+
|
| 491 |
+
b_min = min(updown_profile.keys())
|
| 492 |
+
b_max = max(updown_profile.keys())
|
| 493 |
+
|
| 494 |
+
bin_labels = []
|
| 495 |
+
up_vals = []
|
| 496 |
+
down_vals = []
|
| 497 |
+
delta_vals = []
|
| 498 |
+
for b in range(b_min, b_max + 1):
|
| 499 |
+
p_from = b * beta
|
| 500 |
+
p_until = (b + 1) * beta
|
| 501 |
+
bin_labels.append(f"{int(p_from)}-{int(p_until)}")
|
| 502 |
+
info = updown_profile.get(b, {"labels": [], "up": 0, "down": 0})
|
| 503 |
+
up_vals.append(info["up"])
|
| 504 |
+
down_vals.append(info["down"])
|
| 505 |
+
delta_vals.append(info["up"] - info["down"])
|
| 506 |
+
|
| 507 |
+
y_pos = list(range(len(bin_labels)))
|
| 508 |
+
max_val = max(max(up_vals, default=1), max(down_vals, default=1), 1)
|
| 509 |
+
|
| 510 |
+
fig, ax = plt.subplots(figsize=(8, 5.5))
|
| 511 |
+
_apply_style(ax, "Up/Down-Bin Footprint Profile (GMP-based)")
|
| 512 |
+
|
| 513 |
+
# Down bars extend to the LEFT (negative x)
|
| 514 |
+
bars_down = ax.barh(y_pos, [-d for d in down_vals], color=CLR_DOWN,
|
| 515 |
+
edgecolor="white", linewidth=0.5, height=0.65,
|
| 516 |
+
alpha=0.85, label="Down-bin")
|
| 517 |
+
# Up bars extend to the RIGHT (positive x)
|
| 518 |
+
bars_up = ax.barh(y_pos, up_vals, color=CLR_UP,
|
| 519 |
+
edgecolor="white", linewidth=0.5, height=0.65,
|
| 520 |
+
alpha=0.85, label="Up-bin")
|
| 521 |
+
|
| 522 |
+
# Annotate bars with counts
|
| 523 |
+
for i, (dv, uv, deltav) in enumerate(zip(down_vals, up_vals, delta_vals)):
|
| 524 |
+
if dv > 0:
|
| 525 |
+
ax.text(-dv - 0.15, i, str(dv), va="center", ha="right",
|
| 526 |
+
fontsize=7, color=CLR_DOWN, fontweight="bold")
|
| 527 |
+
if uv > 0:
|
| 528 |
+
ax.text(uv + 0.15, i, str(uv), va="center", ha="left",
|
| 529 |
+
fontsize=7, color=CLR_UP, fontweight="bold")
|
| 530 |
+
# Delta annotation at far right
|
| 531 |
+
delta_color = CLR_UP if deltav > 0 else (CLR_DOWN if deltav < 0 else CLR_MUTED)
|
| 532 |
+
delta_str = f"{deltav:+d}" if deltav != 0 else "0"
|
| 533 |
+
ax.text(max_val + 1.0, i, f"\u0394={delta_str}", va="center", ha="left",
|
| 534 |
+
fontsize=6.5, color=delta_color)
|
| 535 |
+
|
| 536 |
+
ax.set_yticks(y_pos)
|
| 537 |
+
ax.set_yticklabels(bin_labels, fontsize=7)
|
| 538 |
+
ax.set_xlabel("Stacks", fontsize=9)
|
| 539 |
+
ax.set_ylabel("Price Bin (USD)", fontsize=9)
|
| 540 |
+
ax.axvline(0, color=CLR_FG, linewidth=0.6)
|
| 541 |
+
ax.set_xlim(-max_val - 1.5, max_val + 2.5)
|
| 542 |
+
ax.legend(loc="lower right", fontsize=8)
|
| 543 |
+
|
| 544 |
+
fig.tight_layout()
|
| 545 |
+
fig.savefig(path, dpi=CHART_DPI, bbox_inches="tight")
|
| 546 |
+
plt.close(fig)
|
| 547 |
+
print(f"[OK] {path}")
|
| 548 |
+
|
| 549 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 550 |
+
# 7. MAIN
|
| 551 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 552 |
+
|
| 553 |
+
def main():
|
| 554 |
+
out_dir = os.path.dirname(os.path.abspath(__file__))
|
| 555 |
+
|
| 556 |
+
# ββ Build profiles ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 557 |
+
cmp = build_cmp(DATAPOINTS, BIN_SIZE)
|
| 558 |
+
gmp = build_gmp(DATAPOINTS, BIN_SIZE)
|
| 559 |
+
updown = build_updown_profile(DATAPOINTS, BIN_SIZE)
|
| 560 |
+
|
| 561 |
+
# ββ Write CSVs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 562 |
+
write_datapoints_csv(DATAPOINTS, os.path.join(out_dir, "datapoints.csv"))
|
| 563 |
+
write_profile_csv(cmp, BIN_SIZE, os.path.join(out_dir, "cmp_profile.csv"))
|
| 564 |
+
write_profile_csv(gmp, BIN_SIZE, os.path.join(out_dir, "gmp_profile.csv"))
|
| 565 |
+
write_updown_profile_csv(updown, gmp, BIN_SIZE,
|
| 566 |
+
os.path.join(out_dir, "updown_profile.csv"))
|
| 567 |
+
|
| 568 |
+
# ββ Generate charts βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 569 |
+
if HAS_MPL:
|
| 570 |
+
chart_price_scatter(
|
| 571 |
+
DATAPOINTS, os.path.join(out_dir, "fig_price_scatter.png"))
|
| 572 |
+
chart_profile(
|
| 573 |
+
cmp, BIN_SIZE, os.path.join(out_dir, "fig_cmp_profile.png"),
|
| 574 |
+
"Conventional Market Profile (CMP)", CLR_ACCENT2)
|
| 575 |
+
chart_profile(
|
| 576 |
+
gmp, BIN_SIZE, os.path.join(out_dir, "fig_gmp_profile.png"),
|
| 577 |
+
"Gap-Filled Market Profile (GMP)", CLR_ACCENT3)
|
| 578 |
+
chart_cmp_vs_gmp(
|
| 579 |
+
cmp, gmp, BIN_SIZE,
|
| 580 |
+
os.path.join(out_dir, "fig_cmp_vs_gmp.png"))
|
| 581 |
+
chart_combined_3panel(
|
| 582 |
+
DATAPOINTS, cmp, gmp, BIN_SIZE,
|
| 583 |
+
os.path.join(out_dir, "fig_combined_3panel.png"))
|
| 584 |
+
chart_updown_footprint(
|
| 585 |
+
updown, BIN_SIZE,
|
| 586 |
+
os.path.join(out_dir, "fig_updown_footprint.png"))
|
| 587 |
+
|
| 588 |
+
# ββ Print summary βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 589 |
+
print("\nββ CMP Profile ββ")
|
| 590 |
+
b_min = min(cmp.keys())
|
| 591 |
+
b_max = max(cmp.keys())
|
| 592 |
+
for b in range(b_min, b_max + 1):
|
| 593 |
+
info = cmp.get(b, {"labels": [], "count": 0})
|
| 594 |
+
grp = "".join(sorted(info["labels"]))
|
| 595 |
+
print(f" Bin {b - b_min + 1}: {int(b * BIN_SIZE)}β{int((b+1) * BIN_SIZE)} "
|
| 596 |
+
f"group={grp or 'β':6s} stacks={info['count']}")
|
| 597 |
+
|
| 598 |
+
print("\nββ GMP Profile ββ")
|
| 599 |
+
b_min = min(gmp.keys())
|
| 600 |
+
b_max = max(gmp.keys())
|
| 601 |
+
for b in range(b_min, b_max + 1):
|
| 602 |
+
info = gmp.get(b, {"labels": [], "count": 0})
|
| 603 |
+
grp = "".join(sorted(info["labels"]))
|
| 604 |
+
print(f" Bin {b - b_min + 1}: {int(b * BIN_SIZE)}β{int((b+1) * BIN_SIZE)} "
|
| 605 |
+
f"group={grp or 'β':6s} stacks={info['count']}")
|
| 606 |
+
|
| 607 |
+
print("\nββ Up/Down-Bin Footprint Profile ββ")
|
| 608 |
+
b_min = min(updown.keys())
|
| 609 |
+
b_max = max(updown.keys())
|
| 610 |
+
for b in range(b_min, b_max + 1):
|
| 611 |
+
info = updown.get(b, {"labels": [], "up": 0, "down": 0})
|
| 612 |
+
grp = "".join(info["labels"])
|
| 613 |
+
delta = info["up"] - info["down"]
|
| 614 |
+
print(f" Bin {b - b_min + 1}: {int(b * BIN_SIZE)}β{int((b+1) * BIN_SIZE)} "
|
| 615 |
+
f"group={grp or 'β':6s} up={info['up']} down={info['down']} "
|
| 616 |
+
f"delta={delta:+d}")
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
if __name__ == "__main__":
|
| 620 |
+
main()
|
mBA-GMP.v3/generate_profiles.py_output/cmp_profile.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
bin (with binsize = 1 symbol's price unit),price from,price until,datapoint group,number of profile's stacks
|
| 2 |
+
1,3000,3001,A,1
|
| 3 |
+
2,3001,3002,,0
|
| 4 |
+
3,3002,3003,C,1
|
| 5 |
+
4,3003,3004,BGIJ,4
|
| 6 |
+
5,3004,3005,H,1
|
| 7 |
+
6,3005,3006,,0
|
| 8 |
+
7,3006,3007,,0
|
| 9 |
+
8,3007,3008,E,1
|
| 10 |
+
9,3008,3009,,0
|
| 11 |
+
10,3009,3010,DF,2
|
mBA-GMP.v3/generate_profiles.py_output/datapoints.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
datapoint,x-axis trades (raw trades or time),y-axis Price
|
| 2 |
+
A,1,3000.914
|
| 3 |
+
B,2,3003.837
|
| 4 |
+
C,3,3002.432
|
| 5 |
+
D,4,3009.892
|
| 6 |
+
E,5,3007.698
|
| 7 |
+
F,6,3009.176
|
| 8 |
+
G,7,3003.381
|
| 9 |
+
H,8,3004.283
|
| 10 |
+
I,9,3003.512
|
| 11 |
+
J,10,3003.012
|
mBA-GMP.v3/generate_profiles.py_output/fig_cmp_profile.png
ADDED
|
mBA-GMP.v3/generate_profiles.py_output/fig_cmp_vs_gmp.png
ADDED
|
Git LFS Details
|
mBA-GMP.v3/generate_profiles.py_output/fig_combined_3panel.png
ADDED
|
Git LFS Details
|
mBA-GMP.v3/generate_profiles.py_output/fig_gmp_profile.png
ADDED
|
mBA-GMP.v3/generate_profiles.py_output/fig_price_scatter.png
ADDED
|
Git LFS Details
|
mBA-GMP.v3/generate_profiles.py_output/fig_updown_footprint.png
ADDED
|
Git LFS Details
|
mBA-GMP.v3/generate_profiles.py_output/gmp_profile.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
bin (with binsize = 1 symbol's price unit),price from,price until,datapoint group,number of profile's stacks
|
| 2 |
+
1,3000,3001,A,1
|
| 3 |
+
2,3001,3002,A,1
|
| 4 |
+
3,3002,3003,AC,2
|
| 5 |
+
4,3003,3004,BCGIJ,5
|
| 6 |
+
5,3004,3005,CFH,3
|
| 7 |
+
6,3005,3006,CF,2
|
| 8 |
+
7,3006,3007,CF,2
|
| 9 |
+
8,3007,3008,CEF,3
|
| 10 |
+
9,3008,3009,CDEF,4
|
| 11 |
+
10,3009,3010,DF,2
|
mBA-GMP.v3/generate_profiles.py_output/raw_draft_of_concept.png
ADDED
|
Git LFS Details
|
mBA-GMP.v3/generate_profiles.py_output/updown_profile.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
bin (with binsize = 1 symbol's price unit),price from,price until,datapoint group,down-bin profile's stacks,up-bin profile's stacks,delta-bin profile's stacks
|
| 2 |
+
1,3000,3001,A,0,0,0
|
| 3 |
+
2,3001,3002,A,0,1,1
|
| 4 |
+
3,3002,3003,AC,1,1,0
|
| 5 |
+
4,3003,3004,BCGIJ,3,2,-1
|
| 6 |
+
5,3004,3005,CFH,1,2,1
|
| 7 |
+
6,3005,3006,CF,1,1,0
|
| 8 |
+
7,3006,3007,CF,1,1,0
|
| 9 |
+
8,3007,3008,CEF,2,1,-1
|
| 10 |
+
9,3008,3009,CDEF,2,2,0
|
| 11 |
+
10,3009,3010,DF,0,2,2
|