bbstrader 0.1.94__py3-none-any.whl → 0.2.0__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/__ini__.py +2 -2
- bbstrader/btengine/data.py +14 -4
- bbstrader/btengine/strategy.py +12 -8
- bbstrader/config.py +4 -0
- bbstrader/core/__init__.py +0 -0
- bbstrader/core/data.py +23 -0
- bbstrader/core/utils.py +0 -0
- bbstrader/ibkr/__init__.py +0 -0
- bbstrader/metatrader/rates.py +3 -3
- bbstrader/metatrader/trade.py +17 -4
- bbstrader/models/__init__.py +1 -1
- bbstrader/models/factors.py +275 -0
- bbstrader/models/ml.py +1026 -0
- bbstrader/models/optimization.py +17 -16
- bbstrader/models/{portfolios.py → portfolio.py} +20 -11
- bbstrader/models/risk.py +1 -1
- bbstrader/trading/execution.py +48 -26
- bbstrader/trading/strategies.py +1 -1
- bbstrader/tseries.py +412 -63
- {bbstrader-0.1.94.dist-info → bbstrader-0.2.0.dist-info}/METADATA +5 -1
- bbstrader-0.2.0.dist-info/RECORD +36 -0
- bbstrader-0.1.94.dist-info/RECORD +0 -32
- {bbstrader-0.1.94.dist-info → bbstrader-0.2.0.dist-info}/LICENSE +0 -0
- {bbstrader-0.1.94.dist-info → bbstrader-0.2.0.dist-info}/WHEEL +0 -0
- {bbstrader-0.1.94.dist-info → bbstrader-0.2.0.dist-info}/top_level.txt +0 -0
bbstrader/__ini__.py
CHANGED
|
@@ -5,9 +5,9 @@ Simplified Investment & Trading Toolkit
|
|
|
5
5
|
"""
|
|
6
6
|
__author__ = "Bertin Balouki SIMYELI"
|
|
7
7
|
__copyright__ = "2023-2024 Bertin Balouki SIMYELI"
|
|
8
|
-
__email__ = "
|
|
8
|
+
__email__ = "bertin@bbstrader.com"
|
|
9
9
|
__license__ = "MIT"
|
|
10
|
-
__version__ = '0.
|
|
10
|
+
__version__ = '0.2.0'
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
from bbstrader import btengine
|
bbstrader/btengine/data.py
CHANGED
|
@@ -153,12 +153,19 @@ class BaseCSVDataHandler(DataHandler):
|
|
|
153
153
|
@property
|
|
154
154
|
def symbols(self)-> List[str]:
|
|
155
155
|
return self.symbol_list
|
|
156
|
+
|
|
156
157
|
@property
|
|
157
158
|
def data(self)-> Dict[str, pd.DataFrame]:
|
|
158
159
|
return self.symbol_data
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def datadir(self)-> str:
|
|
163
|
+
return self.csv_dir
|
|
164
|
+
|
|
159
165
|
@property
|
|
160
166
|
def labels(self)-> List[str]:
|
|
161
167
|
return self.columns
|
|
168
|
+
|
|
162
169
|
@property
|
|
163
170
|
def index(self)-> str | List[str]:
|
|
164
171
|
return self._index
|
|
@@ -203,6 +210,7 @@ class BaseCSVDataHandler(DataHandler):
|
|
|
203
210
|
'adj_close' if 'adj_close' in new_names else 'close'
|
|
204
211
|
].pct_change().dropna()
|
|
205
212
|
self._index = self.symbol_data[s].index.name
|
|
213
|
+
self.symbol_data[s].to_csv(os.path.join(self.csv_dir, f'{s}.csv'))
|
|
206
214
|
if self.events is not None:
|
|
207
215
|
self.symbol_data[s] = self.symbol_data[s].iterrows()
|
|
208
216
|
|
|
@@ -474,7 +482,8 @@ class YFDataHandler(BaseCSVDataHandler):
|
|
|
474
482
|
filepath = os.path.join(cache_dir, f"{symbol}.csv")
|
|
475
483
|
try:
|
|
476
484
|
data = yf.download(
|
|
477
|
-
symbol, start=self.start_date, end=self.end_date,
|
|
485
|
+
symbol, start=self.start_date, end=self.end_date,
|
|
486
|
+
multi_level_index=False, progress=False)
|
|
478
487
|
if data.empty:
|
|
479
488
|
raise ValueError(f"No data found for {symbol}")
|
|
480
489
|
data.to_csv(filepath)
|
|
@@ -634,12 +643,13 @@ class FMPDataHandler(BaseCSVDataHandler):
|
|
|
634
643
|
api_key=self.__api_key,
|
|
635
644
|
start_date=self.start_date,
|
|
636
645
|
end_date=self.end_date,
|
|
637
|
-
benchmark_ticker=None
|
|
646
|
+
benchmark_ticker=None,
|
|
647
|
+
progress_bar=False
|
|
638
648
|
)
|
|
639
649
|
if period in ['daily', 'weekly', 'monthly', 'quarterly', 'yearly']:
|
|
640
|
-
return toolkit.get_historical_data(period=period)
|
|
650
|
+
return toolkit.get_historical_data(period=period, progress_bar=False)
|
|
641
651
|
elif period in ['1min', '5min', '15min', '30min', '1hour']:
|
|
642
|
-
return toolkit.get_intraday_data(period=period)
|
|
652
|
+
return toolkit.get_intraday_data(period=period, progress_bar=False)
|
|
643
653
|
|
|
644
654
|
def _format_data(self, data: pd.DataFrame, period: str) -> pd.DataFrame:
|
|
645
655
|
if data.empty or len(data) == 0:
|
bbstrader/btengine/strategy.py
CHANGED
|
@@ -86,6 +86,7 @@ class MT5Strategy(Strategy):
|
|
|
86
86
|
self.tf = kwargs.get("time_frame", 'D1')
|
|
87
87
|
self.logger = kwargs.get("logger")
|
|
88
88
|
self._initialize_portfolio()
|
|
89
|
+
self.kwargs = kwargs
|
|
89
90
|
|
|
90
91
|
@property
|
|
91
92
|
def cash(self) -> float:
|
|
@@ -201,19 +202,22 @@ class MT5Strategy(Strategy):
|
|
|
201
202
|
- ``action``: The action to take for the symbol (LONG, SHORT, EXIT, etc.)
|
|
202
203
|
- ``price``: The price at which to execute the action.
|
|
203
204
|
- ``stoplimit``: The stop-limit price for STOP-LIMIT orders.
|
|
205
|
+
- ``id``: The unique identifier for the strategy or order.
|
|
204
206
|
|
|
205
|
-
The dictionary can be use for pending orders (limit, stop, stop-limit) where the price is required
|
|
207
|
+
The dictionary can be use for pending orders (limit, stop, stop-limit) where the price is required
|
|
208
|
+
or for executing orders where the each order has a unique identifier.
|
|
206
209
|
"""
|
|
207
210
|
pass
|
|
208
211
|
|
|
209
|
-
def apply_risk_management(self, optimer, freq=252) -> Dict[str, float] | None:
|
|
212
|
+
def apply_risk_management(self, optimer, symbols=None, freq=252) -> Dict[str, float] | None:
|
|
210
213
|
"""
|
|
211
214
|
Apply risk management rules to the strategy.
|
|
212
215
|
"""
|
|
213
216
|
if optimer is None:
|
|
214
217
|
return None
|
|
218
|
+
symbols = symbols or self.symbols
|
|
215
219
|
prices = self.get_asset_values(
|
|
216
|
-
symbol_list=
|
|
220
|
+
symbol_list=symbols, bars=self.data, mode=self.mode,
|
|
217
221
|
window=freq, value_type='close', array=False, tf=self.tf
|
|
218
222
|
)
|
|
219
223
|
prices = pd.DataFrame(prices)
|
|
@@ -222,7 +226,7 @@ class MT5Strategy(Strategy):
|
|
|
222
226
|
weights = optimized_weights(prices=prices, freq=freq, method=optimer)
|
|
223
227
|
return {symbol: weight for symbol, weight in weights.items()}
|
|
224
228
|
except Exception:
|
|
225
|
-
return {symbol: 0.0 for symbol in
|
|
229
|
+
return {symbol: 0.0 for symbol in symbols}
|
|
226
230
|
|
|
227
231
|
def get_quantity(self, symbol, weight, price=None, volume=None, maxqty=None) -> int:
|
|
228
232
|
"""
|
|
@@ -530,7 +534,7 @@ class MT5Strategy(Strategy):
|
|
|
530
534
|
asset_values[asset] = getattr(values, value_type)
|
|
531
535
|
elif mode == 'live':
|
|
532
536
|
for asset in symbol_list:
|
|
533
|
-
rates = Rates(
|
|
537
|
+
rates = Rates(asset, timeframe=tf, count=window + 1, **self.kwargs)
|
|
534
538
|
if array:
|
|
535
539
|
values = getattr(rates, value_type).values
|
|
536
540
|
asset_values[asset] = values[~np.isnan(values)]
|
|
@@ -575,11 +579,11 @@ class MT5Strategy(Strategy):
|
|
|
575
579
|
Returns:
|
|
576
580
|
bool : True if there are open positions, False otherwise
|
|
577
581
|
"""
|
|
578
|
-
account = account or Account()
|
|
582
|
+
account = account or Account(**self.kwargs)
|
|
579
583
|
positions = account.get_positions(symbol=symbol)
|
|
580
584
|
if positions is not None:
|
|
581
585
|
open_positions = [
|
|
582
|
-
pos for pos in positions if pos.type == position
|
|
586
|
+
pos.ticket for pos in positions if pos.type == position
|
|
583
587
|
and pos.magic == strategy_id
|
|
584
588
|
]
|
|
585
589
|
if one_true:
|
|
@@ -600,7 +604,7 @@ class MT5Strategy(Strategy):
|
|
|
600
604
|
Returns:
|
|
601
605
|
prices : numpy array of buy or sell prices for open positions if any or an empty array.
|
|
602
606
|
"""
|
|
603
|
-
account = account or Account()
|
|
607
|
+
account = account or Account(**self.kwargs)
|
|
604
608
|
positions = account.get_positions(symbol=symbol)
|
|
605
609
|
if positions is not None:
|
|
606
610
|
prices = np.array([
|
bbstrader/config.py
CHANGED
|
@@ -3,6 +3,10 @@ from typing import List
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
|
|
6
|
+
|
|
7
|
+
ADMIRAL_PATH = "C:\\Program Files\\Admirals Group MT5 Terminal\\terminal64.exe"
|
|
8
|
+
FTMO_PATH = "C:\\Program Files\\FTMO MetaTrader 5\\terminal64.exe"
|
|
9
|
+
|
|
6
10
|
def get_config_dir(name: str=".bbstrader") -> Path:
|
|
7
11
|
"""
|
|
8
12
|
Get the path to the configuration directory.
|
|
File without changes
|
bbstrader/core/data.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from financetoolkit import Toolkit
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'FMP',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
class FMP(Toolkit):
|
|
11
|
+
"""
|
|
12
|
+
FMPData class for fetching data from Financial Modeling Prep API
|
|
13
|
+
using the Toolkit class from financetoolkit package.
|
|
14
|
+
|
|
15
|
+
See `financetoolkit` for more details.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
def __init__(self, api_key: str ='', symbols: str | list = 'AAPL'):
|
|
19
|
+
super().__init__(tickers=symbols, api_key=api_key)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DataBendo:
|
|
23
|
+
...
|
bbstrader/core/utils.py
ADDED
|
File without changes
|
|
File without changes
|
bbstrader/metatrader/rates.py
CHANGED
|
@@ -20,7 +20,9 @@ from exchange_calendars import(
|
|
|
20
20
|
__all__ = [
|
|
21
21
|
'Rates',
|
|
22
22
|
'download_historical_data',
|
|
23
|
-
'get_data_from_pos'
|
|
23
|
+
'get_data_from_pos',
|
|
24
|
+
'get_data_from_date'
|
|
25
|
+
|
|
24
26
|
]
|
|
25
27
|
|
|
26
28
|
MAX_BARS = 10_000_000
|
|
@@ -121,7 +123,6 @@ class Rates(object):
|
|
|
121
123
|
For `session_duration` check your broker symbols details
|
|
122
124
|
"""
|
|
123
125
|
self.symbol = symbol
|
|
124
|
-
tf = kwargs.get('time_frame')
|
|
125
126
|
self.time_frame = self._validate_time_frame(timeframe)
|
|
126
127
|
self.sd = session_duration
|
|
127
128
|
self.start_pos = self._get_start_pos(start_pos, timeframe)
|
|
@@ -223,7 +224,6 @@ class Rates(object):
|
|
|
223
224
|
df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume']
|
|
224
225
|
df['Adj Close'] = df['Close']
|
|
225
226
|
df = df[['Date', 'Open', 'High', 'Low', 'Close', 'Adj Close', 'Volume']]
|
|
226
|
-
#df = df.columns.rename(str.lower).str.replace(' ', '_')
|
|
227
227
|
df['Date'] = pd.to_datetime(df['Date'], unit='s', utc=utc)
|
|
228
228
|
df.set_index('Date', inplace=True)
|
|
229
229
|
if lower_colnames:
|
bbstrader/metatrader/trade.py
CHANGED
|
@@ -874,8 +874,9 @@ class Trade(RiskManagement):
|
|
|
874
874
|
"""
|
|
875
875
|
This function checks if it's time to set the break-even level for a trading position.
|
|
876
876
|
If it is, it sets the break-even level. If the break-even level has already been set,
|
|
877
|
-
it checks if the price has moved in a favorable direction.
|
|
878
|
-
it
|
|
877
|
+
it checks if the price has moved in a favorable direction.
|
|
878
|
+
If it has, and the trail parameter is set to True, it updates
|
|
879
|
+
the break-even level based on the trail_after_points and stop_trail parameters.
|
|
879
880
|
|
|
880
881
|
Args:
|
|
881
882
|
id (int): The strategy ID or expert ID.
|
|
@@ -883,8 +884,10 @@ class Trade(RiskManagement):
|
|
|
883
884
|
trail (bool): Whether to trail the stop loss or not.
|
|
884
885
|
stop_trail (int): Number of points to trail the stop loss by.
|
|
885
886
|
It represent the distance from the current price to the stop loss.
|
|
886
|
-
trail_after_points (int): Number of points in profit
|
|
887
|
-
|
|
887
|
+
trail_after_points (int): Number of points in profit
|
|
888
|
+
from where the strategy will start to trail the stop loss.
|
|
889
|
+
be_plus_points (int): Number of points to add to the break-even level.
|
|
890
|
+
Represents the minimum profit to secure.
|
|
888
891
|
"""
|
|
889
892
|
time.sleep(0.1)
|
|
890
893
|
if not mm:
|
|
@@ -1515,6 +1518,7 @@ def create_trade_instance(
|
|
|
1515
1518
|
based on the importance of the symbol in the portfolio or strategy.
|
|
1516
1519
|
"""
|
|
1517
1520
|
logger = params.get('logger', None)
|
|
1521
|
+
ids = params.get('expert_id', None)
|
|
1518
1522
|
trade_instances = {}
|
|
1519
1523
|
if not symbols:
|
|
1520
1524
|
raise ValueError("The 'symbols' list cannot be empty.")
|
|
@@ -1534,10 +1538,19 @@ def create_trade_instance(
|
|
|
1534
1538
|
for symbol in symbols:
|
|
1535
1539
|
if symbol not in pchange_sl:
|
|
1536
1540
|
raise ValueError(f"Missing percentage change for symbol '{symbol}'.")
|
|
1541
|
+
if isinstance(ids, dict):
|
|
1542
|
+
for symbol in symbols:
|
|
1543
|
+
if symbol not in ids:
|
|
1544
|
+
raise ValueError(f"Missing expert ID for symbol '{symbol}'.")
|
|
1537
1545
|
|
|
1538
1546
|
for symbol in symbols:
|
|
1539
1547
|
try:
|
|
1540
1548
|
params['symbol'] = symbol
|
|
1549
|
+
params['expert_id'] = (
|
|
1550
|
+
ids[symbol] if ids is not None and isinstance(ids, dict) else
|
|
1551
|
+
ids if ids is not None and isinstance(ids, (int, float)) else
|
|
1552
|
+
params['expert_id'] if 'expert_id' in params else None
|
|
1553
|
+
)
|
|
1541
1554
|
params['pchange_sl'] = (
|
|
1542
1555
|
pchange_sl[symbol] if pchange_sl is not None
|
|
1543
1556
|
and isinstance(pchange_sl, dict) else
|
bbstrader/models/__init__.py
CHANGED
|
@@ -5,6 +5,6 @@ It is designed to be a versatile base module for different types of models used
|
|
|
5
5
|
"""
|
|
6
6
|
from bbstrader.models.risk import *
|
|
7
7
|
from bbstrader.models.optimization import *
|
|
8
|
-
from bbstrader.models.
|
|
8
|
+
from bbstrader.models.portfolio import *
|
|
9
9
|
from bbstrader.models.factors import *
|
|
10
10
|
from bbstrader.models.ml import *
|
bbstrader/models/factors.py
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import yfinance as yf
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from bbstrader.metatrader.rates import download_historical_data
|
|
6
|
+
from bbstrader.btengine.data import FMPDataHandler, EODHDataHandler
|
|
7
|
+
from typing import List, Dict, Literal, Union
|
|
8
|
+
from bbstrader.tseries import (
|
|
9
|
+
find_cointegrated_pairs,
|
|
10
|
+
select_candidate_pairs,
|
|
11
|
+
select_assets,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"search_coint_candidate_pairs",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
def search_coint_candidate_pairs(
|
|
19
|
+
securities: pd.DataFrame | List[str] = None,
|
|
20
|
+
candidates: pd.DataFrame | List[str] = None,
|
|
21
|
+
start: str = None,
|
|
22
|
+
end: str = None,
|
|
23
|
+
period_search: bool = False,
|
|
24
|
+
select: bool = True,
|
|
25
|
+
source: str = None,
|
|
26
|
+
universe: int = 100,
|
|
27
|
+
window: int = 2,
|
|
28
|
+
rolling_window: int = None,
|
|
29
|
+
npairs: int = 10,
|
|
30
|
+
tf: str = 'D1',
|
|
31
|
+
path: str = None,
|
|
32
|
+
**kwargs
|
|
33
|
+
) -> List[Dict[str, str]] | pd.DataFrame:
|
|
34
|
+
"""
|
|
35
|
+
Searches for candidate pairs of securities based on cointegration analysis.
|
|
36
|
+
|
|
37
|
+
This function either processes preloaded securities and candidates data
|
|
38
|
+
(as pandas DataFrames) or downloads historical data from a specified
|
|
39
|
+
source (e.g., Yahoo Finance, MetaTrader 5, Financial Modeling Prep, or EODHD).
|
|
40
|
+
It then selects the top `npairs` based on cointegration.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
securities (pd.DataFrame | List[str], optional):
|
|
44
|
+
A DataFrame or list of tickers representing the securities for analysis.
|
|
45
|
+
If using a DataFrame, it should include a MultiIndex with levels
|
|
46
|
+
['ticker', 'date'].
|
|
47
|
+
candidates (pd.DataFrame | List[str], optional):
|
|
48
|
+
A DataFrame or list of tickers representing the candidate securities
|
|
49
|
+
for pair selection.
|
|
50
|
+
start (str, optional):
|
|
51
|
+
The start date for data retrieval in 'YYYY-MM-DD' format. Ignored
|
|
52
|
+
if both `securities` and `candidates` are DataFrames.
|
|
53
|
+
end (str, optional):
|
|
54
|
+
The end date for data retrieval in 'YYYY-MM-DD' format. Ignored
|
|
55
|
+
if both `securities` and `candidates` are DataFrames.
|
|
56
|
+
period_search (bool, optional):
|
|
57
|
+
If True, the function will perform a periodic search for cointegrated from 3 years
|
|
58
|
+
to the end date by taking 2 yerars rolling window. So you need to have at least 3 years of data
|
|
59
|
+
or set the `window` parameter to 3. Defaults to False.
|
|
60
|
+
select (bool, optional):
|
|
61
|
+
If True, the function will select the top cointegrated pairs based on the
|
|
62
|
+
cointegration test results in form of List[dict].
|
|
63
|
+
If False, the function will return all cointegrated pairs in form of DataFrame.
|
|
64
|
+
This can be useful for further analysis or visualization.
|
|
65
|
+
source (str, optional):
|
|
66
|
+
The data source for historical data retrieval. Must be one of
|
|
67
|
+
['yf', 'mt5', 'fmp', 'eodhd']. Required if `securities` and
|
|
68
|
+
`candidates` are lists of tickers.
|
|
69
|
+
universe (int, optional):
|
|
70
|
+
The maximum number of assets to retain for analysis. Defaults to 100.
|
|
71
|
+
window (int, optional):
|
|
72
|
+
The number of years of historical data to retrieve if `start` and `end`
|
|
73
|
+
are not specified. Defaults to 2 years.
|
|
74
|
+
rolling_window (int, optional):
|
|
75
|
+
The size of the rolling window (in days) used for asset selection.
|
|
76
|
+
Defaults to None.
|
|
77
|
+
npairs (int, optional):
|
|
78
|
+
The number of top cointegrated pairs to select. Defaults to 10.
|
|
79
|
+
tf (str, optional):
|
|
80
|
+
The timeframe for MetaTrader 5 data retrieval. Defaults to 'D1'.
|
|
81
|
+
path (str, optional):
|
|
82
|
+
The path to MetaTrader 5 historical data files. Required if `source='mt5'`.
|
|
83
|
+
**kwargs:
|
|
84
|
+
Additional parameters for data retrieval (e.g., API keys, date ranges
|
|
85
|
+
for specific sources), see ``bbstrader.btengine.data.FMPDataHandler`` or
|
|
86
|
+
``bbstrader.btengine.data.EODHDataHandler`` for more details.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List[dict]: A list containing the selected top cointegrated pairs if `select=True`.
|
|
90
|
+
pd.DataFrame: A DataFrame containing all cointegrated pairs if `select=False`.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If the inputs are invalid or if the `source` is not one of
|
|
94
|
+
the supported sources.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
Using preloaded DataFrames:
|
|
98
|
+
>>> securities = pd.read_csv('securities.csv', index_col=['ticker', 'date'])
|
|
99
|
+
>>> candidates = pd.read_csv('candidates.csv', index_col=['ticker', 'date'])
|
|
100
|
+
>>> pairs = search_candidate_pairs(securities=securities, candidates=candidates)
|
|
101
|
+
|
|
102
|
+
Using a data source (Yahoo Finance):
|
|
103
|
+
>>> securities = ['SPY', 'IWM', 'XLF', 'HYG', 'XLE', 'LQD', 'GDX', 'FXI', 'EWZ', ...]
|
|
104
|
+
>>> candidates = ['AAPL', 'AMZN', 'NVDA', 'MSFT', 'GOOGL', 'AMD', 'BAC', 'NFLX', ...]
|
|
105
|
+
|
|
106
|
+
>>> pairs = search_candidate_pairs(
|
|
107
|
+
... securities=securities,
|
|
108
|
+
... candidates=candidates,
|
|
109
|
+
... start='2022-12-12',
|
|
110
|
+
... end='2024-12-10',
|
|
111
|
+
... source='yf',
|
|
112
|
+
... npairs=10
|
|
113
|
+
... )
|
|
114
|
+
>>> [
|
|
115
|
+
... {'x': 'LQD', 'y': 'TMO'},
|
|
116
|
+
... {'x': 'IEF', 'y': 'COP'},
|
|
117
|
+
... {'x': 'WMT', 'y': 'IWM'},
|
|
118
|
+
... {'x': 'MDT', 'y': 'OIH'},
|
|
119
|
+
... {'x': 'EWZ', 'y': 'CMCSA'},
|
|
120
|
+
... {'x': 'VLO', 'y': 'XOP'},
|
|
121
|
+
... {'x': 'SHY', 'y': 'F'},
|
|
122
|
+
... {'x': 'ABT', 'y': 'LQD'},
|
|
123
|
+
... {'x': 'PFE', 'y': 'USO'},
|
|
124
|
+
... {'x': 'LQD', 'y': 'MDT'}
|
|
125
|
+
... ]
|
|
126
|
+
|
|
127
|
+
Using MetaTrader 5:
|
|
128
|
+
>>> securities = ['EURUSD', 'GBPUSD']
|
|
129
|
+
>>> candidates = ['USDJPY', 'AUDUSD']
|
|
130
|
+
>>> pairs = search_candidate_pairs(
|
|
131
|
+
... securities=securities,
|
|
132
|
+
... candidates=candidates,
|
|
133
|
+
... source='mt5',
|
|
134
|
+
... tf='H1',
|
|
135
|
+
... path='/path/to/terminal64.exe',
|
|
136
|
+
... )
|
|
137
|
+
|
|
138
|
+
Notes:
|
|
139
|
+
- If `securities` and `candidates` are DataFrames, the function assumes
|
|
140
|
+
the data is already preprocessed and indexed by ['ticker', 'date'].
|
|
141
|
+
- When using `source='fmp'` or `source='eodhd'`, API keys and other
|
|
142
|
+
required parameters should be passed via `kwargs`.
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def _download_and_process_data(source, tickers, start, end, tf, path, **kwargs):
|
|
147
|
+
"""Download and process data for a list of tickers from the specified source."""
|
|
148
|
+
data_list = []
|
|
149
|
+
for ticker in tickers:
|
|
150
|
+
try:
|
|
151
|
+
if source == 'yf':
|
|
152
|
+
data = yf.download(
|
|
153
|
+
ticker, start=start, end=end, progress=False, multi_level_index=False)
|
|
154
|
+
data = data.drop(columns=['Adj Close'], axis=1)
|
|
155
|
+
elif source == 'mt5':
|
|
156
|
+
start, end = pd.Timestamp(start), pd.Timestamp(end)
|
|
157
|
+
data = download_historical_data(
|
|
158
|
+
symbol=ticker, timeframe=tf, date_from=start, date_to=end, **{'path': path})
|
|
159
|
+
data = data.drop(columns=['adj_close'], axis=1)
|
|
160
|
+
elif source in ['fmp', 'eodhd']:
|
|
161
|
+
handler_class = FMPDataHandler if source == 'fmp' else EODHDataHandler
|
|
162
|
+
handler = handler_class(
|
|
163
|
+
events=None, symbol_list=[ticker], **kwargs)
|
|
164
|
+
data = handler.data[ticker]
|
|
165
|
+
else:
|
|
166
|
+
raise ValueError(f"Invalid source: {source}")
|
|
167
|
+
|
|
168
|
+
data = data.reset_index()
|
|
169
|
+
data = data.rename(columns=str.lower)
|
|
170
|
+
data['ticker'] = ticker
|
|
171
|
+
data_list.append(data)
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
print(f"No Data found for {ticker}: {e}")
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
return pd.concat(data_list)
|
|
178
|
+
|
|
179
|
+
def _handle_date_range(start, end, window):
|
|
180
|
+
"""Handle start and end date generation."""
|
|
181
|
+
if start is None or end is None:
|
|
182
|
+
end = pd.Timestamp(datetime.now()).strftime('%Y-%m-%d')
|
|
183
|
+
start = (
|
|
184
|
+
pd.Timestamp(datetime.now()) -
|
|
185
|
+
pd.DateOffset(years=window) + pd.DateOffset(days=1)
|
|
186
|
+
).strftime('%Y-%m-%d')
|
|
187
|
+
return start, end
|
|
188
|
+
|
|
189
|
+
def _period_search(start, end, securities, candidates, npairs=npairs):
|
|
190
|
+
if window < 3 or (pd.Timestamp(end) - pd.Timestamp(start)).days / 365 < 3:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"The date range must be at least two (2) years for period search."
|
|
193
|
+
)
|
|
194
|
+
top_pairs = []
|
|
195
|
+
p_start = pd.Timestamp(end) - pd.DateOffset(years=1)
|
|
196
|
+
periods = pd.date_range(start=p_start, end=pd.Timestamp(end), freq='BQE')
|
|
197
|
+
npairs = max(round(npairs/2), 1)
|
|
198
|
+
for period in periods:
|
|
199
|
+
s_start = period - pd.DateOffset(years=2) + pd.DateOffset(days=1)
|
|
200
|
+
print(f"Searching for pairs in period: {s_start} - {period}")
|
|
201
|
+
pairs = find_cointegrated_pairs(
|
|
202
|
+
securities, candidates, n=npairs, start=str(s_start), stop=str(period), coint=True)
|
|
203
|
+
pairs['period'] = period
|
|
204
|
+
top_pairs.append(pairs)
|
|
205
|
+
top_pairs = pd.concat(top_pairs)
|
|
206
|
+
if len(top_pairs.columns) <= 1:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
"No pairs found in the specified period."
|
|
209
|
+
"Please adjust the date range or increase the number of pairs."
|
|
210
|
+
)
|
|
211
|
+
return top_pairs.head(npairs*2)
|
|
212
|
+
|
|
213
|
+
def _process_asset_data(securities, candidates, universe, rolling_window):
|
|
214
|
+
"""Process and select assets from the data."""
|
|
215
|
+
securities = select_assets(
|
|
216
|
+
securities, n=universe, rolling_window=rolling_window)
|
|
217
|
+
candidates = select_assets(
|
|
218
|
+
candidates, n=universe, rolling_window=rolling_window)
|
|
219
|
+
return securities, candidates
|
|
220
|
+
|
|
221
|
+
if (securities is not None and candidates is not None
|
|
222
|
+
and isinstance(securities, pd.DataFrame)
|
|
223
|
+
and isinstance(candidates, pd.DataFrame)
|
|
224
|
+
):
|
|
225
|
+
if isinstance(securities.index, pd.MultiIndex) and isinstance(candidates.index, pd.MultiIndex):
|
|
226
|
+
securities, candidates = _process_asset_data(
|
|
227
|
+
securities, candidates, universe, rolling_window)
|
|
228
|
+
if period_search:
|
|
229
|
+
start = securities.index.get_level_values('date').min()
|
|
230
|
+
end = securities.index.get_level_values('date').max()
|
|
231
|
+
top_pairs = _period_search(start, end, securities, candidates)
|
|
232
|
+
else:
|
|
233
|
+
top_pairs = find_cointegrated_pairs(
|
|
234
|
+
securities, candidates, n=npairs, coint=True)
|
|
235
|
+
if select:
|
|
236
|
+
return select_candidate_pairs(top_pairs, period=True if period_search else False)
|
|
237
|
+
else:
|
|
238
|
+
return top_pairs
|
|
239
|
+
|
|
240
|
+
elif source is not None:
|
|
241
|
+
if source not in ['yf', 'mt5', 'fmp', 'eodhd']:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"source must be either 'yf', 'mt5', 'fmp', or 'eodhd'")
|
|
244
|
+
if not isinstance(securities, list) or not isinstance(candidates, list):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"securities and candidates must be a list of tickers")
|
|
247
|
+
|
|
248
|
+
start, end = _handle_date_range(start, end, window)
|
|
249
|
+
if source in ['fmp', 'eodhd']:
|
|
250
|
+
kwargs[f'{source}_start'] = kwargs.get(f'{source}_start') or start
|
|
251
|
+
kwargs[f'{source}_end'] = kwargs.get(f'{source}_end') or end
|
|
252
|
+
|
|
253
|
+
securities_data = _download_and_process_data(
|
|
254
|
+
source, securities, start, end, tf, path, **kwargs)
|
|
255
|
+
candidates_data = _download_and_process_data(
|
|
256
|
+
source, candidates, start, end, tf, path, **kwargs)
|
|
257
|
+
securities_data = securities_data.set_index(['ticker', 'date'])
|
|
258
|
+
candidates_data = candidates_data.set_index(['ticker', 'date'])
|
|
259
|
+
securities_data, candidates_data = _process_asset_data(
|
|
260
|
+
securities_data, candidates_data, universe, rolling_window)
|
|
261
|
+
if period_search:
|
|
262
|
+
top_pairs = _period_search(start, end, securities_data, candidates_data).head(npairs)
|
|
263
|
+
else:
|
|
264
|
+
top_pairs = find_cointegrated_pairs(securities_data, candidates_data, n=npairs, coint=True)
|
|
265
|
+
if select:
|
|
266
|
+
return select_candidate_pairs(top_pairs, period=True if period_search else False)
|
|
267
|
+
else:
|
|
268
|
+
return top_pairs
|
|
269
|
+
|
|
270
|
+
else:
|
|
271
|
+
msg = (
|
|
272
|
+
"Invalid input. Either provide securities"
|
|
273
|
+
"and candidates as DataFrames or specify a data source."
|
|
274
|
+
)
|
|
275
|
+
raise ValueError(msg)
|