Upload 29 files
Browse files- .gitattributes +3 -35
- .gitignore +2 -0
- IDEA.md +0 -0
- LICENSE +21 -0
- Python/mt5_filled_ticks.py +224 -0
- Python/mt5_raw_ticks.py +166 -0
- README.md +95 -0
- STRUCTURE.md +41 -0
- TECHSTACK.md +14 -0
- images/filled_ticks_4panel.png +3 -0
- images/raw_ticks_4panel.png +3 -0
- market_profile_paper.tex +730 -0
- output/filled_ask_XAUUSDc_20260212.csv +3 -0
- output/filled_bid_XAUUSDc_20260212.csv +3 -0
- output/raw_ticks_XAUUSDc_20260212.csv +3 -0
- requirements.txt +11 -0
- scripts/debug_mt5.py +87 -0
- src/config.py +48 -0
- src/core/data_worker.py +165 -0
- src/core/market_profile.py +195 -0
- src/core/mt5_interface.py +85 -0
- src/main.py +24 -0
- src/ui/chart_widget.py +178 -0
- src/ui/control_panel.py +78 -0
- src/ui/main_window.py +96 -0
- tests/test_interactive_chart.py +69 -0
- tests/test_logic.py +69 -0
- tests/test_profile_logic.py +73 -0
- tests/verify_levels.py +44 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,3 @@
|
|
| 1 |
-
*.
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz 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
|
|
|
|
| 1 |
+
*.csv filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
images/filled_ticks_4panel.png filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
images/raw_ticks_4panel.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
.env
|
IDEA.md
ADDED
|
File without changes
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 ContinualQuasars
|
| 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.
|
Python/mt5_filled_ticks.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script 2 — Gap-Filled Microsecond Bid-Ask Unit Data Visualization
|
| 3 |
+
Fetches the SAME XAUUSDc data as Script 1, then fills every missing
|
| 4 |
+
price level between consecutive data points so that the Y-distribution
|
| 5 |
+
histogram reflects the full path traversed, not just the endpoints.
|
| 6 |
+
|
| 7 |
+
Output: 4-panel figure identical in layout to Script 1 but built on the
|
| 8 |
+
gap-filled DataFrame.
|
| 9 |
+
|
| 10 |
+
0.01 unit = $0.01 XAU price change.
|
| 11 |
+
The 'c' suffix in XAUUSDc is an Exness broker account-type indicator
|
| 12 |
+
(standard cent live account), not related to XAU pricing.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import MetaTrader5 as mt5
|
| 16 |
+
import pandas as pd
|
| 17 |
+
import numpy as np
|
| 18 |
+
import matplotlib
|
| 19 |
+
matplotlib.use('Agg') # Headless backend — no GUI window
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import matplotlib.dates as mdates
|
| 22 |
+
from datetime import datetime, timezone
|
| 23 |
+
|
| 24 |
+
# ──────────────────────────────────────────────
|
| 25 |
+
# 1. Connect to MT5
|
| 26 |
+
# ──────────────────────────────────────────────
|
| 27 |
+
if not mt5.initialize():
|
| 28 |
+
print(f"MT5 initialize() failed, error code = {mt5.last_error()}")
|
| 29 |
+
quit()
|
| 30 |
+
|
| 31 |
+
# ──────────────────────────────────────────────
|
| 32 |
+
# 2. Define time range (Feb 12 2026, full day UTC)
|
| 33 |
+
# ──────────────────────────────────────────────
|
| 34 |
+
utc_from = datetime(2026, 2, 12, 0, 0, 0, tzinfo=timezone.utc)
|
| 35 |
+
utc_to = datetime(2026, 2, 12, 23, 59, 59, tzinfo=timezone.utc)
|
| 36 |
+
|
| 37 |
+
SYMBOL = "XAUUSDc"
|
| 38 |
+
UNIT_SIZE = 0.01 # the binsize (0.01 unit = $0.01 XAU price change)
|
| 39 |
+
|
| 40 |
+
# ──────────────────────────────────────────────
|
| 41 |
+
# 3. Fetch data from MT5 (same query as Script 1)
|
| 42 |
+
# ──────────────────────────────────────────────
|
| 43 |
+
ticks = mt5.copy_ticks_range(SYMBOL, utc_from, utc_to, mt5.COPY_TICKS_ALL)
|
| 44 |
+
|
| 45 |
+
if ticks is None or len(ticks) == 0:
|
| 46 |
+
print(f"No data retrieved for {SYMBOL}. Error: {mt5.last_error()}")
|
| 47 |
+
mt5.shutdown()
|
| 48 |
+
quit()
|
| 49 |
+
|
| 50 |
+
df = pd.DataFrame(ticks)
|
| 51 |
+
df['datetime'] = pd.to_datetime(df['time_msc'], unit='ms', utc=True)
|
| 52 |
+
|
| 53 |
+
print(f"Fetched {len(df):,} raw data points for {SYMBOL}")
|
| 54 |
+
mt5.shutdown()
|
| 55 |
+
|
| 56 |
+
# Save raw unit CSV (same data as Script 1)
|
| 57 |
+
csv_raw = "raw_ticks_XAUUSDc_20260212.csv" # filename kept for compatibility
|
| 58 |
+
df[['datetime', 'bid', 'ask', 'last', 'volume', 'flags']].to_csv(csv_raw, index=False)
|
| 59 |
+
print(f"Saved CSV → {csv_raw} ({len(df):,} rows)")
|
| 60 |
+
|
| 61 |
+
# ──────────────────────────────────────────────
|
| 62 |
+
# 4. Vectorised gap-filling function
|
| 63 |
+
# ──────────────────────────────────────────────
|
| 64 |
+
def fill_gaps(prices: np.ndarray, timestamps_ns: np.ndarray, unit_size: float):
|
| 65 |
+
"""
|
| 66 |
+
Vectorised gap-fill: for every consecutive pair (A → B),
|
| 67 |
+
insert intermediate price levels at every unit_size step.
|
| 68 |
+
timestamps_ns should be int64 nanoseconds.
|
| 69 |
+
"""
|
| 70 |
+
diff_units = np.round(np.diff(prices) / unit_size).astype(np.int64)
|
| 71 |
+
counts = np.abs(diff_units)
|
| 72 |
+
# Last point gets a count of 1 (just itself)
|
| 73 |
+
counts = np.append(counts, 1)
|
| 74 |
+
|
| 75 |
+
total = int(np.sum(counts))
|
| 76 |
+
indices = np.repeat(np.arange(len(prices)), counts)
|
| 77 |
+
|
| 78 |
+
# Offset within each segment
|
| 79 |
+
cum = np.cumsum(counts)
|
| 80 |
+
starts = np.empty_like(cum)
|
| 81 |
+
starts[0] = 0
|
| 82 |
+
starts[1:] = cum[:-1]
|
| 83 |
+
offsets = np.arange(total) - np.repeat(starts, counts)
|
| 84 |
+
|
| 85 |
+
# Direction per segment
|
| 86 |
+
directions = np.zeros(len(prices), dtype=np.float64)
|
| 87 |
+
directions[:-1] = np.sign(diff_units)
|
| 88 |
+
|
| 89 |
+
# Time step per segment
|
| 90 |
+
dt = np.zeros(len(prices), dtype=np.float64)
|
| 91 |
+
dt[:-1] = np.diff(timestamps_ns).astype(np.float64)
|
| 92 |
+
steps = dt / np.where(counts > 0, counts, 1)
|
| 93 |
+
|
| 94 |
+
filled_prices = prices[indices] + offsets * directions[indices] * unit_size
|
| 95 |
+
filled_ts = timestamps_ns[indices].astype(np.float64) + offsets * steps[indices]
|
| 96 |
+
|
| 97 |
+
return np.round(filled_prices, 2), filled_ts.astype(np.int64)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ──────────────────────────────────────────────
|
| 101 |
+
# 5. Apply gap-filling to bid and ask separately
|
| 102 |
+
# ──────────────────────────────────────────────
|
| 103 |
+
ts_ns = df['datetime'].values.astype('datetime64[ns]').astype(np.int64)
|
| 104 |
+
|
| 105 |
+
print("Gap-filling bid...")
|
| 106 |
+
bid_prices_filled, bid_ts_filled = fill_gaps(df['bid'].values, ts_ns, UNIT_SIZE)
|
| 107 |
+
print("Gap-filling ask...")
|
| 108 |
+
ask_prices_filled, ask_ts_filled = fill_gaps(df['ask'].values, ts_ns, UNIT_SIZE)
|
| 109 |
+
|
| 110 |
+
print(f"Bid: {len(df):,} raw → {len(bid_prices_filled):,} filled rows")
|
| 111 |
+
print(f"Ask: {len(df):,} raw → {len(ask_prices_filled):,} filled rows")
|
| 112 |
+
|
| 113 |
+
# Save gap-filled CSVs
|
| 114 |
+
bid_filled_df = pd.DataFrame({
|
| 115 |
+
'datetime': pd.to_datetime(bid_ts_filled, unit='ns', utc=True),
|
| 116 |
+
'bid_filled': bid_prices_filled,
|
| 117 |
+
})
|
| 118 |
+
bid_csv = "filled_bid_XAUUSDc_20260212.csv"
|
| 119 |
+
bid_filled_df.to_csv(bid_csv, index=False)
|
| 120 |
+
print(f"Saved CSV → {bid_csv} ({len(bid_filled_df):,} rows)")
|
| 121 |
+
|
| 122 |
+
ask_filled_df = pd.DataFrame({
|
| 123 |
+
'datetime': pd.to_datetime(ask_ts_filled, unit='ns', utc=True),
|
| 124 |
+
'ask_filled': ask_prices_filled,
|
| 125 |
+
})
|
| 126 |
+
ask_csv = "filled_ask_XAUUSDc_20260212.csv"
|
| 127 |
+
ask_filled_df.to_csv(ask_csv, index=False)
|
| 128 |
+
print(f"Saved CSV → {ask_csv} ({len(ask_filled_df):,} rows)")
|
| 129 |
+
|
| 130 |
+
# Convert ns timestamps to matplotlib date floats
|
| 131 |
+
# matplotlib dates = days since 0001-01-01; Unix epoch = day 719163
|
| 132 |
+
_UNIX_EPOCH_MPLDATE = 719163.0
|
| 133 |
+
bid_times = bid_ts_filled / 1e9 / 86400.0 + _UNIX_EPOCH_MPLDATE
|
| 134 |
+
ask_times = ask_ts_filled / 1e9 / 86400.0 + _UNIX_EPOCH_MPLDATE
|
| 135 |
+
|
| 136 |
+
# ──────────────────────────────────────────────
|
| 137 |
+
# 6. Build histogram bins (1 bin = 0.01 unit)
|
| 138 |
+
# ──────────────────────────────────────────────
|
| 139 |
+
overall_min = min(bid_prices_filled.min(), ask_prices_filled.min())
|
| 140 |
+
overall_max = max(bid_prices_filled.max(), ask_prices_filled.max())
|
| 141 |
+
|
| 142 |
+
bin_lo = np.floor(overall_min / UNIT_SIZE) * UNIT_SIZE - UNIT_SIZE
|
| 143 |
+
bin_hi = np.ceil(overall_max / UNIT_SIZE) * UNIT_SIZE + UNIT_SIZE
|
| 144 |
+
bins = np.round(np.arange(bin_lo, bin_hi + UNIT_SIZE, UNIT_SIZE), 2)
|
| 145 |
+
|
| 146 |
+
print("Plotting...")
|
| 147 |
+
|
| 148 |
+
# ──────────────────────────────────────────────
|
| 149 |
+
# 7. Plot 4-panel figure
|
| 150 |
+
# ──────────────────────────────────────────────
|
| 151 |
+
fig, axes = plt.subplots(
|
| 152 |
+
2, 2,
|
| 153 |
+
figsize=(20, 12),
|
| 154 |
+
gridspec_kw={'width_ratios': [1, 4]},
|
| 155 |
+
sharey='row',
|
| 156 |
+
)
|
| 157 |
+
fig.suptitle(
|
| 158 |
+
f'{SYMBOL} — Gap-Filled Unit Data (Path-Weighted) | {utc_from.strftime("%Y-%m-%d")}',
|
| 159 |
+
fontsize=16, fontweight='bold',
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Colors — 100% blue and 100% red per IDEA.md
|
| 163 |
+
BID_COLOR = '#0000FF'
|
| 164 |
+
ASK_COLOR = '#FF0000'
|
| 165 |
+
|
| 166 |
+
# ── Row 0: BID ─────────────────────────────
|
| 167 |
+
ax_hist_bid = axes[0, 0]
|
| 168 |
+
ax_line_bid = axes[0, 1]
|
| 169 |
+
|
| 170 |
+
ax_hist_bid.hist(
|
| 171 |
+
bid_prices_filled, bins=bins, orientation='horizontal',
|
| 172 |
+
color=BID_COLOR, alpha=1.0, edgecolor='white', linewidth=0.3,
|
| 173 |
+
)
|
| 174 |
+
ax_hist_bid.set_xlabel('Count (path-weighted)', fontsize=10)
|
| 175 |
+
ax_hist_bid.set_ylabel('Bid Price', fontsize=10)
|
| 176 |
+
ax_hist_bid.set_title('Bid Y-Distribution — Gap-Filled (0.01-unit bins)', fontsize=12)
|
| 177 |
+
# histogram grows left-to-right (starts from 0)
|
| 178 |
+
|
| 179 |
+
# Line only — no markers for 4M+ points, rasterized
|
| 180 |
+
ax_line_bid.plot(
|
| 181 |
+
bid_times, bid_prices_filled,
|
| 182 |
+
color=BID_COLOR, linewidth=0.5, alpha=1.0,
|
| 183 |
+
rasterized=True,
|
| 184 |
+
)
|
| 185 |
+
ax_line_bid.xaxis_date()
|
| 186 |
+
ax_line_bid.set_title('Bid Price — Gap-Filled (Time Series)', fontsize=12)
|
| 187 |
+
ax_line_bid.set_xlabel('Time (UTC)', fontsize=10)
|
| 188 |
+
ax_line_bid.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
|
| 189 |
+
ax_line_bid.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
| 190 |
+
plt.setp(ax_line_bid.xaxis.get_majorticklabels(), rotation=45, ha='right')
|
| 191 |
+
ax_line_bid.grid(True, alpha=0.3)
|
| 192 |
+
|
| 193 |
+
# ── Row 1: ASK ─────────────────────────────
|
| 194 |
+
ax_hist_ask = axes[1, 0]
|
| 195 |
+
ax_line_ask = axes[1, 1]
|
| 196 |
+
|
| 197 |
+
ax_hist_ask.hist(
|
| 198 |
+
ask_prices_filled, bins=bins, orientation='horizontal',
|
| 199 |
+
color=ASK_COLOR, alpha=1.0, edgecolor='white', linewidth=0.3,
|
| 200 |
+
)
|
| 201 |
+
ax_hist_ask.set_xlabel('Count (path-weighted)', fontsize=10)
|
| 202 |
+
ax_hist_ask.set_ylabel('Ask Price', fontsize=10)
|
| 203 |
+
ax_hist_ask.set_title('Ask Y-Distribution — Gap-Filled (0.01-unit bins)', fontsize=12)
|
| 204 |
+
# histogram grows left-to-right (starts from 0)
|
| 205 |
+
|
| 206 |
+
ax_line_ask.plot(
|
| 207 |
+
ask_times, ask_prices_filled,
|
| 208 |
+
color=ASK_COLOR, linewidth=0.5, alpha=1.0,
|
| 209 |
+
rasterized=True,
|
| 210 |
+
)
|
| 211 |
+
ax_line_ask.xaxis_date()
|
| 212 |
+
ax_line_ask.set_title('Ask Price — Gap-Filled (Time Series)', fontsize=12)
|
| 213 |
+
ax_line_ask.set_xlabel('Time (UTC)', fontsize=10)
|
| 214 |
+
ax_line_ask.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
|
| 215 |
+
ax_line_ask.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
| 216 |
+
plt.setp(ax_line_ask.xaxis.get_majorticklabels(), rotation=45, ha='right')
|
| 217 |
+
ax_line_ask.grid(True, alpha=0.3)
|
| 218 |
+
|
| 219 |
+
# ── Final layout ───────────────────────────
|
| 220 |
+
plt.tight_layout(rect=[0, 0, 1, 0.95])
|
| 221 |
+
|
| 222 |
+
output_path = "filled_ticks_4panel.png"
|
| 223 |
+
fig.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 224 |
+
print(f"Saved → {output_path}")
|
Python/mt5_raw_ticks.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script 1 — Raw Microsecond Bid-Ask Unit Data Visualization
|
| 3 |
+
Fetches XAUUSDc data from MetaTrader 5 for February 12, 2026 (full day),
|
| 4 |
+
and produces a 4-panel figure:
|
| 5 |
+
Top-left: Bid Y-distribution histogram (blue, 0.01-unit bins)
|
| 6 |
+
Top-right: Bid line chart with dot markers (blue)
|
| 7 |
+
Bottom-left: Ask Y-distribution histogram (red, 0.01-unit bins)
|
| 8 |
+
Bottom-right:Ask line chart with dot markers (red)
|
| 9 |
+
|
| 10 |
+
0.01 unit = $0.01 XAU price change.
|
| 11 |
+
The 'c' suffix in XAUUSDc is an Exness broker account-type indicator
|
| 12 |
+
(standard cent live account), not related to XAU pricing.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import MetaTrader5 as mt5
|
| 16 |
+
import pandas as pd
|
| 17 |
+
import numpy as np
|
| 18 |
+
import matplotlib
|
| 19 |
+
matplotlib.use('Agg') # Headless backend — no GUI window
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import matplotlib.dates as mdates
|
| 22 |
+
from datetime import datetime, timezone
|
| 23 |
+
|
| 24 |
+
# ──────────────────────────────────────────────
|
| 25 |
+
# 1. Connect to MT5
|
| 26 |
+
# ──────────────────────────────────────────────
|
| 27 |
+
if not mt5.initialize():
|
| 28 |
+
print(f"MT5 initialize() failed, error code = {mt5.last_error()}")
|
| 29 |
+
quit()
|
| 30 |
+
|
| 31 |
+
# ──────────────────────────────────────────────
|
| 32 |
+
# 2. Define time range (Feb 12 2026, full day UTC)
|
| 33 |
+
# ──────────────────────────────────────────────
|
| 34 |
+
utc_from = datetime(2026, 2, 12, 0, 0, 0, tzinfo=timezone.utc)
|
| 35 |
+
utc_to = datetime(2026, 2, 12, 23, 59, 59, tzinfo=timezone.utc)
|
| 36 |
+
|
| 37 |
+
SYMBOL = "XAUUSDc"
|
| 38 |
+
UNIT_SIZE = 0.01 # the binsize (0.01 unit = $0.01 XAU price change)
|
| 39 |
+
|
| 40 |
+
# ──────────────────────────────────────────────
|
| 41 |
+
# 3. Fetch data from MT5
|
| 42 |
+
# ──────────────────────────────────────────────
|
| 43 |
+
ticks = mt5.copy_ticks_range(SYMBOL, utc_from, utc_to, mt5.COPY_TICKS_ALL)
|
| 44 |
+
|
| 45 |
+
if ticks is None or len(ticks) == 0:
|
| 46 |
+
print(f"No data retrieved for {SYMBOL}. Error: {mt5.last_error()}")
|
| 47 |
+
mt5.shutdown()
|
| 48 |
+
quit()
|
| 49 |
+
|
| 50 |
+
df = pd.DataFrame(ticks)
|
| 51 |
+
# MT5 returns time in seconds since epoch; time_msc is milliseconds
|
| 52 |
+
df['datetime'] = pd.to_datetime(df['time_msc'], unit='ms', utc=True)
|
| 53 |
+
|
| 54 |
+
print(f"Fetched {len(df):,} data points for {SYMBOL}")
|
| 55 |
+
print(f"Time range: {df['datetime'].iloc[0]} → {df['datetime'].iloc[-1]}")
|
| 56 |
+
print(f"Bid range : {df['bid'].min():.2f} – {df['bid'].max():.2f}")
|
| 57 |
+
print(f"Ask range : {df['ask'].min():.2f} – {df['ask'].max():.2f}")
|
| 58 |
+
|
| 59 |
+
mt5.shutdown()
|
| 60 |
+
|
| 61 |
+
# ──────────────────────────────────────────────
|
| 62 |
+
# 3b. Save raw unit data to CSV
|
| 63 |
+
# ──────────────────────────────────────────────
|
| 64 |
+
csv_path = "raw_ticks_XAUUSDc_20260212.csv"
|
| 65 |
+
df[['datetime', 'bid', 'ask', 'last', 'volume', 'flags']].to_csv(csv_path, index=False)
|
| 66 |
+
print(f"Saved CSV → {csv_path} ({len(df):,} rows)")
|
| 67 |
+
|
| 68 |
+
# ──────────────────────────────────────────────
|
| 69 |
+
# 4. Build histogram bins (1 bin = 0.01 unit)
|
| 70 |
+
# ──────────────────────────────────────────────
|
| 71 |
+
overall_min = min(df['bid'].min(), df['ask'].min())
|
| 72 |
+
overall_max = max(df['bid'].max(), df['ask'].max())
|
| 73 |
+
|
| 74 |
+
bin_lo = np.floor(overall_min / UNIT_SIZE) * UNIT_SIZE - UNIT_SIZE
|
| 75 |
+
bin_hi = np.ceil(overall_max / UNIT_SIZE) * UNIT_SIZE + UNIT_SIZE
|
| 76 |
+
bins = np.arange(bin_lo, bin_hi + UNIT_SIZE, UNIT_SIZE)
|
| 77 |
+
bins = np.round(bins, 2)
|
| 78 |
+
|
| 79 |
+
# ──────────────────────────────────────────────
|
| 80 |
+
# 5. Convert datetimes to float (much faster for plotting)
|
| 81 |
+
# ──────────────────────────────────────────────
|
| 82 |
+
bid_times = mdates.date2num(df['datetime'].values)
|
| 83 |
+
ask_times = bid_times # same timestamps
|
| 84 |
+
|
| 85 |
+
print("Plotting...")
|
| 86 |
+
|
| 87 |
+
# ──────────────────────────────────────────────
|
| 88 |
+
# 6. Plot 4-panel figure
|
| 89 |
+
# ──────────────────────────────────────────────
|
| 90 |
+
fig, axes = plt.subplots(
|
| 91 |
+
2, 2,
|
| 92 |
+
figsize=(20, 12),
|
| 93 |
+
gridspec_kw={'width_ratios': [1, 4]},
|
| 94 |
+
sharey='row',
|
| 95 |
+
)
|
| 96 |
+
fig.suptitle(
|
| 97 |
+
f'{SYMBOL} — Raw Microsecond Unit Data | {utc_from.strftime("%Y-%m-%d")}',
|
| 98 |
+
fontsize=16, fontweight='bold',
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Colors — 100% blue and 100% red per IDEA.md
|
| 102 |
+
BID_COLOR = '#0000FF'
|
| 103 |
+
ASK_COLOR = '#FF0000'
|
| 104 |
+
|
| 105 |
+
# ── Row 0: BID ─────────────────────────────
|
| 106 |
+
ax_hist_bid = axes[0, 0]
|
| 107 |
+
ax_line_bid = axes[0, 1]
|
| 108 |
+
|
| 109 |
+
# Histogram (horizontal)
|
| 110 |
+
ax_hist_bid.hist(
|
| 111 |
+
df['bid'].values, bins=bins, orientation='horizontal',
|
| 112 |
+
color=BID_COLOR, alpha=1.0, edgecolor='white', linewidth=0.3,
|
| 113 |
+
)
|
| 114 |
+
ax_hist_bid.set_xlabel('Count', fontsize=10)
|
| 115 |
+
ax_hist_bid.set_ylabel('Bid Price', fontsize=10)
|
| 116 |
+
ax_hist_bid.set_title('Bid Y-Distribution (0.01-unit bins)', fontsize=12)
|
| 117 |
+
# histogram grows left-to-right (starts from 0)
|
| 118 |
+
|
| 119 |
+
# Line chart — use line only (no markers) for massive data, rasterized
|
| 120 |
+
ax_line_bid.plot(
|
| 121 |
+
bid_times, df['bid'].values,
|
| 122 |
+
color=BID_COLOR, linewidth=0.5, alpha=1.0,
|
| 123 |
+
rasterized=True,
|
| 124 |
+
)
|
| 125 |
+
ax_line_bid.xaxis_date()
|
| 126 |
+
ax_line_bid.set_title('Bid Price (Time Series)', fontsize=12)
|
| 127 |
+
ax_line_bid.set_xlabel('Time (UTC)', fontsize=10)
|
| 128 |
+
ax_line_bid.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
|
| 129 |
+
ax_line_bid.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
| 130 |
+
plt.setp(ax_line_bid.xaxis.get_majorticklabels(), rotation=45, ha='right')
|
| 131 |
+
ax_line_bid.grid(True, alpha=0.3)
|
| 132 |
+
|
| 133 |
+
# ── Row 1: ASK ─────────────────────────────
|
| 134 |
+
ax_hist_ask = axes[1, 0]
|
| 135 |
+
ax_line_ask = axes[1, 1]
|
| 136 |
+
|
| 137 |
+
# Histogram (horizontal)
|
| 138 |
+
ax_hist_ask.hist(
|
| 139 |
+
df['ask'].values, bins=bins, orientation='horizontal',
|
| 140 |
+
color=ASK_COLOR, alpha=1.0, edgecolor='white', linewidth=0.3,
|
| 141 |
+
)
|
| 142 |
+
ax_hist_ask.set_xlabel('Count', fontsize=10)
|
| 143 |
+
ax_hist_ask.set_ylabel('Ask Price', fontsize=10)
|
| 144 |
+
ax_hist_ask.set_title('Ask Y-Distribution (0.01-unit bins)', fontsize=12)
|
| 145 |
+
# histogram grows left-to-right (starts from 0)
|
| 146 |
+
|
| 147 |
+
# Line chart — line only, rasterized
|
| 148 |
+
ax_line_ask.plot(
|
| 149 |
+
ask_times, df['ask'].values,
|
| 150 |
+
color=ASK_COLOR, linewidth=0.5, alpha=1.0,
|
| 151 |
+
rasterized=True,
|
| 152 |
+
)
|
| 153 |
+
ax_line_ask.xaxis_date()
|
| 154 |
+
ax_line_ask.set_title('Ask Price (Time Series)', fontsize=12)
|
| 155 |
+
ax_line_ask.set_xlabel('Time (UTC)', fontsize=10)
|
| 156 |
+
ax_line_ask.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
|
| 157 |
+
ax_line_ask.xaxis.set_major_locator(mdates.HourLocator(interval=2))
|
| 158 |
+
plt.setp(ax_line_ask.xaxis.get_majorticklabels(), rotation=45, ha='right')
|
| 159 |
+
ax_line_ask.grid(True, alpha=0.3)
|
| 160 |
+
|
| 161 |
+
# ── Final layout ───────────────────────────
|
| 162 |
+
plt.tight_layout(rect=[0, 0, 1, 0.95])
|
| 163 |
+
|
| 164 |
+
output_path = "raw_ticks_4panel.png"
|
| 165 |
+
fig.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 166 |
+
print(f"Saved → {output_path}")
|
README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# What Happens if We Increase or Decrease the Bin Size of Market Profiles (TPO & Volume)?
|
| 2 |
+
|
| 3 |
+
This applies specifically to the standard or free-tier market profiles available on most charting platforms. Market profiles are typically built on either the **Time Price Opportunity (TPO)** profile or the **Volume Profile (VP)**, whether real or tick-based. Regardless of the type, the underlying calculation is the same: raw data is cleaned through dataframes (xlsx, csv, etc.) and represented through graphs (lines, bars, plots, etc.). A market profile is simply datapoints collapsed into a y-axis distribution, forming a "profile." That is it -- nothing more.
|
| 4 |
+
|
| 5 |
+
But what actually happens when we increase or decrease the **bin size** (the price-step) of the market profile?
|
| 6 |
+
|
| 7 |
+
<br><br>
|
| 8 |
+
|
| 9 |
+
## Collapsing Price Action into a Profile
|
| 10 |
+
|
| 11 |
+
<table>
|
| 12 |
+
<tr>
|
| 13 |
+
<td align="center">
|
| 14 |
+
<img width="995" height="488" alt="Collapsing price action into a market profile" src="https://github.com/user-attachments/assets/30f8a35e-1060-48c2-8f63-bbda3b989c93" />
|
| 15 |
+
</td>
|
| 16 |
+
</tr>
|
| 17 |
+
</table>
|
| 18 |
+
|
| 19 |
+
In the figure above, we have one MP chart (left) and one line chart (right), both derived from the same dataset. The price action over time (line chart) moves from point **A** to **B**, **C**, and **D**. When we collapse those datapoints (A through D) into a y-axis distribution histogram, a Market Profile chart is formed.
|
| 20 |
+
|
| 21 |
+
If we use a bin size of **1.000** and the price range spans **3000.000 to 3010.000**, then between those prices we get **10 bins** worth of grouping. Same data, different representation.
|
| 22 |
+
|
| 23 |
+
<br><br>
|
| 24 |
+
|
| 25 |
+
## Larger Datasets and Stacking
|
| 26 |
+
|
| 27 |
+
<table>
|
| 28 |
+
<tr>
|
| 29 |
+
<td align="center">
|
| 30 |
+
<img width="996" height="488" alt="Larger dataset forming a market profile" src="https://github.com/user-attachments/assets/f05cdd33-80a0-47e1-8041-56543ef5d7d5" />
|
| 31 |
+
</td>
|
| 32 |
+
</tr>
|
| 33 |
+
</table>
|
| 34 |
+
|
| 35 |
+
With larger datasets, the principle remains the same. Datapoints collapse and stack to form a y-distribution. The more stacking occurs, the larger the profile becomes. In this example, the lowest profile value is **0** and the largest profile value is **4 stacks**.
|
| 36 |
+
|
| 37 |
+
<br><br>
|
| 38 |
+
|
| 39 |
+
## The Effect of Changing Bin Size
|
| 40 |
+
|
| 41 |
+
<table>
|
| 42 |
+
<tr>
|
| 43 |
+
<td align="center">
|
| 44 |
+
<img width="995" height="484" alt="Market profile with different bin size" src="https://github.com/user-attachments/assets/6637ea25-0abc-40ad-a8a1-b35d2abd89cb" />
|
| 45 |
+
</td>
|
| 46 |
+
</tr>
|
| 47 |
+
</table>
|
| 48 |
+
|
| 49 |
+
The figure above uses the **same dataset** as the previous one, yet the profile looks different. It now has a lowest value of **0** and a largest value of only **2 stacks**. If you are a beginner, this might feel suspicious when experimenting on your preferred charting platform -- but it is completely normal.
|
| 50 |
+
|
| 51 |
+
The reason is straightforward: **more bins means more price groups for the datapoints to distribute across.** As bin size decreases (more granular bins), each datapoint lands in a more specific price bucket. This spreads the data across more bins, resulting in shorter stacks and a flatter profile. Conversely, increasing the bin size consolidates datapoints into fewer groups, producing taller stacks and a more concentrated profile.
|
| 52 |
+
|
| 53 |
+
That is the kind of market profile you typically get on free-tier charting and trading platforms.
|
| 54 |
+
|
| 55 |
+
<br><br>
|
| 56 |
+
|
| 57 |
+
## An Alternative Approach: Trail-Price Clustering
|
| 58 |
+
|
| 59 |
+
There is another way to model a market profile. Without going into full detail -- if you are an algorithmic trader or software developer familiar with feature engineering, this will be straightforward. The core idea is to **add data to your original dataframes by clustering trail-prices** (an original concept) to produce a more complete set of datapoints.
|
| 60 |
+
|
| 61 |
+
<table>
|
| 62 |
+
<tr>
|
| 63 |
+
<td align="center">
|
| 64 |
+
<img width="1006" height="490" alt="Trail-price clustering concept" src="https://github.com/user-attachments/assets/8277ff21-9999-4ac1-b9db-e7c8b99578a3" />
|
| 65 |
+
</td>
|
| 66 |
+
</tr>
|
| 67 |
+
</table>
|
| 68 |
+
|
| 69 |
+
This concept extends well beyond these illustrations. You can fill in missing data in dataframes (for any dataset) by applying a custom formula using your preferred programming language.
|
| 70 |
+
|
| 71 |
+
<br><br>
|
| 72 |
+
|
| 73 |
+
<table>
|
| 74 |
+
<tr>
|
| 75 |
+
<td align="center">
|
| 76 |
+
<img width="1066" height="519" alt="Enhanced market profile model" src="https://github.com/user-attachments/assets/e2bcdea8-1944-4c4f-a467-1d8943972512" />
|
| 77 |
+
</td>
|
| 78 |
+
</tr>
|
| 79 |
+
</table>
|
| 80 |
+
|
| 81 |
+
The drawings may be rough, but the point stands. In our case, we model a market profile not based on **TOCHL** (Time, Open, Close, High, Low) or **Volume** (real, tick) but on **mBA** (microsecond raw bid/ask) formation.
|
| 82 |
+
|
| 83 |
+
<br><br>
|
| 84 |
+
|
| 85 |
+
## Reference
|
| 86 |
+
|
| 87 |
+
```bibtex
|
| 88 |
+
@misc{continualquasars2026blog1,
|
| 89 |
+
title = {What Happens if We Increase or Decrease the Bin Size of Market Profiles (TPO \& Volume)?},
|
| 90 |
+
author = {ContinualQuasars},
|
| 91 |
+
year = {2026},
|
| 92 |
+
url = {https://github.com/ContinualQuasars/BLOG-1},
|
| 93 |
+
note = {What Happens if We Increase or Decrease the Bin Size of Market Profiles (TPO \& Volume)?}
|
| 94 |
+
}
|
| 95 |
+
```
|
STRUCTURE.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Project Structure
|
| 2 |
+
|
| 3 |
+
```text
|
| 4 |
+
mBA-Terminal/
|
| 5 |
+
├── images/
|
| 6 |
+
│ ├── filled_ticks_4panel.png
|
| 7 |
+
│ └── raw_ticks_4panel.png
|
| 8 |
+
├── output/
|
| 9 |
+
│ ├── filled_ask_XAUUSDc_20260212.csv
|
| 10 |
+
│ ├── filled_bid_XAUUSDc_20260212.csv
|
| 11 |
+
│ └── raw_ticks_XAUUSDc_20260212.csv
|
| 12 |
+
├── Python/
|
| 13 |
+
│ ├── mt5_filled_ticks.py
|
| 14 |
+
│ └── mt5_raw_ticks.py
|
| 15 |
+
├── scripts/
|
| 16 |
+
│ └── debug_mt5.py
|
| 17 |
+
├── src/
|
| 18 |
+
│ ├── core/
|
| 19 |
+
│ │ ├── data_worker.py
|
| 20 |
+
│ │ ├── market_profile.py
|
| 21 |
+
│ │ └── mt5_interface.py
|
| 22 |
+
│ ├── ui/
|
| 23 |
+
│ │ ├── chart_widget.py
|
| 24 |
+
│ │ ├── control_panel.py
|
| 25 |
+
│ │ └── main_window.py
|
| 26 |
+
│ ├── config.py
|
| 27 |
+
│ └── main.py
|
| 28 |
+
├── tests/
|
| 29 |
+
│ ├── test_interactive_chart.py
|
| 30 |
+
│ ├── test_logic.py
|
| 31 |
+
│ ├── test_profile_logic.py
|
| 32 |
+
│ └── verify_levels.py
|
| 33 |
+
├── .gitattributes
|
| 34 |
+
├── .gitignore
|
| 35 |
+
├── IDEA.md
|
| 36 |
+
├── LICENSE
|
| 37 |
+
├── market_profile_paper.tex
|
| 38 |
+
├── README.md
|
| 39 |
+
├── requirements.txt
|
| 40 |
+
└── TECHSTACK.md
|
| 41 |
+
```
|
TECHSTACK.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Techstack
|
| 2 |
+
|
| 3 |
+
Audit of **mBA-Terminal** project files (excluding environment and cache):
|
| 4 |
+
|
| 5 |
+
| File Type | Count | Size (KB) |
|
| 6 |
+
| :--- | :--- | :--- |
|
| 7 |
+
| Python (.py) | 15 | 62.1 |
|
| 8 |
+
| (no extension) | 3 | 1.1 |
|
| 9 |
+
| CSV (.csv) | 3 | 426,797.2 |
|
| 10 |
+
| Markdown (.md) | 2 | 4.9 |
|
| 11 |
+
| PNG Image (.png) | 2 | 482.2 |
|
| 12 |
+
| LaTeX (.tex) | 1 | 31.3 |
|
| 13 |
+
| Plain Text (.txt) | 1 | 0.1 |
|
| 14 |
+
| **Total** | **27** | **427,379.1** |
|
images/filled_ticks_4panel.png
ADDED
|
Git LFS Details
|
images/raw_ticks_4panel.png
ADDED
|
Git LFS Details
|
market_profile_paper.tex
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
\documentclass[conference]{IEEEtran}
|
| 2 |
+
|
| 3 |
+
% ─── Packages ───────────────────────────────────────────
|
| 4 |
+
\usepackage[utf8]{inputenc}
|
| 5 |
+
\usepackage[T1]{fontenc}
|
| 6 |
+
\usepackage{amsmath, amssymb, amsfonts}
|
| 7 |
+
\usepackage{graphicx}
|
| 8 |
+
\usepackage{booktabs}
|
| 9 |
+
\usepackage{hyperref}
|
| 10 |
+
\usepackage{float}
|
| 11 |
+
\usepackage{caption}
|
| 12 |
+
\usepackage{subcaption}
|
| 13 |
+
\usepackage{xcolor}
|
| 14 |
+
\usepackage{enumitem}
|
| 15 |
+
\usepackage{cite}
|
| 16 |
+
\usepackage{array}
|
| 17 |
+
\usepackage{url}
|
| 18 |
+
|
| 19 |
+
\hypersetup{
|
| 20 |
+
colorlinks=true,
|
| 21 |
+
linkcolor=blue!70!black,
|
| 22 |
+
citecolor=blue!70!black,
|
| 23 |
+
urlcolor=blue!60!black,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
% ─── Title ──────────────────────────────────────────────
|
| 27 |
+
\title{mBA-Profile: Market Profile Construction from Microsecond Bid-Ask Unit Data Using The Path-weighted Gap-filling Approach}
|
| 28 |
+
|
| 29 |
+
\author{
|
| 30 |
+
\IEEEauthorblockN{
|
| 31 |
+
Rembrant Oyangoren Albeos~%
|
| 32 |
+
\href{https://orcid.org/0009-0006-8743-4419}{%
|
| 33 |
+
\includegraphics[height=8pt]{ORCID_icon.png}%
|
| 34 |
+
}%
|
| 35 |
+
\textsuperscript{\hyperref[sec:author_info]{$\dagger$}}
|
| 36 |
+
}
|
| 37 |
+
\IEEEauthorblockA{%
|
| 38 |
+
\includegraphics[height=7pt]{ContinualQuasars_icon.png}\hspace{0.4em}Continual Quasars\\
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
\begin{document}
|
| 43 |
+
\maketitle
|
| 44 |
+
|
| 45 |
+
% ════════════════════════════════════════════════════════
|
| 46 |
+
\begin{abstract}
|
| 47 |
+
Conventional market profile construction collapses raw price data
|
| 48 |
+
directly into a Y-distribution histogram, recording only the price
|
| 49 |
+
levels that were explicitly quoted by the exchange or broker feed.
|
| 50 |
+
This paper presents an alternative approach---termed
|
| 51 |
+
\emph{path-weighted gap-filling}---in which synthetic trail-datapoints
|
| 52 |
+
are inserted at every intermediate unit-level price between
|
| 53 |
+
consecutive observations, producing an extended dataset that yields a
|
| 54 |
+
substantially denser and more continuous market profile. The
|
| 55 |
+
modelling is grounded in microsecond-resolution raw bid/ask unit data
|
| 56 |
+
rather than aggregated TOHLC (time, open, high, low, close) bars or
|
| 57 |
+
volume figures, thereby preserving the highest available fidelity of
|
| 58 |
+
the underlying price process. We demonstrate the approach on a full
|
| 59 |
+
trading day of \texttt{XAUUSDc} data collected from a live trading
|
| 60 |
+
environment, and show that the gap-filled profile eliminates the empty
|
| 61 |
+
bins and sparse regions that afflict raw-unit profiles during fast
|
| 62 |
+
directional moves, producing a more representative picture of
|
| 63 |
+
intraday price dynamics. All resources and code used in this work are available on GitHub at \url{https://github.com/ContinualQuasars/mBA-Profile}.
|
| 64 |
+
|
| 65 |
+
\end{abstract}
|
| 66 |
+
|
| 67 |
+
\begin{IEEEkeywords}
|
| 68 |
+
Market profile, unit data, microsecond bid--ask, market microstructure, path-weighted, gap-filling.
|
| 69 |
+
\end{IEEEkeywords}
|
| 70 |
+
|
| 71 |
+
% ════════════════════════════════════════════════════════
|
| 72 |
+
\section{Introduction}
|
| 73 |
+
\label{sec:intro}
|
| 74 |
+
|
| 75 |
+
\subsection{Market Microstructure and Unit Data}
|
| 76 |
+
|
| 77 |
+
At the most granular level of market data, financial instruments are
|
| 78 |
+
quoted through discrete data updates: each update represents a change in
|
| 79 |
+
the best bid price, the best ask price, or both
|
| 80 |
+
simultaneously~\cite{hasbrouck2007,ohara1995}. Modern trading
|
| 81 |
+
platforms such as MetaTrader~5 (MT5) record these events with
|
| 82 |
+
millisecond-resolution timestamps, and the data is made available
|
| 83 |
+
through a Python API~\cite{mt5docs}.
|
| 84 |
+
|
| 85 |
+
The instrument studied in this paper is \texttt{XAUUSDc}, a
|
| 86 |
+
gold CFD (Contract for Difference) traded on a
|
| 87 |
+
standard cent live trading account provided by the Exness broker,
|
| 88 |
+
accessed through the MT5 platform. The \texttt{c} suffix in
|
| 89 |
+
\texttt{XAUUSDc} is an Exness broker account-type indicator
|
| 90 |
+
(denoting a standard cent live account) and has no bearing on the
|
| 91 |
+
XAU price data itself---extracting data from \texttt{XAUUSDc}
|
| 92 |
+
(cent account) or \texttt{XAUUSDm} (dollar account) yields the
|
| 93 |
+
same XAUUSD price data with three decimal places. The minimum price
|
| 94 |
+
increment for this instrument is exactly \$0.001.
|
| 95 |
+
Because the standard lot size for gold is 100 troy ounces, a single
|
| 96 |
+
price movement of \$0.001 corresponds to a profit-or-loss change of
|
| 97 |
+
\$0.10 per standard lot. In this study, the market profile
|
| 98 |
+
bin size is set to \$0.01 (one unit, where 0.01~unit = \$0.01 XAU
|
| 99 |
+
price change), to produce a more
|
| 100 |
+
stable and interpretable distribution.
|
| 101 |
+
|
| 102 |
+
\subsection{The Market Profile Concept}
|
| 103 |
+
|
| 104 |
+
A market profile is a rotated histogram of price over a defined time
|
| 105 |
+
window. The concept was introduced by J.~Peter Steidlmayer at the
|
| 106 |
+
Chicago Board of Trade in the 1980s~\cite{steidlmayer1986}.
|
| 107 |
+
Traditionally, a market profile uses 30-minute ``Time Price
|
| 108 |
+
Opportunity'' (TPO) letters stacked at each price level to show where
|
| 109 |
+
price spent the most time during a trading session~\cite{dalton2007}.
|
| 110 |
+
The horizontal axis represents frequency or time density, while the
|
| 111 |
+
vertical axis represents price.
|
| 112 |
+
|
| 113 |
+
In this study, the concept is adapted to microsecond unit data.
|
| 114 |
+
Instead of 30-minute TPO letters, each histogram bar represents the
|
| 115 |
+
number of data updates (or interpolated price levels, in the
|
| 116 |
+
gap-filled approach) observed at that price. The construction is
|
| 117 |
+
based exclusively on raw bid/ask unit data---not on TOHLC candles or
|
| 118 |
+
volume bars---ensuring that no information is lost to
|
| 119 |
+
aggregation~\cite{ane2000,engle2000}.
|
| 120 |
+
|
| 121 |
+
\subsection{Paper Outline}
|
| 122 |
+
|
| 123 |
+
Section~\ref{sec:data} describes the data acquisition pipeline and
|
| 124 |
+
the dataset used. Section~\ref{sec:raw} details the raw unit
|
| 125 |
+
approach. Section~\ref{sec:filled} introduces the gap-filled
|
| 126 |
+
(path-weighted) approach, including a detailed explanation of why
|
| 127 |
+
path-weighting is used. Section~\ref{sec:comparison} provides a
|
| 128 |
+
comprehensive comparison of the two approaches.
|
| 129 |
+
Section~\ref{sec:conclusion} concludes.
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
% ════════════════════════════════════════════════════════
|
| 133 |
+
\section{Data Acquisition}
|
| 134 |
+
\label{sec:data}
|
| 135 |
+
|
| 136 |
+
\subsection{Trading Environment}
|
| 137 |
+
|
| 138 |
+
The unit data used in this study was collected from a standard cent
|
| 139 |
+
live trading account on the Exness broker, accessed through MetaTrader~5.
|
| 140 |
+
MT5 is a multi-asset trading platform developed by MetaQuotes Software
|
| 141 |
+
Corp.\ that is widely used for forex and CFD
|
| 142 |
+
trading~\cite{mt5docs,metaquotes2024}. Its Python integration exposes
|
| 143 |
+
the function \texttt{copy\_ticks\_range()}, which returns every data
|
| 144 |
+
update within a specified time window as a structured NumPy
|
| 145 |
+
array~\cite{numpy2020}. Each data record contains the following
|
| 146 |
+
fields: a Unix timestamp in seconds, a millisecond-precision timestamp
|
| 147 |
+
providing sub-second resolution, the best bid price, the best ask
|
| 148 |
+
price, and additional metadata including flags indicating which fields
|
| 149 |
+
changed on that particular update.
|
| 150 |
+
|
| 151 |
+
Although the exposed timestamp has millisecond granularity, the MT5
|
| 152 |
+
documentation describes the system as operating at microsecond
|
| 153 |
+
internal resolution~\cite{mt5docs}; the millisecond field is what is
|
| 154 |
+
exposed through the Python API.
|
| 155 |
+
|
| 156 |
+
\subsection{Dataset Summary}
|
| 157 |
+
|
| 158 |
+
The symbol is \texttt{XAUUSDc}. The time range covers the full UTC
|
| 159 |
+
day of February~12, 2026, from 00:00:00 to 23:59:59. The flag used
|
| 160 |
+
retrieves all data updates regardless of whether the bid, ask, or last
|
| 161 |
+
price changed.
|
| 162 |
+
|
| 163 |
+
The query returned exactly \textbf{393,252~data points}. The first data point was
|
| 164 |
+
recorded at \textbf{2026-02-12 00:00:00.149~UTC} and the last data point at
|
| 165 |
+
\textbf{2026-02-12 23:59:57.820~UTC}. The bid price ranged from a
|
| 166 |
+
low of \textbf{\$4,878.380} to a high of \textbf{\$5,083.750}, a span
|
| 167 |
+
of \textbf{\$205.370} (20,537~units). The ask price ranged from
|
| 168 |
+
\textbf{\$4,878.620} to \textbf{\$5,083.990}, a span of
|
| 169 |
+
\textbf{\$205.370} (20,537~units).
|
| 170 |
+
|
| 171 |
+
\subsection{Unit Size}
|
| 172 |
+
|
| 173 |
+
The unit size for \texttt{XAUUSDc} is \textbf{\$0.010} (0.01~unit,
|
| 174 |
+
where 0.01~unit = \$0.01 XAU price change).
|
| 175 |
+
This value is determined by the broker's symbol specification and is
|
| 176 |
+
not configurable by the user. The lowest price resolution of XAU is
|
| 177 |
+
three decimal places: a change of 0.001 corresponds to a
|
| 178 |
+
\$0.001 price movement. Throughout this paper, $\delta = 0.010$
|
| 179 |
+
denotes the unit size, and the bin width used for histogram
|
| 180 |
+
construction equals 0.01~unit ($w = \delta = 0.010$).
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
% ════════════════════════════════════════════════════════
|
| 184 |
+
\section{Approach~1: Raw Unit Y-Distribution}
|
| 185 |
+
\label{sec:raw}
|
| 186 |
+
|
| 187 |
+
\subsection{Methodology}
|
| 188 |
+
|
| 189 |
+
The raw unit approach constructs a market profile histogram directly
|
| 190 |
+
from the 393,252 observed unit prices without any interpolation or
|
| 191 |
+
modification. The procedure begins by extracting the bid and ask
|
| 192 |
+
columns as separate arrays from the dataset. Histogram bin edges are
|
| 193 |
+
computed starting from
|
| 194 |
+
$\lfloor p_{\min}/\delta \rfloor \cdot \delta - \delta$ up to
|
| 195 |
+
$\lceil p_{\max}/\delta \rceil \cdot \delta + \delta$, spaced by
|
| 196 |
+
exactly $\delta = 0.010$. This ensures that every observed price
|
| 197 |
+
falls cleanly within a bin whose width is exactly 0.01~unit. Bin edges
|
| 198 |
+
are rounded to avoid floating-point precision
|
| 199 |
+
artefacts~\cite{goldberg1991}.
|
| 200 |
+
|
| 201 |
+
A standard frequency histogram is then computed---the count of data points
|
| 202 |
+
whose price falls within each bin---separately for bid and ask. The
|
| 203 |
+
histogram is plotted horizontally, with price on the vertical axis and
|
| 204 |
+
count on the horizontal axis, creating the conventional market-profile
|
| 205 |
+
appearance where the thickest region corresponds to the price level
|
| 206 |
+
that received the most data updates.
|
| 207 |
+
|
| 208 |
+
\subsection{Feature Engineering}
|
| 209 |
+
|
| 210 |
+
The feature engineering pipeline for the raw approach consists of the
|
| 211 |
+
following stages. First, the raw unit data from MT5 (a structured
|
| 212 |
+
array) is converted into a tabular format. The millisecond-precision
|
| 213 |
+
timestamp column is transformed into a UTC-aware datetime
|
| 214 |
+
representation. Next, the datetime values are converted to
|
| 215 |
+
floating-point date numbers suitable for high-performance
|
| 216 |
+
plotting~\cite{matplotlib2007}. This pre-conversion is performed once
|
| 217 |
+
before plotting because passing raw datetime objects to the plotting
|
| 218 |
+
library triggers an internal per-element conversion that is extremely
|
| 219 |
+
slow for arrays of 393,252 elements---the pre-conversion reduces
|
| 220 |
+
plotting time from several minutes to under one minute for the full
|
| 221 |
+
dataset.
|
| 222 |
+
|
| 223 |
+
The histogram bin edges are constructed using a range function with
|
| 224 |
+
step size equal to $\delta$ and then rounded. For the observed data
|
| 225 |
+
range of \$4,878.380 to \$5,083.990, this produces 20,563 bin edges
|
| 226 |
+
defining 20,562 bins, each exactly \$0.010 wide (0.01~unit).
|
| 227 |
+
|
| 228 |
+
\subsection{Output}
|
| 229 |
+
|
| 230 |
+
The output is a $2 \times 2$ subplot figure. The top row displays the
|
| 231 |
+
bid data: a horizontal histogram on the left (blue) and a time-series
|
| 232 |
+
line chart on the right (blue). The bottom row displays the ask data
|
| 233 |
+
in the same layout using red. The two rows share their respective
|
| 234 |
+
Y-axes so that price levels align horizontally between the histogram
|
| 235 |
+
and the line chart.
|
| 236 |
+
|
| 237 |
+
\begin{figure*}[t]
|
| 238 |
+
\centering
|
| 239 |
+
\includegraphics[width=\textwidth]{raw_ticks_4panel.png}
|
| 240 |
+
\caption{Raw unit Y-distribution histograms (left column) and
|
| 241 |
+
time-series line charts (right column) for bid (top, blue) and ask
|
| 242 |
+
(bottom, red) prices of \texttt{XAUUSDc} on February~12, 2026.
|
| 243 |
+
The dataset contains 393,252 data points. Bin size = 0.01~unit (\$0.010).
|
| 244 |
+
The histogram X-axis shows the count of data points observed at each
|
| 245 |
+
price level.}
|
| 246 |
+
\label{fig:raw_4panel}
|
| 247 |
+
\end{figure*}
|
| 248 |
+
|
| 249 |
+
\subsection{Interpretation}
|
| 250 |
+
|
| 251 |
+
In the raw histogram (Figure~\ref{fig:raw_4panel}), the count at each
|
| 252 |
+
price level reflects how many times the market's best bid or best ask
|
| 253 |
+
was updated to that exact price. Levels where the market
|
| 254 |
+
consolidated---spending extended time with many small quote
|
| 255 |
+
updates---accumulate high counts and form the thick horizontal bars in
|
| 256 |
+
the profile~\cite{dalton2007}.
|
| 257 |
+
|
| 258 |
+
However, when the market jumps from price $A$ to price $B$ in a single
|
| 259 |
+
update without quoting any intermediate level, those intermediate levels
|
| 260 |
+
receive zero counts in the histogram. The raw profile therefore
|
| 261 |
+
contains \emph{gaps}---entire price levels with no
|
| 262 |
+
representation---that correspond to fast directional moves. This is a
|
| 263 |
+
fundamental limitation: the profile faithfully records only what was
|
| 264 |
+
quoted, but it does not capture the price path traversed between
|
| 265 |
+
observations. This motivates the gap-filled approach presented in
|
| 266 |
+
Section~\ref{sec:filled}.
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
% ════════════════════════════════════════════════════════
|
| 270 |
+
\section{Approach~2: Gap-Filled (Path-Weighted) Y-Distribution}
|
| 271 |
+
\label{sec:filled}
|
| 272 |
+
|
| 273 |
+
\subsection{Motivation}
|
| 274 |
+
|
| 275 |
+
Consider a scenario where the bid price moves from \$5,060.000 to
|
| 276 |
+
\$5,060.100 in a single update. In the raw approach, only two price
|
| 277 |
+
levels---\$5,060.000 and \$5,060.100---register a count, while the
|
| 278 |
+
eight intermediate levels (\$5,060.010 through \$5,060.090) receive no
|
| 279 |
+
representation at all. Yet, under the assumption that price is a
|
| 280 |
+
continuous process sampled at discrete intervals, the price must have
|
| 281 |
+
traversed those eight levels to arrive at
|
| 282 |
+
\$5,060.100~\cite{cont2001,bacry2012}. The gap-filled approach
|
| 283 |
+
addresses this by inserting synthetic trail-datapoints at every
|
| 284 |
+
intermediate unit-level price between consecutive observations,
|
| 285 |
+
thereby constructing a profile that reflects the full path traversed
|
| 286 |
+
by the market rather than only the endpoints of each move.
|
| 287 |
+
|
| 288 |
+
\subsection{Why Path-Weighting?}
|
| 289 |
+
\label{sec:whypathweight}
|
| 290 |
+
|
| 291 |
+
The term \emph{path-weighted} refers to the fact that each price
|
| 292 |
+
level's histogram count is weighted by the number of times the price
|
| 293 |
+
path crossed that level, not merely the number of times it was
|
| 294 |
+
explicitly quoted. The rationale for this weighting rests on three
|
| 295 |
+
observations:
|
| 296 |
+
|
| 297 |
+
\begin{enumerate}[leftmargin=*]
|
| 298 |
+
\item \textbf{Continuity of the price process.} Financial prices
|
| 299 |
+
are fundamentally continuous stochastic processes sampled at
|
| 300 |
+
discrete intervals by the exchange or broker
|
| 301 |
+
feed~\cite{cont2001,bacry2012}. Between any two consecutive
|
| 302 |
+
observations at prices $p_A$ and $p_B$, the underlying price
|
| 303 |
+
process must have traversed every intermediate level. The raw
|
| 304 |
+
profile discards this traversal information; the path-weighted
|
| 305 |
+
profile recovers it.
|
| 306 |
+
|
| 307 |
+
\item \textbf{Elimination of empty bins.} In the raw profile,
|
| 308 |
+
fast directional moves produce stretches of price levels with zero
|
| 309 |
+
counts, creating discontinuities in the histogram that can mislead
|
| 310 |
+
visual interpretation. Path-weighting ensures that every price
|
| 311 |
+
level between $p_{\min}$ and $p_{\max}$ receives a non-zero count,
|
| 312 |
+
producing a continuous and visually coherent
|
| 313 |
+
profile~\cite{steidlmayer1986}.
|
| 314 |
+
|
| 315 |
+
\item \textbf{Traversal as a proxy for significance.} A price
|
| 316 |
+
level that is crossed repeatedly---even by fast-moving price
|
| 317 |
+
swings that do not dwell there---is a level that the market
|
| 318 |
+
revisits often. Such levels frequently correspond to support,
|
| 319 |
+
resistance, or areas of high liquidity~\cite{dalton2007,
|
| 320 |
+
steidlmayer1986}. Path-weighting captures this repeated-traversal
|
| 321 |
+
signal, which raw unit counting misses entirely.
|
| 322 |
+
\end{enumerate}
|
| 323 |
+
|
| 324 |
+
In summary, path-weighting transforms the market profile from a
|
| 325 |
+
histogram of \emph{quoting intensity} into a histogram of
|
| 326 |
+
\emph{traversal frequency}, which is a richer and more informative
|
| 327 |
+
representation of where the market has been.
|
| 328 |
+
|
| 329 |
+
\subsection{Algorithm}
|
| 330 |
+
|
| 331 |
+
The gap-filling algorithm operates on pairs of consecutive data points. For
|
| 332 |
+
each pair $(A, B)$ with prices $p_A$ and $p_B$ and timestamps $t_A$
|
| 333 |
+
and $t_B$ (represented as nanosecond integers for computational
|
| 334 |
+
efficiency), the algorithm first computes the signed unit difference
|
| 335 |
+
$\Delta n = \text{round}((p_B - p_A) / \delta)$. If
|
| 336 |
+
$|\Delta n| \le 1$, no interpolation is needed because the two prices
|
| 337 |
+
are adjacent or identical, and the pair is left unchanged. If
|
| 338 |
+
$|\Delta n| > 1$, the algorithm inserts $|\Delta n| - 1$ intermediate
|
| 339 |
+
rows. Each intermediate row $k$ (where $1 \le k < |\Delta n|$)
|
| 340 |
+
receives a price of
|
| 341 |
+
$p_A + k \cdot \text{sgn}(\Delta n) \cdot \delta$ and a timestamp of
|
| 342 |
+
$t_A + \frac{k}{|\Delta n|} \cdot (t_B - t_A)$. The timestamp
|
| 343 |
+
interpolation is linear, distributing the intermediate points evenly
|
| 344 |
+
across the time interval between data points $A$ and
|
| 345 |
+
$B$~\cite{dacorogna2001}.
|
| 346 |
+
|
| 347 |
+
The implementation is fully vectorised using array operations rather
|
| 348 |
+
than interpreted loops~\cite{numpy2020}. The key operations are
|
| 349 |
+
element repetition (to repeat each source index by the number of units
|
| 350 |
+
in its segment), cumulative summation (to compute segment start
|
| 351 |
+
positions), and element-wise arithmetic for price and timestamp
|
| 352 |
+
interpolation. This vectorised approach processes the entire
|
| 353 |
+
393,252-point dataset in under 2~seconds on a consumer-grade machine.
|
| 354 |
+
|
| 355 |
+
The gap-filling is applied independently to the bid series and the ask
|
| 356 |
+
series because the bid and ask prices can move by different amounts on
|
| 357 |
+
the same data update. After gap-filling, the bid series expands from
|
| 358 |
+
393,252 rows to exactly \textbf{4,614,400~rows} (an expansion factor
|
| 359 |
+
of $11.73\times$), and the ask series expands from 393,252 rows to
|
| 360 |
+
exactly \textbf{4,619,918~rows} (an expansion factor of
|
| 361 |
+
$11.75\times$).
|
| 362 |
+
|
| 363 |
+
\subsection{Feature Engineering}
|
| 364 |
+
|
| 365 |
+
The feature engineering pipeline for the gap-filled approach shares the
|
| 366 |
+
initial stages with the raw approach: data fetching, tabular
|
| 367 |
+
conversion, and datetime derivation are identical. The additional
|
| 368 |
+
stage is the gap-filling itself, which produces two new arrays of
|
| 369 |
+
expanded prices and their corresponding interpolated timestamps.
|
| 370 |
+
|
| 371 |
+
For plotting, the expanded nanosecond timestamps must be converted to
|
| 372 |
+
floating-point date numbers. Because the expanded arrays contain
|
| 373 |
+
approximately 4.6 million elements, calling a datetime conversion
|
| 374 |
+
function on individual objects would be prohibitively slow. Instead,
|
| 375 |
+
the conversion is performed arithmetically: the nanosecond integer is
|
| 376 |
+
divided by $10^9$ to get seconds, then by 86,400 to get fractional
|
| 377 |
+
days since the Unix epoch, and finally offset by the appropriate
|
| 378 |
+
constant to align with the plotting library's date
|
| 379 |
+
system~\cite{matplotlib2007}. This bypasses all object-level datetime
|
| 380 |
+
creation and processes the 4.6 million timestamps in a single
|
| 381 |
+
vectorised operation.
|
| 382 |
+
|
| 383 |
+
The histogram bins are constructed identically to the raw approach,
|
| 384 |
+
using 0.01-unit (\$0.010) bin widths. Because the gap-filled data has
|
| 385 |
+
the same price range as the raw data (\$4,878.380 to \$5,083.990),
|
| 386 |
+
the number of bins is also 20,562.
|
| 387 |
+
|
| 388 |
+
\subsection{Output}
|
| 389 |
+
|
| 390 |
+
The output figure has the identical $2 \times 2$ subplot layout as
|
| 391 |
+
Figure~\ref{fig:raw_4panel}.
|
| 392 |
+
|
| 393 |
+
\begin{figure*}[t]
|
| 394 |
+
\centering
|
| 395 |
+
\includegraphics[width=\textwidth]{filled_ticks_4panel.png}
|
| 396 |
+
\caption{Gap-filled (path-weighted) Y-distribution histograms
|
| 397 |
+
(left column) and time-series line charts (right column) for bid
|
| 398 |
+
(top, blue) and ask (bottom, red) prices of \texttt{XAUUSDc} on
|
| 399 |
+
February~12, 2026. The bid series contains 4,614,400 data points
|
| 400 |
+
and the ask series contains 4,619,918 data points after
|
| 401 |
+
gap-filling. Bin size = 0.01~unit (\$0.010). The histogram X-axis
|
| 402 |
+
shows the path-weighted count: the number of times each price
|
| 403 |
+
level was traversed between consecutive data points, including synthetic
|
| 404 |
+
intermediate points.}
|
| 405 |
+
\label{fig:filled_4panel}
|
| 406 |
+
\end{figure*}
|
| 407 |
+
|
| 408 |
+
\subsection{Interpretation}
|
| 409 |
+
|
| 410 |
+
The gap-filled histogram (Figure~\ref{fig:filled_4panel}) answers a
|
| 411 |
+
fundamentally different question than the raw histogram. Where the
|
| 412 |
+
raw profile asks ``how many times was price \emph{quoted} at this
|
| 413 |
+
level,'' the gap-filled profile asks ``how many times did the price
|
| 414 |
+
\emph{path} cross this level.'' The practical consequence is visible
|
| 415 |
+
in the histogram scale: the raw histogram peaks at counts near 120,
|
| 416 |
+
while the gap-filled histogram peaks at counts near 1,200 (consistent
|
| 417 |
+
with the $\approx 11.7\times$ average expansion factor).
|
| 418 |
+
|
| 419 |
+
Price regions that were traversed frequently---even if the market did
|
| 420 |
+
not dwell there long enough to generate many raw tick
|
| 421 |
+
updates---accumulate higher counts in the gap-filled profile. The
|
| 422 |
+
large sell-off visible around 16:00~UTC, where the bid price dropped
|
| 423 |
+
from the \$5,050.000 region to the \$4,878.000 region in a
|
| 424 |
+
concentrated burst of activity, produces substantial counts at every
|
| 425 |
+
intermediate price level in the gap-filled profile, whereas those same
|
| 426 |
+
levels appear sparse or empty in the raw profile because the market
|
| 427 |
+
jumped through them in large increments.
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
% ════════════════════════════════════════════════════════
|
| 431 |
+
\section{Raw vs.\ Gap-Filled: Comprehensive Comparison}
|
| 432 |
+
\label{sec:comparison}
|
| 433 |
+
|
| 434 |
+
\subsection{What Each Approach Measures}
|
| 435 |
+
|
| 436 |
+
The raw approach counts only actual tick updates from the broker's
|
| 437 |
+
data feed. When a price level receives a high count, it means the
|
| 438 |
+
market's best bid or ask was actively updated to that level many
|
| 439 |
+
times. This is a direct measurement of \emph{quoting
|
| 440 |
+
intensity}~\cite{ohara1995}: how frequently market participants were
|
| 441 |
+
placing or modifying orders at that price.
|
| 442 |
+
|
| 443 |
+
The gap-filled approach counts every tick-level price between
|
| 444 |
+
consecutive updates, including synthetic intermediate points that were
|
| 445 |
+
never explicitly quoted. When a price level receives a high count in
|
| 446 |
+
the gap-filled profile, it means the price \emph{path} crossed that
|
| 447 |
+
level many times---either through actual quoting or through
|
| 448 |
+
interpolation during price jumps. This is a measurement of
|
| 449 |
+
\emph{traversal frequency}.
|
| 450 |
+
|
| 451 |
+
\subsection{Detailed Comparison}
|
| 452 |
+
|
| 453 |
+
Table~\ref{tab:comparison} presents a comprehensive side-by-side
|
| 454 |
+
comparison of the two approaches across all relevant variables.
|
| 455 |
+
|
| 456 |
+
\begin{table*}[t]
|
| 457 |
+
\centering
|
| 458 |
+
\caption{Comprehensive comparison of raw tick vs.\ gap-filled
|
| 459 |
+
(path-weighted) market profile construction for \texttt{XAUUSDc} on
|
| 460 |
+
February~12, 2026.}
|
| 461 |
+
\label{tab:comparison}
|
| 462 |
+
\small
|
| 463 |
+
\begin{tabular}{@{}p{3.8cm}p{5.8cm}p{5.8cm}@{}}
|
| 464 |
+
\toprule
|
| 465 |
+
\textbf{Variable} & \textbf{Raw Tick Profile} & \textbf{Gap-Filled (Path-Weighted) Profile} \\
|
| 466 |
+
\midrule
|
| 467 |
+
Data source &
|
| 468 |
+
Microsecond bid/ask ticks from MT5 &
|
| 469 |
+
Same raw ticks, plus synthetic trail-datapoints \\
|
| 470 |
+
\midrule
|
| 471 |
+
Bid data points &
|
| 472 |
+
393,252 &
|
| 473 |
+
4,614,400 ($11.73\times$ expansion) \\
|
| 474 |
+
\midrule
|
| 475 |
+
Ask data points &
|
| 476 |
+
393,252 &
|
| 477 |
+
4,619,918 ($11.75\times$ expansion) \\
|
| 478 |
+
\midrule
|
| 479 |
+
Price range &
|
| 480 |
+
\$4,878.380 -- \$5,083.990 &
|
| 481 |
+
\$4,878.380 -- \$5,083.990 (identical) \\
|
| 482 |
+
\midrule
|
| 483 |
+
Bin width &
|
| 484 |
+
$\delta = \$0.010$ (1 tick) &
|
| 485 |
+
$\delta = \$0.010$ (1 tick, identical) \\
|
| 486 |
+
\midrule
|
| 487 |
+
Number of bins &
|
| 488 |
+
20,562 &
|
| 489 |
+
20,562 (identical) \\
|
| 490 |
+
\midrule
|
| 491 |
+
Avg.\ count per bin (bid) &
|
| 492 |
+
$393{,}252 / 20{,}537 \approx 19.15$ &
|
| 493 |
+
$4{,}614{,}400 / 20{,}537 \approx 224.7$ \\
|
| 494 |
+
\midrule
|
| 495 |
+
Peak histogram count &
|
| 496 |
+
$\sim$120 &
|
| 497 |
+
$\sim$1,200 \\
|
| 498 |
+
\midrule
|
| 499 |
+
Empty bins in profile &
|
| 500 |
+
Many (fast moves leave gaps) &
|
| 501 |
+
None (all intermediate levels filled) \\
|
| 502 |
+
\midrule
|
| 503 |
+
Profile continuity &
|
| 504 |
+
Discontinuous; sparse in trending regions &
|
| 505 |
+
Continuous; no gaps across entire price range \\
|
| 506 |
+
\midrule
|
| 507 |
+
What is measured &
|
| 508 |
+
Quoting intensity (how often each level was quoted) &
|
| 509 |
+
Traversal frequency (how often price path crossed each level) \\
|
| 510 |
+
\midrule
|
| 511 |
+
Consolidation zones &
|
| 512 |
+
High counts---dense, well-represented &
|
| 513 |
+
Similar to raw (few gaps to fill when moves are small) \\
|
| 514 |
+
\midrule
|
| 515 |
+
Fast directional moves &
|
| 516 |
+
Sparse or empty---underrepresented &
|
| 517 |
+
Well-represented with interpolated traversals \\
|
| 518 |
+
\midrule
|
| 519 |
+
Support/resistance detection &
|
| 520 |
+
Based on quoting density only &
|
| 521 |
+
Enhanced: repeated traversals indicate revisited levels \\
|
| 522 |
+
\midrule
|
| 523 |
+
Interpolation method &
|
| 524 |
+
None &
|
| 525 |
+
Linear timestamp interpolation, tick-step price fill \\
|
| 526 |
+
\midrule
|
| 527 |
+
Computational cost &
|
| 528 |
+
Minimal (direct histogram of raw data) &
|
| 529 |
+
Higher ($\sim$11.7$\times$ more data to process) \\
|
| 530 |
+
\bottomrule
|
| 531 |
+
\end{tabular}
|
| 532 |
+
\end{table*}
|
| 533 |
+
|
| 534 |
+
\subsection{Superiority of the Gap-Filled Approach}
|
| 535 |
+
|
| 536 |
+
The gap-filled approach produces a fundamentally more representative
|
| 537 |
+
market profile than the raw tick approach. Its advantages are
|
| 538 |
+
threefold:
|
| 539 |
+
|
| 540 |
+
\begin{enumerate}[leftmargin=*]
|
| 541 |
+
\item \textbf{Complete price coverage.} The gap-filled profile
|
| 542 |
+
assigns a non-zero count to every price level within the day's
|
| 543 |
+
range, eliminating the misleading empty bins that appear in the
|
| 544 |
+
raw profile during fast moves. This provides a structurally
|
| 545 |
+
complete picture of where the market traded.
|
| 546 |
+
|
| 547 |
+
\item \textbf{Traversal information.} By counting path crossings
|
| 548 |
+
rather than only explicit quotes, the gap-filled profile captures
|
| 549 |
+
information about how frequently the market revisited each price
|
| 550 |
+
level---information that is entirely absent from the raw profile.
|
| 551 |
+
This traversal signal is directly relevant to identifying dynamic
|
| 552 |
+
support and resistance~\cite{dalton2007}.
|
| 553 |
+
|
| 554 |
+
\item \textbf{Robustness to feed granularity.} Different brokers
|
| 555 |
+
and feed providers update tick data at different rates. A slower
|
| 556 |
+
feed produces larger jumps between consecutive ticks, which
|
| 557 |
+
creates more gaps in the raw profile. The gap-filled approach is
|
| 558 |
+
robust to this variation because it reconstructs the intermediate
|
| 559 |
+
path regardless of the feed's update frequency.
|
| 560 |
+
\end{enumerate}
|
| 561 |
+
|
| 562 |
+
The primary trade-off is computational cost: the gap-filling process
|
| 563 |
+
multiplies the dataset by a factor of approximately $11.7\times$ in
|
| 564 |
+
this study, which proportionally increases the time required for
|
| 565 |
+
histogram computation and rendering compared to a typical raw-data
|
| 566 |
+
market profile. For very long time horizons or very volatile
|
| 567 |
+
instruments, this expansion factor could be significantly larger.
|
| 568 |
+
|
| 569 |
+
\subsection{Interaction with Bin Size}
|
| 570 |
+
|
| 571 |
+
At the 1-tick bin width ($w = 0.010$) used throughout this study,
|
| 572 |
+
the difference between the raw and gap-filled profiles is maximal
|
| 573 |
+
because gaps in the raw profile (empty bins where no tick was
|
| 574 |
+
observed) are filled in by the gap-filling process. As the bin width
|
| 575 |
+
increases, the practical difference between the two approaches
|
| 576 |
+
diminishes because larger bins tend to capture at least some ticks
|
| 577 |
+
even in the raw profile, and the synthetic intermediate points are
|
| 578 |
+
absorbed into the same bins as the observed ticks. At sufficiently
|
| 579 |
+
large bin widths, the raw and gap-filled histograms become nearly
|
| 580 |
+
indistinguishable~\cite{scott1979}.
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
% ════════════════════════════════════════════════════════
|
| 584 |
+
\section{Conclusion}
|
| 585 |
+
\label{sec:conclusion}
|
| 586 |
+
|
| 587 |
+
This paper presented two approaches to constructing market profiles
|
| 588 |
+
from 393,252 microsecond-resolution bid/ask tick updates of
|
| 589 |
+
\texttt{XAUUSDc} on February~12, 2026, collected from a standard cent
|
| 590 |
+
live trading account on the Exness broker via MetaTrader~5. The raw
|
| 591 |
+
approach counted only observed tick levels, producing a profile that
|
| 592 |
+
reflects quoting intensity. The gap-filled (path-weighted) approach
|
| 593 |
+
interpolated every intermediate price level between consecutive ticks,
|
| 594 |
+
expanding the dataset to 4,614,400 bid rows and 4,619,918 ask rows,
|
| 595 |
+
and producing a profile that reflects path traversal frequency.
|
| 596 |
+
|
| 597 |
+
The gap-filled approach yields a more complete and informative market
|
| 598 |
+
profile by eliminating empty bins, capturing traversal information,
|
| 599 |
+
and providing robustness to variations in feed update frequency. The
|
| 600 |
+
primary cost of this approach is computational: the gap-filling
|
| 601 |
+
process multiplies the dataset size by a factor of approximately
|
| 602 |
+
$11.7\times$, which may result in slower calculation times compared to
|
| 603 |
+
typical market profile construction from raw data.
|
| 604 |
+
|
| 605 |
+
%% ============================================================================
|
| 606 |
+
%% AUTHOR INFORMATION
|
| 607 |
+
%% ============================================================================
|
| 608 |
+
\newpage
|
| 609 |
+
\vspace{2em}
|
| 610 |
+
\section*{Author Information}
|
| 611 |
+
\label{sec:author_info}
|
| 612 |
+
|
| 613 |
+
\begin{center}
|
| 614 |
+
\textbf{Rembrant Oyangoren Albeos}~\href{https://orcid.org/0009-0006-8743-4419}{\includegraphics[height=10pt]{ORCID_icon.png}}
|
| 615 |
+
\end{center}
|
| 616 |
+
|
| 617 |
+
\noindent\textbf{ORCID:} \url{https://orcid.org/0009-0006-8743-4419}
|
| 618 |
+
|
| 619 |
+
\noindent\textbf{Email:} algorembrant@gmail.com
|
| 620 |
+
|
| 621 |
+
\noindent\textbf{Affiliation:} Developer \& Researcher at ConQ
|
| 622 |
+
|
| 623 |
+
\noindent\textbf{Organization:} Continual Quasars~\includegraphics[height=7pt]{ContinualQuasars_icon.png}
|
| 624 |
+
|
| 625 |
+
\noindent\textbf{Organization GitHub:} \url{https://github.com/ContinualQuasars}
|
| 626 |
+
|
| 627 |
+
\noindent\textbf{This Version:} Febuary 14, 2026
|
| 628 |
+
|
| 629 |
+
\noindent\textbf{GitHub:} \url{https://github.com/ContinualQuasars/mBA-Profile}
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
% ════════════════════════════════════════════════════════
|
| 633 |
+
\newpage
|
| 634 |
+
\vspace{20}
|
| 635 |
+
\begin{thebibliography}{99}
|
| 636 |
+
|
| 637 |
+
\bibitem{steidlmayer1986}
|
| 638 |
+
J.~P. Steidlmayer and K.~Koy,
|
| 639 |
+
\textit{Markets and Market Logic},
|
| 640 |
+
Porcupine Press, 1986.
|
| 641 |
+
|
| 642 |
+
\bibitem{dalton2007}
|
| 643 |
+
J.~Dalton, E.~Jones, and R.~Dalton,
|
| 644 |
+
\textit{Mind Over Markets: Power Trading with Market Generated
|
| 645 |
+
Information}, Wiley, 2007.
|
| 646 |
+
|
| 647 |
+
\bibitem{mt5docs}
|
| 648 |
+
MetaQuotes Software Corp.,
|
| 649 |
+
``MetaTrader~5 Python Integration,''
|
| 650 |
+
\url{https://www.mql5.com/en/docs/python_metatrader5}, 2024.
|
| 651 |
+
|
| 652 |
+
\bibitem{metaquotes2024}
|
| 653 |
+
MetaQuotes Software Corp.,
|
| 654 |
+
``MetaTrader~5 Trading Platform,''
|
| 655 |
+
\url{https://www.metatrader5.com}, 2024.
|
| 656 |
+
|
| 657 |
+
\bibitem{ohara1995}
|
| 658 |
+
M.~O'Hara,
|
| 659 |
+
\textit{Market Microstructure Theory},
|
| 660 |
+
Blackwell Publishers, 1995.
|
| 661 |
+
|
| 662 |
+
\bibitem{hasbrouck2007}
|
| 663 |
+
J.~Hasbrouck,
|
| 664 |
+
\textit{Empirical Market Microstructure: The Institutions, Economics,
|
| 665 |
+
and Econometrics of Securities Trading},
|
| 666 |
+
Oxford University Press, 2007.
|
| 667 |
+
|
| 668 |
+
\bibitem{cont2001}
|
| 669 |
+
R.~Cont,
|
| 670 |
+
``Empirical properties of asset returns: Stylized facts and
|
| 671 |
+
statistical issues,''
|
| 672 |
+
\textit{Quantitative Finance}, vol.~1, no.~2, pp.~223--236, 2001.
|
| 673 |
+
|
| 674 |
+
\bibitem{bacry2012}
|
| 675 |
+
E.~Bacry, M.~Mastromatteo, and J.-F. Muzy,
|
| 676 |
+
``Hawkes processes in finance,''
|
| 677 |
+
\textit{Market Microstructure and Liquidity}, vol.~1, no.~1, 2015.
|
| 678 |
+
|
| 679 |
+
\bibitem{dacorogna2001}
|
| 680 |
+
M.~M. Dacorogna, R.~Gen\c{c}ay, U.~A. M\"{u}ller, R.~B. Olsen, and
|
| 681 |
+
O.~V. Pictet,
|
| 682 |
+
\textit{An Introduction to High-Frequency Finance},
|
| 683 |
+
Academic Press, 2001.
|
| 684 |
+
|
| 685 |
+
\bibitem{engle2000}
|
| 686 |
+
R.~F. Engle and J.~R. Russell,
|
| 687 |
+
``Autoregressive conditional duration: A new model for irregularly
|
| 688 |
+
spaced transaction data,''
|
| 689 |
+
\textit{Econometrica}, vol.~66, no.~5, pp.~1127--1162, 1998.
|
| 690 |
+
|
| 691 |
+
\bibitem{ane2000}
|
| 692 |
+
T.~An\'{e} and H.~Geman,
|
| 693 |
+
``Order flow, transaction clock, and normality of asset returns,''
|
| 694 |
+
\textit{The Journal of Finance}, vol.~55, no.~5, pp.~2259--2284,
|
| 695 |
+
2000.
|
| 696 |
+
|
| 697 |
+
\bibitem{goldberg1991}
|
| 698 |
+
D.~Goldberg,
|
| 699 |
+
``What every computer scientist should know about floating-point
|
| 700 |
+
arithmetic,''
|
| 701 |
+
\textit{ACM Computing Surveys}, vol.~23, no.~1, pp.~5--48, 1991.
|
| 702 |
+
|
| 703 |
+
\bibitem{numpy2020}
|
| 704 |
+
C.~R. Harris \textit{et al.},
|
| 705 |
+
``Array programming with NumPy,''
|
| 706 |
+
\textit{Nature}, vol.~585, pp.~357--362, 2020.
|
| 707 |
+
|
| 708 |
+
\bibitem{matplotlib2007}
|
| 709 |
+
J.~D. Hunter,
|
| 710 |
+
``Matplotlib: A 2D graphics environment,''
|
| 711 |
+
\textit{Computing in Science \& Engineering}, vol.~9, no.~3,
|
| 712 |
+
pp.~90--95, 2007.
|
| 713 |
+
|
| 714 |
+
\bibitem{cmegroup2024}
|
| 715 |
+
CME Group,
|
| 716 |
+
``Gold Futures Contract Specifications,''
|
| 717 |
+
\url{https://www.cmegroup.com/markets/metals/precious/gold.contractSpecs.html},
|
| 718 |
+
2024.
|
| 719 |
+
|
| 720 |
+
\bibitem{scott1979}
|
| 721 |
+
D.~W. Scott,
|
| 722 |
+
``On optimal and data-based histograms,''
|
| 723 |
+
\textit{Biometrika}, vol.~66, no.~3, pp.~605--610, 1979.
|
| 724 |
+
|
| 725 |
+
\end{thebibliography}
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
\end{document}
|
| 730 |
+
|
output/filled_ask_XAUUSDc_20260212.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4bb8d664f7c5a62ba89c59050d50b5af3a23ac9fc97055ae7bcd62cd18aa007b
|
| 3 |
+
size 206497481
|
output/filled_bid_XAUUSDc_20260212.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:39ea0b63d98e6b8127956f9546e210088e2a132c6ae497b1b9c16bade40e2f3a
|
| 3 |
+
size 206250499
|
output/raw_ticks_XAUUSDc_20260212.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2d65fb9a10b9979529a28551b3be2ddf2bb30487f00c3d1267bdf214563b957a
|
| 3 |
+
size 24292358
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MetaTrader5
|
| 2 |
+
pandas
|
| 3 |
+
numpy
|
| 4 |
+
matplotlib
|
| 5 |
+
datetime
|
| 6 |
+
|
| 7 |
+
PyQt6
|
| 8 |
+
pyqtgraph
|
| 9 |
+
MetaTrader5
|
| 10 |
+
pandas
|
| 11 |
+
numpy
|
scripts/debug_mt5.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
import MetaTrader5 as mt5
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
# Add project root to sys.path
|
| 8 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 9 |
+
project_root = os.path.dirname(current_dir)
|
| 10 |
+
sys.path.insert(0, project_root)
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
import src.config as config
|
| 14 |
+
except ImportError:
|
| 15 |
+
print("Error: Could not import src.config")
|
| 16 |
+
config = None
|
| 17 |
+
|
| 18 |
+
def test_connection():
|
| 19 |
+
print("=== MT5 Connection Debugger ===")
|
| 20 |
+
print(f"Python: {sys.version}")
|
| 21 |
+
print(f"MT5 Package Version: {mt5.__version__}")
|
| 22 |
+
print(f"MT5 Package Author: {mt5.__author__}")
|
| 23 |
+
|
| 24 |
+
path = getattr(config, 'MT5_PATH', '')
|
| 25 |
+
login = getattr(config, 'MT5_LOGIN', 0)
|
| 26 |
+
server = getattr(config, 'MT5_SERVER', '')
|
| 27 |
+
password = getattr(config, 'MT5_PASSWORD', '')
|
| 28 |
+
|
| 29 |
+
print(f"\nConfigured Path: '{path}'")
|
| 30 |
+
print(f"Configured Login: {login}")
|
| 31 |
+
print(f"Configured Server: '{server}'")
|
| 32 |
+
print(f"Configured Password: {'******' if password else 'Not Set'}")
|
| 33 |
+
|
| 34 |
+
print("\nAttempting Initialization...")
|
| 35 |
+
|
| 36 |
+
if path:
|
| 37 |
+
if not mt5.initialize(path=path):
|
| 38 |
+
print(f"FAILED: mt5.initialize(path='{path}')")
|
| 39 |
+
print(f"Error Code: {mt5.last_error()}")
|
| 40 |
+
return
|
| 41 |
+
else:
|
| 42 |
+
if not mt5.initialize():
|
| 43 |
+
print("FAILED: mt5.initialize()")
|
| 44 |
+
print(f"Error Code: {mt5.last_error()}")
|
| 45 |
+
print("Tip: If you have multiple MT5 terminals or it's installed in a custom location, set MT5_PATH in src/config.py")
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
print("SUCCESS: MT5 Initialized.")
|
| 49 |
+
|
| 50 |
+
# Check Terminal Info
|
| 51 |
+
terminal_info = mt5.terminal_info()
|
| 52 |
+
if terminal_info:
|
| 53 |
+
print("\nTerminal Info:")
|
| 54 |
+
print(f" Path: {terminal_info.path}")
|
| 55 |
+
print(f" Name: {terminal_info.name}")
|
| 56 |
+
print(f" Company: {terminal_info.company}")
|
| 57 |
+
print(f" Connected: {terminal_info.connected}")
|
| 58 |
+
else:
|
| 59 |
+
print("WARNING: Could not get terminal info.")
|
| 60 |
+
|
| 61 |
+
# Check Account Info
|
| 62 |
+
account_info = mt5.account_info()
|
| 63 |
+
if account_info:
|
| 64 |
+
print("\nAccount Info (Current):")
|
| 65 |
+
print(f" Login: {account_info.login}")
|
| 66 |
+
print(f" Server: {account_info.server}")
|
| 67 |
+
print(f" Variable Margin: {account_info.margin_so_mode}")
|
| 68 |
+
else:
|
| 69 |
+
print("\nWARNING: No account currently logged in or accessible.")
|
| 70 |
+
|
| 71 |
+
# Attempt Login if provided
|
| 72 |
+
if login and password and server:
|
| 73 |
+
print(f"\nAttempting Login to {login} on {server}...")
|
| 74 |
+
authorized = mt5.login(login=login, password=password, server=server)
|
| 75 |
+
if authorized:
|
| 76 |
+
print("SUCCESS: Authorized.")
|
| 77 |
+
account_info = mt5.account_info()
|
| 78 |
+
print(f" Balance: {account_info.balance}")
|
| 79 |
+
print(f" Equity: {account_info.equity}")
|
| 80 |
+
else:
|
| 81 |
+
print(f"FAILED: Login failed. Error: {mt5.last_error()}")
|
| 82 |
+
|
| 83 |
+
mt5.shutdown()
|
| 84 |
+
print("\nDisconnected.")
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
test_connection()
|
src/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import time
|
| 3 |
+
import pytz
|
| 4 |
+
|
| 5 |
+
# ──────────────────────────────────────────────
|
| 6 |
+
# Global Configuration
|
| 7 |
+
# ──────────────────────────────────────────────
|
| 8 |
+
|
| 9 |
+
# MT5 Connection
|
| 10 |
+
MT5_PATH = r"" # Path to terminal64.exe, e.g., r"C:\Program Files\MetaTrader 5\terminal64.exe"
|
| 11 |
+
MT5_LOGIN = 0 # Account number (int)
|
| 12 |
+
MT5_PASSWORD = "" # Account password
|
| 13 |
+
MT5_SERVER = "" # Broker server name
|
| 14 |
+
|
| 15 |
+
DEFAULT_SYMBOL = "XAUUSD"
|
| 16 |
+
TIMEFRAME = 1 # 1 minute for basic candles if needed, but we focus on ticks
|
| 17 |
+
|
| 18 |
+
# Market Profile Settings
|
| 19 |
+
# Session defined as 22:00 UTC (previous day) to 00:00 UTC (current day) as per request?
|
| 20 |
+
# Actually user said: "after the feature-engineered micro marketprofile is established at 22 UTC to 0 UTC, then make sure there is a developing VAH VAL and POC line."
|
| 21 |
+
# This implies the "profile building" phase is 22:00 -> 00:00.
|
| 22 |
+
# And "developing" lines start from 00:00 onwards?
|
| 23 |
+
# We will define the PROFILE_START_TIME and PROFILE_END_TIME.
|
| 24 |
+
|
| 25 |
+
TIMEZONE_UTC = pytz.utc
|
| 26 |
+
PROFILE_START_TIME = time(22, 0, 0) # 22:00 UTC
|
| 27 |
+
PROFILE_END_TIME = time(0, 0, 0) # 00:00 UTC (next day effectively)
|
| 28 |
+
|
| 29 |
+
# The user might mean a 2-hour window [22, 00) to build the initial profile?
|
| 30 |
+
# Or maybe the "day" starts at 22:00 UTC?
|
| 31 |
+
# "focus on ... gap-filled ... approach ... established at 22 UTC to 0 UTC"
|
| 32 |
+
# Let's assume we use data from 22:00 (prev day) to 00:00 (current day) to ESTABLISH the levels.
|
| 33 |
+
# And then we CONTINUE updating/developing them? Or we use those fixed levels?
|
| 34 |
+
# "make sure there is a developing VAH VAL and POC line." -> implies they continue to evolve?
|
| 35 |
+
# Or maybe they start plotting from 00:00 based on the 22-00 data?
|
| 36 |
+
# I will implement it such that the profile *accumulates* starting from a specialized time.
|
| 37 |
+
|
| 38 |
+
# Visualization Colors
|
| 39 |
+
COLOR_BID = '#0000FF' # Blue
|
| 40 |
+
COLOR_ASK = '#FF0000' # Red
|
| 41 |
+
COLOR_VAH = '#00FF00' # Green
|
| 42 |
+
COLOR_VAL = '#FF00FF' # Magenta
|
| 43 |
+
COLOR_POC = '#FFFF00' # Yellow
|
| 44 |
+
COLOR_BACKGROUND = '#000000'
|
| 45 |
+
COLOR_TEXT = '#FFFFFF'
|
| 46 |
+
|
| 47 |
+
# Technical
|
| 48 |
+
UNIT_SIZE = 0.01 # For XAUUSD, $0.01
|
src/core/data_worker.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from datetime import datetime, timedelta, timezone
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
| 6 |
+
|
| 7 |
+
from src.core.mt5_interface import MT5Interface
|
| 8 |
+
from src.core.market_profile import MarketProfile
|
| 9 |
+
|
| 10 |
+
class DataWorker(QThread):
|
| 11 |
+
# Signals
|
| 12 |
+
data_signal = pyqtSignal(object, object) # ticks_df, profile_counts
|
| 13 |
+
levels_signal = pyqtSignal(object, object, object, object) # times, vah, val, poc (can be arrays or scalars)
|
| 14 |
+
status_signal = pyqtSignal(str)
|
| 15 |
+
finished_signal = pyqtSignal()
|
| 16 |
+
|
| 17 |
+
def __init__(self, symbol, date_obj, multiplier=1.0):
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.symbol = symbol
|
| 20 |
+
self.date_obj = date_obj
|
| 21 |
+
self.multiplier = multiplier
|
| 22 |
+
self.running = True
|
| 23 |
+
self.mt5_interface = MT5Interface()
|
| 24 |
+
self.market_profile = MarketProfile(multiplier=self.multiplier)
|
| 25 |
+
|
| 26 |
+
def run(self):
|
| 27 |
+
self.status_signal.emit(f"Connecting to MT5... (Multiplier: {self.multiplier}x)")
|
| 28 |
+
if not self.mt5_interface.initialize():
|
| 29 |
+
self.status_signal.emit("Failed to connect to MT5.")
|
| 30 |
+
self.finished_signal.emit()
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
# Calculate session times
|
| 34 |
+
# Target Date (00:00 UTC of the selected day)
|
| 35 |
+
target_date_utc = datetime(self.date_obj.year, self.date_obj.month, self.date_obj.day, tzinfo=timezone.utc)
|
| 36 |
+
|
| 37 |
+
# Establishment Start: 22:00 UTC previous day
|
| 38 |
+
start_establishment = target_date_utc - timedelta(days=1) + timedelta(hours=22)
|
| 39 |
+
# Establishment End (Developing Start): 00:00 UTC target day
|
| 40 |
+
end_establishment = target_date_utc
|
| 41 |
+
# Session End: 00:00 UTC next day (24h later)
|
| 42 |
+
end_session = target_date_utc + timedelta(days=1)
|
| 43 |
+
|
| 44 |
+
# Current time
|
| 45 |
+
now_utc = datetime.now(timezone.utc)
|
| 46 |
+
|
| 47 |
+
# Determine Fetch Range
|
| 48 |
+
is_historical = end_session < now_utc
|
| 49 |
+
fetch_end = end_session if is_historical else now_utc
|
| 50 |
+
|
| 51 |
+
self.status_signal.emit(f"Fetch Range: {start_establishment} to {fetch_end} ...")
|
| 52 |
+
|
| 53 |
+
# 1. Fetch History
|
| 54 |
+
ticks_df = self.mt5_interface.get_ticks(self.symbol, start_establishment, fetch_end)
|
| 55 |
+
|
| 56 |
+
if not ticks_df.empty:
|
| 57 |
+
# Split Data
|
| 58 |
+
mask_est = (ticks_df['datetime'] >= start_establishment) & (ticks_df['datetime'] < end_establishment)
|
| 59 |
+
df_est = ticks_df.loc[mask_est]
|
| 60 |
+
|
| 61 |
+
mask_dev = (ticks_df['datetime'] >= end_establishment)
|
| 62 |
+
df_dev = ticks_df.loc[mask_dev]
|
| 63 |
+
|
| 64 |
+
self.status_signal.emit(f"Data: {len(ticks_df)} total. Est: {len(df_est)}, Dev: {len(df_dev)}")
|
| 65 |
+
|
| 66 |
+
# 2. Process Establishment Phase
|
| 67 |
+
if not df_est.empty:
|
| 68 |
+
self.market_profile.update(df_est)
|
| 69 |
+
self.status_signal.emit(f"Profile Established. Ticks: {self.market_profile.total_ticks}")
|
| 70 |
+
else:
|
| 71 |
+
self.status_signal.emit("Warning: No Establishment Data (22:00-00:00). Starting empty.")
|
| 72 |
+
|
| 73 |
+
# 3. Process Developing Phase (History Replay)
|
| 74 |
+
dev_times = []
|
| 75 |
+
dev_vah = []
|
| 76 |
+
dev_val = []
|
| 77 |
+
dev_poc = []
|
| 78 |
+
|
| 79 |
+
if not df_dev.empty:
|
| 80 |
+
# Resample to 1 minute to calculate trajectory
|
| 81 |
+
df_dev_indexed = df_dev.set_index('datetime')
|
| 82 |
+
grouped = df_dev_indexed.resample('1min')
|
| 83 |
+
|
| 84 |
+
count_steps = 0
|
| 85 |
+
for time_idx, group in grouped:
|
| 86 |
+
if group.empty:
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
# Update profile
|
| 90 |
+
group_reset = group.reset_index()
|
| 91 |
+
self.market_profile.update(group_reset)
|
| 92 |
+
|
| 93 |
+
# Calculate levels
|
| 94 |
+
v, l, p = self.market_profile.get_vah_val_poc()
|
| 95 |
+
if v is not None:
|
| 96 |
+
# Use timestamp
|
| 97 |
+
ts_float = time_idx.timestamp()
|
| 98 |
+
dev_times.append(ts_float)
|
| 99 |
+
dev_vah.append(v)
|
| 100 |
+
dev_val.append(l)
|
| 101 |
+
dev_poc.append(p)
|
| 102 |
+
count_steps += 1
|
| 103 |
+
|
| 104 |
+
self.status_signal.emit(f"Calculated {count_steps} developing points.")
|
| 105 |
+
|
| 106 |
+
# Emit History Data: Ticks
|
| 107 |
+
# If extremely large, maybe downsample? But for now send all.
|
| 108 |
+
print(f"DEBUG: Worker Emitting Ticks: {len(ticks_df)}")
|
| 109 |
+
self.data_signal.emit(ticks_df, self.market_profile.counts)
|
| 110 |
+
|
| 111 |
+
# Emit Levels
|
| 112 |
+
if dev_times:
|
| 113 |
+
print(f"DEBUG: Worker Emitting Levels: {len(dev_times)} pts. Times: {dev_times[0]} -> {dev_times[-1]}")
|
| 114 |
+
self.levels_signal.emit(
|
| 115 |
+
np.array(dev_times),
|
| 116 |
+
np.array(dev_vah),
|
| 117 |
+
np.array(dev_val),
|
| 118 |
+
np.array(dev_poc)
|
| 119 |
+
)
|
| 120 |
+
else:
|
| 121 |
+
print("DEBUG: No developing levels calculated to emit.")
|
| 122 |
+
self.status_signal.emit("No developing levels calculated (insufficient dev data?).")
|
| 123 |
+
|
| 124 |
+
else:
|
| 125 |
+
self.status_signal.emit("No ticks returned from MT5.")
|
| 126 |
+
|
| 127 |
+
# 4. Live Streaming (Only if not historical)
|
| 128 |
+
if not is_historical:
|
| 129 |
+
self.status_signal.emit("Live streaming active...")
|
| 130 |
+
|
| 131 |
+
last_time = now_utc
|
| 132 |
+
if not ticks_df.empty:
|
| 133 |
+
last_time = ticks_df['datetime'].iloc[-1].to_pydatetime()
|
| 134 |
+
|
| 135 |
+
while self.running:
|
| 136 |
+
time.sleep(1.0)
|
| 137 |
+
|
| 138 |
+
cur_time = datetime.now(timezone.utc)
|
| 139 |
+
new_ticks = self.mt5_interface.get_ticks(self.symbol, last_time, cur_time + timedelta(seconds=1))
|
| 140 |
+
|
| 141 |
+
if not new_ticks.empty:
|
| 142 |
+
new_ticks = new_ticks[new_ticks['datetime'] > last_time]
|
| 143 |
+
|
| 144 |
+
if not new_ticks.empty:
|
| 145 |
+
self.market_profile.update(new_ticks)
|
| 146 |
+
last_time = new_ticks['datetime'].iloc[-1].to_pydatetime()
|
| 147 |
+
|
| 148 |
+
# Emit Tick Data
|
| 149 |
+
# print(f"DEBUG: Live Tick Update: {len(new_ticks)}")
|
| 150 |
+
self.data_signal.emit(new_ticks, self.market_profile.counts)
|
| 151 |
+
|
| 152 |
+
# Emit Level Update
|
| 153 |
+
v, l, p = self.market_profile.get_vah_val_poc()
|
| 154 |
+
if v is not None:
|
| 155 |
+
ts_now = cur_time.timestamp()
|
| 156 |
+
# print(f"DEBUG: Live Level Update: POC {p}")
|
| 157 |
+
self.levels_signal.emit([ts_now], [v], [l], [p])
|
| 158 |
+
else:
|
| 159 |
+
self.status_signal.emit("Historical view loaded. Live stream inactive.")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def stop(self):
|
| 163 |
+
self.running = False
|
| 164 |
+
self.mt5_interface.shutdown()
|
| 165 |
+
self.wait()
|
src/core/market_profile.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from datetime import datetime, time
|
| 4 |
+
|
| 5 |
+
class MarketProfile:
|
| 6 |
+
def __init__(self, multiplier=2.0):
|
| 7 |
+
self.multiplier = multiplier
|
| 8 |
+
self.counts = {} # price -> count (time/tick opportunity)
|
| 9 |
+
self.total_ticks = 0
|
| 10 |
+
self.min_price = float('inf')
|
| 11 |
+
self.max_price = float('-inf')
|
| 12 |
+
|
| 13 |
+
def reset(self):
|
| 14 |
+
self.counts = {}
|
| 15 |
+
self.total_ticks = 0
|
| 16 |
+
self.min_price = float('inf')
|
| 17 |
+
self.max_price = float('-inf')
|
| 18 |
+
|
| 19 |
+
def fill_gaps(self, prices: np.ndarray, timestamps_ns: np.ndarray, step_sizes: np.ndarray):
|
| 20 |
+
"""
|
| 21 |
+
Vectorised gap-fill with dynamic step sizes.
|
| 22 |
+
step_sizes: array of shape (N,) corresponding to each price point.
|
| 23 |
+
We use step_sizes[:-1] for the gaps starting at prices[:-1].
|
| 24 |
+
Returns: (filled_prices, filled_timestamps_ns)
|
| 25 |
+
"""
|
| 26 |
+
if len(prices) < 2:
|
| 27 |
+
return prices, timestamps_ns
|
| 28 |
+
|
| 29 |
+
# Step sizes for the intervals (from point i -> i+1)
|
| 30 |
+
# If scalar, broadcast. If array, slice.
|
| 31 |
+
if np.isscalar(step_sizes):
|
| 32 |
+
# Broadcast to shape (N-1,)
|
| 33 |
+
steps_interval = np.full(len(prices)-1, step_sizes, dtype=np.float64)
|
| 34 |
+
else:
|
| 35 |
+
# Assume step_sizes corresponds to prices. The step for gap i->i+1 is step_sizes[i].
|
| 36 |
+
steps_interval = step_sizes[:-1]
|
| 37 |
+
|
| 38 |
+
# Avoid division by zero or extremely small steps
|
| 39 |
+
steps_interval = np.where(steps_interval < 0.000001, 0.01, steps_interval)
|
| 40 |
+
|
| 41 |
+
diff = np.diff(prices)
|
| 42 |
+
# Number of units (steps) to fill for each gap
|
| 43 |
+
diff_units = np.round(diff / steps_interval).astype(np.int64)
|
| 44 |
+
counts = np.abs(diff_units)
|
| 45 |
+
|
| 46 |
+
# Last point gets a count of 1 (itself)
|
| 47 |
+
counts = np.append(counts, 1)
|
| 48 |
+
|
| 49 |
+
total = int(np.sum(counts))
|
| 50 |
+
if total == 0:
|
| 51 |
+
return prices, timestamps_ns
|
| 52 |
+
|
| 53 |
+
indices = np.repeat(np.arange(len(prices)), counts)
|
| 54 |
+
|
| 55 |
+
# Offset within each segment (0, 1, 2...)
|
| 56 |
+
cum = np.cumsum(counts)
|
| 57 |
+
starts = np.empty_like(cum)
|
| 58 |
+
starts[0] = 0
|
| 59 |
+
starts[1:] = cum[:-1]
|
| 60 |
+
offsets = np.arange(total) - np.repeat(starts, counts)
|
| 61 |
+
|
| 62 |
+
# Direction per segment (+1 or -1)
|
| 63 |
+
directions = np.zeros(len(prices), dtype=np.float64)
|
| 64 |
+
directions[:-1] = np.sign(diff_units)
|
| 65 |
+
|
| 66 |
+
# Time step per segment
|
| 67 |
+
# We need to interpolate time as well
|
| 68 |
+
dt = np.zeros(len(prices), dtype=np.float64)
|
| 69 |
+
dt[:-1] = np.diff(timestamps_ns).astype(np.float64)
|
| 70 |
+
|
| 71 |
+
# Avoid division by zero in time steps if counts is 0 (shouldn't happen with counts > 0 check, but be safe)
|
| 72 |
+
div_counts = np.where(counts > 0, counts, 1)
|
| 73 |
+
time_steps = dt / div_counts
|
| 74 |
+
|
| 75 |
+
# Expand step sizes and time steps
|
| 76 |
+
if np.isscalar(step_sizes):
|
| 77 |
+
expanded_steps = np.full(len(indices), step_sizes, dtype=np.float64)
|
| 78 |
+
else:
|
| 79 |
+
expanded_steps = step_sizes[indices]
|
| 80 |
+
|
| 81 |
+
expanded_time_steps = time_steps[indices]
|
| 82 |
+
|
| 83 |
+
# Calculate filled prices and times
|
| 84 |
+
filled_prices = prices[indices] + offsets * directions[indices] * expanded_steps
|
| 85 |
+
filled_ts = timestamps_ns[indices].astype(np.float64) + offsets * expanded_time_steps
|
| 86 |
+
|
| 87 |
+
return np.round(filled_prices, 2), filled_ts.astype(np.int64)
|
| 88 |
+
|
| 89 |
+
def update(self, ticks_df: pd.DataFrame):
|
| 90 |
+
"""
|
| 91 |
+
Updates the profile with new ticks.
|
| 92 |
+
ticks_df must have 'bid', 'ask', 'datetime'.
|
| 93 |
+
"""
|
| 94 |
+
if ticks_df.empty:
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
timestamps_ns = ticks_df['datetime'].values.astype('datetime64[ns]').astype(np.int64)
|
| 98 |
+
bids = ticks_df['bid'].values.astype(np.float64)
|
| 99 |
+
|
| 100 |
+
# Calculate dynamic step sizes based on Spread
|
| 101 |
+
# Spread = Ask - Bid
|
| 102 |
+
# Step = Spread * Multiplier
|
| 103 |
+
|
| 104 |
+
# Ensure 'ask' exists
|
| 105 |
+
if 'ask' in ticks_df.columns:
|
| 106 |
+
asks = ticks_df['ask'].values.astype(np.float64)
|
| 107 |
+
spreads = asks - bids
|
| 108 |
+
# Ensure non-negative/non-zero spread fallback
|
| 109 |
+
spreads = np.maximum(spreads, 0.00001)
|
| 110 |
+
step_sizes = spreads * self.multiplier
|
| 111 |
+
|
| 112 |
+
# Update Bid
|
| 113 |
+
self.add_data(bids, timestamps_ns, step_sizes)
|
| 114 |
+
# Update Ask
|
| 115 |
+
self.add_data(asks, timestamps_ns, step_sizes)
|
| 116 |
+
|
| 117 |
+
else:
|
| 118 |
+
# Fallback if no ask column
|
| 119 |
+
step_sizes = np.full(len(bids), 0.01 * self.multiplier)
|
| 120 |
+
self.add_data(bids, timestamps_ns, step_sizes)
|
| 121 |
+
|
| 122 |
+
def add_data(self, prices: np.ndarray, timestamps_ns: np.ndarray, step_sizes: np.ndarray):
|
| 123 |
+
"""
|
| 124 |
+
Gap-fills the data and updates the histogram counts.
|
| 125 |
+
"""
|
| 126 |
+
filled_prices, filled_ts = self.fill_gaps(prices, timestamps_ns, step_sizes)
|
| 127 |
+
|
| 128 |
+
# Update histogram
|
| 129 |
+
unique, counts = np.unique(filled_prices, return_counts=True)
|
| 130 |
+
|
| 131 |
+
for p, c in zip(unique, counts):
|
| 132 |
+
p = round(float(p), 2)
|
| 133 |
+
self.counts[p] = self.counts.get(p, 0) + c
|
| 134 |
+
self.total_ticks += c
|
| 135 |
+
if p < self.min_price: self.min_price = p
|
| 136 |
+
if p > self.max_price: self.max_price = p
|
| 137 |
+
|
| 138 |
+
def get_vah_val_poc(self):
|
| 139 |
+
"""
|
| 140 |
+
Calculates Value Area High (VAH), Value Area Low (VAL), and Point of Control (POC).
|
| 141 |
+
Standard definition: 70% of volume around POC.
|
| 142 |
+
"""
|
| 143 |
+
if not self.counts:
|
| 144 |
+
return None, None, None
|
| 145 |
+
|
| 146 |
+
# Convert to sorted list of (price, count)
|
| 147 |
+
sorted_prices = sorted(self.counts.keys())
|
| 148 |
+
counts_list = [self.counts[p] for p in sorted_prices]
|
| 149 |
+
|
| 150 |
+
counts_array = np.array(counts_list, dtype=np.int64)
|
| 151 |
+
prices_array = np.array(sorted_prices, dtype=np.float64)
|
| 152 |
+
|
| 153 |
+
# POC
|
| 154 |
+
poc_idx = np.argmax(counts_array)
|
| 155 |
+
poc_price = prices_array[poc_idx]
|
| 156 |
+
|
| 157 |
+
# Value Area (70%)
|
| 158 |
+
total_count = np.sum(counts_array)
|
| 159 |
+
target_count = total_count * 0.70
|
| 160 |
+
|
| 161 |
+
current_count = counts_array[poc_idx]
|
| 162 |
+
left_idx = poc_idx
|
| 163 |
+
right_idx = poc_idx
|
| 164 |
+
|
| 165 |
+
# Greedily expand
|
| 166 |
+
while current_count < target_count:
|
| 167 |
+
# Try to pick best side
|
| 168 |
+
can_go_left = left_idx > 0
|
| 169 |
+
can_go_right = right_idx < len(counts_array) - 1
|
| 170 |
+
|
| 171 |
+
if not can_go_left and not can_go_right:
|
| 172 |
+
break
|
| 173 |
+
|
| 174 |
+
count_left = counts_array[left_idx - 1] if can_go_left else -1
|
| 175 |
+
count_right = counts_array[right_idx + 1] if can_go_right else -1
|
| 176 |
+
|
| 177 |
+
if count_left > count_right:
|
| 178 |
+
current_count += count_left
|
| 179 |
+
left_idx -= 1
|
| 180 |
+
elif count_right > count_left:
|
| 181 |
+
current_count += count_right
|
| 182 |
+
right_idx += 1
|
| 183 |
+
else:
|
| 184 |
+
# Equal counts, expand both if possible
|
| 185 |
+
if can_go_left:
|
| 186 |
+
current_count += count_left
|
| 187 |
+
left_idx -= 1
|
| 188 |
+
if can_go_right:
|
| 189 |
+
current_count += count_right
|
| 190 |
+
right_idx += 1
|
| 191 |
+
|
| 192 |
+
val_price = prices_array[left_idx]
|
| 193 |
+
vah_price = prices_array[right_idx]
|
| 194 |
+
|
| 195 |
+
return vah_price, val_price, poc_price
|
src/core/mt5_interface.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import MetaTrader5 as mt5
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
import pytz
|
| 5 |
+
import time
|
| 6 |
+
import src.config as config
|
| 7 |
+
|
| 8 |
+
class MT5Interface:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self.connected = False
|
| 11 |
+
|
| 12 |
+
def initialize(self):
|
| 13 |
+
"""Initializes the connection to MetaTrader 5."""
|
| 14 |
+
path = getattr(config, 'MT5_PATH', '')
|
| 15 |
+
login = getattr(config, 'MT5_LOGIN', 0)
|
| 16 |
+
password = getattr(config, 'MT5_PASSWORD', '')
|
| 17 |
+
server = getattr(config, 'MT5_SERVER', '')
|
| 18 |
+
|
| 19 |
+
if path:
|
| 20 |
+
if not mt5.initialize(path=path):
|
| 21 |
+
print(f"MT5 initialize(path={path}) failed, error code = {mt5.last_error()}")
|
| 22 |
+
self.connected = False
|
| 23 |
+
return False
|
| 24 |
+
else:
|
| 25 |
+
if not mt5.initialize():
|
| 26 |
+
print(f"MT5 initialize() failed, error code = {mt5.last_error()}")
|
| 27 |
+
self.connected = False
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
# Attempt login if credentials provided
|
| 31 |
+
if login and password and server:
|
| 32 |
+
authorized = mt5.login(login=login, password=password, server=server)
|
| 33 |
+
if not authorized:
|
| 34 |
+
print(f"MT5 login failed, error code = {mt5.last_error()}")
|
| 35 |
+
mt5.shutdown()
|
| 36 |
+
self.connected = False
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
print("MT5 Initialized successfully.")
|
| 40 |
+
self.connected = True
|
| 41 |
+
return True
|
| 42 |
+
|
| 43 |
+
def shutdown(self):
|
| 44 |
+
"""Shuts down the connection."""
|
| 45 |
+
mt5.shutdown()
|
| 46 |
+
self.connected = False
|
| 47 |
+
|
| 48 |
+
def get_ticks(self, symbol, start_time_utc: datetime, end_time_utc: datetime):
|
| 49 |
+
"""
|
| 50 |
+
Fetches ticks for a given symbol and time range.
|
| 51 |
+
Returns a DataFrame with 'time_msc', 'bid', 'ask', 'flags', 'volume'.
|
| 52 |
+
"""
|
| 53 |
+
if not self.connected:
|
| 54 |
+
if not self.initialize():
|
| 55 |
+
return pd.DataFrame()
|
| 56 |
+
|
| 57 |
+
# Ensure timestamps are timezone-aware (UTC)
|
| 58 |
+
if start_time_utc.tzinfo is None:
|
| 59 |
+
start_time_utc = start_time_utc.replace(tzinfo=pytz.utc)
|
| 60 |
+
if end_time_utc.tzinfo is None:
|
| 61 |
+
end_time_utc = end_time_utc.replace(tzinfo=pytz.utc)
|
| 62 |
+
|
| 63 |
+
ticks = mt5.copy_ticks_range(symbol, start_time_utc, end_time_utc, mt5.COPY_TICKS_ALL)
|
| 64 |
+
|
| 65 |
+
if ticks is None or len(ticks) == 0:
|
| 66 |
+
print(f"No ticks found for {symbol} between {start_time_utc} and {end_time_utc}")
|
| 67 |
+
return pd.DataFrame()
|
| 68 |
+
|
| 69 |
+
df = pd.DataFrame(ticks)
|
| 70 |
+
# Convert time_msc to datetime
|
| 71 |
+
df['datetime'] = pd.to_datetime(df['time_msc'], unit='ms', utc=True)
|
| 72 |
+
return df
|
| 73 |
+
|
| 74 |
+
def get_last_tick(self, symbol):
|
| 75 |
+
"""Fetches the latest tick for the symbol."""
|
| 76 |
+
if not self.connected:
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
tick = mt5.symbol_info_tick(symbol)
|
| 80 |
+
return tick
|
| 81 |
+
|
| 82 |
+
def get_symbol_info(self, symbol):
|
| 83 |
+
"""Returns symbol specification."""
|
| 84 |
+
info = mt5.symbol_info(symbol)
|
| 85 |
+
return info
|
src/main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
# Add project root to sys.path to ensure imports work
|
| 5 |
+
# We want the parent of 'src' (project root) in sys.path so 'import src.xxx' works
|
| 6 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 7 |
+
project_root = os.path.dirname(current_dir)
|
| 8 |
+
sys.path.append(project_root)
|
| 9 |
+
|
| 10 |
+
from PyQt6.QtWidgets import QApplication
|
| 11 |
+
from src.ui.main_window import MainWindow
|
| 12 |
+
|
| 13 |
+
def main():
|
| 14 |
+
app = QApplication(sys.argv)
|
| 15 |
+
|
| 16 |
+
# Optional: Set a dark theme/palette here
|
| 17 |
+
|
| 18 |
+
window = MainWindow()
|
| 19 |
+
window.show()
|
| 20 |
+
|
| 21 |
+
sys.exit(app.exec())
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
main()
|
src/ui/chart_widget.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pyqtgraph as pg
|
| 2 |
+
from PyQt6.QtWidgets import QWidget, QVBoxLayout
|
| 3 |
+
from PyQt6.QtCore import pyqtSlot
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
import pytz
|
| 8 |
+
|
| 9 |
+
class ChartWidget(QWidget):
|
| 10 |
+
def __init__(self, parent=None):
|
| 11 |
+
super().__init__(parent)
|
| 12 |
+
self.layout = QVBoxLayout()
|
| 13 |
+
self.setLayout(self.layout)
|
| 14 |
+
|
| 15 |
+
# Initialize PyQtGraph layout
|
| 16 |
+
self.glw = pg.GraphicsLayoutWidget()
|
| 17 |
+
self.layout.addWidget(self.glw)
|
| 18 |
+
|
| 19 |
+
# Col 0: Profile (Count vs Price)
|
| 20 |
+
self.profile_plot = self.glw.addPlot(row=0, col=0)
|
| 21 |
+
self.profile_plot.setMaximumWidth(200)
|
| 22 |
+
self.profile_plot.hideAxis('bottom')
|
| 23 |
+
self.profile_plot.showAxis('top')
|
| 24 |
+
self.profile_plot.setLabel('top', 'Volume')
|
| 25 |
+
self.profile_plot.setClipToView(True)
|
| 26 |
+
# self.profile_plot.setDownsampling(auto=True, mode='peak')
|
| 27 |
+
|
| 28 |
+
# Col 1: Price (Time vs Price)
|
| 29 |
+
self.price_plot = self.glw.addPlot(row=0, col=1)
|
| 30 |
+
self.price_plot.setLabel('bottom', 'Time')
|
| 31 |
+
self.price_plot.setLabel('right', 'Price')
|
| 32 |
+
self.price_plot.showAxis('right')
|
| 33 |
+
self.price_plot.hideAxis('left')
|
| 34 |
+
self.price_plot.showAxis('right')
|
| 35 |
+
self.price_plot.hideAxis('left')
|
| 36 |
+
self.price_plot.setClipToView(False) # Disable for debugging
|
| 37 |
+
# self.price_plot.setDownsampling(auto=True, mode='peak') # Disable for debugging
|
| 38 |
+
|
| 39 |
+
# Link Y-axes
|
| 40 |
+
# self.profile_plot.setYLink(self.price_plot) # temporarily unlink to rule out profile plot issues
|
| 41 |
+
|
| 42 |
+
# Initialize Chart Items
|
| 43 |
+
self.bid_curve = self.price_plot.plot(pen=pg.mkPen('b', width=1), name="Bid")
|
| 44 |
+
self.ask_curve = self.price_plot.plot(pen=pg.mkPen('r', width=1), name="Ask")
|
| 45 |
+
|
| 46 |
+
# Profile Histogram Item
|
| 47 |
+
self.profile_bars = pg.BarGraphItem(x0=0, y=0, width=0, height=0.01, brush='c')
|
| 48 |
+
self.profile_plot.addItem(self.profile_bars)
|
| 49 |
+
|
| 50 |
+
# Developing Lines (PlotCurveItems)
|
| 51 |
+
self.curve_vah = self.price_plot.plot(pen=pg.mkPen('g', width=2), name="VAH")
|
| 52 |
+
self.curve_val = self.price_plot.plot(pen=pg.mkPen('m', width=2), name="VAL")
|
| 53 |
+
self.curve_poc = self.price_plot.plot(pen=pg.mkPen('y', width=2), name="POC")
|
| 54 |
+
|
| 55 |
+
# Date axis formatter
|
| 56 |
+
self.date_axis = self.price_plot.getAxis('bottom')
|
| 57 |
+
# self.date_axis.setTickSpacing(3600, 1800) # Grid every hour - Caused MemoryError
|
| 58 |
+
self.price_plot.showGrid(x=True, y=True, alpha=0.3)
|
| 59 |
+
|
| 60 |
+
# Data storage
|
| 61 |
+
self.times = np.array([])
|
| 62 |
+
self.bids = np.array([])
|
| 63 |
+
self.asks = np.array([])
|
| 64 |
+
|
| 65 |
+
# Storage for developing levels
|
| 66 |
+
self.level_times = np.array([])
|
| 67 |
+
self.level_vah = np.array([])
|
| 68 |
+
self.level_val = np.array([])
|
| 69 |
+
self.level_poc = np.array([])
|
| 70 |
+
|
| 71 |
+
def clear(self):
|
| 72 |
+
self.times = np.array([])
|
| 73 |
+
self.bids = np.array([])
|
| 74 |
+
self.asks = np.array([])
|
| 75 |
+
self.level_times = np.array([])
|
| 76 |
+
self.level_vah = np.array([])
|
| 77 |
+
self.level_val = np.array([])
|
| 78 |
+
self.level_poc = np.array([])
|
| 79 |
+
|
| 80 |
+
self.bid_curve.setData([], [])
|
| 81 |
+
self.ask_curve.setData([], [])
|
| 82 |
+
self.profile_bars.setOpts(x0=[], y=[], width=[], height=[])
|
| 83 |
+
self.curve_vah.setData([], [])
|
| 84 |
+
self.curve_val.setData([], [])
|
| 85 |
+
self.curve_poc.setData([], [])
|
| 86 |
+
|
| 87 |
+
def update_ticks(self, df):
|
| 88 |
+
"""
|
| 89 |
+
Updates the tick chart by appending new data.
|
| 90 |
+
df: DataFrame with 'datetime' (ns timestamp) and 'bid', 'ask'.
|
| 91 |
+
"""
|
| 92 |
+
if df.empty:
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
# Convert timestamps for pyqtgraph (seconds since epoch)
|
| 96 |
+
# df['datetime'] is numpy datetime64[ns]
|
| 97 |
+
new_times = df['datetime'].values.astype(np.float64) / 1e9
|
| 98 |
+
new_bids = df['bid'].values
|
| 99 |
+
new_asks = df['ask'].values if 'ask' in df.columns else np.zeros_like(new_bids)
|
| 100 |
+
|
| 101 |
+
if len(self.times) == 0:
|
| 102 |
+
self.times = new_times
|
| 103 |
+
self.bids = new_bids
|
| 104 |
+
self.asks = new_asks
|
| 105 |
+
else:
|
| 106 |
+
self.times = np.concatenate((self.times, new_times))
|
| 107 |
+
self.bids = np.concatenate((self.bids, new_bids))
|
| 108 |
+
self.asks = np.concatenate((self.asks, new_asks))
|
| 109 |
+
|
| 110 |
+
# Debug Log
|
| 111 |
+
if len(self.times) > 0:
|
| 112 |
+
t_min, t_max = self.times[0], self.times[-1]
|
| 113 |
+
b_min, b_max = np.min(self.bids), np.max(self.bids)
|
| 114 |
+
print(f"DEBUG: Chart Ticks: {len(self.times)} pts.")
|
| 115 |
+
print(f"DEBUG: Time Range: {t_min:.1f} -> {t_max:.1f} ({datetime.fromtimestamp(t_min)} -> {datetime.fromtimestamp(t_max)})")
|
| 116 |
+
print(f"DEBUG: Price Range: {b_min:.4f} -> {b_max:.4f}")
|
| 117 |
+
|
| 118 |
+
# Update curves
|
| 119 |
+
self.bid_curve.setData(self.times, self.bids)
|
| 120 |
+
if len(self.asks) > 0:
|
| 121 |
+
self.ask_curve.setData(self.times, self.asks)
|
| 122 |
+
|
| 123 |
+
# Force range on first large update
|
| 124 |
+
if len(self.times) > 0 and len(self.times) == len(new_times):
|
| 125 |
+
self.price_plot.setXRange(self.times[0], self.times[-1], padding=0.02)
|
| 126 |
+
self.price_plot.setYRange(np.min(self.bids), np.max(self.bids), padding=0.02)
|
| 127 |
+
|
| 128 |
+
def update_profile(self, counts_dict, unit_size=0.01):
|
| 129 |
+
"""
|
| 130 |
+
Updates the side profile histogram.
|
| 131 |
+
counts: dict {price: count}
|
| 132 |
+
"""
|
| 133 |
+
if not counts_dict:
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
prices = np.array(list(counts_dict.keys()))
|
| 137 |
+
counts = np.array(list(counts_dict.values()))
|
| 138 |
+
|
| 139 |
+
# Horizontal bars: x0=0, y=prices, width=counts, height=unit_size
|
| 140 |
+
self.profile_bars.setOpts(x0=np.zeros(len(prices)), y=prices, width=counts, height=unit_size, brush=(0, 255, 255, 100))
|
| 141 |
+
|
| 142 |
+
def update_levels(self, new_times, new_vah, new_val, new_poc):
|
| 143 |
+
"""
|
| 144 |
+
Updates the developing VAH/VAL/POC lines.
|
| 145 |
+
Expects arrays or scalars.
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
# Ensure inputs are 1D arrays
|
| 149 |
+
nt = np.atleast_1d(np.array(new_times, dtype=np.float64))
|
| 150 |
+
nv = np.atleast_1d(np.array(new_vah, dtype=np.float64))
|
| 151 |
+
nl = np.atleast_1d(np.array(new_val, dtype=np.float64))
|
| 152 |
+
yp = np.atleast_1d(np.array(new_poc, dtype=np.float64))
|
| 153 |
+
|
| 154 |
+
if len(nt) == 0:
|
| 155 |
+
# print("Chart Update Levels: Empty new_times")
|
| 156 |
+
return
|
| 157 |
+
|
| 158 |
+
# Append logic
|
| 159 |
+
if len(self.level_times) == 0:
|
| 160 |
+
self.level_times = nt
|
| 161 |
+
self.level_vah = nv
|
| 162 |
+
self.level_val = nl
|
| 163 |
+
self.level_poc = yp
|
| 164 |
+
else:
|
| 165 |
+
self.level_times = np.concatenate((self.level_times, nt))
|
| 166 |
+
self.level_vah = np.concatenate((self.level_vah, nv))
|
| 167 |
+
self.level_val = np.concatenate((self.level_val, nl))
|
| 168 |
+
self.level_poc = np.concatenate((self.level_poc, yp))
|
| 169 |
+
|
| 170 |
+
if len(nt) > 1:
|
| 171 |
+
print(f"DEBUG: Chart Levels Loaded: {len(nt)} pts. POC Range: {self.level_poc[0]:.2f} -> {self.level_poc[-1]:.2f}")
|
| 172 |
+
|
| 173 |
+
# Update plots
|
| 174 |
+
self.curve_vah.setData(self.level_times, self.level_vah)
|
| 175 |
+
self.curve_val.setData(self.level_times, self.level_val)
|
| 176 |
+
self.curve_poc.setData(self.level_times, self.level_poc)
|
| 177 |
+
except Exception as e:
|
| 178 |
+
print(f"Error updating levels: {e}")
|
src/ui/control_panel.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PyQt6.QtWidgets import (
|
| 2 |
+
QWidget, QVBoxLayout, QLabel, QLineEdit, QDateEdit,
|
| 3 |
+
QPushButton, QGroupBox, QFormLayout, QDoubleSpinBox
|
| 4 |
+
)
|
| 5 |
+
from PyQt6.QtCore import QDate, pyqtSignal
|
| 6 |
+
|
| 7 |
+
class ControlPanel(QWidget):
|
| 8 |
+
# Signals to notify Main Window
|
| 9 |
+
# Adjusted to include multiplier
|
| 10 |
+
start_signal = pyqtSignal(str, object, float) # symbol, date, multiplier
|
| 11 |
+
stop_signal = pyqtSignal()
|
| 12 |
+
|
| 13 |
+
def __init__(self, parent=None):
|
| 14 |
+
super().__init__(parent)
|
| 15 |
+
self.init_ui()
|
| 16 |
+
|
| 17 |
+
def init_ui(self):
|
| 18 |
+
layout = QVBoxLayout()
|
| 19 |
+
|
| 20 |
+
# Group: Settings
|
| 21 |
+
group = QGroupBox("Settings")
|
| 22 |
+
form = QFormLayout()
|
| 23 |
+
|
| 24 |
+
self.symbol_input = QLineEdit("XAUUSD")
|
| 25 |
+
|
| 26 |
+
self.date_input = QDateEdit()
|
| 27 |
+
self.date_input.setDate(QDate.currentDate())
|
| 28 |
+
self.date_input.setCalendarPopup(True)
|
| 29 |
+
|
| 30 |
+
self.multiplier_input = QDoubleSpinBox()
|
| 31 |
+
self.multiplier_input.setRange(0.1, 100.0)
|
| 32 |
+
self.multiplier_input.setDecimals(2)
|
| 33 |
+
self.multiplier_input.setValue(2.0)
|
| 34 |
+
self.multiplier_input.setSingleStep(0.5)
|
| 35 |
+
|
| 36 |
+
form.addRow("Symbol:", self.symbol_input)
|
| 37 |
+
form.addRow("Date:", self.date_input)
|
| 38 |
+
form.addRow("Spread Multiplier (x):", self.multiplier_input)
|
| 39 |
+
|
| 40 |
+
group.setLayout(form)
|
| 41 |
+
layout.addWidget(group)
|
| 42 |
+
|
| 43 |
+
# Buttons
|
| 44 |
+
self.btn_start = QPushButton("Start Stream")
|
| 45 |
+
self.btn_start.clicked.connect(self.on_start)
|
| 46 |
+
self.btn_start.setStyleSheet("background-color: green; color: white; font-weight: bold;")
|
| 47 |
+
|
| 48 |
+
self.btn_stop = QPushButton("Stop Stream")
|
| 49 |
+
self.btn_stop.clicked.connect(self.on_stop)
|
| 50 |
+
self.btn_stop.setStyleSheet("background-color: red; color: white; font-weight: bold;")
|
| 51 |
+
self.btn_stop.setEnabled(False)
|
| 52 |
+
|
| 53 |
+
layout.addWidget(self.btn_start)
|
| 54 |
+
layout.addWidget(self.btn_stop)
|
| 55 |
+
|
| 56 |
+
layout.addStretch()
|
| 57 |
+
self.setLayout(layout)
|
| 58 |
+
|
| 59 |
+
def on_start(self):
|
| 60 |
+
symbol = self.symbol_input.text()
|
| 61 |
+
date = self.date_input.date().toPyDate()
|
| 62 |
+
multiplier = self.multiplier_input.value()
|
| 63 |
+
self.start_signal.emit(symbol, date, multiplier)
|
| 64 |
+
|
| 65 |
+
self.btn_start.setEnabled(False)
|
| 66 |
+
self.btn_stop.setEnabled(True)
|
| 67 |
+
self.symbol_input.setEnabled(False)
|
| 68 |
+
self.date_input.setEnabled(False)
|
| 69 |
+
self.multiplier_input.setEnabled(False)
|
| 70 |
+
|
| 71 |
+
def on_stop(self):
|
| 72 |
+
self.stop_signal.emit()
|
| 73 |
+
|
| 74 |
+
self.btn_start.setEnabled(True)
|
| 75 |
+
self.btn_stop.setEnabled(False)
|
| 76 |
+
self.symbol_input.setEnabled(True)
|
| 77 |
+
self.date_input.setEnabled(True)
|
| 78 |
+
self.multiplier_input.setEnabled(True)
|
src/ui/main_window.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PyQt6.QtWidgets import (
|
| 2 |
+
QMainWindow, QDockWidget, QStatusBar, QMessageBox, QWidget
|
| 3 |
+
)
|
| 4 |
+
from PyQt6.QtCore import Qt
|
| 5 |
+
|
| 6 |
+
from src.ui.control_panel import ControlPanel
|
| 7 |
+
from src.ui.chart_widget import ChartWidget
|
| 8 |
+
from src.core.data_worker import DataWorker
|
| 9 |
+
|
| 10 |
+
class MainWindow(QMainWindow):
|
| 11 |
+
def __init__(self):
|
| 12 |
+
super().__init__()
|
| 13 |
+
self.setWindowTitle("Python Trading Terminal (MT5 + Gap-Filled Profile)")
|
| 14 |
+
self.resize(1200, 800)
|
| 15 |
+
|
| 16 |
+
self.init_ui()
|
| 17 |
+
self.worker = None
|
| 18 |
+
|
| 19 |
+
def init_ui(self):
|
| 20 |
+
# Status Bar
|
| 21 |
+
self.status_bar = QStatusBar()
|
| 22 |
+
self.setStatusBar(self.status_bar)
|
| 23 |
+
|
| 24 |
+
# Dock: Control Panel
|
| 25 |
+
self.dock_controls = QDockWidget("Controls", self)
|
| 26 |
+
self.control_panel = ControlPanel()
|
| 27 |
+
self.dock_controls.setWidget(self.control_panel)
|
| 28 |
+
self.dock_controls.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
|
| 29 |
+
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.dock_controls)
|
| 30 |
+
|
| 31 |
+
# Central: Chart
|
| 32 |
+
self.chart_widget = ChartWidget()
|
| 33 |
+
self.setCentralWidget(self.chart_widget)
|
| 34 |
+
|
| 35 |
+
# Connections
|
| 36 |
+
self.control_panel.start_signal.connect(self.start_worker)
|
| 37 |
+
self.control_panel.stop_signal.connect(self.stop_worker)
|
| 38 |
+
|
| 39 |
+
def start_worker(self, symbol, date, multiplier):
|
| 40 |
+
if self.worker is not None and self.worker.isRunning():
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
self.chart_widget.clear()
|
| 44 |
+
self.chart_widget.price_plot.setTitle(f"{symbol} - {date} (Multiplier: {multiplier}x)")
|
| 45 |
+
|
| 46 |
+
self.worker = DataWorker(symbol, date, multiplier=multiplier)
|
| 47 |
+
self.worker.status_signal.connect(self.status_bar.showMessage)
|
| 48 |
+
self.worker.data_signal.connect(self.handle_data)
|
| 49 |
+
self.worker.levels_signal.connect(self.handle_levels)
|
| 50 |
+
self.worker.finished.connect(self.on_worker_finished)
|
| 51 |
+
|
| 52 |
+
self.worker.start()
|
| 53 |
+
|
| 54 |
+
def stop_worker(self):
|
| 55 |
+
if self.worker:
|
| 56 |
+
self.worker.stop()
|
| 57 |
+
self.worker = None
|
| 58 |
+
self.status_bar.showMessage("Stream stopped.")
|
| 59 |
+
|
| 60 |
+
def handle_data(self, ticks_df, profile_counts):
|
| 61 |
+
# Update Chart with new ticks
|
| 62 |
+
# Note: ChartWidget.update_ticks expects a dataframe.
|
| 63 |
+
# If we just append, we might need to handle state inside chart widget better.
|
| 64 |
+
# But simpler: pass the full set or update logic.
|
| 65 |
+
# For performance, we should probably append.
|
| 66 |
+
# In current ChartWidget, update_ticks SETS data.
|
| 67 |
+
# DataWorker emits chunks (new_ticks) during loop, but FULL history at start.
|
| 68 |
+
# We need to distinguish or accumulate in ChartWidget?
|
| 69 |
+
# Actually, DataWorker emits distinct chunks in the loop.
|
| 70 |
+
# But ChartWidget `setData` replaces content.
|
| 71 |
+
# We need to accumulate data in ChartWidget or pass accumulated data from Worker.
|
| 72 |
+
# Passing Full DF every 500ms with millions of rows is bad.
|
| 73 |
+
# Better: ChartWidget accumulates.
|
| 74 |
+
|
| 75 |
+
# Actually, let's just make ChartWidget append.
|
| 76 |
+
# Or better: Worker sends everything? No.
|
| 77 |
+
# Let's Modify ChartWidget to accumulate.
|
| 78 |
+
|
| 79 |
+
# WAIT: My ChartWidget implementation `update_ticks` does: `self.bid_curve.setData(t_float, bids)`
|
| 80 |
+
# This REPLACES.
|
| 81 |
+
# I need to fix ChartWidget to handle incremental updates or large data better.
|
| 82 |
+
# For now, let's just pass the full accumulated history from Worker?
|
| 83 |
+
# Worker doesn't store accumulated history openly, it just emits `ticks_df` (chunk).
|
| 84 |
+
# We need a way to append.
|
| 85 |
+
|
| 86 |
+
# Let's fix ChartWidget in next step. For now assume it appends.
|
| 87 |
+
self.chart_widget.update_ticks(ticks_df)
|
| 88 |
+
self.chart_widget.update_profile(profile_counts) # Full profile is cheap (dict)
|
| 89 |
+
|
| 90 |
+
def handle_levels(self, times, vah, val, poc):
|
| 91 |
+
self.chart_widget.update_levels(times, vah, val, poc)
|
| 92 |
+
|
| 93 |
+
def on_worker_finished(self):
|
| 94 |
+
self.control_panel.on_stop()
|
| 95 |
+
self.worker = None
|
| 96 |
+
self.status_bar.showMessage("Ready.")
|
tests/test_interactive_chart.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from PyQt6.QtWidgets import QApplication
|
| 6 |
+
from PyQt6.QtCore import QTimer
|
| 7 |
+
import pyqtgraph as pg
|
| 8 |
+
|
| 9 |
+
# Add project root to path
|
| 10 |
+
sys.path.append(os.getcwd())
|
| 11 |
+
|
| 12 |
+
from src.ui.chart_widget import ChartWidget
|
| 13 |
+
|
| 14 |
+
def verify_chart_render():
|
| 15 |
+
app = QApplication(sys.argv)
|
| 16 |
+
widget = ChartWidget()
|
| 17 |
+
widget.resize(800, 600)
|
| 18 |
+
widget.show()
|
| 19 |
+
|
| 20 |
+
print("Widget shown. Generating dummy data...")
|
| 21 |
+
|
| 22 |
+
# 1. Simulate Tick Data
|
| 23 |
+
now = pd.Timestamp.now()
|
| 24 |
+
dates = pd.date_range(start=now, periods=100, freq='1s')
|
| 25 |
+
bids = np.linspace(100, 105, 100) + np.random.normal(0, 0.1, 100)
|
| 26 |
+
asks = bids + 0.2
|
| 27 |
+
|
| 28 |
+
df = pd.DataFrame({
|
| 29 |
+
'datetime': dates,
|
| 30 |
+
'bid': bids,
|
| 31 |
+
'ask': asks
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
# Update ticks
|
| 35 |
+
widget.update_ticks(df)
|
| 36 |
+
print("Ticks updated.")
|
| 37 |
+
|
| 38 |
+
# Check if curves have data
|
| 39 |
+
x, y = widget.bid_curve.getData()
|
| 40 |
+
if x is not None and len(x) == 100:
|
| 41 |
+
print("PASS: Bid curve has data.")
|
| 42 |
+
else:
|
| 43 |
+
print(f"FAIL: Bid curve data mismatch. Len: {len(x) if x is not None else 0}")
|
| 44 |
+
|
| 45 |
+
# 2. Simulate Levels
|
| 46 |
+
# timestamps for 10 min
|
| 47 |
+
level_times = [dates[i].timestamp() for i in range(0, 100, 10)]
|
| 48 |
+
level_vah = np.linspace(101, 104, 10)
|
| 49 |
+
level_val = np.linspace(99, 102, 10)
|
| 50 |
+
level_poc = np.linspace(100, 103, 10)
|
| 51 |
+
|
| 52 |
+
widget.update_levels(level_times, level_vah, level_val, level_poc)
|
| 53 |
+
print("Levels updated.")
|
| 54 |
+
|
| 55 |
+
x_vah, y_vah = widget.curve_vah.getData()
|
| 56 |
+
if x_vah is not None and len(x_vah) == 10:
|
| 57 |
+
print("PASS: VAH curve has data.")
|
| 58 |
+
else:
|
| 59 |
+
print(f"FAIL: VAH curve data mismatch. Len: {len(x_vah) if x_vah is not None else 0}")
|
| 60 |
+
|
| 61 |
+
# Set a timer to close the app automatically after a few seconds if running in automation
|
| 62 |
+
# QTimer.singleShot(2000, app.quit)
|
| 63 |
+
|
| 64 |
+
# For now, just quit immediately to verify logic
|
| 65 |
+
app.quit()
|
| 66 |
+
print("Test finished.")
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
verify_chart_render()
|
tests/test_logic.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import unittest
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
# Add src to path
|
| 8 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
| 9 |
+
|
| 10 |
+
from src.core.market_profile import MarketProfile
|
| 11 |
+
|
| 12 |
+
class TestMarketProfile(unittest.TestCase):
|
| 13 |
+
def setUp(self):
|
| 14 |
+
self.mp = MarketProfile(unit_size=1.0) # Simple unit size
|
| 15 |
+
|
| 16 |
+
def test_gap_fill(self):
|
| 17 |
+
prices = np.array([100.0, 105.0])
|
| 18 |
+
timestamps = np.array([0, 100], dtype=np.int64)
|
| 19 |
+
|
| 20 |
+
filled = self.mp.fill_gaps(prices, timestamps)
|
| 21 |
+
|
| 22 |
+
# Expected: 100, 101, 102, 103, 104, 105
|
| 23 |
+
expected = np.array([100.0, 101.0, 102.0, 103.0, 104.0, 105.0])
|
| 24 |
+
|
| 25 |
+
np.testing.assert_array_equal(filled, expected)
|
| 26 |
+
|
| 27 |
+
def test_profile_calculation(self):
|
| 28 |
+
# Create a skewed profile
|
| 29 |
+
# 100: 10 counts
|
| 30 |
+
# 101: 20 counts (POC)
|
| 31 |
+
# 102: 5 counts
|
| 32 |
+
|
| 33 |
+
# Construct dataframe
|
| 34 |
+
data = {
|
| 35 |
+
'bid': np.concatenate([
|
| 36 |
+
np.full(10, 100.0),
|
| 37 |
+
np.full(20, 101.0),
|
| 38 |
+
np.full(5, 102.0)
|
| 39 |
+
]),
|
| 40 |
+
'datetime': np.zeros(35, dtype='datetime64[ns]') # Timestamps don't matter much for counts
|
| 41 |
+
}
|
| 42 |
+
df = pd.DataFrame(data)
|
| 43 |
+
|
| 44 |
+
# Since gap filling needs consecutive diffs, and here we have flat regions,
|
| 45 |
+
# gap filling on [100, 100] produces just [100, 100].
|
| 46 |
+
# But `fill_gaps` logic: diff=0 -> count=0 -> total=0?
|
| 47 |
+
# My implementation: counts = abs(diff) ... append 1 for last point.
|
| 48 |
+
# If diff=0, count=0. Total = 0 + 1 = 1.
|
| 49 |
+
# It handles flat lines correctly (just repeats the point).
|
| 50 |
+
|
| 51 |
+
self.mp.update(df)
|
| 52 |
+
|
| 53 |
+
vah, val, poc = self.mp.get_vah_val_poc()
|
| 54 |
+
|
| 55 |
+
self.assertEqual(poc, 101.0)
|
| 56 |
+
|
| 57 |
+
# Total vol = 35. 70% = 24.5.
|
| 58 |
+
# POC volume = 20.
|
| 59 |
+
# Neighbors: 100 (10), 102 (5).
|
| 60 |
+
# 100 is larger. So it should expand to 100.
|
| 61 |
+
# Current vol = 20 + 10 = 30 > 24.5. Stop.
|
| 62 |
+
# So Value Area = [100, 101].
|
| 63 |
+
# VAL = 100, VAH = 101.
|
| 64 |
+
|
| 65 |
+
self.assertEqual(val, 100.0)
|
| 66 |
+
self.assertEqual(vah, 101.0)
|
| 67 |
+
|
| 68 |
+
if __name__ == '__main__':
|
| 69 |
+
unittest.main()
|
tests/test_profile_logic.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
# Add project root to sys.path
|
| 7 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 8 |
+
project_root = os.path.dirname(current_dir)
|
| 9 |
+
sys.path.append(project_root)
|
| 10 |
+
|
| 11 |
+
from src.core.market_profile import MarketProfile
|
| 12 |
+
|
| 13 |
+
def test_market_profile_logic():
|
| 14 |
+
print("Testing Market Profile Logic (Bid + Ask + Timestamp Interpolation)...")
|
| 15 |
+
|
| 16 |
+
# 1. Setup Data
|
| 17 |
+
# Bid moves from 100.00 to 101.00 (Diff 1.0)
|
| 18 |
+
# Ask moves from 100.10 to 101.10 (Diff 1.0)
|
| 19 |
+
# Spread = 0.10
|
| 20 |
+
# Multiplier = 2.0 -> Bin Size = 0.20
|
| 21 |
+
|
| 22 |
+
# Expected Gaps for Bid: 1.0 / 0.20 = 5 steps.
|
| 23 |
+
# Bid points: 100.0, 100.2, 100.4, 100.6, 100.8, 101.0 -> 6 points.
|
| 24 |
+
|
| 25 |
+
# Expected Gaps for Ask: 1.0 / 0.20 = 5 steps.
|
| 26 |
+
# Ask points: 100.1, 100.3, 100.5, 100.7, 100.9, 101.1 -> 6 points.
|
| 27 |
+
|
| 28 |
+
# Total Ticks = 12.
|
| 29 |
+
|
| 30 |
+
multiplier = 2.0
|
| 31 |
+
|
| 32 |
+
data = {
|
| 33 |
+
'datetime': [pd.Timestamp('2023-01-01 10:00:00'), pd.Timestamp('2023-01-01 10:00:01')],
|
| 34 |
+
'bid': [100.00, 101.00],
|
| 35 |
+
'ask': [100.10, 101.10]
|
| 36 |
+
}
|
| 37 |
+
df = pd.DataFrame(data)
|
| 38 |
+
|
| 39 |
+
# 2. Initialize Profile
|
| 40 |
+
mp = MarketProfile(multiplier=multiplier)
|
| 41 |
+
|
| 42 |
+
# 3. Update
|
| 43 |
+
mp.update(df)
|
| 44 |
+
|
| 45 |
+
# 4. Verify
|
| 46 |
+
print(f"Total Ticks (Count sum): {mp.total_ticks}")
|
| 47 |
+
expected_ticks = 12
|
| 48 |
+
|
| 49 |
+
if mp.total_ticks == expected_ticks:
|
| 50 |
+
print("SUCCESS: Total Ticks match expected (Bid + Ask filled).")
|
| 51 |
+
else:
|
| 52 |
+
print(f"FAILURE: Expected {expected_ticks}, got {mp.total_ticks}")
|
| 53 |
+
|
| 54 |
+
print(f"Counts: {mp.counts}")
|
| 55 |
+
|
| 56 |
+
# Check specific prices
|
| 57 |
+
expected_bids = [100.00, 100.20, 100.40, 100.60, 100.80, 101.00]
|
| 58 |
+
expected_asks = [100.10, 100.30, 100.50, 100.70, 100.90, 101.10]
|
| 59 |
+
all_expected = expected_bids + expected_asks
|
| 60 |
+
|
| 61 |
+
missing = []
|
| 62 |
+
for p in all_expected:
|
| 63 |
+
p_rounded = round(p, 2)
|
| 64 |
+
if p_rounded not in mp.counts:
|
| 65 |
+
missing.append(p_rounded)
|
| 66 |
+
|
| 67 |
+
if not missing:
|
| 68 |
+
print("SUCCESS: All expected Bid and Ask gap prices found.")
|
| 69 |
+
else:
|
| 70 |
+
print(f"FAILURE: Missing prices: {missing}")
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
test_market_profile_logic()
|
tests/verify_levels.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# Add project root to path
|
| 7 |
+
sys.path.append(os.getcwd())
|
| 8 |
+
|
| 9 |
+
from src.core.market_profile import MarketProfile
|
| 10 |
+
|
| 11 |
+
def test_market_profile():
|
| 12 |
+
print("Testing MarketProfile...")
|
| 13 |
+
mp = MarketProfile(multiplier=2.0)
|
| 14 |
+
|
| 15 |
+
# Create dummy data
|
| 16 |
+
# Price oscillates between 100 and 110
|
| 17 |
+
dates = pd.date_range(start='2024-01-01 10:00', periods=100, freq='1min')
|
| 18 |
+
bids = np.linspace(100, 110, 50)
|
| 19 |
+
bids = np.concatenate((bids, np.linspace(110, 100, 50)))
|
| 20 |
+
|
| 21 |
+
df = pd.DataFrame({
|
| 22 |
+
'datetime': dates,
|
| 23 |
+
'bid': bids,
|
| 24 |
+
'ask': bids + 0.1
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
# Update profile
|
| 28 |
+
mp.update(df)
|
| 29 |
+
|
| 30 |
+
# Check counts
|
| 31 |
+
print(f"Total ticks: {mp.total_ticks}")
|
| 32 |
+
print(f"Counts keys: {len(mp.counts)}")
|
| 33 |
+
|
| 34 |
+
# Check Levels
|
| 35 |
+
vah, val, poc = mp.get_vah_val_poc()
|
| 36 |
+
print(f"VAH: {vah}, VAL: {val}, POC: {poc}")
|
| 37 |
+
|
| 38 |
+
if vah is None or val is None or poc is None:
|
| 39 |
+
print("FAIL: Levels are None")
|
| 40 |
+
else:
|
| 41 |
+
print("PASS: Levels calculated")
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
test_market_profile()
|