• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ig-python / ig-markets-api-python-library / 3870296990

pending completion
3870296990

push

github

Andy Geach
version bump for release 0.0.19

1036 of 1373 relevant lines covered (75.46%)

0.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.83
/trading_ig/rest.py
1
#!/usr/bin/env python
2
# -*- coding:utf-8 -*-
3

4
"""
1✔
5
IG Markets REST API Library for Python
6
https://labs.ig.com/rest-trading-api-reference
7
Original version by Lewis Barber - 2014 - https://uk.linkedin.com/in/lewisbarber/
8
Modified by Femto Trader - 2014-2015 - https://github.com/femtotrader/
9
"""  # noqa
10

11
import json
1✔
12
import logging
1✔
13
import time
1✔
14
from base64 import b64encode, b64decode
1✔
15

16
from Crypto.Cipher import PKCS1_v1_5
1✔
17
from Crypto.PublicKey import RSA
1✔
18
from requests import Session
1✔
19
from urllib.parse import urlparse, parse_qs
1✔
20

21
from datetime import timedelta, datetime
1✔
22
from .utils import _HAS_PANDAS, _HAS_MUNCH
1✔
23
from .utils import conv_resol, conv_datetime, conv_to_ms, DATE_FORMATS
1✔
24

25
if _HAS_MUNCH:
1✔
26
    from .utils import munchify
1✔
27

28
if _HAS_PANDAS:
1✔
29
    from .utils import pd, np
1✔
30
    from pandas import json_normalize
1✔
31

32
from threading import Thread
1✔
33
from queue import Queue, Empty
1✔
34

35
logger = logging.getLogger(__name__)
1✔
36

37

38
class ApiExceededException(Exception):
1✔
39
    """Raised when our code hits the IG endpoint too often"""
40
    pass
1✔
41

42

43
class IGException(Exception):
1✔
44
    pass
1✔
45

46

47
class IGSessionCRUD(object):
1✔
48
    """Session with CRUD operation"""
49

50
    BASE_URL = None
1✔
51

52
    def __init__(self, base_url, api_key, session):
1✔
53
        self.BASE_URL = base_url
1✔
54
        self.API_KEY = api_key
1✔
55
        self.session = session
1✔
56

57
        self.session.headers.update({
1✔
58
            "X-IG-API-KEY": self.API_KEY,
59
            'Content-Type': 'application/json',
60
            'Accept': 'application/json; charset=UTF-8'
61
        })
62

63
    def _get_session(self, session):
1✔
64
        """Returns a Requests session if session is None
65
        or session if it's not None (cached session
66
        with requests-cache for example)
67

68
        :param session:
69
        :return:
70
        """
71
        if session is None:
1✔
72
            session = self.session  # requests Session
×
73
        else:
74
            session = session
1✔
75
        return session
1✔
76

77
    def _url(self, endpoint):
1✔
78
        """Returns url from endpoint and base url"""
79
        return self.BASE_URL + endpoint
1✔
80

81
    def create(self, endpoint, params, session, version):
1✔
82
        """Create = POST"""
83
        url = self._url(endpoint)
1✔
84
        session = self._get_session(session)
1✔
85
        session.headers.update({'VERSION': version})
1✔
86
        response = session.post(url, data=json.dumps(params))
1✔
87
        logging.info(f"POST '{endpoint}', resp {response.status_code}")
1✔
88
        if response.status_code in [401, 403]:
1✔
89
            if 'exceeded-api-key-allowance' in response.text:
1✔
90
                raise ApiExceededException()
1✔
91
            else:
92
                raise IGException(f"HTTP error: {response.status_code} {response.text}")
1✔
93

94
        return response
1✔
95

96
    def read(self, endpoint, params, session, version):
1✔
97
        """Read = GET"""
98
        url = self._url(endpoint)
1✔
99
        session = self._get_session(session)
1✔
100
        session.headers.update({'VERSION': version})
1✔
101
        response = session.get(url, params=params)
1✔
102
        # handle 'read_session' with 'fetchSessionTokens=true'
103
        handle_session_tokens(response, self.session)
1✔
104
        logging.info(f"GET '{endpoint}', resp {response.status_code}")
1✔
105
        return response
1✔
106

107
    def update(self, endpoint, params, session, version):
1✔
108
        """Update = PUT"""
109
        url = self._url(endpoint)
1✔
110
        session = self._get_session(session)
1✔
111
        session.headers.update({'VERSION': version})
1✔
112
        response = session.put(url, data=json.dumps(params))
1✔
113
        logging.info(f"PUT '{endpoint}', resp {response.status_code}")
1✔
114
        return response
1✔
115

116
    def delete(self, endpoint, params, session, version):
1✔
117
        """Delete = POST"""
118
        url = self._url(endpoint)
1✔
119
        session = self._get_session(session)
1✔
120
        session.headers.update({'VERSION': version})
1✔
121
        session.headers.update({'_method': 'DELETE'})
1✔
122
        response = session.post(url, data=json.dumps(params))
1✔
123
        logging.info(f"DELETE (POST) '{endpoint}', resp {response.status_code}")
1✔
124
        if '_method' in session.headers:
1✔
125
            del session.headers['_method']
1✔
126
        return response
1✔
127

128
    def req(self, action, endpoint, params, session, version):
1✔
129
        """Send a request (CREATE READ UPDATE or DELETE)"""
130
        d_actions = {
1✔
131
            "create": self.create,
132
            "read": self.read,
133
            "update": self.update,
134
            "delete": self.delete,
135
        }
136
        return d_actions[action](endpoint, params, session, version)
1✔
137

138

139
class IGService:
1✔
140

141
    D_BASE_URL = {
1✔
142
        "live": "https://api.ig.com/gateway/deal",
143
        "demo": "https://demo-api.ig.com/gateway/deal",
144
    }
145

146
    API_KEY = None
1✔
147
    IG_USERNAME = None
1✔
148
    IG_PASSWORD = None
1✔
149
    _refresh_token = None
1✔
150
    _valid_until = None
1✔
151

152
    def __init__(
1✔
153
        self,
154
        username,
155
        password,
156
        api_key,
157
        acc_type="demo",
158
        acc_number=None,
159
        session=None,
160
        return_dataframe=_HAS_PANDAS,
161
        return_munch=_HAS_MUNCH,
162
        retryer=None,
163
        use_rate_limiter=False
164
    ):
165
        """Constructor, calls the method required to connect to
166
        the API (accepts acc_type = LIVE or DEMO)"""
167
        self.API_KEY = api_key
1✔
168
        self.IG_USERNAME = username
1✔
169
        self.IG_PASSWORD = password
1✔
170
        self.ACC_NUMBER = acc_number
1✔
171
        self._retryer = retryer
1✔
172
        self._use_rate_limiter = use_rate_limiter
1✔
173
        self._bucket_threads_run = False
1✔
174
        try:
1✔
175
            self.BASE_URL = self.D_BASE_URL[acc_type.lower()]
1✔
176
        except Exception:
1✔
177
            raise IGException("Invalid account type '%s', please provide LIVE or DEMO" %
1✔
178
                              acc_type)
179

180
        self.return_dataframe = return_dataframe
1✔
181
        self.return_munch = return_munch
1✔
182

183
        if session is None:
1✔
184
            self.session = Session()  # Requests Session (global)
1✔
185
        else:
186
            self.session = session
×
187

188
        self.crud_session = IGSessionCRUD(self.BASE_URL, self.API_KEY, self.session)
1✔
189

190
    def setup_rate_limiter(self, ):
1✔
191

192
        data = self.get_client_apps()
1✔
193
        for acc in data:
1✔
194
            if acc['apiKey'] == self.API_KEY:
1✔
195
                break
×
196

197
        # If self.create_session() is called a second time, we should exit any currently running threads
198
        self._exit_bucket_threads()
1✔
199

200
        # Horrific magic number to reduce API published allowable requests per minute to a
201
        # value that wont result in 403 -> error.public-api.exceeded-account-trading-allowance
202
        # Tested for non_trading = 30 (live) and 10 (demo) requests per minute.
203
        # This wouldn't be needed if IG's API functioned as published!
204
        MAGIC_NUMBER = 2
1✔
205

206
        self._trading_requests_per_minute = acc['allowanceAccountTrading'] - MAGIC_NUMBER
1✔
207
        logging.info(f"Published IG Trading Request limits for trading request: "
1✔
208
                     f"{acc['allowanceAccountTrading']} per minute. Using: {self._trading_requests_per_minute}")
209

210
        self._non_trading_requests_per_minute = acc['allowanceAccountOverall'] - MAGIC_NUMBER
1✔
211
        logging.info(f"Published IG Trading Request limits for non-trading request: "
1✔
212
                     f"{acc['allowanceAccountOverall']} per minute. Using {self._non_trading_requests_per_minute}")
213

214
        time.sleep(60.0 / self._non_trading_requests_per_minute)
1✔
215

216
        self._bucket_threads_run = True  # Thread exit variable
1✔
217

218
        # Create a leaky token bucket for trading requests
219
        trading_requests_burst = 1  # If IG ever allow bursting, increase this
1✔
220
        self._trading_requests_queue = Queue(trading_requests_burst)
1✔
221
        # prefill the bucket so we can burst
222
        [self._trading_requests_queue.put(True) for i in range(trading_requests_burst)]
1✔
223
        token_bucket_trading_thread = Thread(target=self._token_bucket_trading,)
1✔
224
        token_bucket_trading_thread.start()
1✔
225
        self._trading_times = []
1✔
226

227
        # Create a leaky token bucket for non-trading requests
228
        non_trading_requests_burst = 1  # If IG ever allow bursting, increase this
1✔
229
        self._non_trading_requests_queue = Queue(non_trading_requests_burst)
1✔
230
        # prefill the bucket so we can burst
231
        [self._non_trading_requests_queue.put(True) for i in range(non_trading_requests_burst)]
1✔
232
        token_bucket_non_trading_thread = Thread(target=self._token_bucket_non_trading,)
1✔
233
        token_bucket_non_trading_thread.start()
1✔
234
        self._non_trading_times = []
1✔
235

236
        # TODO
237
        # Create a leaky token bucket for allowanceAccountHistoricalData
238
        return
1✔
239

240
    def _token_bucket_trading(self, ):
1✔
241
        while self._bucket_threads_run:
1✔
242
            time.sleep(60.0/self._trading_requests_per_minute)
1✔
243
            self._trading_requests_queue.put(True, block=True)
1✔
244
        return
1✔
245

246
    def _token_bucket_non_trading(self, ):
1✔
247
        while self._bucket_threads_run:
1✔
248
            time.sleep(60.0/self._non_trading_requests_per_minute)
1✔
249
            self._non_trading_requests_queue.put(True, block=True)
1✔
250
        return
×
251

252
    def trading_rate_limit_pause_or_pass(self, ):
1✔
253
        if self._use_rate_limiter:
1✔
254
            self._trading_requests_queue.get(block=True)
×
255
            self._trading_times.append(time.time())
×
256
            self._trading_times = [req_time for req_time in self._trading_times if req_time > time.time()-60]
×
257
            logging.info(f'Number of trading requests in last 60 seonds = '
×
258
                         f'{len(self._trading_times)} of {self._trading_requests_per_minute}')
259
        return
1✔
260

261
    def non_trading_rate_limit_pause_or_pass(self, ):
1✔
262
        if self._use_rate_limiter:
1✔
263
            self._non_trading_requests_queue.get(block=True)
1✔
264
            self._non_trading_times.append(time.time())
1✔
265
            self._non_trading_times = [req_time for req_time in self._non_trading_times if req_time > time.time()-60]
1✔
266
            logging.info(f'Number of non trading requests in last 60 seonds = '
1✔
267
                         f'{len(self._non_trading_times)} of {self._non_trading_requests_per_minute}')
268
        return
1✔
269

270
    def _exit_bucket_threads(self,):
1✔
271
        if self._use_rate_limiter:
1✔
272
            if self._bucket_threads_run:
1✔
273
                self._bucket_threads_run = False
1✔
274
                try:
1✔
275
                    self._trading_requests_queue.get(block=False)
1✔
276
                except Empty:
×
277
                    pass
×
278
                try:
1✔
279
                    self._non_trading_requests_queue.get(block=False)
1✔
280
                except Empty:
1✔
281
                    pass
1✔
282
        return
1✔
283

284
    def _get_session(self, session):
1✔
285
        """Returns a Requests session (from self.session) if session is None
286
        or session if it's not None (cached session with requests-cache
287
        for example)
288
        """
289
        if session is None:
1✔
290
            session = self.session  # requests Session
1✔
291
        else:
292
            assert isinstance(
×
293
                session, Session
294
            ), "session must be <requests.session.Session object> not %s" % type(
295
                session
296
            )
297
            session = session
×
298
        return session
1✔
299

300
    def _req(self, action, endpoint, params, session, version='1', check=True):
1✔
301
        """
302
        Wraps the _request() function, applying a tenacity.Retrying object if configured
303
        """
304
        if self._retryer is not None:
1✔
305
            result = self._retryer.__call__(self._request, action, endpoint, params, session, version, check)
1✔
306
        else:
307
            result = self._request(action, endpoint, params, session, version, check)
1✔
308

309
        return result
1✔
310

311
    def _request(self, action, endpoint, params, session, version='1', check=True):
1✔
312
        """Creates a CRUD request and returns response"""
313
        session = self._get_session(session)
1✔
314
        if check:
1✔
315
            self._check_session()
1✔
316
        response = self.crud_session.req(action, endpoint, params, session, version)
1✔
317

318
        if response.status_code >= 500:
1✔
319
            raise (IGException(f"Server problem: status code: {response.status_code}, reason: {response.reason}"))
×
320

321
        response.encoding = 'utf-8'
1✔
322
        if self._api_limit_hit(response.text):
1✔
323
            raise ApiExceededException()
1✔
324
        return response
1✔
325

326
    @staticmethod
1✔
327
    def _api_limit_hit(response_text):
1✔
328
        # note we don't check for historical data allowance - it only gets reset once a week
329
        return 'exceeded-api-key-allowance' in response_text or \
1✔
330
               'exceeded-account-allowance' in response_text or \
331
               'exceeded-account-trading-allowance' in response_text
332

333
    # ---------- PARSE_RESPONSE ----------- #
334

335
    @staticmethod
1✔
336
    def parse_response(*args, **kwargs):
1✔
337
        """Parses JSON response
338
        returns dict
339
        exception raised when error occurs"""
340
        response = json.loads(*args, **kwargs)
1✔
341
        if "errorCode" in response:
1✔
342
            raise (Exception(response["errorCode"]))
1✔
343
        return response
1✔
344

345
    # --------- END -------- #
346

347
    # ------ DATAFRAME TOOLS -------- #
348

349
    @staticmethod
1✔
350
    def colname_unique(d_cols):
1✔
351
        """Returns a set of column names (unique)"""
352
        s = set()
1✔
353
        for lst in d_cols.values():
1✔
354
            s.update(lst)
1✔
355
        return list(s)
1✔
356

357
    @staticmethod
1✔
358
    def expand_columns(data, d_cols, flag_col_prefix=False, col_overlap_allowed=None):
1✔
359
        """Expand columns"""
360
        if col_overlap_allowed is None:
1✔
361
            col_overlap_allowed = []
1✔
362
        for (col_lev1, lst_col) in d_cols.items():
1✔
363
            ser = data[col_lev1]
1✔
364
            del data[col_lev1]
1✔
365
            for col in lst_col:
1✔
366
                if col not in data.columns or col in col_overlap_allowed:
1✔
367
                    if flag_col_prefix:
1✔
368
                        colname = col_lev1 + "_" + col
×
369
                    else:
370
                        colname = col
1✔
371
                    data[colname] = ser.map(lambda x: x[col], na_action='ignore')
1✔
372
                else:
373
                    raise (NotImplementedError("col overlap: %r" % col))
×
374
        return data
1✔
375

376
    # -------- END ------- #
377

378
    # -------- ACCOUNT ------- #
379

380
    def fetch_accounts(self, session=None):
1✔
381
        """Returns a list of accounts belonging to the logged-in client"""
382
        self.non_trading_rate_limit_pause_or_pass()
1✔
383
        version = "1"
1✔
384
        params = {}
1✔
385
        endpoint = "/accounts"
1✔
386
        action = "read"
1✔
387
        response = self._req(action, endpoint, params, session, version)
1✔
388
        data = self.parse_response(response.text)
1✔
389
        if self.return_dataframe:
1✔
390

391
            data = pd.DataFrame(data["accounts"])
1✔
392
            d_cols = {"balance": [u"available", u"balance", u"deposit", u"profitLoss"]}
1✔
393
            data = self.expand_columns(data, d_cols, False)
1✔
394

395
            if len(data) == 0:
1✔
396
                columns = [
×
397
                    "accountAlias",
398
                    "accountId",
399
                    "accountName",
400
                    "accountType",
401
                    "balance",
402
                    "available",
403
                    "balance",
404
                    "deposit",
405
                    "profitLoss",
406
                    "canTransferFrom",
407
                    "canTransferTo",
408
                    "currency",
409
                    "preferred",
410
                    "status",
411
                ]
412
                data = pd.DataFrame(columns=columns)
×
413
                return data
×
414

415
        return data
1✔
416

417
    def fetch_account_preferences(self, session=None):
1✔
418
        """
419
        Gets the preferences for the logged in account
420
        :param session: session object. Optional
421
        :type session: requests.Session
422
        :return: preference values
423
        :rtype: dict
424
        """
425
        self.non_trading_rate_limit_pause_or_pass()
1✔
426
        version = "1"
1✔
427
        params = {}
1✔
428
        endpoint = "/accounts/preferences"
1✔
429
        action = "read"
1✔
430
        response = self._req(action, endpoint, params, session, version)
1✔
431
        prefs = self.parse_response(response.text)
1✔
432
        return prefs
1✔
433

434
    def update_account_preferences(self, trailing_stops_enabled=False, session=None):
1✔
435
        """
436
        Updates the account preferences. Currently only one value supported - trailing stops
437
        :param trailing_stops_enabled: whether trailing stops should be enabled for the account
438
        :type trailing_stops_enabled: bool
439
        :param session: session object. Optional
440
        :type session: requests.Session
441
        :return: status of the update request
442
        :rtype: str
443
        """
444
        self.non_trading_rate_limit_pause_or_pass()
1✔
445
        version = "1"
1✔
446
        params = {}
1✔
447
        endpoint = "/accounts/preferences"
1✔
448
        action = "update"
1✔
449
        params['trailingStopsEnabled'] = 'true' if trailing_stops_enabled else 'false'
1✔
450
        response = self._req(action, endpoint, params, session, version)
1✔
451
        update_status = self.parse_response(response.text)
1✔
452
        return update_status['status']
1✔
453

454
    def fetch_account_activity_by_period(self, milliseconds, session=None):
1✔
455
        """
456
        Returns the account activity history for the last specified period
457
        """
458
        self.non_trading_rate_limit_pause_or_pass()
1✔
459
        version = "1"
1✔
460
        milliseconds = conv_to_ms(milliseconds)
1✔
461
        params = {}
1✔
462
        url_params = {"milliseconds": milliseconds}
1✔
463
        endpoint = "/history/activity/{milliseconds}".format(**url_params)
1✔
464
        action = "read"
1✔
465
        response = self._req(action, endpoint, params, session, version)
1✔
466
        data = self.parse_response(response.text)
1✔
467
        if self.return_dataframe:
1✔
468

469
            data = pd.DataFrame(data["activities"])
1✔
470

471
            if len(data) == 0:
1✔
472
                columns = [
1✔
473
                    "actionStatus", "activity", "activityHistoryId", "channel", "currency", "date",
474
                    "dealId", "epic", "level", "limit", "marketName", "period", "result", "size",
475
                    "stop", "stopType", "time"
476
                ]
477
                data = pd.DataFrame(columns=columns)
1✔
478
                return data
1✔
479

480
        return data
1✔
481

482
    def fetch_account_activity_by_date(self, from_date: datetime, to_date: datetime, session=None):
1✔
483
        """
484
        Returns the account activity history for period between the specified dates
485
        """
486
        self.non_trading_rate_limit_pause_or_pass()
1✔
487
        version = "1"
1✔
488
        if from_date is None or to_date is None:
1✔
489
            raise IGException("Both from_date and to_date must be specified")
×
490
        if from_date > to_date:
1✔
491
            raise IGException("from_date must be before to_date")
×
492

493
        params = {}
1✔
494
        url_params = {
1✔
495
            "fromDate": from_date.strftime('%d-%m-%Y'),
496
            "toDate": to_date.strftime('%d-%m-%Y')
497
        }
498
        endpoint = "/history/activity/{fromDate}/{toDate}".format(**url_params)
1✔
499
        action = "read"
1✔
500
        response = self._req(action, endpoint, params, session, version)
1✔
501
        data = self.parse_response(response.text)
1✔
502
        if _HAS_PANDAS and self.return_dataframe:
1✔
503

504
            data = pd.DataFrame(data["activities"])
1✔
505

506
            if len(data) == 0:
1✔
507
                columns = [
×
508
                    "actionStatus", "activity", "activityHistoryId", "channel", "currency", "date",
509
                    "dealId", "epic", "level", "limit", "marketName", "period", "result", "size",
510
                    "stop", "stopType", "time"
511
                ]
512
                data = pd.DataFrame(columns=columns)
×
513
                return data
×
514

515
        return data
1✔
516

517
    def fetch_account_activity_v2(
1✔
518
            self,
519
            from_date: datetime = None,
520
            to_date: datetime = None,
521
            max_span_seconds: int = None,
522
            page_size: int = 20,
523
            session=None):
524

525
        """
526
        Returns the account activity history (v2)
527

528
        If the result set spans multiple 'pages', this method will automatically get all the results and
529
        bundle them into one object.
530

531
        :param from_date: start date and time. Optional
532
        :type from_date: datetime
533
        :param to_date: end date and time. A date without time refers to the end of that day. Defaults to
534
        today. Optional
535
        :type to_date: datetime
536
        :param max_span_seconds: Limits the timespan in seconds through to current time (not applicable if a
537
        date range has been specified). Default 600. Optional
538
        :type max_span_seconds: int
539
        :param page_size: number of records per page. Default 20. Optional. Use 0 to turn off paging
540
        :type page_size: int
541
        :param session: session object. Optional
542
        :type session: Session
543
        :return: results set
544
        :rtype: Pandas DataFrame if configured, otherwise a dict
545
        """
546
        self.non_trading_rate_limit_pause_or_pass()
1✔
547
        version = "2"
1✔
548
        params = {}
1✔
549
        if from_date:
1✔
550
            params["from"] = from_date.strftime('%Y-%m-%dT%H:%M:%S')
1✔
551
        if to_date:
1✔
552
            params["to"] = to_date.strftime('%Y-%m-%dT%H:%M:%S')
1✔
553
        if max_span_seconds:
1✔
554
            params["maxSpanSeconds"] = max_span_seconds
1✔
555
        params["pageSize"] = page_size
1✔
556
        endpoint = "/history/activity/"
1✔
557
        action = "read"
1✔
558
        data = {}
1✔
559
        activities = []
1✔
560
        pagenumber = 1
1✔
561
        more_results = True
1✔
562

563
        while more_results:
1✔
564
            params["pageNumber"] = pagenumber
1✔
565
            response = self._req(action, endpoint, params, session, version)
1✔
566
            data = self.parse_response(response.text)
1✔
567
            activities.extend(data["activities"])
1✔
568
            page_data = data["metadata"]["pageData"]
1✔
569
            if page_data["totalPages"] == 0 or \
1✔
570
                    (page_data["pageNumber"] == page_data["totalPages"]):
571
                more_results = False
1✔
572
            else:
573
                pagenumber += 1
1✔
574

575
        data["activities"] = activities
1✔
576
        if _HAS_PANDAS and self.return_dataframe:
1✔
577
            data = pd.DataFrame(data["activities"])
1✔
578

579
        return data
1✔
580

581
    def fetch_account_activity(
1✔
582
            self,
583
            from_date: datetime = None,
584
            to_date: datetime = None,
585
            detailed=False,
586
            deal_id: str = None,
587
            fiql_filter: str = None,
588
            page_size: int = 50,
589
            session=None):
590

591
        """
592
        Returns the account activity history (v3)
593

594
        If the result set spans multiple 'pages', this method will automatically get all the results and
595
        bundle them into one object.
596

597
        :param from_date: start date and time. Optional
598
        :type from_date: datetime
599
        :param to_date: end date and time. A date without time refers to the end of that day. Defaults to
600
        today. Optional
601
        :type to_date: datetime
602
        :param detailed: Indicates whether to retrieve additional details about the activity. Default False. Optional
603
        :type detailed: bool
604
        :param deal_id: deal ID. Optional
605
        :type deal_id: str
606
        :param fiql_filter: FIQL filter (supported operators: ==|!=|,|;). Optional
607
        :type fiql_filter: str
608
        :param page_size: page size (min: 10, max: 500). Default 50. Optional
609
        :type page_size: int
610
        :param session: session object. Optional
611
        :type session: Session
612
        :return: results set
613
        :rtype: Pandas DataFrame if configured, otherwise a dict
614
        """
615
        self.non_trading_rate_limit_pause_or_pass()
1✔
616
        version = "3"
1✔
617
        params = {}
1✔
618
        if from_date:
1✔
619
            params["from"] = from_date.strftime('%Y-%m-%dT%H:%M:%S')
1✔
620
        if to_date:
1✔
621
            params["to"] = to_date.strftime('%Y-%m-%dT%H:%M:%S')
1✔
622
        if detailed:
1✔
623
            params["detailed"] = "true"
1✔
624
        if deal_id:
1✔
625
            params["dealId"] = deal_id
×
626
        if fiql_filter:
1✔
627
            params["filter"] = fiql_filter
1✔
628

629
        params["pageSize"] = page_size
1✔
630
        endpoint = "/history/activity/"
1✔
631
        action = "read"
1✔
632
        data = {}
1✔
633
        activities = []
1✔
634
        more_results = True
1✔
635

636
        while more_results:
1✔
637
            response = self._req(action, endpoint, params, session, version)
1✔
638
            data = self.parse_response(response.text)
1✔
639
            activities.extend(data["activities"])
1✔
640
            paging = data["metadata"]["paging"]
1✔
641
            if paging["next"] is None:
1✔
642
                more_results = False
1✔
643
            else:
644
                parse_result = urlparse(paging["next"])
1✔
645
                query = parse_qs(parse_result.query)
1✔
646
                logging.debug(f"fetch_account_activity() next query: '{query}'")
1✔
647
                if 'from' in query:
1✔
648
                    params["from"] = query["from"][0]
1✔
649
                else:
650
                    del params["from"]
×
651
                if 'to' in query:
1✔
652
                    params["to"] = query["to"][0]
1✔
653
                else:
654
                    del params["to"]
×
655

656
        data["activities"] = activities
1✔
657
        if _HAS_PANDAS and self.return_dataframe:
1✔
658
            if detailed:
1✔
659
                data = self.format_activities(data)
1✔
660
            else:
661
                data = pd.DataFrame(data["activities"])
1✔
662

663
        return data
1✔
664

665
    @staticmethod
1✔
666
    def format_activities(data):
1✔
667
        data = pd.json_normalize(data["activities"],
1✔
668
                                 record_path=['details', ['actions']],
669
                                 meta=['date', 'epic', 'period', 'dealId', 'channel', 'type', 'status', 'description',
670
                                       ['details', 'marketName'],
671
                                       ['details', 'goodTillDate'],
672
                                       ['details', 'currency'],
673
                                       ['details', 'direction'],
674
                                       ['details', 'level'],
675
                                       ['details', 'stopLevel'],
676
                                       ['details', 'stopDistance'],
677
                                       ['details', 'guaranteedStop'],
678
                                       ['details', 'trailingStopDistance'],
679
                                       ['details', 'trailingStep'],
680
                                       ['details', 'limitLevel'],
681
                                       ['details', 'limitDistance']],
682
                                 )
683

684
        data = data.rename(columns={'details.marketName': 'marketName',
1✔
685
                                    'details.goodTillDate': 'goodTillDate',
686
                                    'details.currency': 'currency',
687
                                    'details.direction': 'direction',
688
                                    'details.level': 'level',
689
                                    'details.stopLevel': 'stopLevel',
690
                                    'details.stopDistance': 'stopDistance',
691
                                    'details.guaranteedStop': 'guaranteedStop',
692
                                    'details.trailingStopDistance': 'trailingStopDistance',
693
                                    'details.trailingStep': 'trailingStep',
694
                                    'details.limitLevel': 'limitLevel',
695
                                    'details.limitDistance': 'limitDistance'})
696

697
        cols = data.columns.tolist()
1✔
698
        cols = cols[2:] + cols[:2]
1✔
699
        data = data[cols]
1✔
700

701
        return data
1✔
702

703
    def fetch_transaction_history_by_type_and_period(
1✔
704
        self, milliseconds, trans_type, session=None
705
    ):
706
        """Returns the transaction history for the specified transaction
707
        type and period"""
708
        self.non_trading_rate_limit_pause_or_pass()
1✔
709
        version = "1"
1✔
710
        milliseconds = conv_to_ms(milliseconds)
1✔
711
        params = {}
1✔
712
        url_params = {"milliseconds": milliseconds, "trans_type": trans_type}
1✔
713
        endpoint = "/history/transactions/{trans_type}/{milliseconds}".format(
1✔
714
            **url_params
715
        )
716
        action = "read"
1✔
717
        response = self._req(action, endpoint, params, session, version)
1✔
718
        data = self.parse_response(response.text)
1✔
719
        if self.return_dataframe:
1✔
720

721
            data = pd.DataFrame(data["transactions"])
1✔
722

723
            if len(data) == 0:
1✔
724
                columns = [
1✔
725
                    "cashTransaction",
726
                    "closeLevel",
727
                    "currency",
728
                    "date",
729
                    "instrumentName",
730
                    "openLevel",
731
                    "period",
732
                    "profitAndLoss",
733
                    "reference",
734
                    "size",
735
                    "transactionType",
736
                ]
737
                data = pd.DataFrame(columns=columns)
1✔
738
                return data
1✔
739

740
        return data
×
741

742
    def fetch_transaction_history(
1✔
743
        self,
744
        trans_type=None,
745
        from_date=None,
746
        to_date=None,
747
        max_span_seconds=None,
748
        page_size=None,
749
        page_number=None,
750
        session=None,
751
    ):
752
        """Returns the transaction history for the specified transaction
753
        type and period"""
754
        self.non_trading_rate_limit_pause_or_pass()
1✔
755
        version = "2"
1✔
756
        params = {}
1✔
757
        if trans_type:
1✔
758
            params["type"] = trans_type
×
759
        if from_date:
1✔
760
            if hasattr(from_date, "isoformat"):
×
761
                from_date = from_date.isoformat()
×
762
            params["from"] = from_date
×
763
        if to_date:
1✔
764
            if hasattr(to_date, "isoformat"):
×
765
                to_date = to_date.isoformat()
×
766
            params["to"] = to_date
×
767
        if max_span_seconds:
1✔
768
            params["maxSpanSeconds"] = max_span_seconds
×
769
        if page_size:
1✔
770
            params["pageSize"] = page_size
×
771
        if page_number:
1✔
772
            params["pageNumber"] = page_number
×
773

774
        endpoint = "/history/transactions"
1✔
775
        action = "read"
1✔
776

777
        response = self._req(action, endpoint, params, session, version)
1✔
778
        data = self.parse_response(response.text)
1✔
779
        if self.return_dataframe:
1✔
780

781
            data = pd.DataFrame(data["transactions"])
1✔
782

783
            if len(data) == 0:
1✔
784
                columns = [
×
785
                    "cashTransaction",
786
                    "closeLevel",
787
                    "currency",
788
                    "date",
789
                    "dateUtc",
790
                    "instrumentName",
791
                    "openLevel",
792
                    "period",
793
                    "profitAndLoss",
794
                    "reference",
795
                    "size",
796
                    "transactionType",
797
                ]
798
                data = pd.DataFrame(columns=columns)
×
799
                return data
×
800

801
        return data
1✔
802

803
    # -------- END -------- #
804

805
    # -------- DEALING -------- #
806

807
    def fetch_deal_by_deal_reference(self, deal_reference, session=None):
1✔
808
        """Returns a deal confirmation for the given deal reference"""
809
        self.non_trading_rate_limit_pause_or_pass()
1✔
810
        version = "1"
1✔
811
        params = {}
1✔
812
        url_params = {"deal_reference": deal_reference}
1✔
813
        endpoint = "/confirms/{deal_reference}".format(**url_params)
1✔
814
        action = "read"
1✔
815
        for i in range(5):
1✔
816
            response = self._req(action, endpoint, params, session, version)
1✔
817
            if not response.status_code == 200:
1✔
818
                logger.info("Deal reference %s not found, retrying." % deal_reference)
×
819
                time.sleep(1)
×
820
            else:
821
                break
1✔
822
        data = self.parse_response(response.text)
1✔
823
        return data
1✔
824

825
    def fetch_open_position_by_deal_id(self, deal_id, session=None):
1✔
826
        """Return the open position by deal id for the active account"""
827
        self.non_trading_rate_limit_pause_or_pass()
×
828
        version = "2"
×
829
        params = {}
×
830
        url_params = {"deal_id": deal_id}
×
831
        endpoint = "/positions/{deal_id}".format(**url_params)
×
832
        action = "read"
×
833
        for i in range(5):
×
834
            response = self._req(action, endpoint, params, session, version)
×
835
            if not response.status_code == 200:
×
836
                logger.info("Deal id %s not found, retrying." % deal_id)
×
837
                time.sleep(1)
×
838
            else:
839
                break
×
840
        data = self.parse_response(response.text)
×
841
        return data
×
842

843
    def fetch_open_positions(self, session=None, version='2'):
1✔
844
        """
845
        Returns all open positions for the active account. Supports both v1 and v2
846
        :param session: session object, otional
847
        :type session: Session
848
        :param version: API version, 1 or 2
849
        :type version: str
850
        :return: table of position data, one per row
851
        :rtype: pd.Dataframe
852
        """
853
        self.non_trading_rate_limit_pause_or_pass()
1✔
854
        params = {}
1✔
855
        endpoint = "/positions"
1✔
856
        action = "read"
1✔
857
        for i in range(5):
1✔
858
            response = self._req(action, endpoint, params, session, version)
1✔
859
            if not response.status_code == 200:
1✔
860
                logger.info("Error fetching open positions, retrying.")
×
861
                time.sleep(1)
×
862
            else:
863
                break
1✔
864
        data = self.parse_response(response.text)
1✔
865

866
        if self.return_dataframe:
1✔
867

868
            lst = data["positions"]
1✔
869
            data = pd.DataFrame(lst)
1✔
870

871
            cols = {
1✔
872
                "position": [
873
                    "contractSize", "createdDate", "createdDateUTC", "dealId", "dealReference", "size", "direction",
874
                    "limitLevel", "level", "currency", "controlledRisk", "stopLevel", "trailingStep",
875
                    "trailingStopDistance", "limitedRiskPremium"
876
                ],
877
                "market": [
878
                    "instrumentName", "expiry", "epic", "instrumentType", "lotSize", "high", "low",
879
                    "percentageChange", "netChange", "bid", "offer", "updateTime", "updateTimeUTC",
880
                    "delayTime", "streamingPricesAvailable", "marketStatus", "scalingFactor"
881
                ]
882
            }
883

884
            if version == '1':
1✔
885
                cols['position'].remove('createdDateUTC')
1✔
886
                cols['position'].remove('dealReference')
1✔
887
                cols['position'].remove('size')
1✔
888
                cols['position'].insert(3, 'dealSize')
1✔
889
                cols['position'].remove('level')
1✔
890
                cols['position'].insert(6, 'openLevel')
1✔
891
                cols['market'].remove('updateTimeUTC')
1✔
892

893
            if len(data) == 0:
1✔
894
                data = pd.DataFrame(columns=self.colname_unique(cols))
1✔
895
                return data
1✔
896

897
            data = self.expand_columns(data, cols)
1✔
898

899
        return data
1✔
900

901
    def close_open_position(
1✔
902
        self,
903
        deal_id,
904
        direction,
905
        epic,
906
        expiry,
907
        level,
908
        order_type,
909
        quote_id,
910
        size,
911
        session=None,
912
    ):
913
        """Closes one or more OTC positions"""
914
        self.trading_rate_limit_pause_or_pass()
1✔
915
        version = "1"
1✔
916
        params = {
1✔
917
            "dealId": deal_id,
918
            "direction": direction,
919
            "epic": epic,
920
            "expiry": expiry,
921
            "level": level,
922
            "orderType": order_type,
923
            "quoteId": quote_id,
924
            "size": size,
925
        }
926
        endpoint = "/positions/otc"
1✔
927
        action = "delete"
1✔
928
        response = self._req(action, endpoint, params, session, version)
1✔
929

930
        if response.status_code == 200:
1✔
931
            deal_reference = json.loads(response.text)["dealReference"]
1✔
932
            return self.fetch_deal_by_deal_reference(deal_reference)
1✔
933
        else:
934
            raise IGException(response.text)
×
935

936
    def create_open_position(
1✔
937
        self,
938
        currency_code,
939
        direction,
940
        epic,
941
        expiry,
942
        force_open,
943
        guaranteed_stop,
944
        level,
945
        limit_distance,
946
        limit_level,
947
        order_type,
948
        quote_id,
949
        size,
950
        stop_distance,
951
        stop_level,
952
        trailing_stop,
953
        trailing_stop_increment,
954
        session=None,
955
    ):
956
        """Creates an OTC position"""
957
        self.trading_rate_limit_pause_or_pass()
1✔
958
        version = "2"
1✔
959
        params = {
1✔
960
            "currencyCode": currency_code,
961
            "direction": direction,
962
            "epic": epic,
963
            "expiry": expiry,
964
            "forceOpen": force_open,
965
            "guaranteedStop": guaranteed_stop,
966
            "level": level,
967
            "limitDistance": limit_distance,
968
            "limitLevel": limit_level,
969
            "orderType": order_type,
970
            "quoteId": quote_id,
971
            "size": size,
972
            "stopDistance": stop_distance,
973
            "stopLevel": stop_level,
974
            "trailingStop": trailing_stop,
975
            "trailingStopIncrement": trailing_stop_increment,
976
        }
977

978
        endpoint = "/positions/otc"
1✔
979
        action = "create"
1✔
980

981
        response = self._req(action, endpoint, params, session, version)
1✔
982

983
        if response.status_code == 200:
1✔
984
            deal_reference = json.loads(response.text)["dealReference"]
1✔
985
            return self.fetch_deal_by_deal_reference(deal_reference)
1✔
986
        else:
987
            raise IGException(response.text)
×
988

989
    def update_open_position(
1✔
990
            self,
991
            limit_level,
992
            stop_level,
993
            deal_id,
994
            guaranteed_stop=False,
995
            trailing_stop=False,
996
            trailing_stop_distance=None,
997
            trailing_stop_increment=None,
998
            session=None,
999
            version='2'):
1000
        """Updates an OTC position"""
1001
        self.trading_rate_limit_pause_or_pass()
1✔
1002
        params = {}
1✔
1003
        if limit_level is not None:
1✔
1004
            params["limitLevel"] = limit_level
1✔
1005
        if stop_level is not None:
1✔
1006
            params["stopLevel"] = stop_level
1✔
1007
        if guaranteed_stop:
1✔
1008
            params["guaranteedStop"] = 'true'
×
1009
        if trailing_stop:
1✔
1010
            params["trailingStop"] = 'true'
1✔
1011
        if trailing_stop_distance is not None:
1✔
1012
            params["trailingStopDistance"] = trailing_stop_distance
1✔
1013
        if trailing_stop_increment is not None:
1✔
1014
            params["trailingStopIncrement"] = trailing_stop_increment
1✔
1015

1016
        url_params = {"deal_id": deal_id}
1✔
1017
        endpoint = "/positions/otc/{deal_id}".format(**url_params)
1✔
1018
        action = "update"
1✔
1019
        response = self._req(action, endpoint, params, session, version)
1✔
1020

1021
        if response.status_code == 200:
1✔
1022
            deal_reference = json.loads(response.text)["dealReference"]
1✔
1023
            return self.fetch_deal_by_deal_reference(deal_reference)
1✔
1024
        else:
1025
            raise IGException(response.text)
×
1026

1027
    def fetch_working_orders(self, session=None, version='2'):
1✔
1028
        """Returns all open working orders for the active account"""
1029
        self.non_trading_rate_limit_pause_or_pass()  # ?? maybe considered trading request
1✔
1030
        params = {}
1✔
1031
        endpoint = "/workingorders"
1✔
1032
        action = "read"
1✔
1033
        response = self._req(action, endpoint, params, session, version)
1✔
1034
        data = self.parse_response(response.text)
1✔
1035
        if self.return_dataframe:
1✔
1036

1037
            lst = data["workingOrders"]
1✔
1038
            data = pd.DataFrame(lst)
1✔
1039

1040
            col_names_v1 = [u"size", u"trailingStopDistance", u"direction", u"level", u"requestType", u"currencyCode",
1✔
1041
                            u"contingentLimit", u"trailingTriggerIncrement", u"dealId", u"contingentStop", u"goodTill",
1042
                            u"controlledRisk", u"trailingStopIncrement", u"createdDate", u"epic",
1043
                            u"trailingTriggerDistance", u"dma"]
1044
            col_names_v2 = [u"createdDate", u"currencyCode", u"dealId", u"direction", u"dma", u"epic",
1✔
1045
                            u"goodTillDate", u"goodTillDateISO", u"guaranteedStop", u"limitDistance",
1046
                            u"orderLevel", u"orderSize", u"orderType", u"stopDistance", u"timeInForce"]
1047

1048
            d_cols = {
1✔
1049
                "marketData": [
1050
                    u"instrumentName",
1051
                    u"exchangeId",
1052
                    u"streamingPricesAvailable",
1053
                    u"offer",
1054
                    u"low",
1055
                    u"bid",
1056
                    u"updateTime",
1057
                    u"expiry",
1058
                    u"high",
1059
                    u"marketStatus",
1060
                    u"delayTime",
1061
                    u"lotSize",
1062
                    u"percentageChange",
1063
                    u"epic",
1064
                    u"netChange",
1065
                    u"instrumentType",
1066
                    u"scalingFactor",
1067
                ]
1068
            }
1069

1070
            if version == '1':
1✔
1071
                d_cols["workingOrderData"] = col_names_v1
1✔
1072
            else:
1073
                d_cols["workingOrderData"] = col_names_v2
1✔
1074

1075
            if len(data) == 0:
1✔
1076
                data = pd.DataFrame(columns=self.colname_unique(d_cols))
1✔
1077
                return data
1✔
1078

1079
            col_overlap_allowed = ["epic"]
1✔
1080

1081
            data = self.expand_columns(data, d_cols, False, col_overlap_allowed)
1✔
1082

1083
            # d = data.to_dict()
1084
            # data = pd.concat(list(map(pd.DataFrame, d.values())),
1085
            #                  keys=list(d.keys())).T
1086

1087
        return data
1✔
1088

1089
    def create_working_order(
1✔
1090
        self,
1091
        currency_code,
1092
        direction,
1093
        epic,
1094
        expiry,
1095
        guaranteed_stop,
1096
        level,
1097
        size,
1098
        time_in_force,
1099
        order_type,
1100
        limit_distance=None,
1101
        limit_level=None,
1102
        stop_distance=None,
1103
        stop_level=None,
1104
        good_till_date=None,
1105
        deal_reference=None,
1106
        force_open=False,
1107
        session=None,
1108
    ):
1109
        """Creates an OTC working order"""
1110
        self.trading_rate_limit_pause_or_pass()
1✔
1111
        version = "2"
1✔
1112
        if good_till_date is not None and type(good_till_date) is not int:
1✔
1113
            good_till_date = conv_datetime(good_till_date, version)
×
1114

1115
        params = {
1✔
1116
            "currencyCode": currency_code,
1117
            "direction": direction,
1118
            "epic": epic,
1119
            "expiry": expiry,
1120
            "guaranteedStop": guaranteed_stop,
1121
            "level": level,
1122
            "size": size,
1123
            "timeInForce": time_in_force,
1124
            "type": order_type,
1125
        }
1126
        if limit_distance:
1✔
1127
            params["limitDistance"] = limit_distance
×
1128
        if limit_level:
1✔
1129
            params["limitLevel"] = limit_level
×
1130
        if stop_distance:
1✔
1131
            params["stopDistance"] = stop_distance
1✔
1132
        if stop_level:
1✔
1133
            params["stopLevel"] = stop_level
×
1134
        if deal_reference:
1✔
1135
            params["dealReference"] = deal_reference
×
1136
        if force_open:
1✔
1137
            params["forceOpen"] = 'true'
×
1138
        if good_till_date:
1✔
1139
            params["goodTillDate"] = good_till_date
×
1140

1141
        endpoint = "/workingorders/otc"
1✔
1142
        action = "create"
1✔
1143

1144
        response = self._req(action, endpoint, params, session, version)
1✔
1145

1146
        if response.status_code == 200:
1✔
1147
            deal_reference = json.loads(response.text)["dealReference"]
1✔
1148
            return self.fetch_deal_by_deal_reference(deal_reference)
1✔
1149
        else:
1150
            raise IGException(response.text)
×
1151

1152
    def delete_working_order(self, deal_id, session=None):
1✔
1153
        """Deletes an OTC working order"""
1154
        self.trading_rate_limit_pause_or_pass()
1✔
1155
        version = "2"
1✔
1156
        params = {}
1✔
1157
        url_params = {"deal_id": deal_id}
1✔
1158
        endpoint = "/workingorders/otc/{deal_id}".format(**url_params)
1✔
1159
        action = "delete"
1✔
1160
        response = self._req(action, endpoint, params, session, version)
1✔
1161

1162
        if response.status_code == 200:
1✔
1163
            deal_reference = json.loads(response.text)["dealReference"]
1✔
1164
            return self.fetch_deal_by_deal_reference(deal_reference)
1✔
1165
        else:
1166
            raise IGException(response.text)
×
1167

1168
    def update_working_order(
1✔
1169
        self,
1170
        good_till_date,
1171
        level,
1172
        limit_distance,
1173
        limit_level,
1174
        stop_distance,
1175
        stop_level,
1176
        guaranteed_stop,
1177
        time_in_force,
1178
        order_type,
1179
        deal_id,
1180
        session=None,
1181
    ):
1182
        """Updates an OTC working order"""
1183
        self.trading_rate_limit_pause_or_pass()
1✔
1184
        version = "2"
1✔
1185
        if good_till_date is not None and type(good_till_date) is not int:
1✔
1186
            good_till_date = conv_datetime(good_till_date, version)
×
1187
        params = {
1✔
1188
            "goodTillDate": good_till_date,
1189
            "limitDistance": limit_distance,
1190
            "level": level,
1191
            "limitLevel": limit_level,
1192
            "stopDistance": stop_distance,
1193
            "stopLevel": stop_level,
1194
            "guaranteedStop": guaranteed_stop,
1195
            "timeInForce": time_in_force,
1196
            "type": order_type,
1197
        }
1198
        url_params = {"deal_id": deal_id}
1✔
1199
        endpoint = "/workingorders/otc/{deal_id}".format(**url_params)
1✔
1200
        action = "update"
1✔
1201
        response = self._req(action, endpoint, params, session, version)
1✔
1202

1203
        if response.status_code == 200:
1✔
1204
            deal_reference = json.loads(response.text)["dealReference"]
1✔
1205
            return self.fetch_deal_by_deal_reference(deal_reference)
1✔
1206
        else:
1207
            raise IGException(response.text)
×
1208

1209
    # -------- END -------- #
1210

1211
    # -------- MARKETS -------- #
1212

1213
    def fetch_client_sentiment_by_instrument(self, market_id, session=None):
1✔
1214
        """Returns the client sentiment for the given instrument's market"""
1215
        self.non_trading_rate_limit_pause_or_pass()
1✔
1216
        version = "1"
1✔
1217
        params = {}
1✔
1218
        if isinstance(market_id, (list,)):
1✔
1219
            market_ids = ",".join(market_id)
1✔
1220
            url_params = {"market_ids": market_ids}
1✔
1221
            endpoint = "/clientsentiment/?marketIds={market_ids}".format(**url_params)
1✔
1222
        else:
1223
            url_params = {"market_id": market_id}
1✔
1224
            endpoint = "/clientsentiment/{market_id}".format(**url_params)
1✔
1225
        action = "read"
1✔
1226
        response = self._req(action, endpoint, params, session, version)
1✔
1227
        data = self.parse_response(response.text)
1✔
1228
        if self.return_munch:
1✔
1229
            data = munchify(data)
1✔
1230
        return data
1✔
1231

1232
    def fetch_related_client_sentiment_by_instrument(self, market_id, session=None):
1✔
1233
        """Returns a list of related (also traded) client sentiment for
1234
        the given instrument's market"""
1235
        self.non_trading_rate_limit_pause_or_pass()
1✔
1236
        version = "1"
1✔
1237
        params = {}
1✔
1238
        url_params = {"market_id": market_id}
1✔
1239
        endpoint = "/clientsentiment/related/{market_id}".format(**url_params)
1✔
1240
        action = "read"
1✔
1241
        response = self._req(action, endpoint, params, session, version)
1✔
1242
        data = self.parse_response(response.text)
1✔
1243
        if self.return_dataframe:
1✔
1244
            data = pd.DataFrame(data["clientSentiments"])
1✔
1245
        return data
1✔
1246

1247
    def fetch_top_level_navigation_nodes(self, session=None):
1✔
1248
        """Returns all top-level nodes (market categories) in the market
1249
        navigation hierarchy."""
1250
        self.non_trading_rate_limit_pause_or_pass()
1✔
1251
        version = "1"
1✔
1252
        params = {}
1✔
1253
        endpoint = "/marketnavigation"
1✔
1254
        action = "read"
1✔
1255
        response = self._req(action, endpoint, params, session, version)
1✔
1256
        data = self.parse_response(response.text)
1✔
1257
        if self.return_dataframe:
1✔
1258

1259
            data["markets"] = pd.DataFrame(data["markets"])
1✔
1260
            if len(data["markets"]) == 0:
1✔
1261
                columns = [
1✔
1262
                    "bid",
1263
                    "delayTime",
1264
                    "epic",
1265
                    "expiry",
1266
                    "high",
1267
                    "instrumentName",
1268
                    "instrumentType",
1269
                    "lotSize",
1270
                    "low",
1271
                    "marketStatus",
1272
                    "netChange",
1273
                    "offer",
1274
                    "otcTradeable",
1275
                    "percentageChange",
1276
                    "scalingFactor",
1277
                    "streamingPricesAvailable",
1278
                    "updateTime",
1279
                ]
1280
                data["markets"] = pd.DataFrame(columns=columns)
1✔
1281
            data["nodes"] = pd.DataFrame(data["nodes"])
1✔
1282
            if len(data["nodes"]) == 0:
1✔
1283
                columns = ["id", "name"]
×
1284
                data["nodes"] = pd.DataFrame(columns=columns)
×
1285
        # if self.return_munch:
1286
        #     # ToFix: ValueError: The truth value of a DataFrame is ambiguous.
1287
        #     # Use a.empty, a.bool(), a.item(), a.any() or a.all().
1288
        #     from .utils import munchify
1289
        #     data = munchify(data)
1290
        return data
1✔
1291

1292
    def fetch_sub_nodes_by_node(self, node, session=None):
1✔
1293
        """Returns all sub-nodes of the given node in the market
1294
        navigation hierarchy"""
1295
        self.non_trading_rate_limit_pause_or_pass()
1✔
1296
        version = "1"
1✔
1297
        params = {}
1✔
1298
        url_params = {"node": node}
1✔
1299
        endpoint = "/marketnavigation/{node}".format(**url_params)
1✔
1300
        action = "read"
1✔
1301
        response = self._req(action, endpoint, params, session, version)
1✔
1302
        data = self.parse_response(response.text)
1✔
1303
        if self.return_dataframe:
1✔
1304

1305
            data["markets"] = pd.DataFrame(data["markets"])
1✔
1306
            data["nodes"] = pd.DataFrame(data["nodes"])
1✔
1307
        return data
1✔
1308

1309
    def fetch_market_by_epic(self, epic, session=None):
1✔
1310
        """Returns the details of the given market"""
1311
        self.non_trading_rate_limit_pause_or_pass()
1✔
1312
        version = "3"
1✔
1313
        params = {}
1✔
1314
        url_params = {"epic": epic}
1✔
1315
        endpoint = "/markets/{epic}".format(**url_params)
1✔
1316
        action = "read"
1✔
1317
        response = self._req(action, endpoint, params, session, version)
1✔
1318
        data = self.parse_response(response.text)
1✔
1319
        if self.return_munch:
1✔
1320
            data = munchify(data)
1✔
1321
        return data
1✔
1322

1323
    def fetch_markets_by_epics(self, epics, detailed=True, session=None, version='2'):
1✔
1324
        """
1325
        Returns the details of the given markets
1326
        :param epics: comma separated list of epics
1327
        :type epics: str
1328
        :param detailed: Whether to return detailed info or snapshot data only. Only supported for
1329
        version 2. Optional, default True
1330
        :type detailed: bool
1331
        :param session: session object. Optional, default None
1332
        :type session: requests.Session
1333
        :param version: IG API method version. Optional, default '2'
1334
        :type version: str
1335
        :return: list of market details
1336
        :rtype: Munch instance if configured, else dict
1337
        """
1338
        self.non_trading_rate_limit_pause_or_pass()
1✔
1339
        params = {"epics": epics}
1✔
1340
        if version == '2':
1✔
1341
            params["filter"] = 'ALL' if detailed else 'SNAPSHOT_ONLY'
1✔
1342
        endpoint = "/markets"
1✔
1343
        action = "read"
1✔
1344
        response = self._req(action, endpoint, params, session, version)
1✔
1345
        data = self.parse_response(response.text)
1✔
1346
        if self.return_munch:
1✔
1347
            data = munchify(data['marketDetails'])
1✔
1348
        else:
1349
            data = data['marketDetails']
×
1350
        return data
1✔
1351

1352
    def search_markets(self, search_term, session=None):
1✔
1353
        """Returns all markets matching the search term"""
1354
        self.non_trading_rate_limit_pause_or_pass()
1✔
1355
        version = "1"
1✔
1356
        endpoint = "/markets"
1✔
1357
        params = {"searchTerm": search_term}
1✔
1358
        action = "read"
1✔
1359
        response = self._req(action, endpoint, params, session, version)
1✔
1360
        data = self.parse_response(response.text)
1✔
1361
        if self.return_dataframe:
1✔
1362
            data = pd.DataFrame(data["markets"])
1✔
1363
        return data
1✔
1364

1365
    def format_prices(self, prices, version, flag_calc_spread=False):
1✔
1366
        """
1367
        Format prices data as a DataFrame with hierarchical columns
1368

1369
        Do not call this method directly - it is designed to be passed into
1370
        the fetch_historical_prices*() methods. See tests for examples
1371

1372
        param prices: raw price data
1373
        :type prices: list of dict
1374
        :param version: API endpoint version
1375
        :type version: str
1376
        :param flag_calc_spread: include spread
1377
        :type flag_calc_spread: bool
1378
        :return: prices as pandas.DataFrame
1379
        :rtype: pandas.DataFrame
1380
        """
1381

1382
        if len(prices) == 0:
1✔
1383
            raise (Exception("Historical price data not found"))
×
1384

1385
        def cols(typ):
1✔
1386
            return {
1✔
1387
                "openPrice.%s" % typ: "Open",
1388
                "highPrice.%s" % typ: "High",
1389
                "lowPrice.%s" % typ: "Low",
1390
                "closePrice.%s" % typ: "Close",
1391
                "lastTradedVolume": "Volume",
1392
            }
1393

1394
        last = prices[0]["lastTradedVolume"] or prices[0]["closePrice"]["lastTraded"]
1✔
1395
        df = json_normalize(prices)
1✔
1396
        df = df.set_index("snapshotTime")
1✔
1397
        df.index = pd.to_datetime(df.index, format=DATE_FORMATS[int(version)])
1✔
1398
        df.index.name = "DateTime"
1✔
1399

1400
        df_ask = df[
1✔
1401
            ["openPrice.ask", "highPrice.ask", "lowPrice.ask", "closePrice.ask"]
1402
        ]
1403
        df_ask = df_ask.rename(columns=cols("ask"))
1✔
1404

1405
        df_bid = df[
1✔
1406
            ["openPrice.bid", "highPrice.bid", "lowPrice.bid", "closePrice.bid"]
1407
        ]
1408
        df_bid = df_bid.rename(columns=cols("bid"))
1✔
1409

1410
        if flag_calc_spread:
1✔
1411
            df_spread = df_ask - df_bid
×
1412

1413
        if last:
1✔
1414
            df_last = df[
1✔
1415
                [
1416
                    "openPrice.lastTraded",
1417
                    "highPrice.lastTraded",
1418
                    "lowPrice.lastTraded",
1419
                    "closePrice.lastTraded",
1420
                    "lastTradedVolume",
1421
                ]
1422
            ]
1423
            df_last = df_last.rename(columns=cols("lastTraded"))
1✔
1424

1425
        data = [df_bid, df_ask]
1✔
1426
        keys = ["bid", "ask"]
1✔
1427
        if flag_calc_spread:
1✔
1428
            data.append(df_spread)
×
1429
            keys.append("spread")
×
1430

1431
        if last:
1✔
1432
            data.append(df_last)
1✔
1433
            keys.append("last")
1✔
1434

1435
        df2 = pd.concat(data, axis=1, keys=keys)
1✔
1436
        return df2
1✔
1437

1438
    def flat_prices(self, prices, version):
1✔
1439

1440
        """
1441
        Format prices data as a flat DataFrame, no hierarchy
1442

1443
        Do not call this method directly - it is designed to be passed into
1444
        the fetch_historical_prices*() methods. See tests for examples
1445

1446
        param prices: raw price data
1447
        :type prices: list of dict
1448
        :param version: API endpoint version
1449
        :type version: str
1450
        :return: prices as pandas.DataFrame
1451
        :rtype: pandas.DataFrame
1452
        """
1453

1454
        if len(prices) == 0:
1✔
1455
            raise (Exception("Historical price data not found"))
×
1456

1457
        df = json_normalize(prices)
1✔
1458
        if version == "3":
1✔
1459
            df = df.set_index("snapshotTimeUTC")
1✔
1460
            df = df.drop(columns=['snapshotTime'])
1✔
1461
        else:
1462
            df = df.set_index("snapshotTime")
1✔
1463
        df.index = pd.to_datetime(df.index, format=DATE_FORMATS[int(version)])
1✔
1464
        df.index.name = "DateTime"
1✔
1465
        df = df.drop(columns=['openPrice.lastTraded',
1✔
1466
                              'closePrice.lastTraded',
1467
                              'highPrice.lastTraded',
1468
                              'lowPrice.lastTraded'])
1469
        df = df.rename(columns={"openPrice.bid": "open.bid",
1✔
1470
                                "openPrice.ask": "open.ask",
1471
                                "closePrice.bid": "close.bid",
1472
                                "closePrice.ask": "close.ask",
1473
                                "highPrice.bid": "high.bid",
1474
                                "highPrice.ask": "high.ask",
1475
                                "lowPrice.bid": "low.bid",
1476
                                "lowPrice.ask": "low.ask",
1477
                                "lastTradedVolume": "volume"})
1478
        return df
1✔
1479

1480
    def mid_prices(self, prices, version):
1✔
1481

1482
        """
1483
        Format price data as a flat DataFrame, no hierarchy, calculating
1484
        mid-prices
1485

1486
        Do not call this method directly - it is designed to be passed into
1487
        the fetch_historical_prices*() methods. See tests for examples
1488

1489
        param prices: raw price data
1490
        :type prices: list of dict
1491
        :param version: API endpoint version
1492
        :type version: str
1493
        :return: prices as pandas.DataFrame
1494
        :rtype: pandas.DataFrame
1495
        """
1496

1497
        if len(prices) == 0:
1✔
1498
            raise (Exception("Historical price data not found"))
×
1499

1500
        df = json_normalize(prices)
1✔
1501
        if version == "3":
1✔
1502
            df = df.set_index("snapshotTimeUTC")
1✔
1503
            df = df.drop(columns=['snapshotTime'])
1✔
1504
        else:
1505
            df = df.set_index("snapshotTime")
1✔
1506
        df.index = pd.to_datetime(df.index, format=DATE_FORMATS[int(version)])
1✔
1507
        df.index.name = "DateTime"
1✔
1508

1509
        df['Open'] = df[['openPrice.bid', 'openPrice.ask']].mean(axis=1)
1✔
1510
        df['High'] = df[['highPrice.bid', 'highPrice.ask']].mean(axis=1)
1✔
1511
        df['Low'] = df[['lowPrice.bid', 'lowPrice.ask']].mean(axis=1)
1✔
1512
        df['Close'] = df[['closePrice.bid', 'closePrice.ask']].mean(axis=1)
1✔
1513

1514
        df = df.drop(columns=['openPrice.lastTraded', 'closePrice.lastTraded',
1✔
1515
                              'highPrice.lastTraded', 'lowPrice.lastTraded',
1516
                              "openPrice.bid", "openPrice.ask",
1517
                              "closePrice.bid", "closePrice.ask",
1518
                              "highPrice.bid", "highPrice.ask",
1519
                              "lowPrice.bid", "lowPrice.ask"])
1520
        df = df.rename(columns={"lastTradedVolume": "Volume"})
1✔
1521

1522
        return df
1✔
1523

1524
    def fetch_historical_prices_by_epic(
1✔
1525
        self,
1526
        epic,
1527
        resolution=None,
1528
        start_date=None,
1529
        end_date=None,
1530
        numpoints=None,
1531
        pagesize=20,
1532
        session=None,
1533
        format=None,
1534
        wait=1
1535
    ):
1536

1537
        """
1538
        Fetches historical prices for the given epic.
1539

1540
        This method wraps the IG v3 /prices/{epic} endpoint. With this method you can
1541
        choose to get either a fixed number of prices in the past, or to get the
1542
        prices between two points in time. By default it will return the last 10
1543
        prices at 1 minute resolution.
1544

1545
        If the result set spans multiple 'pages', this method will automatically
1546
        get all the results and bundle them into one object.
1547

1548
        :param epic: (str) The epic key for which historical prices are being
1549
            requested
1550
        :param resolution: (str, optional) timescale resolution. Expected values
1551
            are 1Min, 2Min, 3Min, 5Min, 10Min, 15Min, 30Min, 1H, 2H, 3H, 4H, D,
1552
            W, M. Default is 1Min
1553
        :param start_date: (datetime, optional) date range start, format
1554
            yyyy-MM-dd'T'HH:mm:ss
1555
        :param end_date: (datetime, optional) date range end, format
1556
            yyyy-MM-dd'T'HH:mm:ss
1557
        :param numpoints: (int, optional) number of data points. Default is 10
1558
        :param pagesize: (int, optional) number of data points. Default is 20
1559
        :param session: (Session, optional) session object
1560
        :param format: (function, optional) function to convert the raw
1561
            JSON response
1562
        :param wait: (int, optional) how many seconds to wait between successive
1563
            calls in a multi-page scenario. Default is 1
1564
        :returns: Pandas DataFrame if configured, otherwise a dict
1565
        :raises Exception: raises an exception if any error is encountered
1566
        """
1567

1568
        version = "3"
1✔
1569
        params = {}
1✔
1570
        if resolution and self.return_dataframe:
1✔
1571
            params["resolution"] = conv_resol(resolution)
1✔
1572
        if start_date:
1✔
1573
            params["from"] = start_date
1✔
1574
        if end_date:
1✔
1575
            params["to"] = end_date
1✔
1576
        if numpoints:
1✔
1577
            params["max"] = numpoints
1✔
1578
        params["pageSize"] = pagesize
1✔
1579
        url_params = {"epic": epic}
1✔
1580
        endpoint = "/prices/{epic}".format(**url_params)
1✔
1581
        action = "read"
1✔
1582
        prices = []
1✔
1583
        pagenumber = 1
1✔
1584
        more_results = True
1✔
1585

1586
        while more_results:
1✔
1587
            params["pageNumber"] = pagenumber
1✔
1588
            response = self._req(action, endpoint, params, session, version)
1✔
1589
            data = self.parse_response(response.text)
1✔
1590
            prices.extend(data["prices"])
1✔
1591
            page_data = data["metadata"]["pageData"]
1✔
1592
            if page_data["totalPages"] == 0 or \
1✔
1593
                    (page_data["pageNumber"] == page_data["totalPages"]):
1594
                more_results = False
1✔
1595
            else:
1596
                pagenumber += 1
1✔
1597
            time.sleep(wait)
1✔
1598

1599
        data["prices"] = prices
1✔
1600

1601
        if format is None:
1✔
1602
            format = self.format_prices
1✔
1603
        if self.return_dataframe:
1✔
1604
            data["prices"] = format(data["prices"], version)
1✔
1605
            data['prices'] = data['prices'].fillna(value=np.nan)
1✔
1606
        self.log_allowance(data["metadata"])
1✔
1607
        return data
1✔
1608

1609
    def fetch_historical_prices_by_epic_and_num_points(self, epic, resolution,
1✔
1610
                                                       numpoints, session=None,
1611
                                                       format=None):
1612
        """Returns a list of historical prices for the given epic, resolution,
1613
        number of points"""
1614
        version = "2"
1✔
1615
        if self.return_dataframe:
1✔
1616
            resolution = conv_resol(resolution)
1✔
1617
        params = {}
1✔
1618
        url_params = {"epic": epic, "resolution": resolution, "numpoints": numpoints}
1✔
1619
        endpoint = "/prices/{epic}/{resolution}/{numpoints}".format(**url_params)
1✔
1620
        action = "read"
1✔
1621
        response = self._req(action, endpoint, params, session, version)
1✔
1622
        data = self.parse_response(response.text)
1✔
1623
        if format is None:
1✔
1624
            format = self.format_prices
1✔
1625
        if self.return_dataframe:
1✔
1626
            data["prices"] = format(data["prices"], version)
1✔
1627
            data['prices'] = data['prices'].fillna(value=np.nan)
1✔
1628
        return data
1✔
1629

1630
    def fetch_historical_prices_by_epic_and_date_range(
1✔
1631
        self, epic, resolution, start_date, end_date, session=None, format=None, version='2'
1632
    ):
1633
        """
1634
        Returns a list of historical prices for the given epic, resolution, multiplier and date range. Supports
1635
        both versions 1 and 2
1636
        :param epic: IG epic
1637
        :type epic: str
1638
        :param resolution: timescale for returned data. Expected values 'M', 'D', '1H' etc
1639
        :type resolution: str
1640
        :param start_date: start date for returned data. For v1, format '2020:09:01-00:00:00', for v2 use
1641
            '2020-09-01 00:00:00'
1642
        :type start_date: str
1643
        :param end_date: end date for returned data. For v1, format '2020:09:01-00:00:00', for v2 use
1644
            '2020-09-01 00:00:00'
1645
        :type end_date: str
1646
        :param session: HTTP session
1647
        :type session: requests.Session
1648
        :param format: function defining how the historic price data should be converted into a Dataframe
1649
        :type format: function
1650
        :param version: API method version
1651
        :type version: str
1652
        :return: historic data
1653
        :rtype: dict, with 'prices' element as pandas.Dataframe
1654
        """
1655
        if self.return_dataframe:
1✔
1656
            resolution = conv_resol(resolution)
1✔
1657
        params = {}
1✔
1658
        if version == '1':
1✔
1659
            start_date = conv_datetime(start_date, version)
1✔
1660
            end_date = conv_datetime(end_date, version)
1✔
1661
            params = {"startdate": start_date, "enddate": end_date}
1✔
1662
            url_params = {"epic": epic, "resolution": resolution}
1✔
1663
            endpoint = "/prices/{epic}/{resolution}".format(**url_params)
1✔
1664
        else:
1665
            url_params = {"epic": epic, "resolution": resolution, "startDate": start_date, "endDate": end_date}
1✔
1666
            endpoint = "/prices/{epic}/{resolution}/{startDate}/{endDate}".format(**url_params)
1✔
1667
        action = "read"
1✔
1668
        response = self._req(action, endpoint, params, session, version)
1✔
1669
        del self.session.headers["VERSION"]
1✔
1670
        data = self.parse_response(response.text)
1✔
1671
        if format is None:
1✔
1672
            format = self.format_prices
1✔
1673
        if self.return_dataframe:
1✔
1674
            data["prices"] = format(data["prices"], version)
1✔
1675
            data['prices'] = data['prices'].fillna(value=np.nan)
1✔
1676
        return data
1✔
1677

1678
    def log_allowance(self, data):
1✔
1679
        remaining_allowance = data['allowance']['remainingAllowance']
1✔
1680
        allowance_expiry_secs = data['allowance']['allowanceExpiry']
1✔
1681
        allowance_expiry = datetime.today() + timedelta(seconds=allowance_expiry_secs)
1✔
1682
        logger.info("Historic price data allowance: %s remaining until %s" %
1✔
1683
                    (remaining_allowance, allowance_expiry))
1684

1685
    # -------- END -------- #
1686

1687
    # -------- WATCHLISTS -------- #
1688

1689
    def fetch_all_watchlists(self, session=None):
1✔
1690
        """Returns all watchlists belonging to the active account"""
1691
        self.non_trading_rate_limit_pause_or_pass()
1✔
1692
        version = "1"
1✔
1693
        params = {}
1✔
1694
        endpoint = "/watchlists"
1✔
1695
        action = "read"
1✔
1696
        response = self._req(action, endpoint, params, session, version)
1✔
1697
        data = self.parse_response(response.text)
1✔
1698
        if self.return_dataframe:
1✔
1699
            data = pd.DataFrame(data["watchlists"])
1✔
1700
        return data
1✔
1701

1702
    def create_watchlist(self, name, epics, session=None):
1✔
1703
        """Creates a watchlist"""
1704
        self.non_trading_rate_limit_pause_or_pass()
1✔
1705
        version = "1"
1✔
1706
        params = {"name": name, "epics": epics}
1✔
1707
        endpoint = "/watchlists"
1✔
1708
        action = "create"
1✔
1709
        response = self._req(action, endpoint, params, session, version)
1✔
1710
        data = self.parse_response(response.text)
1✔
1711
        return data
1✔
1712

1713
    def delete_watchlist(self, watchlist_id, session=None):
1✔
1714
        """Deletes a watchlist"""
1715
        self.non_trading_rate_limit_pause_or_pass()
1✔
1716
        version = "1"
1✔
1717
        params = {}
1✔
1718
        url_params = {"watchlist_id": watchlist_id}
1✔
1719
        endpoint = "/watchlists/{watchlist_id}".format(**url_params)
1✔
1720
        action = "delete"
1✔
1721
        response = self._req(action, endpoint, params, session, version)
1✔
1722
        data = self.parse_response(response.text)
1✔
1723
        return data
1✔
1724

1725
    def fetch_watchlist_markets(self, watchlist_id, session=None):
1✔
1726
        """Returns the given watchlist's markets"""
1727
        self.non_trading_rate_limit_pause_or_pass()
1✔
1728
        version = "1"
1✔
1729
        params = {}
1✔
1730
        url_params = {"watchlist_id": watchlist_id}
1✔
1731
        endpoint = "/watchlists/{watchlist_id}".format(**url_params)
1✔
1732
        action = "read"
1✔
1733
        response = self._req(action, endpoint, params, session, version)
1✔
1734
        data = self.parse_response(response.text)
1✔
1735
        if self.return_dataframe:
1✔
1736
            data = pd.DataFrame(data["markets"])
1✔
1737
        return data
1✔
1738

1739
    def add_market_to_watchlist(self, watchlist_id, epic, session=None):
1✔
1740
        """Adds a market to a watchlist"""
1741
        self.non_trading_rate_limit_pause_or_pass()
1✔
1742
        version = "1"
1✔
1743
        params = {"epic": epic}
1✔
1744
        url_params = {"watchlist_id": watchlist_id}
1✔
1745
        endpoint = "/watchlists/{watchlist_id}".format(**url_params)
1✔
1746
        action = "update"
1✔
1747
        response = self._req(action, endpoint, params, session, version)
1✔
1748
        data = self.parse_response(response.text)
1✔
1749
        return data
1✔
1750

1751
    def remove_market_from_watchlist(self, watchlist_id, epic, session=None):
1✔
1752
        """Remove a market from a watchlist"""
1753
        self.non_trading_rate_limit_pause_or_pass()
1✔
1754
        version = "1"
1✔
1755
        params = {}
1✔
1756
        url_params = {"watchlist_id": watchlist_id, "epic": epic}
1✔
1757
        endpoint = "/watchlists/{watchlist_id}/{epic}".format(**url_params)
1✔
1758
        action = "delete"
1✔
1759
        response = self._req(action, endpoint, params, session, version)
1✔
1760
        data = self.parse_response(response.text)
1✔
1761
        return data
1✔
1762

1763
    # -------- END -------- #
1764

1765
    # -------- LOGIN -------- #
1766

1767
    def logout(self, session=None):
1✔
1768
        """Log out of the current session"""
1769
        version = "1"
1✔
1770
        params = {}
1✔
1771
        endpoint = "/session"
1✔
1772
        action = "delete"
1✔
1773
        self._req(action, endpoint, params, session, version)
1✔
1774
        self.session.close()
1✔
1775
        self._exit_bucket_threads()
1✔
1776

1777
    def get_encryption_key(self, session=None):
1✔
1778
        """Get encryption key to encrypt the password"""
1779
        endpoint = "/session/encryptionKey"
1✔
1780
        session = self._get_session(session)
1✔
1781
        response = session.get(self.BASE_URL + endpoint)
1✔
1782
        if not response.ok:
1✔
1783
            raise IGException("Could not get encryption key for login.")
1✔
1784
        data = response.json()
1✔
1785
        return data["encryptionKey"], data["timeStamp"]
1✔
1786

1787
    def encrypted_password(self, session=None):
1✔
1788
        """Encrypt password for login"""
1789
        key, timestamp = self.get_encryption_key(session)
1✔
1790
        rsakey = RSA.importKey(b64decode(key))
1✔
1791
        string = self.IG_PASSWORD + "|" + str(int(timestamp))
1✔
1792
        message = b64encode(string.encode())
1✔
1793
        return b64encode(PKCS1_v1_5.new(rsakey).encrypt(message)).decode()
1✔
1794

1795
    def create_session(self, session=None, encryption=False, version='2'):
1✔
1796
        """
1797
        Creates a session, obtaining tokens for subsequent API access
1798

1799
        ** April 2021 v3 has been implemented, but is not the default for now
1800

1801
        :param session: HTTP session
1802
        :type session: requests.Session
1803
        :param encryption: whether or not the password should be encrypted. Required for some regions
1804
        :type encryption: Boolean
1805
        :param version: API method version
1806
        :type version: str
1807
        :return: JSON response body, parsed into dict
1808
        :rtype: dict
1809
        """
1810
        if version == '3' and self.ACC_NUMBER is None:
1✔
1811
            raise IGException('Account number must be set for v3 sessions')
1✔
1812

1813
        logging.info(f"Creating new v{version} session for user '{self.IG_USERNAME}' at '{self.BASE_URL}'")
1✔
1814
        password = self.encrypted_password(session) if encryption else self.IG_PASSWORD
1✔
1815
        params = {"identifier": self.IG_USERNAME, "password": password}
1✔
1816
        if encryption:
1✔
1817
            params["encryptedPassword"] = True
1✔
1818
        endpoint = "/session"
1✔
1819
        action = "create"
1✔
1820
        response = self._req(action, endpoint, params, session, version, check=False)
1✔
1821
        self._manage_headers(response)
1✔
1822
        data = self.parse_response(response.text)
1✔
1823

1824
        if self._use_rate_limiter:
1✔
1825
            self.setup_rate_limiter()
1✔
1826

1827
        return data
1✔
1828

1829
    def refresh_session(self, session=None, version='1'):
1✔
1830
        """
1831
        Refreshes a v3 session. Tokens only last for 60 seconds, so need to be renewed regularly
1832
        :param session: HTTP session object
1833
        :type session: requests.Session
1834
        :param version: API method version
1835
        :type version: str
1836
        :return: HTTP status code
1837
        :rtype: int
1838
        """
1839
        logging.info(f"Refreshing session '{self.IG_USERNAME}'")
1✔
1840
        params = {"refresh_token": self._refresh_token}
1✔
1841
        endpoint = "/session/refresh-token"
1✔
1842
        action = "create"
1✔
1843
        response = self._req(action, endpoint, params, session, version, check=False)
1✔
1844
        self._handle_oauth(json.loads(response.text))
1✔
1845
        return response.status_code
1✔
1846

1847
    def _manage_headers(self, response):
1✔
1848
        """
1849
        Manages authentication headers - different behaviour depending on the session creation version
1850
        :param response: HTTP response
1851
        :type response: requests.Response
1852
        """
1853
        # handle v1 and v2 logins
1854
        handle_session_tokens(response, self.session)
1✔
1855
        # handle v3 logins
1856
        if response.text:
1✔
1857
            self.session.headers.update({'IG-ACCOUNT-ID': self.ACC_NUMBER})
1✔
1858
            payload = json.loads(response.text)
1✔
1859
            if 'oauthToken' in payload:
1✔
1860
                self._handle_oauth(payload['oauthToken'])
1✔
1861

1862
    def _handle_oauth(self, oauth):
1✔
1863
        """
1864
        Handle the v3 headers during session creation and refresh
1865
        :param oauth: 'oauth' portion of the response body
1866
        :type oauth: dict
1867
        """
1868
        access_token = oauth['access_token']
1✔
1869
        token_type = oauth['token_type']
1✔
1870
        self.session.headers.update({'Authorization': f"{token_type} {access_token}"})
1✔
1871
        self._refresh_token = oauth['refresh_token']
1✔
1872
        validity = int(oauth['expires_in'])
1✔
1873
        self._valid_until = datetime.now() + timedelta(seconds=validity)
1✔
1874

1875
    def _check_session(self):
1✔
1876
        """
1877
        Check the v3 session status before making an API request:
1878
            - v3 tokens only last for 60 seconds
1879
            - if possible, the session can be renewed with a special refresh token
1880
            - if not, a new session will be created
1881
        """
1882
        logging.debug("Checking session status...")
1✔
1883
        if self._valid_until is not None and datetime.now() > self._valid_until:
1✔
1884
            if self._refresh_token:
1✔
1885
                # we are in a v3 session, need to refresh
1886
                try:
1✔
1887
                    logging.info("Current session has expired, refreshing...")
1✔
1888
                    self.refresh_session()
1✔
1889
                except IGException:
×
1890
                    logging.info("Refresh failed, logging in again...")
×
1891
                    self._refresh_token = None
×
1892
                    self._valid_until = None
×
1893
                    del self.session.headers['Authorization']
×
1894
                    self.create_session(version='3')
×
1895

1896
    def switch_account(self, account_id, default_account, session=None):
1✔
1897
        """Switches active accounts, optionally setting the default account"""
1898
        version = "1"
1✔
1899
        params = {"accountId": account_id, "defaultAccount": default_account}
1✔
1900
        endpoint = "/session"
1✔
1901
        action = "update"
1✔
1902
        response = self._req(action, endpoint, params, session, version)
1✔
1903
        self._manage_headers(response)
1✔
1904
        data = self.parse_response(response.text)
1✔
1905
        return data
1✔
1906

1907
    def read_session(self, fetch_session_tokens='false', session=None):
1✔
1908
        """Retrieves current session details"""
1909
        version = "1"
1✔
1910
        params = {"fetchSessionTokens": fetch_session_tokens}
1✔
1911
        endpoint = "/session"
1✔
1912
        action = "read"
1✔
1913
        response = self._req(action, endpoint, params, session, version)
1✔
1914
        if not response.ok:
1✔
1915
            raise IGException("Error in read_session() %s" % response.status_code)
×
1916
        data = self.parse_response(response.text)
1✔
1917
        return data
1✔
1918

1919
    # -------- END -------- #
1920

1921
    # -------- GENERAL -------- #
1922

1923
    def get_client_apps(self, session=None):
1✔
1924
        """Returns a list of client-owned applications"""
1925
        version = "1"
1✔
1926
        params = {}
1✔
1927
        endpoint = "/operations/application"
1✔
1928
        action = "read"
1✔
1929
        response = self._req(action, endpoint, params, session, version)
1✔
1930
        data = self.parse_response(response.text)
1✔
1931
        return data
1✔
1932

1933
    def update_client_app(
1✔
1934
        self,
1935
        allowance_account_overall,
1936
        allowance_account_trading,
1937
        api_key,
1938
        status,
1939
        session=None,
1940
    ):
1941
        """Updates an application"""
1942
        version = "1"
×
1943
        params = {
×
1944
            "allowanceAccountOverall": allowance_account_overall,
1945
            "allowanceAccountTrading": allowance_account_trading,
1946
            "apiKey": api_key,
1947
            "status": status,
1948
        }
1949
        endpoint = "/operations/application"
×
1950
        action = "update"
×
1951
        response = self._req(action, endpoint, params, session, version)
×
1952
        data = self.parse_response(response.text)
×
1953
        return data
×
1954

1955
    def disable_client_app_key(self, session=None):
1✔
1956
        """
1957
        Disables the current application key from processing further requests.
1958
        Disabled keys may be re-enabled via the My Account section on
1959
        the IG Web Dealing Platform.
1960
        """
1961
        version = "1"
×
1962
        params = {}
×
1963
        endpoint = "/operations/application/disable"
×
1964
        action = "update"
×
1965
        response = self._req(action, endpoint, params, session, version)
×
1966
        data = self.parse_response(response.text)
×
1967
        return data
×
1968

1969
    # -------- END -------- #
1970

1971

1972
def handle_session_tokens(response, session):
1✔
1973
    """
1974
    Copy session tokens from response to headers, so they will be present for all future requests
1975
    :param response: HTTP response object
1976
    :type response: requests.Response
1977
    :param session: HTTP session object
1978
    :type session: requests.Session
1979
    """
1980
    if "CST" in response.headers:
1✔
1981
        session.headers.update({'CST': response.headers['CST']})
1✔
1982
    if "X-SECURITY-TOKEN" in response.headers:
1✔
1983
        session.headers.update({'X-SECURITY-TOKEN': response.headers['X-SECURITY-TOKEN']})
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc