bbstrader 0.3.0__py3-none-any.whl → 0.3.1__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.

@@ -2,9 +2,18 @@ import argparse
2
2
  import sys
3
3
 
4
4
  from bbstrader.metatrader.copier import RunCopier, config_copier
5
+ from bbstrader.apps._copier import main as RunCopyAPP
5
6
 
6
7
 
7
8
  def copier_args(parser: argparse.ArgumentParser):
9
+ parser.add_argument(
10
+ "-m",
11
+ "--mode",
12
+ type=str,
13
+ default="CLI",
14
+ choices=("CLI", "GUI"),
15
+ help="Run the copier in the terminal or using the GUI",
16
+ )
8
17
  parser.add_argument(
9
18
  "-s", "--source", type=str, nargs="?", default=None, help="Source section name"
10
19
  )
@@ -52,6 +61,7 @@ def copy_trades(unknown):
52
61
  python -m bbstrader --run copier [options]
53
62
 
54
63
  Options:
64
+ -m, --mode: CLI for terminal app and GUI for Desktop app
55
65
  -s, --source: Source Account section name
56
66
  -d, --destinations: Destination Account section names (multiple allowed)
57
67
  -i, --interval: Update interval in seconds
@@ -67,15 +77,19 @@ def copy_trades(unknown):
67
77
  copy_parser = copier_args(copy_parser)
68
78
  copy_args = copy_parser.parse_args(unknown)
69
79
 
70
- source, destinations = config_copier(
71
- source_section=copy_args.source,
72
- dest_sections=copy_args.destinations,
73
- inifile=copy_args.config,
74
- )
75
- RunCopier(
76
- source,
77
- destinations,
78
- copy_args.interval,
79
- copy_args.start,
80
- copy_args.end,
81
- )
80
+ if copy_args.mode == "GUI":
81
+ RunCopyAPP()
82
+
83
+ elif copy_args.mode == "CLI":
84
+ source, destinations = config_copier(
85
+ source_section=copy_args.source,
86
+ dest_sections=copy_args.destinations,
87
+ inifile=copy_args.config,
88
+ )
89
+ RunCopier(
90
+ source,
91
+ destinations,
92
+ copy_args.interval,
93
+ copy_args.start,
94
+ copy_args.end,
95
+ )
@@ -134,15 +134,17 @@ class TradeSignal:
134
134
  def __repr__(self):
135
135
  return (
136
136
  f"TradeSignal(id={self.id}, symbol='{self.symbol}', action='{self.action.value}', "
137
- f"price={self.price}, stoplimit={self.stoplimit}), comment='{self.comment}'"
137
+ f"price={self.price}, stoplimit={self.stoplimit}, comment='{self.comment or ''}')"
138
138
  )
139
139
 
140
+
140
141
  class TradingMode(Enum):
141
142
  BACKTEST = "BACKTEST"
142
143
  LIVE = "LIVE"
143
144
 
144
145
  def isbacktest(self) -> bool:
145
146
  return self == TradingMode.BACKTEST
147
+
146
148
  def islive(self) -> bool:
147
149
  return self == TradingMode.LIVE
148
150
 
@@ -740,10 +742,8 @@ class Trade(RiskManagement):
740
742
  self.check_order(request)
741
743
  result = self.send_order(request)
742
744
  except Exception as e:
743
- print(f"{self.current_datetime()} -", end=" ")
744
- trade_retcode_message(
745
- result.retcode, display=True, add_msg=f"{e}{addtionnal}"
746
- )
745
+ msg = trade_retcode_message(result.retcode)
746
+ LOGGER.error(f"Trade Order Request, {msg}{addtionnal}, {e}")
747
747
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
748
748
  if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL: # 10030
749
749
  for fill in FILLING_TYPE:
@@ -773,10 +773,8 @@ class Trade(RiskManagement):
773
773
  self.check_order(request)
774
774
  result = self.send_order(request)
775
775
  except Exception as e:
776
- print(f"{self.current_datetime()} -", end=" ")
777
- trade_retcode_message(
778
- result.retcode, display=True, add_msg=f"{e}{addtionnal}"
779
- )
776
+ msg = trade_retcode_message(result.retcode)
777
+ LOGGER.error(f"Trade Order Request, {msg}{addtionnal}, {e}")
780
778
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
781
779
  break
782
780
  tries += 1
@@ -787,7 +785,7 @@ class Trade(RiskManagement):
787
785
  if type != "BMKT" or type != "SMKT":
788
786
  self.opened_orders.append(result.order)
789
787
  long_msg = (
790
- f"1. {pos} Order #{result.order} Sent, Symbol: {self.symbol}, Price: @{price}, "
788
+ f"1. {pos} Order #{result.order} Sent, Symbol: {self.symbol}, Price: @{round(price, 5)}, "
791
789
  f"Lot(s): {result.volume}, Sl: {self.get_stop_loss()}, "
792
790
  f"Tp: {self.get_take_profit()}"
793
791
  )
@@ -808,7 +806,7 @@ class Trade(RiskManagement):
808
806
  profit = round(self.get_account_info().profit, 5)
809
807
  order_info = (
810
808
  f"2. {order_type} Position Opened, Symbol: {self.symbol}, Price: @{round(position.price_open, 5)}, "
811
- f"Sl: @{position.sl} Tp: @{position.tp}"
809
+ f"Sl: @{round(position.sl, 5)} Tp: @{round(position.tp, 5)}"
812
810
  )
813
811
  LOGGER.info(order_info)
814
812
  pos_info = (
@@ -1294,10 +1292,8 @@ class Trade(RiskManagement):
1294
1292
  self.check_order(request)
1295
1293
  result = self.send_order(request)
1296
1294
  except Exception as e:
1297
- print(f"{self.current_datetime()} -", end=" ")
1298
- trade_retcode_message(
1299
- result.retcode, display=True, add_msg=f"{e}{addtionnal}"
1300
- )
1295
+ msg = trade_retcode_message(result.retcode)
1296
+ LOGGER.error(f"Break-Even Order Request, {msg}{addtionnal}, Error: {e}")
1301
1297
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
1302
1298
  msg = trade_retcode_message(result.retcode)
1303
1299
  if result.retcode != Mt5.TRADE_RETCODE_NO_CHANGES:
@@ -1314,17 +1310,15 @@ class Trade(RiskManagement):
1314
1310
  self.check_order(request)
1315
1311
  result = self.send_order(request)
1316
1312
  except Exception as e:
1317
- print(f"{self.current_datetime()} -", end=" ")
1318
- trade_retcode_message(
1319
- result.retcode, display=True, add_msg=f"{e}{addtionnal}"
1320
- )
1313
+ msg = trade_retcode_message(result.retcode)
1314
+ LOGGER.error(f"Break-Even Order Request, {msg}{addtionnal}, Error: {e}")
1321
1315
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
1322
1316
  break
1323
1317
  tries += 1
1324
1318
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
1325
1319
  msg = trade_retcode_message(result.retcode)
1326
1320
  LOGGER.info(f"Break-Even Order {msg}{addtionnal}")
1327
- info = f"Stop loss set to Break-even, Position: #{tiket}, Symbol: {self.symbol}, Price: @{price}"
1321
+ info = f"Stop loss set to Break-even, Position: #{tiket}, Symbol: {self.symbol}, Price: @{round(price, 5)}"
1328
1322
  LOGGER.info(info)
1329
1323
  self.break_even_status.append(tiket)
1330
1324
 
@@ -1402,10 +1396,8 @@ class Trade(RiskManagement):
1402
1396
  self.check_order(request)
1403
1397
  result = self.send_order(request)
1404
1398
  except Exception as e:
1405
- print(f"{self.current_datetime()} -", end=" ")
1406
- trade_retcode_message(
1407
- result.retcode, display=True, add_msg=f"{e}{addtionnal}"
1408
- )
1399
+ msg = trade_retcode_message(result.retcode)
1400
+ LOGGER.error(f"Closing {type.capitalize()} Request, {msg}{addtionnal}, Error: {e}")
1409
1401
  if result.retcode != Mt5.TRADE_RETCODE_DONE:
1410
1402
  if result.retcode == Mt5.TRADE_RETCODE_INVALID_FILL: # 10030
1411
1403
  for fill in FILLING_TYPE:
@@ -1417,7 +1409,8 @@ class Trade(RiskManagement):
1417
1409
  self._retcodes.append(result.retcode)
1418
1410
  msg = trade_retcode_message(result.retcode)
1419
1411
  LOGGER.error(
1420
- f"Closing Order Request, {type.capitalize()}: #{ticket}, RETCODE={result.retcode}: {msg}{addtionnal}"
1412
+ f"Closing Order Request, {type.capitalize()}: #{ticket}, "
1413
+ f"RETCODE={result.retcode}: {msg}{addtionnal}"
1421
1414
  )
1422
1415
  else:
1423
1416
  tries = 0
@@ -1427,17 +1420,18 @@ class Trade(RiskManagement):
1427
1420
  self.check_order(request)
1428
1421
  result = self.send_order(request)
1429
1422
  except Exception as e:
1430
- print(f"{self.current_datetime()} -", end=" ")
1431
- trade_retcode_message(
1432
- result.retcode, display=True, add_msg=f"{e}{addtionnal}"
1433
- )
1423
+ msg = trade_retcode_message(result.retcode)
1424
+ LOGGER.error(f"Closing {type.capitalize()} Request, {msg}{addtionnal}, Error: {e}")
1434
1425
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
1435
1426
  break
1436
1427
  tries += 1
1437
1428
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
1438
1429
  msg = trade_retcode_message(result.retcode)
1439
1430
  LOGGER.info(f"Closing Order {msg}{addtionnal}")
1440
- info = f"{type.capitalize()} #{ticket} closed, Symbol: {self.symbol}, Price: @{request.get('price', 0.0)}"
1431
+ info = (
1432
+ f"{type.capitalize()} #{ticket} closed, Symbol: {self.symbol},"
1433
+ f"Price: @{round(request.get('price', 0.0), 5)}"
1434
+ )
1441
1435
  LOGGER.info(info)
1442
1436
  return True
1443
1437
  else:
@@ -1466,7 +1460,7 @@ class Trade(RiskManagement):
1466
1460
  orders = self.get_orders(ticket=ticket) or []
1467
1461
  if len(orders) == 0:
1468
1462
  LOGGER.error(
1469
- f"Order #{ticket} not found, SYMBOL={self.symbol}, PRICE={price}"
1463
+ f"Order #{ticket} not found, SYMBOL={self.symbol}, PRICE={round(price, 5)}"
1470
1464
  )
1471
1465
  return
1472
1466
  order = orders[0]
@@ -1482,7 +1476,8 @@ class Trade(RiskManagement):
1482
1476
  result = self.send_order(request)
1483
1477
  if result.retcode == Mt5.TRADE_RETCODE_DONE:
1484
1478
  LOGGER.info(
1485
- f"Order #{ticket} modified, SYMBOL={self.symbol}, PRICE={price}, SL={sl}, TP={tp}, STOP_LIMIT={stoplimit}"
1479
+ f"Order #{ticket} modified, SYMBOL={self.symbol}, PRICE={round(price, 5)},"
1480
+ f"SL={round(sl, 5)}, TP={round(tp, 5)}, STOP_LIMIT={round(stoplimit, 5)}"
1486
1481
  )
1487
1482
  else:
1488
1483
  msg = trade_retcode_message(result.retcode)
@@ -14,6 +14,7 @@ __all__ = [
14
14
  "TerminalInfo",
15
15
  "AccountInfo",
16
16
  "SymbolInfo",
17
+ "SymbolType",
17
18
  "TickInfo",
18
19
  "TradeRequest",
19
20
  "OrderCheckResult",
bbstrader/models/nlp.py CHANGED
@@ -1,14 +1,16 @@
1
+ import contextlib
2
+ import os
1
3
  import re
2
4
  import time
3
5
  from datetime import datetime
4
- from typing import Dict
6
+ from typing import Dict, List, Tuple
5
7
 
6
8
  import dash
7
9
  import matplotlib.pyplot as plt
8
10
  import nltk
9
11
  import pandas as pd
10
12
  import plotly.express as px
11
- import spacy
13
+ import en_core_web_sm
12
14
  from dash import dcc, html
13
15
  from dash.dependencies import Input, Output
14
16
  from nltk.corpus import stopwords
@@ -18,6 +20,7 @@ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
18
20
 
19
21
  from bbstrader.core.data import FinancialNews
20
22
 
23
+
21
24
  __all__ = [
22
25
  "TopicModeler",
23
26
  "SentimentAnalyzer",
@@ -335,15 +338,14 @@ class TopicModeler(object):
335
338
  nltk.download("stopwords", quiet=True)
336
339
 
337
340
  try:
338
- self.nlp = spacy.load("en_core_web_sm")
341
+ self.nlp = en_core_web_sm.load()
339
342
  self.nlp.disable_pipes("ner")
340
343
  except OSError:
341
- raise RuntimeError(
342
- "The SpaCy model 'en_core_web_sm' is not installed.\n"
343
- "Please install it by running:\n"
344
- " python -m spacy download en_core_web_sm"
344
+ raise OSError(
345
+ "SpaCy model 'en_core_web_sm' not found. "
346
+ "Please install it using 'python -m spacy download en_core_web_sm'."
345
347
  )
346
-
348
+
347
349
  def preprocess_texts(self, texts: list[str]):
348
350
  def clean_doc(Doc):
349
351
  doc = []
@@ -392,22 +394,25 @@ class SentimentAnalyzer(object):
392
394
  - Downloads NLTK tokenization (`punkt`) and stopwords.
393
395
  - Loads the `en_core_web_sm` SpaCy model with Named Entity Recognition (NER) disabled.
394
396
  - Initializes VADER's SentimentIntensityAnalyzer for sentiment scoring.
397
+
398
+ Args:
399
+ use_spacy (bool): If True, uses SpaCy for lemmatization. Defaults to False.
395
400
  """
396
401
  nltk.download("punkt", quiet=True)
397
402
  nltk.download("stopwords", quiet=True)
398
403
 
404
+ self.analyzer = SentimentIntensityAnalyzer()
405
+ self._stopwords = set(stopwords.words("english"))
406
+
399
407
  try:
400
- self.nlp = spacy.load("en_core_web_sm")
408
+ self.nlp = en_core_web_sm.load()
401
409
  self.nlp.disable_pipes("ner")
402
410
  except OSError:
403
- raise RuntimeError(
404
- "The SpaCy model 'en_core_web_sm' is not installed.\n"
405
- "Please install it by running:\n"
406
- " python -m spacy download en_core_web_sm"
411
+ raise OSError(
412
+ "SpaCy model 'en_core_web_sm' not found. "
413
+ "Please install it using 'python -m spacy download en_core_web_sm'."
407
414
  )
408
-
409
- self.analyzer = SentimentIntensityAnalyzer()
410
- self._stopwords = set(stopwords.words("english"))
415
+ self.news = FinancialNews()
411
416
 
412
417
  def preprocess_text(self, text: str):
413
418
  """
@@ -425,13 +430,18 @@ class SentimentAnalyzer(object):
425
430
  Returns:
426
431
  str: The cleaned and lemmatized text.
427
432
  """
433
+ if not isinstance(text, str):
434
+ raise ValueError(f"{self.__class__.__name__}: preprocess_text expects a string, got {type(text)}")
428
435
  text = text.lower()
429
436
  text = re.sub(r"http\S+", "", text)
430
437
  text = re.sub(r"[^a-zA-Z\s]", "", text)
438
+
431
439
  words = word_tokenize(text)
432
440
  words = [word for word in words if word not in self._stopwords]
441
+
433
442
  doc = self.nlp(" ".join(words))
434
443
  words = [t.lemma_ for t in doc if t.lemma_ != "-PRON-"]
444
+
435
445
  return " ".join(words)
436
446
 
437
447
  def analyze_sentiment(self, texts, lexicon=None, textblob=False) -> float:
@@ -470,7 +480,7 @@ class SentimentAnalyzer(object):
470
480
  return avg_sentiment
471
481
 
472
482
  def get_sentiment_for_tickers(
473
- self, tickers, lexicon=None, asset_type="stock", top_news=10, **kwargs
483
+ self, tickers: List[str] | List[Tuple[str, str]], lexicon=None, asset_type="stock", top_news=10, **kwargs
474
484
  ) -> Dict[str, float]:
475
485
  """
476
486
  Computes sentiment scores for a list of financial tickers based on news and social media data.
@@ -487,9 +497,18 @@ class SentimentAnalyzer(object):
487
497
  3. Computes an overall sentiment score using a weighted average approach.
488
498
 
489
499
  Args:
490
- tickers (list of str): A list of stock, forex, crypto, or other asset tickers.
500
+ tickers (List[str] | List[Tuple[str, str]]): A list of asset tickers to analyze
501
+ - if using tuples, the first element is the ticker and the second is the asset type.
502
+ - if using a single string, the asset type must be specified or the default is "stock".
491
503
  lexicon (dict, optional): A custom sentiment lexicon to update VADER's default lexicon.
492
- asset_type (str, optional): The type of asset (e.g., "stock", "forex", "crypto"). Defaults to "stock".
504
+ asset_type (str, optional): The type of asset, Defaults to "stock",
505
+ supported types include:
506
+ - "stock": Stock symbols (e.g., AAPL, MSFT)
507
+ - "etf": Exchange-traded funds (e.g., SPY, QQQ)
508
+ - "future": Futures contracts (e.g., CL=F for crude oil)
509
+ - "forex": Forex pairs (e.g., EURUSD=X, USDJPY=X)
510
+ - "crypto": Cryptocurrency pairs (e.g., BTC-USD, ETH-USD)
511
+ - "index": Stock market indices (e.g., ^GSPC for S&P 500)
493
512
  top_news (int, optional): Number of news articles/posts to fetch per source. Defaults to 10.
494
513
  **kwargs: Additional parameters for API authentication and data retrieval, including:
495
514
  - fmp_api (str): API key for Financial Modeling Prep.
@@ -500,63 +519,85 @@ class SentimentAnalyzer(object):
500
519
  - Positive values indicate positive sentiment.
501
520
  - Negative values indicate negative sentiment.
502
521
  - Zero indicates neutral sentiment.
522
+ Notes:
523
+ The tickers names must follow yahoo finance conventions.
503
524
  """
504
525
  sentiment_results = {}
505
- rd_params = ["client_id", "client_secret", "user_agent"]
506
- news = FinancialNews()
507
- for ticker in tickers:
508
- # Collect data
509
- sources = 0
510
- yahoo_news = news.get_yahoo_finance_news(
511
- ticker, asset_type=asset_type, n_news=top_news
512
- )
513
- google_news = news.get_google_finance_news(
514
- ticker, asset_type=asset_type, n_news=top_news
515
- )
516
- reddit_posts = news.get_reddit_posts(
517
- ticker, n_posts=top_news, **{k: kwargs.get(k) for k in rd_params}
518
- )
519
- coindesk_news = news.get_coindesk_news(query=ticker, list_of_str=True)
520
- fmp_source_news = []
521
- fmp_news = news.get_fmp_news(kwargs.get("fmp_api"))
522
- for source in ["articles"]: # , "releases", asset_type]:
523
- try:
524
- source_news = fmp_news.get_news(
525
- ticker, source=source, symbol=ticker, **kwargs
526
+ rd_params = {"client_id", "client_secret", "user_agent"}
527
+ fm_params = {"start", "end", "page", "limit"}
528
+ with open(os.devnull, 'w') as devnull:
529
+ with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
530
+ for asset in tickers:
531
+ if isinstance(asset, tuple):
532
+ ticker, asset_type = asset
533
+ if asset_type not in [
534
+ "stock",
535
+ "etf",
536
+ "future",
537
+ "forex",
538
+ "crypto",
539
+ "index",
540
+ ]:
541
+ raise ValueError(
542
+ f"Unsupported asset type '{asset_type}'. "
543
+ "Supported types: stock, etf, future, forex, crypto, index."
544
+ )
545
+ # Collect data
546
+ sources = 0
547
+ yahoo_news = self.news.get_yahoo_finance_news(
548
+ ticker, asset_type=asset_type, n_news=top_news
549
+ )
550
+ google_news = self.news.get_google_finance_news(
551
+ ticker, asset_type=asset_type, n_news=top_news
552
+ )
553
+ reddit_posts = []
554
+ if all(kwargs.get(rd) for rd in rd_params):
555
+ reddit_posts = self.news.get_reddit_posts(
556
+ ticker, n_posts=top_news, **{k: kwargs.get(k) for k in rd_params}
557
+ )
558
+ coindesk_news = self.news.get_coindesk_news(query=ticker, list_of_str=True)
559
+ fmp_source_news = []
560
+ if kwargs.get("fmp_api"):
561
+ fmp_news = self.news.get_fmp_news(kwargs.get("fmp_api"))
562
+ for src in ["articles"]: # , "releases", asset_type]:
563
+ try:
564
+ source_news = fmp_news.get_news(
565
+ ticker, source=src, symbol=ticker, **{k: kwargs.get(k) for k in fm_params}
566
+ )
567
+ fmp_source_news += source_news
568
+ except Exception:
569
+ continue
570
+ if any([len(s) > 0 for s in [yahoo_news, google_news]]):
571
+ sources += 1
572
+ for source in [reddit_posts, fmp_source_news, coindesk_news]:
573
+ if len(source) > 0:
574
+ sources += 1
575
+ # Compute sentiment
576
+ news_sentiment = self.analyze_sentiment(
577
+ yahoo_news + google_news, lexicon=lexicon
578
+ )
579
+ reddit_sentiment = self.analyze_sentiment(
580
+ reddit_posts, lexicon=lexicon, textblob=True
581
+ )
582
+ fmp_sentiment = self.analyze_sentiment(
583
+ fmp_source_news, lexicon=lexicon, textblob=True
584
+ )
585
+ coindesk_sentiment = self.analyze_sentiment(
586
+ coindesk_news, lexicon=lexicon, textblob=True
526
587
  )
527
- fmp_source_news += source_news
528
- except Exception:
529
- source_news = []
530
- if any([len(s) > 0 for s in [yahoo_news, google_news]]):
531
- sources += 1
532
- for source in [reddit_posts, fmp_source_news, coindesk_news]:
533
- if len(source) > 0:
534
- sources += 1
535
- # Compute sentiment
536
- news_sentiment = self.analyze_sentiment(
537
- yahoo_news + google_news, lexicon=lexicon
538
- )
539
- reddit_sentiment = self.analyze_sentiment(
540
- reddit_posts, lexicon=lexicon, textblob=True
541
- )
542
- fmp_sentiment = self.analyze_sentiment(
543
- fmp_source_news, lexicon=lexicon, textblob=True
544
- )
545
- coindesk_sentiment = self.analyze_sentiment(
546
- coindesk_news, lexicon=lexicon, textblob=True
547
- )
548
588
 
549
- # Weighted average sentiment score
550
- if sources != 0:
551
- overall_sentiment = (
552
- news_sentiment
553
- + reddit_sentiment
554
- + fmp_sentiment
555
- + coindesk_sentiment
556
- ) / sources
557
- else:
558
- overall_sentiment = 0.0
559
- sentiment_results[ticker] = overall_sentiment
589
+ # Weighted average sentiment score
590
+ if sources != 0:
591
+ overall_sentiment = (
592
+ news_sentiment
593
+ + reddit_sentiment
594
+ + fmp_sentiment
595
+ + coindesk_sentiment
596
+ ) / sources
597
+ else:
598
+ overall_sentiment = 0.0
599
+ sentiment_results[ticker] = overall_sentiment
600
+ time.sleep(1) # To avoid hitting API rate limits
560
601
 
561
602
  return sentiment_results
562
603
 
@@ -651,8 +692,10 @@ class SentimentAnalyzer(object):
651
692
  bar and scatter plots. It fetches new sentiment data at specified intervals.
652
693
 
653
694
  Args:
654
- tickers (list[str]):
655
- A list of asset tickers (e.g., ["AAPL", "GOOGL", "TSLA"]).
695
+ tickers (List[str] | List[Tuple[str, str]]):
696
+ A list of financial asset tickers to analyze.
697
+ - If using tuples, the first element is the ticker and the second is the asset type.
698
+ - If using a single string, the asset type must be specified or defaults to "stock".
656
699
  asset_type (str, optional):
657
700
  The type of financial asset ("stock", "forex", "crypto"). Defaults to "stock".
658
701
  lexicon (dict, optional):