bbstrader 0.3.0__tar.gz → 0.3.1__tar.gz

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.

Files changed (54) hide show
  1. {bbstrader-0.3.0/bbstrader.egg-info → bbstrader-0.3.1}/PKG-INFO +2 -5
  2. {bbstrader-0.3.0 → bbstrader-0.3.1}/README.md +1 -4
  3. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/__init__.py +1 -1
  4. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/__main__.py +17 -13
  5. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/core/data.py +92 -29
  6. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/account.py +4 -10
  7. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/copier.py +112 -36
  8. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/scripts.py +26 -12
  9. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/trade.py +27 -32
  10. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/utils.py +1 -0
  11. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/nlp.py +117 -74
  12. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/trading/execution.py +58 -37
  13. {bbstrader-0.3.0 → bbstrader-0.3.1/bbstrader.egg-info}/PKG-INFO +2 -5
  14. {bbstrader-0.3.0 → bbstrader-0.3.1}/setup.py +1 -2
  15. {bbstrader-0.3.0 → bbstrader-0.3.1}/LICENSE +0 -0
  16. {bbstrader-0.3.0 → bbstrader-0.3.1}/MANIFEST.in +0 -0
  17. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/__init__.py +0 -0
  18. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/backtest.py +0 -0
  19. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/data.py +0 -0
  20. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/event.py +0 -0
  21. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/execution.py +0 -0
  22. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/performance.py +0 -0
  23. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/portfolio.py +0 -0
  24. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/scripts.py +0 -0
  25. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/btengine/strategy.py +0 -0
  26. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/compat.py +0 -0
  27. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/config.py +0 -0
  28. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/core/__init__.py +0 -0
  29. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/core/scripts.py +0 -0
  30. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/core/utils.py +0 -0
  31. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/ibkr/__init__.py +0 -0
  32. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/ibkr/utils.py +0 -0
  33. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/__init__.py +0 -0
  34. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/analysis.py +0 -0
  35. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/rates.py +0 -0
  36. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/metatrader/risk.py +0 -0
  37. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/__init__.py +0 -0
  38. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/factors.py +0 -0
  39. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/ml.py +0 -0
  40. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/optimization.py +0 -0
  41. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/portfolio.py +0 -0
  42. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/models/risk.py +0 -0
  43. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/trading/__init__.py +0 -0
  44. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/trading/scripts.py +0 -0
  45. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/trading/strategies.py +0 -0
  46. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/trading/utils.py +0 -0
  47. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader/tseries.py +0 -0
  48. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader.egg-info/SOURCES.txt +0 -0
  49. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader.egg-info/dependency_links.txt +0 -0
  50. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader.egg-info/entry_points.txt +0 -0
  51. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader.egg-info/requires.txt +0 -0
  52. {bbstrader-0.3.0 → bbstrader-0.3.1}/bbstrader.egg-info/top_level.txt +0 -0
  53. {bbstrader-0.3.0 → bbstrader-0.3.1}/requirements.txt +0 -0
  54. {bbstrader-0.3.0 → bbstrader-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bbstrader
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Simplified Investment & Trading Toolkit
5
5
  Home-page: https://github.com/bbalouki/bbstrader
6
6
  Download-URL: https://pypi.org/project/bbstrader/
@@ -172,10 +172,7 @@ To begin using `bbstrader`, please ensure your system meets the following prereq
172
172
  * [Admirals Group AS](https://cabinet.a-partnership.com/visit/?bta=35537&brand=admiralmarkets) (for Stocks, ETFs, Indices, Commodities, Futures, Forex)
173
173
  * [Just Global Markets Ltd.](https://one.justmarkets.link/a/tufvj0xugm/registration/trader) (for Stocks, Crypto, Indices, Commodities, Forex)
174
174
  * [FTMO](https://trader.ftmo.com/?affiliates=JGmeuQqepAZLMcdOEQRp) (Proprietary Firm)
175
- * **Interactive Brokers (IBKR)** (Optional, for IBKR integration):
176
- * Interactive Brokers Trader Workstation (TWS) or IB Gateway must be installed and running.
177
- * An active account with Interactive Brokers.
178
- * The Python client library for the IBKR API: `ibapi`.
175
+
179
176
 
180
177
  ### Installation
181
178
 
@@ -88,10 +88,7 @@ To begin using `bbstrader`, please ensure your system meets the following prereq
88
88
  * [Admirals Group AS](https://cabinet.a-partnership.com/visit/?bta=35537&brand=admiralmarkets) (for Stocks, ETFs, Indices, Commodities, Futures, Forex)
89
89
  * [Just Global Markets Ltd.](https://one.justmarkets.link/a/tufvj0xugm/registration/trader) (for Stocks, Crypto, Indices, Commodities, Forex)
90
90
  * [FTMO](https://trader.ftmo.com/?affiliates=JGmeuQqepAZLMcdOEQRp) (Proprietary Firm)
91
- * **Interactive Brokers (IBKR)** (Optional, for IBKR integration):
92
- * Interactive Brokers Trader Workstation (TWS) or IB Gateway must be installed and running.
93
- * An active account with Interactive Brokers.
94
- * The Python client library for the IBKR API: `ibapi`.
91
+
95
92
 
96
93
  ### Installation
97
94
 
@@ -7,7 +7,7 @@ __author__ = "Bertin Balouki SIMYELI"
7
7
  __copyright__ = "2023-2025 Bertin Balouki SIMYELI"
8
8
  __email__ = "bertin@bbstrader.com"
9
9
  __license__ = "MIT"
10
- __version__ = "0.2.0"
10
+ __version__ = "0.3.1"
11
11
 
12
12
  from bbstrader import compat # noqa: F401
13
13
  from bbstrader import core # noqa: F401
@@ -49,19 +49,23 @@ def main():
49
49
  if ("-h" in sys.argv or "--help" in sys.argv) and args.run is None:
50
50
  print(Fore.WHITE + USAGE_TEXT)
51
51
  sys.exit(0)
52
-
53
- match args.run:
54
- case Module.COPIER.value:
55
- copy_trades(unknown)
56
- case Module.BACKTEST.value:
57
- backtest(unknown)
58
- case Module.EXECUTION.value:
59
- execute_strategy(unknown)
60
- case Module.NEWS_FEED.value:
61
- send_news_feed(unknown)
62
- case _:
63
- print(Fore.RED + f"Unknown module: {args.run}")
64
- sys.exit(1)
52
+ try:
53
+ match args.run:
54
+ case Module.COPIER.value:
55
+ copy_trades(unknown)
56
+ case Module.BACKTEST.value:
57
+ backtest(unknown)
58
+ case Module.EXECUTION.value:
59
+ execute_strategy(unknown)
60
+ case Module.NEWS_FEED.value:
61
+ send_news_feed(unknown)
62
+ case _:
63
+ print(Fore.RED + f"Unknown module: {args.run}")
64
+ sys.exit(1)
65
+ except KeyboardInterrupt:
66
+ sys.exit(0)
67
+ except Exception:
68
+ sys.exit(1)
65
69
 
66
70
 
67
71
  if __name__ == "__main__":
@@ -149,7 +149,7 @@ class FmpNews(object):
149
149
  try:
150
150
  articles = pd.read_csv("latest_fmp_articles.csv")
151
151
  articles = articles.to_dict(orient="records")
152
- if self._last_date(articles[0]["date"]) < end_date:
152
+ if self._last_date(articles[0]["date"]).hour < end_date.hour:
153
153
  articles = self.get_articles(**kwargs)
154
154
  else:
155
155
  return articles
@@ -161,7 +161,9 @@ class FmpNews(object):
161
161
  df.to_csv("latest_fmp_articles.csv", index=False)
162
162
  return articles
163
163
 
164
- def get_news(self, query, source="articles", articles=None, symbol=None, **kwargs):
164
+ def get_news(
165
+ self, query, source="articles", articles=None, symbol: str = None, **kwargs
166
+ ):
165
167
  """
166
168
  Retrieves relevant financial news based on the specified source.
167
169
 
@@ -183,6 +185,10 @@ class FmpNews(object):
183
185
  Returns an empty list if no relevant news is found.
184
186
  """
185
187
  query = _get_search_query(query)
188
+ if symbol is not None:
189
+ symbol = symbol.replace("-", "").split("=")[
190
+ 0
191
+ ] # if symbol is a yahoo finance ticker
186
192
  source_methods = {
187
193
  "articles": lambda: self.get_latest_articles(articles=articles, save=True),
188
194
  "releases": lambda: self.get_releases(symbol=symbol, **kwargs),
@@ -191,6 +197,8 @@ class FmpNews(object):
191
197
  "forex": lambda: self.get_forex_news(symbol=symbol, **kwargs),
192
198
  }
193
199
  news_source = source_methods.get(source, lambda: [])()
200
+ if source == "articles":
201
+ symbol = None # Articles do not require a symbol filter
194
202
  news = self.parse_news(news_source, symbol=symbol)
195
203
  return _filter_news(news, query)
196
204
 
@@ -203,7 +211,7 @@ class FinancialNews(object):
203
211
 
204
212
  """
205
213
 
206
- def _fetch_news(self, url, query, asset_type, n_news, headline_tag) -> List[str]:
214
+ def _fetch_news(self, url, query, n_news, headline_tag) -> List[str]:
207
215
  headers = {"User-Agent": "Mozilla/5.0"}
208
216
  try:
209
217
  response = requests.get(url, headers=headers)
@@ -224,32 +232,60 @@ class FinancialNews(object):
224
232
  ]
225
233
  return headlines[:n_news]
226
234
 
227
- def get_yahoo_finance_news(self, query, asset_type="stock", n_news=10):
235
+ def get_yahoo_finance_news(self, query: str, asset_type="stock", n_news=10):
228
236
  """
229
237
  Fetches recent Yahoo Finance news headlines for a given financial asset.
230
238
 
231
239
  Args:
232
240
  query (str): The asset symbol or name (e.g., "AAPL").
233
- asset_type (str, optional): The type of asset (e.g., "stock", "etf"). Defaults to "stock".
241
+ asset_type (str, optional): The type of asset (e.g., "stock", "etf"). Defaults to "stock",
242
+ supported types include:
243
+ - "stock": Stock symbols (e.g., AAPL, MSFT)
244
+ - "etf": Exchange-traded funds (e.g., SPY, QQQ)
245
+ - "future": Futures contracts (e.g., CL=F for crude oil)
246
+ - "forex": Forex pairs (e.g., EURUSD=X, USDJPY=X)
247
+ - "crypto": Cryptocurrency pairs (e.g., BTC-USD, ETH-USD)
248
+ - "index": Stock market indices (e.g., ^GSPC for S&P 500)
234
249
  n_news (int, optional): The number of news headlines to return. Defaults to 10.
235
250
 
251
+ Note:
252
+ For commotities and bonds, use the "Future" asset type.
253
+
236
254
  Returns:
237
255
  list[str]: A list of Yahoo Finance news headlines relevant to the query.
238
256
  """
257
+ if asset_type == "forex" or asset_type == "future":
258
+ assert (
259
+ "=" in query
260
+ ), "Forex query must contain '=' for currency pairs (e.g., EURUSD=X, CL=F)"
261
+ if asset_type == "crypto":
262
+ assert (
263
+ "-" in query
264
+ ), "Crypto query must contain '-' for crypto pairs (e.g., BTC-USD, ETH-USD)"
265
+ if asset_type == "index":
266
+ assert query.startswith(
267
+ "^"
268
+ ), "Index query must start with '^' (e.g., ^GSPC for S&P 500)"
239
269
  url = (
240
270
  f"https://finance.yahoo.com/quote/{query}/news"
241
- if asset_type in ["stock", "etf"]
271
+ if asset_type in ["stock", "etf", "index", "future", "forex"]
242
272
  else "https://finance.yahoo.com/news"
243
273
  )
244
- return self._fetch_news(url, query, asset_type, n_news, "h3")
274
+ return self._fetch_news(url, query, n_news, "h3")
245
275
 
246
- def get_google_finance_news(self, query, asset_type="stock", n_news=10):
276
+ def get_google_finance_news(self, query: str, asset_type="stock", n_news=10):
247
277
  """
248
278
  Fetches recent Google Finance news headlines for a given financial asset.
249
279
 
250
280
  Args:
251
281
  query (str): The asset symbol or name (e.g., "AAPL").
252
282
  asset_type (str, optional): The type of asset (e.g., "stock", "crypto"). Defaults to "stock".
283
+ Supported types include:
284
+ - "stock": Stock symbols (e.g., AAPL, MSFT)
285
+ - "etf": Exchange-traded funds (e.g., SPY, QQQ)
286
+ - "future": Futures contracts (e.g., CL=F or crude oil)
287
+ - "forex": Forex pairs (e.g., EURUSD, USDJPY)
288
+ - "crypto": Cryptocurrency pairs (e.g., BTCUSD, ETHUSD)
253
289
  n_news (int, optional): The number of news headlines to return. Defaults to 10.
254
290
 
255
291
  Returns:
@@ -258,20 +294,18 @@ class FinancialNews(object):
258
294
  search_terms = {
259
295
  "stock": f"{query} stock OR {query} shares OR {query} market",
260
296
  "etf": f"{query} ETF OR {query} fund OR {query} exchange-traded fund",
261
- "futures": f"{query} futures OR {query} price OR {query} market",
262
- "commodity": f"{query} price OR {query} futures OR {query} market",
297
+ "future": f"{query} futures OR {query} price OR {query} market",
263
298
  "forex": f"{query} forex OR {query} exchange rate OR {query} market",
264
299
  "crypto": f"{query} cryptocurrency OR {query} price OR {query} market",
265
- "bond": f"{query} bond OR {query} yield OR {query} interest rate",
266
300
  "index": f"{query} index OR {query} stock market OR {query} performance",
267
301
  }
268
302
  search_query = search_terms.get(asset_type, query)
269
303
  url = f"https://news.google.com/search?q={search_query.replace(' ', '+')}"
270
- return self._fetch_news(url, query, asset_type, n_news, "a")
304
+ return self._fetch_news(url, query, n_news, "a")
271
305
 
272
306
  def get_reddit_posts(
273
307
  self,
274
- symbol,
308
+ symbol: str,
275
309
  client_id=None,
276
310
  client_secret=None,
277
311
  user_agent=None,
@@ -294,8 +328,8 @@ class FinancialNews(object):
294
328
  - "stock": Searches in stock-related subreddits (e.g., wallstreetbets, stocks).
295
329
  - "forex": Searches in forex-related subreddits.
296
330
  - "commodities": Searches in commodity-related subreddits (e.g., gold, oil).
297
- - "etfs": Searches in ETF-related subreddits.
298
- - "futures": Searches in futures and options trading subreddits.
331
+ - "etf": Searches in ETF-related subreddits.
332
+ - "future": Searches in futures and options trading subreddits.
299
333
  - "crypto": Searches in cryptocurrency-related subreddits.
300
334
  - If an unrecognized asset class is provided, defaults to stock-related subreddits.
301
335
  n_posts (int, optional): The number of posts to return per subreddit. Defaults to 10.
@@ -317,15 +351,36 @@ class FinancialNews(object):
317
351
  """
318
352
 
319
353
  reddit = praw.Reddit(
320
- client_id=client_id, client_secret=client_secret, user_agent=user_agent
354
+ client_id=client_id,
355
+ client_secret=client_secret,
356
+ user_agent=user_agent,
357
+ check_for_updates=False,
358
+ comment_kind="t1",
359
+ message_kind="t4",
360
+ redditor_kind="t2",
361
+ submission_kind="t3",
362
+ subreddit_kind="t5",
363
+ trophy_kind="t6",
364
+ oauth_url="https://oauth.reddit.com",
365
+ reddit_url="https://www.reddit.com",
366
+ short_url="https://redd.it",
367
+ timeout=16,
368
+ ratelimit_seconds=5,
321
369
  )
322
-
370
+ assert reddit.read_only
323
371
  subreddit_mapping = {
324
372
  "stock": ["wallstreetbets", "stocks", "investing", "StockMarket"],
325
373
  "forex": ["Forex", "ForexTrading", "DayTrading"],
326
- "commodities": ["Commodities", "Gold", "Silverbugs", "oil"],
327
374
  "etfs": ["ETFs", "investing"],
328
- "futures": ["FuturesTrading", "OptionsTrading", "DayTrading"],
375
+ "futures": [
376
+ "FuturesTrading",
377
+ "OptionsTrading",
378
+ "DayTrading",
379
+ "Commodities",
380
+ "Gold",
381
+ "Silverbugs",
382
+ "oil",
383
+ ],
329
384
  "crypto": ["CryptoCurrency", "Bitcoin", "ethereum", "altcoin"],
330
385
  }
331
386
  try:
@@ -473,16 +528,23 @@ class FinancialNews(object):
473
528
  maximum = 100
474
529
  if limit > maximum:
475
530
  raise ValueError(f"Number of total news articles allowed is {maximum}")
476
-
477
- response = requests.get(
478
- "https://data-api.coindesk.com/news/v1/article/list",
479
- params={"lang": lang, "limit": limit},
480
- headers={"Content-type": "application/json; charset=UTF-8"},
481
- )
482
- json_response = response.json()
483
- articles = json_response["Data"]
484
- if len(articles) == 0:
531
+ try:
532
+ response = requests.get(
533
+ "https://data-api.coindesk.com/news/v1/article/list",
534
+ params={"lang": lang, "limit": limit},
535
+ headers={"Content-type": "application/json; charset=UTF-8"},
536
+ )
537
+ response.raise_for_status()
538
+ json_response = response.json()
539
+ except requests.exceptions.RequestException:
485
540
  return []
541
+ if (
542
+ response.status_code != 200
543
+ or "Data" not in json_response
544
+ or len(json_response["Data"]) == 0
545
+ ):
546
+ return []
547
+ articles = json_response["Data"]
486
548
  to_keep = [
487
549
  "PUBLISHED_ON",
488
550
  "TITLE",
@@ -495,10 +557,11 @@ class FinancialNews(object):
495
557
  ]
496
558
  filtered_articles = []
497
559
  for article in articles:
560
+ keys = article.keys()
498
561
  filtered_articles.append(
499
562
  {
500
563
  k.lower(): article[k]
501
- if k in article and k != "PUBLISHED_ON"
564
+ if k in keys and k != "PUBLISHED_ON"
502
565
  else datetime.fromtimestamp(article[k])
503
566
  for k in to_keep
504
567
  if article[k] is not None and "sponsored" not in str(article[k])
@@ -760,10 +760,7 @@ class Account(object):
760
760
  This mthods works primarly with Admirals Group AS products and Pepperstone Group Limited,
761
761
  For other brokers use `get_symbols()` or this method will use it by default.
762
762
  """
763
- if (
764
- self.broker != AdmiralMarktsGroup()
765
- or self.broker != PepperstoneGroupLimited()
766
- ):
763
+ if self.broker not in [AdmiralMarktsGroup(), PepperstoneGroupLimited()]:
767
764
  return self.get_symbols(symbol_type=SymbolType.FOREX)
768
765
  else:
769
766
  fx_categories = {
@@ -816,11 +813,8 @@ class Account(object):
816
813
  This mthods works primarly with Admirals Group AS products and Pepperstone Group Limited,
817
814
  For other brokers use `get_symbols()` or this method will use it by default.
818
815
  """
819
-
820
- if (
821
- self.broker != AdmiralMarktsGroup()
822
- or self.broker != PepperstoneGroupLimited()
823
- ):
816
+
817
+ if self.broker not in [AdmiralMarktsGroup(), PepperstoneGroupLimited()]:
824
818
  return self.get_symbols(symbol_type=SymbolType.STOCKS)
825
819
  else:
826
820
  stocks, etfs = [], []
@@ -883,7 +877,7 @@ class Account(object):
883
877
  SymbolType.STOCKS, exchange_code, exchange_map
884
878
  )
885
879
  etfs = self._get_symbols_by_category(
886
- SymbolType.ETFs, exchange_code, exchange_map
880
+ SymbolType.ETFs, exchange_code, exchange_map
887
881
  )
888
882
  return stocks + etfs if etf else stocks
889
883
 
@@ -1,10 +1,11 @@
1
1
  import multiprocessing
2
+ import threading
2
3
  import time
3
4
  from datetime import datetime
4
5
  from pathlib import Path
5
6
  from typing import Dict, List, Literal, Tuple
6
7
 
7
- from loguru import logger
8
+ from loguru import logger as log
8
9
 
9
10
  from bbstrader.config import BBSTRADER_DIR
10
11
  from bbstrader.metatrader.account import Account, check_mt5_connection
@@ -20,12 +21,14 @@ except ImportError:
20
21
  __all__ = ["TradeCopier", "RunCopier", "RunMultipleCopier", "config_copier"]
21
22
 
22
23
 
23
- logger.add(
24
+ log.add(
24
25
  f"{BBSTRADER_DIR}/logs/copier.log",
25
26
  enqueue=True,
26
27
  level="INFO",
27
28
  format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
28
29
  )
30
+ global logger
31
+ logger = log
29
32
 
30
33
 
31
34
  def fix_lot(fixed):
@@ -113,11 +116,21 @@ def calculate_copy_lot(
113
116
  raise ValueError("Invalid mode selected")
114
117
 
115
118
 
116
- def get_copy_symbols(destination: dict = None):
119
+ def get_copy_symbols(destination: dict, source: dict):
117
120
  symbols = destination.get("symbols", "all")
118
- account = Account(**destination)
121
+ src_account = Account(**source)
122
+ dest_account = Account(**destination)
119
123
  if symbols == "all" or symbols == "*":
120
- return account.get_symbols()
124
+ src_symbols = src_account.get_symbols()
125
+ dest_symbols = dest_account.get_symbols()
126
+ for s in src_symbols:
127
+ if s not in dest_symbols:
128
+ err_msg = (
129
+ f"To use 'all' or '*', Source account@{src_account.number} "
130
+ f"and destination account@{dest_account.number} "
131
+ f"must be the same type and have the same symbols"
132
+ )
133
+ raise ValueError(err_msg)
121
134
  elif isinstance(symbols, (list, dict)):
122
135
  return symbols
123
136
  elif isinstance(symbols, str):
@@ -127,22 +140,6 @@ def get_copy_symbols(destination: dict = None):
127
140
  return symbols.split()
128
141
 
129
142
 
130
- def get_copy_symbol(symbol, destination: dict = None, type="destination"):
131
- symbols = get_copy_symbols(destination)
132
- if isinstance(symbols, list):
133
- if symbol in symbols:
134
- return symbol
135
- if isinstance(symbols, dict):
136
- if type == "destination":
137
- if symbol in symbols.keys():
138
- return symbols[symbol]
139
- if type == "source":
140
- for k, v in symbols.items():
141
- if v == symbol:
142
- return k
143
- raise ValueError(f"Symbol {symbol} not found in {type} account")
144
-
145
-
146
143
  class TradeCopier(object):
147
144
  """
148
145
  ``TradeCopier`` responsible for copying trading orders and positions from a source account to multiple destination accounts.
@@ -159,7 +156,10 @@ class TradeCopier(object):
159
156
  "sleeptime",
160
157
  "start_time",
161
158
  "end_time",
159
+ "shutdown_event",
160
+ "custom_logger",
162
161
  )
162
+ shutdown_event: threading.Event
163
163
 
164
164
  def __init__(
165
165
  self,
@@ -168,6 +168,8 @@ class TradeCopier(object):
168
168
  sleeptime: float = 0.1,
169
169
  start_time: str = None,
170
170
  end_time: str = None,
171
+ shutdown_event=None,
172
+ custom_logger=None,
171
173
  ):
172
174
  """
173
175
  Initializes the ``TradeCopier`` instance, setting up the source and destination trading accounts for trade copying.
@@ -245,8 +247,15 @@ class TradeCopier(object):
245
247
  self.sleeptime = sleeptime
246
248
  self.start_time = start_time
247
249
  self.end_time = end_time
248
- self.errors = set()
250
+ self.shutdown_event = shutdown_event
251
+ self._add_logger(custom_logger)
249
252
  self._add_copy()
253
+ self.errors = set()
254
+
255
+ def _add_logger(self, custom_logger):
256
+ if custom_logger:
257
+ global logger
258
+ logger = custom_logger
250
259
 
251
260
  def _add_copy(self):
252
261
  self.source["copy"] = True
@@ -269,6 +278,21 @@ class TradeCopier(object):
269
278
  check_mt5_connection(**destination)
270
279
  return Account(**destination).get_positions(symbol=symbol)
271
280
 
281
+ def get_copy_symbol(self, symbol, destination: dict = None, type="destination"):
282
+ symbols = get_copy_symbols(destination, self.source)
283
+ if isinstance(symbols, list):
284
+ if symbol in symbols:
285
+ return symbol
286
+ if isinstance(symbols, dict):
287
+ if type == "destination":
288
+ if symbol in symbols.keys():
289
+ return symbols[symbol]
290
+ if type == "source":
291
+ for k, v in symbols.items():
292
+ if v == symbol:
293
+ return k
294
+ raise ValueError(f"Symbol {symbol} not found in {type} account")
295
+
272
296
  def isorder_modified(self, source: TradeOrder, dest: TradeOrder):
273
297
  if source.type == dest.type and source.ticket == dest.magic:
274
298
  return (
@@ -315,7 +339,7 @@ class TradeCopier(object):
315
339
  return
316
340
  check_mt5_connection(**destination)
317
341
  volume = trade.volume if hasattr(trade, "volume") else trade.volume_initial
318
- symbol = get_copy_symbol(trade.symbol, destination)
342
+ symbol = self.get_copy_symbol(trade.symbol, destination)
319
343
  lot = calculate_copy_lot(
320
344
  volume,
321
345
  symbol,
@@ -326,7 +350,9 @@ class TradeCopier(object):
326
350
  dest_eqty=Account(**destination).get_account_info().margin_free,
327
351
  )
328
352
 
329
- trade_instance = Trade(symbol=symbol, **destination, max_risk=100.0, logger=None)
353
+ trade_instance = Trade(
354
+ symbol=symbol, **destination, max_risk=100.0, logger=None
355
+ )
330
356
  try:
331
357
  action = action_type[trade.type]
332
358
  except KeyError:
@@ -463,7 +489,7 @@ class TradeCopier(object):
463
489
 
464
490
  def get_positions(self, destination: dict):
465
491
  source_positions = self.source_positions() or []
466
- dest_symbols = get_copy_symbols(destination)
492
+ dest_symbols = get_copy_symbols(destination, self.source)
467
493
  dest_positions = self.destination_positions(destination) or []
468
494
  source_positions = self.filter_positions_and_orders(
469
495
  source_positions, symbols=dest_symbols
@@ -475,7 +501,7 @@ class TradeCopier(object):
475
501
 
476
502
  def get_orders(self, destination: dict):
477
503
  source_orders = self.source_orders() or []
478
- dest_symbols = get_copy_symbols(destination)
504
+ dest_symbols = get_copy_symbols(destination, self.source)
479
505
  dest_orders = self.destination_orders(destination) or []
480
506
  source_orders = self.filter_positions_and_orders(
481
507
  source_orders, symbols=dest_symbols
@@ -511,7 +537,7 @@ class TradeCopier(object):
511
537
  source_ids = [order.ticket for order in source_orders]
512
538
  for destination_order in destination_orders:
513
539
  if destination_order.magic not in source_ids:
514
- src_symbol = get_copy_symbol(
540
+ src_symbol = self.get_copy_symbol(
515
541
  destination_order.symbol, destination, type="source"
516
542
  )
517
543
  self.remove_order(src_symbol, destination_order, destination)
@@ -547,7 +573,7 @@ class TradeCopier(object):
547
573
  if not destination.get("copy", False):
548
574
  raise ValueError("Destination account not set to copy mode")
549
575
  return destination.get("copy_what", "all")
550
-
576
+
551
577
  def copy_orders(self, destination: dict):
552
578
  what = self._copy_what(destination)
553
579
  if what not in ["all", "orders"]:
@@ -587,7 +613,7 @@ class TradeCopier(object):
587
613
  source_ids = [pos.ticket for pos in source_positions]
588
614
  for destination_position in destination_positions:
589
615
  if destination_position.magic not in source_ids:
590
- src_symbol = get_copy_symbol(
616
+ src_symbol = self.get_copy_symbol(
591
617
  destination_position.symbol, destination, type="source"
592
618
  )
593
619
  self.remove_position(src_symbol, destination_position, destination)
@@ -612,8 +638,15 @@ class TradeCopier(object):
612
638
  logger.info("Trade Copier Running ...")
613
639
  logger.info(f"Source Account: {self.source.get('login')}")
614
640
  while True:
641
+ if self.shutdown_event and self.shutdown_event.is_set():
642
+ logger.info(
643
+ "Shutdown event received, stopping Trade Copier gracefully."
644
+ )
645
+ break
615
646
  try:
616
647
  for destination in self.destinations:
648
+ if self.shutdown_event and self.shutdown_event.is_set():
649
+ break
617
650
  if destination.get("path") == self.source.get("path"):
618
651
  err_msg = "Source and destination accounts are on the same \
619
652
  MetaTrader 5 installation which is not allowed."
@@ -623,18 +656,52 @@ class TradeCopier(object):
623
656
  self.copy_positions(destination)
624
657
  Mt5.shutdown()
625
658
  time.sleep(0.1)
659
+
660
+ if self.shutdown_event and self.shutdown_event.is_set():
661
+ logger.info(
662
+ "Shutdown event received during destination processing, exiting."
663
+ )
664
+ break
665
+
626
666
  except KeyboardInterrupt:
627
- logger.info("Stopping the Trade Copier ...")
628
- exit(0)
667
+ logger.info("KeyboardInterrupt received, stopping the Trade Copier ...")
668
+ if self.shutdown_event:
669
+ self.shutdown_event.set()
670
+ break
629
671
  except Exception as e:
630
672
  self.log_error(e)
673
+ if self.shutdown_event and self.shutdown_event.is_set():
674
+ logger.error(
675
+ "Error occurred after shutdown signaled, exiting loop."
676
+ )
677
+ break
678
+
679
+ # Check shutdown event before sleeping
680
+ if self.shutdown_event and self.shutdown_event.is_set():
681
+ logger.info("Shutdown event checked before sleep, exiting.")
682
+ break
631
683
  time.sleep(self.sleeptime)
684
+ logger.info("Trade Copier has shut down.")
632
685
 
633
686
 
634
687
  def RunCopier(
635
- source: dict, destinations: list, sleeptime: float, start_time: str, end_time: str
688
+ source: dict,
689
+ destinations: list,
690
+ sleeptime: float,
691
+ start_time: str,
692
+ end_time: str,
693
+ shutdown_event=None,
694
+ custom_logger=None,
636
695
  ):
637
- copier = TradeCopier(source, destinations, sleeptime, start_time, end_time)
696
+ copier = TradeCopier(
697
+ source,
698
+ destinations,
699
+ sleeptime,
700
+ start_time,
701
+ end_time,
702
+ shutdown_event,
703
+ custom_logger,
704
+ )
638
705
  copier.run()
639
706
 
640
707
 
@@ -644,6 +711,8 @@ def RunMultipleCopier(
644
711
  start_delay: float = 1.0,
645
712
  start_time: str = None,
646
713
  end_time: str = None,
714
+ shutdown_event=None,
715
+ custom_logger=None,
647
716
  ):
648
717
  processes = []
649
718
 
@@ -661,10 +730,16 @@ def RunMultipleCopier(
661
730
  )
662
731
  continue
663
732
  logger.info(f"Starting process for source account @{source.get('login')}")
664
-
665
733
  process = multiprocessing.Process(
666
734
  target=RunCopier,
667
- args=(source, destinations, sleeptime, start_time, end_time),
735
+ args=(
736
+ source,
737
+ destinations,
738
+ sleeptime,
739
+ start_time,
740
+ end_time,
741
+ ),
742
+ kwargs=dict(shutdown_event=shutdown_event, custom_logger=custom_logger),
668
743
  )
669
744
  processes.append(process)
670
745
  process.start()
@@ -672,13 +747,14 @@ def RunMultipleCopier(
672
747
  if start_delay:
673
748
  time.sleep(start_delay)
674
749
 
675
- # Wait for all processes to complete
676
750
  for process in processes:
677
751
  process.join()
678
752
 
679
753
 
680
754
  def _strtodict(string: str) -> dict:
681
755
  string = string.strip().replace("\n", "").replace(" ", "").replace('"""', "")
756
+ if string.endswith(","):
757
+ string = string[:-1]
682
758
  return dict(item.split(":") for item in string.split(","))
683
759
 
684
760