bbstrader 0.3.3__py3-none-any.whl → 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bbstrader might be problematic. Click here for more details.
- bbstrader/__init__.py +1 -1
- bbstrader/__main__.py +10 -2
- bbstrader/apps/__init__.py +0 -0
- bbstrader/apps/_copier.py +664 -0
- bbstrader/btengine/strategy.py +163 -90
- bbstrader/compat.py +18 -10
- bbstrader/config.py +0 -16
- bbstrader/core/scripts.py +4 -3
- bbstrader/core/utils.py +5 -3
- bbstrader/metatrader/account.py +169 -29
- bbstrader/metatrader/analysis.py +7 -5
- bbstrader/metatrader/copier.py +61 -14
- bbstrader/metatrader/scripts.py +15 -2
- bbstrader/metatrader/trade.py +28 -24
- bbstrader/metatrader/utils.py +64 -0
- bbstrader/models/factors.py +17 -13
- bbstrader/models/ml.py +104 -54
- bbstrader/trading/execution.py +9 -8
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.5.dist-info}/METADATA +25 -28
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.5.dist-info}/RECORD +24 -22
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.5.dist-info}/WHEEL +0 -0
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.5.dist-info}/entry_points.txt +0 -0
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {bbstrader-0.3.3.dist-info → bbstrader-0.3.5.dist-info}/top_level.txt +0 -0
bbstrader/metatrader/account.py
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import re
|
|
3
3
|
import urllib.request
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime
|
|
5
5
|
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
|
6
6
|
|
|
7
|
+
import numpy as np
|
|
7
8
|
import pandas as pd
|
|
9
|
+
from currency_converter import SINGLE_DAY_ECB_URL, CurrencyConverter
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
8
12
|
from bbstrader.metatrader.utils import (
|
|
13
|
+
TIMEFRAMES,
|
|
9
14
|
AccountInfo,
|
|
10
15
|
BookInfo,
|
|
11
16
|
InvalidBroker,
|
|
12
17
|
OrderCheckResult,
|
|
13
18
|
OrderSentResult,
|
|
19
|
+
RateDtype,
|
|
20
|
+
RateInfo,
|
|
14
21
|
SymbolInfo,
|
|
15
22
|
SymbolType,
|
|
16
23
|
TerminalInfo,
|
|
24
|
+
TickDtype,
|
|
25
|
+
TickFlag,
|
|
17
26
|
TickInfo,
|
|
18
27
|
TradeDeal,
|
|
19
28
|
TradeOrder,
|
|
@@ -21,7 +30,6 @@ from bbstrader.metatrader.utils import (
|
|
|
21
30
|
TradeRequest,
|
|
22
31
|
raise_mt5_error,
|
|
23
32
|
)
|
|
24
|
-
from currency_converter import SINGLE_DAY_ECB_URL, CurrencyConverter
|
|
25
33
|
|
|
26
34
|
try:
|
|
27
35
|
import MetaTrader5 as mt5
|
|
@@ -170,7 +178,7 @@ def check_mt5_connection(
|
|
|
170
178
|
if account_info is not None:
|
|
171
179
|
if account_info.login == login and account_info.server == server:
|
|
172
180
|
return True
|
|
173
|
-
|
|
181
|
+
|
|
174
182
|
init = False
|
|
175
183
|
if path is None and (login or password or server):
|
|
176
184
|
raise ValueError(
|
|
@@ -517,7 +525,7 @@ class Account(object):
|
|
|
517
525
|
"""Helper function to print account info"""
|
|
518
526
|
self._show_info(self.get_account_info, "account")
|
|
519
527
|
|
|
520
|
-
def get_terminal_info(self, show=False) ->
|
|
528
|
+
def get_terminal_info(self, show=False) -> TerminalInfo | None:
|
|
521
529
|
"""
|
|
522
530
|
Get the connected MetaTrader 5 client terminal status and settings.
|
|
523
531
|
|
|
@@ -965,7 +973,7 @@ class Account(object):
|
|
|
965
973
|
futures_types["bonds"].append(info.name)
|
|
966
974
|
return futures_types[category]
|
|
967
975
|
|
|
968
|
-
def get_symbol_info(self, symbol: str) ->
|
|
976
|
+
def get_symbol_info(self, symbol: str) -> SymbolInfo | None:
|
|
969
977
|
"""Get symbol properties
|
|
970
978
|
|
|
971
979
|
Args:
|
|
@@ -998,6 +1006,14 @@ class Account(object):
|
|
|
998
1006
|
msg = self._symbol_info_msg(symbol)
|
|
999
1007
|
raise_mt5_error(message=f"{str(e)} {msg}")
|
|
1000
1008
|
|
|
1009
|
+
def _symbol_info_msg(self, symbol):
|
|
1010
|
+
return (
|
|
1011
|
+
f"No history found for {symbol} in Market Watch.\n"
|
|
1012
|
+
f"* Ensure {symbol} is selected and displayed in the Market Watch window.\n"
|
|
1013
|
+
f"* See https://www.metatrader5.com/en/terminal/help/trading/market_watch\n"
|
|
1014
|
+
f"* Ensure the symbol name is correct.\n"
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1001
1017
|
def show_symbol_info(self, symbol: str):
|
|
1002
1018
|
"""
|
|
1003
1019
|
Print symbol properties
|
|
@@ -1007,15 +1023,7 @@ class Account(object):
|
|
|
1007
1023
|
"""
|
|
1008
1024
|
self._show_info(self.get_symbol_info, "symbol", symbol=symbol)
|
|
1009
1025
|
|
|
1010
|
-
def
|
|
1011
|
-
return (
|
|
1012
|
-
f"No history found for {symbol} in Market Watch.\n"
|
|
1013
|
-
f"* Ensure {symbol} is selected and displayed in the Market Watch window.\n"
|
|
1014
|
-
f"* See https://www.metatrader5.com/en/terminal/help/trading/market_watch\n"
|
|
1015
|
-
f"* Ensure the symbol name is correct.\n"
|
|
1016
|
-
)
|
|
1017
|
-
|
|
1018
|
-
def get_tick_info(self, symbol: str) -> Union[TickInfo, None]:
|
|
1026
|
+
def get_tick_info(self, symbol: str) -> TickInfo | None:
|
|
1019
1027
|
"""Get symbol tick properties
|
|
1020
1028
|
|
|
1021
1029
|
Args:
|
|
@@ -1057,7 +1065,148 @@ class Account(object):
|
|
|
1057
1065
|
"""
|
|
1058
1066
|
self._show_info(self.get_tick_info, "tick", symbol=symbol)
|
|
1059
1067
|
|
|
1060
|
-
def
|
|
1068
|
+
def get_rate_info(self, symbol: str, timeframe: str = "1m") -> RateInfo | None:
|
|
1069
|
+
"""Get the most recent bar for a specified symbol and timeframe.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
symbol (str): The symbol for which to get the rate information.
|
|
1073
|
+
timeframe (str): The timeframe for the rate information. Default is '1m'.
|
|
1074
|
+
See ``bbstrader.metatrader.utils.TIMEFRAMES`` for supported timeframes.
|
|
1075
|
+
Returns:
|
|
1076
|
+
RateInfo: The most recent bar as a RateInfo named tuple.
|
|
1077
|
+
None: If no rates are found or an error occurs.
|
|
1078
|
+
Raises:
|
|
1079
|
+
MT5TerminalError: A specific exception based on the error code.
|
|
1080
|
+
"""
|
|
1081
|
+
rates = mt5.copy_rates_from_pos(symbol, TIMEFRAMES[timeframe], 0, 1)
|
|
1082
|
+
if rates is None or len(rates) == 0:
|
|
1083
|
+
return None
|
|
1084
|
+
rate = rates[0]
|
|
1085
|
+
return RateInfo(*rate)
|
|
1086
|
+
|
|
1087
|
+
def get_rates_from_pos(
|
|
1088
|
+
self, symbol: str, timeframe: str, start_pos: int = 0, bars: int = 1
|
|
1089
|
+
) -> NDArray[np.void]:
|
|
1090
|
+
"""
|
|
1091
|
+
Get bars from the MetaTrader 5 terminal starting from the specified index.
|
|
1092
|
+
|
|
1093
|
+
Args:
|
|
1094
|
+
symbol: Financial instrument name, for example, "EURUSD"
|
|
1095
|
+
timeframe: Timeframe the bars are requested for.
|
|
1096
|
+
start_pos: Initial index of the bar the data are requested from.
|
|
1097
|
+
bars: Number of bars to receive.
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
bars as the numpy array with the named time, open, high, low, close, tick_volume, spread and real_volume columns.
|
|
1101
|
+
Returns an empty array in case of an error.
|
|
1102
|
+
"""
|
|
1103
|
+
rates = mt5.copy_rates_from_pos(symbol, TIMEFRAMES[timeframe], start_pos, bars)
|
|
1104
|
+
if rates is None or len(rates) == 0:
|
|
1105
|
+
return np.array([], dtype=RateDtype)
|
|
1106
|
+
return rates
|
|
1107
|
+
|
|
1108
|
+
def get_rates_from_date(
|
|
1109
|
+
self,
|
|
1110
|
+
symbol: str,
|
|
1111
|
+
timeframe: str,
|
|
1112
|
+
date_from: datetime | pd.Timestamp,
|
|
1113
|
+
bars: int = 1,
|
|
1114
|
+
) -> NDArray[np.void]:
|
|
1115
|
+
"""
|
|
1116
|
+
Get bars from the MetaTrader 5 terminal starting from the specified date.
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
symbol: Financial instrument name, for example, "EURUSD"
|
|
1120
|
+
timeframe: Timeframe the bars are requested for.
|
|
1121
|
+
date_from: Date of opening of the first bar from the requested sample.
|
|
1122
|
+
bars: Number of bars to receive.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
bars as the numpy array with the named time, open, high, low, close, tick_volume, spread and real_volume columns.
|
|
1126
|
+
Returns an empty array in case of an error.
|
|
1127
|
+
|
|
1128
|
+
"""
|
|
1129
|
+
rates = mt5.copy_rates_from(symbol, TIMEFRAMES[timeframe], date_from, bars)
|
|
1130
|
+
if rates is None or len(rates) == 0:
|
|
1131
|
+
return np.array([], dtype=RateDtype)
|
|
1132
|
+
return rates
|
|
1133
|
+
|
|
1134
|
+
def get_rates_range(
|
|
1135
|
+
self,
|
|
1136
|
+
symbol: str,
|
|
1137
|
+
timeframe: str,
|
|
1138
|
+
date_from: datetime | pd.Timestamp,
|
|
1139
|
+
date_to: datetime | pd.Timestamp = datetime.now(),
|
|
1140
|
+
) -> NDArray[np.void]:
|
|
1141
|
+
"""
|
|
1142
|
+
Get bars in the specified date range from the MetaTrader 5 terminal.
|
|
1143
|
+
|
|
1144
|
+
Args:
|
|
1145
|
+
symbol: Financial instrument name, for example, "EURUSD"
|
|
1146
|
+
timeframe: Timeframe the bars are requested for.
|
|
1147
|
+
date_from: Date the bars are requested from.
|
|
1148
|
+
date_to: Date, up to which the bars are requested.
|
|
1149
|
+
|
|
1150
|
+
Returns:
|
|
1151
|
+
bars as the numpy array with the named time, open, high, low, close, tick_volume, spread and real_volume columns.
|
|
1152
|
+
Returns an empty array in case of an error.
|
|
1153
|
+
"""
|
|
1154
|
+
rates = mt5.copy_rates_range(symbol, TIMEFRAMES[timeframe], date_from, date_to)
|
|
1155
|
+
if rates is None or len(rates) == 0:
|
|
1156
|
+
return np.array([], dtype=RateDtype)
|
|
1157
|
+
return rates
|
|
1158
|
+
|
|
1159
|
+
def get_tick_from_date(
|
|
1160
|
+
self,
|
|
1161
|
+
symbol: str,
|
|
1162
|
+
date_from: datetime | pd.Timestamp,
|
|
1163
|
+
ticks: int = 1,
|
|
1164
|
+
flag: Literal["all", "info", "trade"] = "all",
|
|
1165
|
+
) -> NDArray[np.void]:
|
|
1166
|
+
"""
|
|
1167
|
+
Get ticks from the MetaTrader 5 terminal starting from the specified date.
|
|
1168
|
+
|
|
1169
|
+
Args:
|
|
1170
|
+
symbol: Financial instrument name, for example, "EURUSD"
|
|
1171
|
+
date_from: Date the ticks are requested from.
|
|
1172
|
+
ticks: Number of bars to receive.
|
|
1173
|
+
flag: A flag to define the type of the requested ticks ("all", "info", "trade").
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
Returns ticks as the numpy array with the named time, bid, ask, last and flags columns.
|
|
1177
|
+
Return an empty array in case of an error.
|
|
1178
|
+
"""
|
|
1179
|
+
ticks_data = mt5.copy_ticks_from(symbol, date_from, ticks, TickFlag[flag])
|
|
1180
|
+
if ticks_data is None or len(ticks_data) == 0:
|
|
1181
|
+
return np.array([], dtype=TickDtype)
|
|
1182
|
+
return ticks_data
|
|
1183
|
+
|
|
1184
|
+
def get_tick_range(
|
|
1185
|
+
self,
|
|
1186
|
+
symbol: str,
|
|
1187
|
+
date_from: datetime | pd.Timestamp,
|
|
1188
|
+
date_to: datetime | pd.Timestamp = datetime.now(),
|
|
1189
|
+
flag: Literal["all", "info", "trade"] = "all",
|
|
1190
|
+
) -> NDArray[np.void]:
|
|
1191
|
+
"""
|
|
1192
|
+
Get ticks for the specified date range from the MetaTrader 5 terminal.
|
|
1193
|
+
|
|
1194
|
+
Args:
|
|
1195
|
+
symbol: Financial instrument name, for example, "EURUSD"
|
|
1196
|
+
date_from: Date the ticks are requested from.
|
|
1197
|
+
date_to: Date, up to which the ticks are requested.
|
|
1198
|
+
flag: A flag to define the type of the requested ticks ("all", "info", "trade").
|
|
1199
|
+
|
|
1200
|
+
Returns:
|
|
1201
|
+
Returns ticks as the numpy array with the named time, bid, ask, last and flags columns.
|
|
1202
|
+
Return an empty array in case of an error.
|
|
1203
|
+
"""
|
|
1204
|
+
ticks_data = mt5.copy_ticks_range(symbol, date_from, date_to, TickFlag[flag])
|
|
1205
|
+
if ticks_data is None or len(ticks_data) == 0:
|
|
1206
|
+
return np.array([], dtype=TickDtype)
|
|
1207
|
+
return ticks_data
|
|
1208
|
+
|
|
1209
|
+
def get_market_book(self, symbol: str) -> Tuple[BookInfo]:
|
|
1061
1210
|
"""
|
|
1062
1211
|
Get the Market Depth content for a specific symbol.
|
|
1063
1212
|
Args:
|
|
@@ -1199,7 +1348,7 @@ class Account(object):
|
|
|
1199
1348
|
group: Optional[str] = None,
|
|
1200
1349
|
ticket: Optional[int] = None,
|
|
1201
1350
|
to_df: bool = False,
|
|
1202
|
-
) -> Union[pd.DataFrame, Tuple[TradePosition]
|
|
1351
|
+
) -> Union[pd.DataFrame, Tuple[TradePosition]]:
|
|
1203
1352
|
"""
|
|
1204
1353
|
Get open positions with the ability to filter by symbol or ticket.
|
|
1205
1354
|
There are four call options:
|
|
@@ -1229,7 +1378,6 @@ class Account(object):
|
|
|
1229
1378
|
Returns:
|
|
1230
1379
|
Union[pd.DataFrame, Tuple[TradePosition], None]:
|
|
1231
1380
|
- `TradePosition` in the form of a named tuple structure (namedtuple) or pd.DataFrame.
|
|
1232
|
-
- `None` in case of an error.
|
|
1233
1381
|
|
|
1234
1382
|
Notes:
|
|
1235
1383
|
The method allows receiving all open positions within a specified period.
|
|
@@ -1285,7 +1433,7 @@ class Account(object):
|
|
|
1285
1433
|
position: Optional[int] = None, # TradePosition.ticket
|
|
1286
1434
|
to_df: bool = True,
|
|
1287
1435
|
save: bool = False,
|
|
1288
|
-
) -> Union[pd.DataFrame, Tuple[TradeDeal]
|
|
1436
|
+
) -> Union[pd.DataFrame, Tuple[TradeDeal]]:
|
|
1289
1437
|
"""
|
|
1290
1438
|
Get deals from trading history within the specified interval
|
|
1291
1439
|
with the ability to filter by `ticket` or `position`.
|
|
@@ -1323,7 +1471,6 @@ class Account(object):
|
|
|
1323
1471
|
Returns:
|
|
1324
1472
|
Union[pd.DataFrame, Tuple[TradeDeal], None]:
|
|
1325
1473
|
- `TradeDeal` in the form of a named tuple structure (namedtuple) or pd.DataFrame().
|
|
1326
|
-
- `None` in case of an error.
|
|
1327
1474
|
|
|
1328
1475
|
Notes:
|
|
1329
1476
|
The method allows receiving all history orders within a specified period.
|
|
@@ -1413,7 +1560,6 @@ class Account(object):
|
|
|
1413
1560
|
Returns:
|
|
1414
1561
|
Union[pd.DataFrame, Tuple[TradeOrder], None]:
|
|
1415
1562
|
- `TradeOrder` in the form of a named tuple structure (namedtuple) or pd.DataFrame().
|
|
1416
|
-
- `None` in case of an error.
|
|
1417
1563
|
|
|
1418
1564
|
Notes:
|
|
1419
1565
|
The method allows receiving all history orders within a specified period.
|
|
@@ -1478,7 +1624,7 @@ class Account(object):
|
|
|
1478
1624
|
position: Optional[int] = None, # position ticket
|
|
1479
1625
|
to_df: bool = True,
|
|
1480
1626
|
save: bool = False,
|
|
1481
|
-
) -> Union[pd.DataFrame, Tuple[TradeOrder]
|
|
1627
|
+
) -> Union[pd.DataFrame, Tuple[TradeOrder]]:
|
|
1482
1628
|
"""
|
|
1483
1629
|
Get orders from trading history within the specified interval
|
|
1484
1630
|
with the ability to filter by `ticket` or `position`.
|
|
@@ -1516,7 +1662,6 @@ class Account(object):
|
|
|
1516
1662
|
Returns:
|
|
1517
1663
|
Union[pd.DataFrame, Tuple[TradeOrder], None]
|
|
1518
1664
|
- `TradeOrder` in the form of a named tuple structure (namedtuple) or pd.DataFrame().
|
|
1519
|
-
- `None` in case of an error.
|
|
1520
1665
|
|
|
1521
1666
|
Notes:
|
|
1522
1667
|
The method allows receiving all history orders within a specified period.
|
|
@@ -1597,16 +1742,11 @@ class Account(object):
|
|
|
1597
1742
|
Returns:
|
|
1598
1743
|
List[TradeDeal]: List of today deals
|
|
1599
1744
|
"""
|
|
1600
|
-
|
|
1601
|
-
history = (
|
|
1602
|
-
self.get_trades_history(date_from=date_from, group=group, to_df=False) or []
|
|
1603
|
-
)
|
|
1745
|
+
history = self.get_trades_history(group=group, to_df=False) or []
|
|
1604
1746
|
positions_ids = set([deal.position_id for deal in history if deal.magic == id])
|
|
1605
1747
|
today_deals = []
|
|
1606
1748
|
for position in positions_ids:
|
|
1607
|
-
deal = self.get_trades_history(
|
|
1608
|
-
date_from=date_from, position=position, to_df=False
|
|
1609
|
-
)
|
|
1749
|
+
deal = self.get_trades_history(position=position, to_df=False) or []
|
|
1610
1750
|
if deal is not None and len(deal) == 2:
|
|
1611
1751
|
deal_time = datetime.fromtimestamp(deal[1].time)
|
|
1612
1752
|
if deal_time.date() == datetime.now().date():
|
bbstrader/metatrader/analysis.py
CHANGED
|
@@ -4,17 +4,15 @@ import numpy as np
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
import seaborn as sns
|
|
6
6
|
|
|
7
|
-
from bbstrader.metatrader.account import check_mt5_connection
|
|
7
|
+
from bbstrader.metatrader.account import check_mt5_connection, shutdown_mt5
|
|
8
8
|
from bbstrader.metatrader.utils import TIMEFRAMES
|
|
9
9
|
|
|
10
10
|
sns.set_theme()
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def _get_data(
|
|
14
|
-
check_mt5_connection(path=path)
|
|
13
|
+
def _get_data(symbol, timeframe, bars):
|
|
15
14
|
rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, bars)
|
|
16
15
|
df = pd.DataFrame(rates)
|
|
17
|
-
df["time"] = pd.to_datetime(df["time"], unit="s")
|
|
18
16
|
return df
|
|
19
17
|
|
|
20
18
|
|
|
@@ -76,10 +74,14 @@ def display_volume_profile(
|
|
|
76
74
|
Returns:
|
|
77
75
|
None: Displays a matplotlib chart of the volume profile.
|
|
78
76
|
"""
|
|
79
|
-
|
|
77
|
+
check_mt5_connection(path=path)
|
|
78
|
+
df = _get_data(symbol, TIMEFRAMES[timeframe], bars)
|
|
79
|
+
if df.empty:
|
|
80
|
+
raise ValueError(f"No data found for {symbol} in {path}")
|
|
80
81
|
hist, bin_edges, bin_centers = volume_profile(df, bins)
|
|
81
82
|
poc, vah, val = value_area(hist, bin_centers, va_percentage)
|
|
82
83
|
current_price = mt5.symbol_info_tick(symbol).bid
|
|
84
|
+
shutdown_mt5()
|
|
83
85
|
|
|
84
86
|
plt.figure(figsize=(6, 10))
|
|
85
87
|
plt.barh(bin_centers, hist, height=bin_centers[1] - bin_centers[0], color="skyblue")
|
bbstrader/metatrader/copier.py
CHANGED
|
@@ -51,6 +51,31 @@ ORDER_TYPE = {
|
|
|
51
51
|
7: (Mt5.ORDER_TYPE_SELL_STOP_LIMIT, "SELL STOP LIMIT"),
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
STOP_RETCODES = [
|
|
55
|
+
Mt5.TRADE_RETCODE_TRADE_DISABLED,
|
|
56
|
+
Mt5.TRADE_RETCODE_NO_MONEY,
|
|
57
|
+
Mt5.TRADE_RETCODE_SERVER_DISABLES_AT,
|
|
58
|
+
Mt5.TRADE_RETCODE_CLIENT_DISABLES_AT,
|
|
59
|
+
Mt5.TRADE_RETCODE_ONLY_REAL,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
RETURN_RETCODE = [
|
|
63
|
+
Mt5.TRADE_RETCODE_MARKET_CLOSED,
|
|
64
|
+
Mt5.TRADE_RETCODE_CONNECTION,
|
|
65
|
+
Mt5.TRADE_RETCODE_LIMIT_ORDERS,
|
|
66
|
+
Mt5.TRADE_RETCODE_LIMIT_VOLUME,
|
|
67
|
+
Mt5.TRADE_RETCODE_LIMIT_POSITIONS,
|
|
68
|
+
Mt5.TRADE_RETCODE_LONG_ONLY,
|
|
69
|
+
Mt5.TRADE_RETCODE_SHORT_ONLY,
|
|
70
|
+
Mt5.TRADE_RETCODE_CLOSE_ONLY,
|
|
71
|
+
Mt5.TRADE_RETCODE_FIFO_CLOSE,
|
|
72
|
+
Mt5.TRADE_RETCODE_INVALID_VOLUME,
|
|
73
|
+
Mt5.TRADE_RETCODE_INVALID_PRICE,
|
|
74
|
+
Mt5.TRADE_RETCODE_INVALID_STOPS,
|
|
75
|
+
Mt5.TRADE_RETCODE_NO_CHANGES
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
54
79
|
|
|
55
80
|
class OrderAction(Enum):
|
|
56
81
|
COPY_NEW = "COPY_NEW"
|
|
@@ -65,25 +90,23 @@ CopyMode = Literal["fix", "multiply", "percentage", "dynamic", "replicate"]
|
|
|
65
90
|
|
|
66
91
|
def fix_lot(fixed):
|
|
67
92
|
if fixed == 0 or fixed is None:
|
|
68
|
-
raise ValueError("Fixed lot must be a number")
|
|
93
|
+
raise ValueError("Fixed lot must be a number > 0")
|
|
69
94
|
return fixed
|
|
70
95
|
|
|
71
96
|
|
|
72
97
|
def multiply_lot(lot, multiplier):
|
|
73
98
|
if multiplier == 0 or multiplier is None:
|
|
74
|
-
raise ValueError("Multiplier lot must be a number")
|
|
99
|
+
raise ValueError("Multiplier lot must be a number > 0")
|
|
75
100
|
return lot * multiplier
|
|
76
101
|
|
|
77
102
|
|
|
78
103
|
def percentage_lot(lot, percentage):
|
|
79
104
|
if percentage == 0 or percentage is None:
|
|
80
|
-
raise ValueError("Percentage lot must be a number")
|
|
105
|
+
raise ValueError("Percentage lot must be a number > 0")
|
|
81
106
|
return round(lot * percentage / 100, 2)
|
|
82
107
|
|
|
83
108
|
|
|
84
109
|
def dynamic_lot(source_lot, source_eqty: float, dest_eqty: float):
|
|
85
|
-
if source_eqty == 0 or dest_eqty == 0:
|
|
86
|
-
raise ValueError("Source or destination account equity is zero")
|
|
87
110
|
try:
|
|
88
111
|
ratio = dest_eqty / source_eqty
|
|
89
112
|
return round(source_lot * ratio, 2)
|
|
@@ -118,6 +141,7 @@ def fixed_lot(lot, symbol, destination) -> float:
|
|
|
118
141
|
else:
|
|
119
142
|
return _check_lot(round(lot), s_info)
|
|
120
143
|
|
|
144
|
+
|
|
121
145
|
def calculate_copy_lot(
|
|
122
146
|
source_lot,
|
|
123
147
|
symbol: str,
|
|
@@ -173,7 +197,7 @@ def get_symbols_from_string(symbols_string: str):
|
|
|
173
197
|
|
|
174
198
|
def get_copy_symbols(destination: dict, source: dict) -> List[str] | Dict[str, str]:
|
|
175
199
|
symbols = destination.get("symbols", "all")
|
|
176
|
-
if symbols == "all" or symbols == "*" or
|
|
200
|
+
if symbols == "all" or symbols == "*" or isinstance(symbols, list):
|
|
177
201
|
src_account = Account(**source)
|
|
178
202
|
src_symbols = src_account.get_symbols()
|
|
179
203
|
dest_account = Account(**destination)
|
|
@@ -360,7 +384,10 @@ class TradeCopier(object):
|
|
|
360
384
|
for destination in self.destinations:
|
|
361
385
|
destination["copy"] = destination.get("copy", True)
|
|
362
386
|
|
|
363
|
-
def log_message(
|
|
387
|
+
def log_message(
|
|
388
|
+
self, message, type: Literal["info", "error", "debug", "warning"] = "info"
|
|
389
|
+
):
|
|
390
|
+
logger.trace
|
|
364
391
|
if self.log_queue:
|
|
365
392
|
try:
|
|
366
393
|
now = datetime.now()
|
|
@@ -370,7 +397,7 @@ class TradeCopier(object):
|
|
|
370
397
|
)
|
|
371
398
|
space = len("exception") # longest log name
|
|
372
399
|
self.log_queue.put(
|
|
373
|
-
f"{formatted} |{type.upper()} {' '*(space - len(type))} | - {message}"
|
|
400
|
+
f"{formatted} |{type.upper()} {' ' * (space - len(type))} | - {message}"
|
|
374
401
|
)
|
|
375
402
|
except Exception:
|
|
376
403
|
pass
|
|
@@ -384,8 +411,8 @@ class TradeCopier(object):
|
|
|
384
411
|
error_msg = repr(e)
|
|
385
412
|
if error_msg not in self.errors:
|
|
386
413
|
self.errors.add(error_msg)
|
|
387
|
-
add_msg = f"SYMBOL={symbol}" if symbol else ""
|
|
388
|
-
message = f"Error encountered: {error_msg}
|
|
414
|
+
add_msg = f", SYMBOL={symbol}" if symbol else ""
|
|
415
|
+
message = f"Error encountered: {error_msg}{add_msg}"
|
|
389
416
|
self.log_message(message, type="error")
|
|
390
417
|
|
|
391
418
|
def _validate_source(self):
|
|
@@ -487,6 +514,14 @@ class TradeCopier(object):
|
|
|
487
514
|
if new_result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
488
515
|
break
|
|
489
516
|
return new_result
|
|
517
|
+
|
|
518
|
+
def handle_retcode(self, retcode) -> int:
|
|
519
|
+
if retcode in STOP_RETCODES:
|
|
520
|
+
msg = trade_retcode_message(retcode)
|
|
521
|
+
self.log_error(f"Critical Error on @{self.source['login']}: {msg} ")
|
|
522
|
+
self.stop()
|
|
523
|
+
if retcode in RETURN_RETCODE:
|
|
524
|
+
return 1
|
|
490
525
|
|
|
491
526
|
def copy_new_trade(self, trade: TradeOrder | TradePosition, destination: dict):
|
|
492
527
|
if not self.iscopy_time():
|
|
@@ -508,7 +543,6 @@ class TradeCopier(object):
|
|
|
508
543
|
trade_action = (
|
|
509
544
|
Mt5.TRADE_ACTION_DEAL if trade.type in [0, 1] else Mt5.TRADE_ACTION_PENDING
|
|
510
545
|
)
|
|
511
|
-
action = ORDER_TYPE[trade.type][1]
|
|
512
546
|
tick = Mt5.symbol_info_tick(symbol)
|
|
513
547
|
price = tick.bid if trade.type == 0 else tick.ask
|
|
514
548
|
try:
|
|
@@ -535,14 +569,18 @@ class TradeCopier(object):
|
|
|
535
569
|
result = Mt5.order_send(request)
|
|
536
570
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
537
571
|
result = self._update_filling_type(request, result)
|
|
572
|
+
action = ORDER_TYPE[trade.type][1]
|
|
573
|
+
copy_action = "Position" if trade.type in [0, 1] else "Order"
|
|
538
574
|
if result.retcode == Mt5.TRADE_RETCODE_DONE:
|
|
539
575
|
self.log_message(
|
|
540
|
-
f"Copy {action}
|
|
576
|
+
f"Copy {action} {copy_action} #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
541
577
|
f"to @{destination.get('login')}::{symbol}",
|
|
542
578
|
)
|
|
543
579
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
580
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
581
|
+
return
|
|
544
582
|
self.log_message(
|
|
545
|
-
f"Error copying {action}
|
|
583
|
+
f"Error copying {action} {copy_action} #{trade.ticket} from @{self.source.get('login')}::{trade.symbol} "
|
|
546
584
|
f"to @{destination.get('login')}::{symbol}, {trade_retcode_message(result.retcode)}",
|
|
547
585
|
type="error",
|
|
548
586
|
)
|
|
@@ -573,6 +611,8 @@ class TradeCopier(object):
|
|
|
573
611
|
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}"
|
|
574
612
|
)
|
|
575
613
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
614
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
615
|
+
return
|
|
576
616
|
self.log_message(
|
|
577
617
|
f"Error modifying {ORDER_TYPE[source_order.type][1]} Order #{ticket} on @{destination.get('login')}::{symbol},"
|
|
578
618
|
f"SOURCE=@{self.source.get('login')}::{source_order.symbol}, {trade_retcode_message(result.retcode)}",
|
|
@@ -595,6 +635,8 @@ class TradeCopier(object):
|
|
|
595
635
|
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
596
636
|
)
|
|
597
637
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
638
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
639
|
+
return
|
|
598
640
|
self.log_message(
|
|
599
641
|
f"Error closing {ORDER_TYPE[order.type][1]} Order #{order.ticket} on @{destination.get('login')}::{order.symbol}, "
|
|
600
642
|
f"SOURCE=@{self.source.get('login')}::{src_symbol}, {trade_retcode_message(result.retcode)}",
|
|
@@ -625,6 +667,8 @@ class TradeCopier(object):
|
|
|
625
667
|
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}"
|
|
626
668
|
)
|
|
627
669
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
670
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
671
|
+
return
|
|
628
672
|
self.log_message(
|
|
629
673
|
f"Error modifying {ORDER_TYPE[source_pos.type][1]} Position #{ticket} on @{destination.get('login')}::{symbol}, "
|
|
630
674
|
f"SOURCE=@{self.source.get('login')}::{source_pos.symbol}, {trade_retcode_message(result.retcode)}",
|
|
@@ -659,6 +703,8 @@ class TradeCopier(object):
|
|
|
659
703
|
f"SOURCE=@{self.source.get('login')}::{src_symbol}"
|
|
660
704
|
)
|
|
661
705
|
if result.retcode != Mt5.TRADE_RETCODE_DONE:
|
|
706
|
+
if self.handle_retcode(result.retcode) == 1:
|
|
707
|
+
return
|
|
662
708
|
self.log_message(
|
|
663
709
|
f"Error closing {ORDER_TYPE[position.type][1]} Position #{position.ticket} "
|
|
664
710
|
f"on @{destination.get('login')}::{position.symbol}, "
|
|
@@ -1019,7 +1065,8 @@ class TradeCopier(object):
|
|
|
1019
1065
|
self.destinations
|
|
1020
1066
|
):
|
|
1021
1067
|
self.log_message(
|
|
1022
|
-
"Two or more destination accounts have the same Terminal path, which is not allowed."
|
|
1068
|
+
"Two or more destination accounts have the same Terminal path, which is not allowed.",
|
|
1069
|
+
type="error",
|
|
1023
1070
|
)
|
|
1024
1071
|
return
|
|
1025
1072
|
|
bbstrader/metatrader/scripts.py
CHANGED
|
@@ -2,7 +2,7 @@ import argparse
|
|
|
2
2
|
import multiprocessing
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
-
from bbstrader.apps._copier import main as
|
|
5
|
+
from bbstrader.apps._copier import main as RunCopyApp
|
|
6
6
|
from bbstrader.metatrader.copier import RunCopier, config_copier, copier_worker_process
|
|
7
7
|
|
|
8
8
|
|
|
@@ -18,6 +18,15 @@ def copier_args(parser: argparse.ArgumentParser):
|
|
|
18
18
|
parser.add_argument(
|
|
19
19
|
"-s", "--source", type=str, nargs="?", default=None, help="Source section name"
|
|
20
20
|
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-I", "--id", type=int, default=0, help="Source Account unique ID"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-U",
|
|
26
|
+
"--unique",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="Specify if the source account is only master",
|
|
29
|
+
)
|
|
21
30
|
parser.add_argument(
|
|
22
31
|
"-d",
|
|
23
32
|
"--destinations",
|
|
@@ -70,6 +79,8 @@ def copy_trades(unknown):
|
|
|
70
79
|
Options:
|
|
71
80
|
-m, --mode: CLI for terminal app and GUI for Desktop app
|
|
72
81
|
-s, --source: Source Account section name
|
|
82
|
+
-I, --id: Source Account unique ID
|
|
83
|
+
-U, --unique: Specify if the source account is only master
|
|
73
84
|
-d, --destinations: Destination Account section names (multiple allowed)
|
|
74
85
|
-i, --interval: Update interval in seconds
|
|
75
86
|
-M, --multiprocess: When set to True, each destination account runs in a separate process.
|
|
@@ -86,7 +97,7 @@ def copy_trades(unknown):
|
|
|
86
97
|
copy_args = copy_parser.parse_args(unknown)
|
|
87
98
|
|
|
88
99
|
if copy_args.mode == "GUI":
|
|
89
|
-
|
|
100
|
+
RunCopyApp()
|
|
90
101
|
|
|
91
102
|
elif copy_args.mode == "CLI":
|
|
92
103
|
source, destinations = config_copier(
|
|
@@ -94,6 +105,8 @@ def copy_trades(unknown):
|
|
|
94
105
|
dest_sections=copy_args.destinations,
|
|
95
106
|
inifile=copy_args.config,
|
|
96
107
|
)
|
|
108
|
+
source["id"] = copy_args.id
|
|
109
|
+
source["unique"] = copy_args.unique
|
|
97
110
|
if copy_args.multiprocess:
|
|
98
111
|
copier_processes = []
|
|
99
112
|
for dest_config in destinations:
|