algorembrant commited on
Commit
e15ab27
Β·
verified Β·
1 Parent(s): 7cdf9e9

Upload 37 files

Browse files
Files changed (38) hide show
  1. .gitattributes +7 -0
  2. LICENSE +21 -0
  3. README.md +7 -0
  4. STRUCTURE.md +54 -0
  5. SUM3API/LICENSE +21 -0
  6. SUM3API/MQL5/Experts/ZmqPublisher.mq5 +450 -0
  7. SUM3API/MQL5/Include/Zmq/Zmq.mqh +144 -0
  8. SUM3API/MQL5/Libraries/libsodium.dll +3 -0
  9. SUM3API/MQL5/Libraries/libzmq.dll +3 -0
  10. SUM3API/Rustmt5-chart/Cargo.lock +0 -0
  11. SUM3API/Rustmt5-chart/Cargo.toml +15 -0
  12. SUM3API/Rustmt5-chart/output/History_XAUUSDc_H1_OHLC_ID0002_20260126_135823.csv +3 -0
  13. SUM3API/Rustmt5-chart/output/History_XAUUSDc_H1_TICKS_ID0001_20260126_140825.csv +0 -0
  14. SUM3API/Rustmt5-chart/output/Live_XAUUSDc_ID0001_20260126_135811.csv +14 -0
  15. SUM3API/Rustmt5-chart/src/main.rs +852 -0
  16. TECHSTACK.md +18 -0
  17. gmp-terminal/Cargo.lock +0 -0
  18. gmp-terminal/Cargo.toml +19 -0
  19. gmp-terminal/src/app.rs +340 -0
  20. gmp-terminal/src/gmp_engine.rs +406 -0
  21. gmp-terminal/src/main.rs +46 -0
  22. gmp-terminal/src/tab_time.rs +207 -0
  23. gmp-terminal/src/tab_trade.rs +68 -0
  24. gmp-terminal/src/timeframe.rs +150 -0
  25. gmp-terminal/src/trading_panel.rs +340 -0
  26. gmp-terminal/src/zmq_bridge.rs +207 -0
  27. mBA-GMP.v3/generate_profiles.py +620 -0
  28. mBA-GMP.v3/generate_profiles.py_output/cmp_profile.csv +11 -0
  29. mBA-GMP.v3/generate_profiles.py_output/datapoints.csv +11 -0
  30. mBA-GMP.v3/generate_profiles.py_output/fig_cmp_profile.png +0 -0
  31. mBA-GMP.v3/generate_profiles.py_output/fig_cmp_vs_gmp.png +3 -0
  32. mBA-GMP.v3/generate_profiles.py_output/fig_combined_3panel.png +3 -0
  33. mBA-GMP.v3/generate_profiles.py_output/fig_gmp_profile.png +0 -0
  34. mBA-GMP.v3/generate_profiles.py_output/fig_price_scatter.png +3 -0
  35. mBA-GMP.v3/generate_profiles.py_output/fig_updown_footprint.png +3 -0
  36. mBA-GMP.v3/generate_profiles.py_output/gmp_profile.csv +11 -0
  37. mBA-GMP.v3/generate_profiles.py_output/raw_draft_of_concept.png +3 -0
  38. 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

  • SHA256: e2e77b3cc26d74929fb66722cab8af53d4dda52b0c4051c828965f10920e0a81
  • Pointer size: 131 Bytes
  • Size of remote file: 131 kB
mBA-GMP.v3/generate_profiles.py_output/fig_combined_3panel.png ADDED

Git LFS Details

  • SHA256: abf7574bc9377fb7aea265fd5c39671341ad8a1a7a1c6ce814b78c8cf2aaba43
  • Pointer size: 131 Bytes
  • Size of remote file: 238 kB
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

  • SHA256: d0bfbedd85715cfbfe6a1d7cd1bf74ee52155a5c0d963aa1a9ea0a171e470971
  • Pointer size: 131 Bytes
  • Size of remote file: 108 kB
mBA-GMP.v3/generate_profiles.py_output/fig_updown_footprint.png ADDED

Git LFS Details

  • SHA256: 1712bcd8d57fc41e3068db5c65b731eea2eeac8c8c50bea66271898c77381607
  • Pointer size: 131 Bytes
  • Size of remote file: 116 kB
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

  • SHA256: c03511fbff70de1bd935d3a816cbcc81ee1709a7f10cb70ab97d885c3e7490d5
  • Pointer size: 131 Bytes
  • Size of remote file: 238 kB
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