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

ig-python / ig-markets-api-python-library / 3784753490

pending completion
3784753490

push

github

GitHub
change integration test schedule

1018 of 1367 relevant lines covered (74.47%)

0.74 hits per line

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

88.58
/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
        """Format prices data as a DataFrame with hierarchical columns"""
1367

1368
        if len(prices) == 0:
1✔
1369
            raise (Exception("Historical price data not found"))
×
1370

1371
        def cols(typ):
1✔
1372
            return {
1✔
1373
                "openPrice.%s" % typ: "Open",
1374
                "highPrice.%s" % typ: "High",
1375
                "lowPrice.%s" % typ: "Low",
1376
                "closePrice.%s" % typ: "Close",
1377
                "lastTradedVolume": "Volume",
1378
            }
1379

1380
        last = prices[0]["lastTradedVolume"] or prices[0]["closePrice"]["lastTraded"]
1✔
1381
        df = json_normalize(prices)
1✔
1382
        df = df.set_index("snapshotTime")
1✔
1383
        df.index = pd.to_datetime(df.index, format=DATE_FORMATS[int(version)])
1✔
1384
        df.index.name = "DateTime"
1✔
1385

1386
        df_ask = df[
1✔
1387
            ["openPrice.ask", "highPrice.ask", "lowPrice.ask", "closePrice.ask"]
1388
        ]
1389
        df_ask = df_ask.rename(columns=cols("ask"))
1✔
1390

1391
        df_bid = df[
1✔
1392
            ["openPrice.bid", "highPrice.bid", "lowPrice.bid", "closePrice.bid"]
1393
        ]
1394
        df_bid = df_bid.rename(columns=cols("bid"))
1✔
1395

1396
        if flag_calc_spread:
1✔
1397
            df_spread = df_ask - df_bid
×
1398

1399
        if last:
1✔
1400
            df_last = df[
1✔
1401
                [
1402
                    "openPrice.lastTraded",
1403
                    "highPrice.lastTraded",
1404
                    "lowPrice.lastTraded",
1405
                    "closePrice.lastTraded",
1406
                    "lastTradedVolume",
1407
                ]
1408
            ]
1409
            df_last = df_last.rename(columns=cols("lastTraded"))
1✔
1410

1411
        data = [df_bid, df_ask]
1✔
1412
        keys = ["bid", "ask"]
1✔
1413
        if flag_calc_spread:
1✔
1414
            data.append(df_spread)
×
1415
            keys.append("spread")
×
1416

1417
        if last:
1✔
1418
            data.append(df_last)
1✔
1419
            keys.append("last")
1✔
1420

1421
        df2 = pd.concat(data, axis=1, keys=keys)
1✔
1422
        return df2
1✔
1423

1424
    def flat_prices(self, prices, version):
1✔
1425

1426
        """Format price data as a flat DataFrame, no hierarchy"""
1427

1428
        if len(prices) == 0:
1✔
1429
            raise (Exception("Historical price data not found"))
×
1430

1431
        df = json_normalize(prices)
1✔
1432
        df = df.set_index("snapshotTimeUTC")
1✔
1433
        df.index = pd.to_datetime(df.index, format="%Y-%m-%dT%H:%M:%S")
1✔
1434
        df.index.name = "DateTime"
1✔
1435
        df = df.drop(columns=['snapshotTime',
1✔
1436
                              'openPrice.lastTraded',
1437
                              'closePrice.lastTraded',
1438
                              'highPrice.lastTraded',
1439
                              'lowPrice.lastTraded'])
1440
        df = df.rename(columns={"openPrice.bid": "open.bid",
1✔
1441
                                "openPrice.ask": "open.ask",
1442
                                "closePrice.bid": "close.bid",
1443
                                "closePrice.ask": "close.ask",
1444
                                "highPrice.bid": "high.bid",
1445
                                "highPrice.ask": "high.ask",
1446
                                "lowPrice.bid": "low.bid",
1447
                                "lowPrice.ask": "low.ask",
1448
                                "lastTradedVolume": "volume"})
1449
        return df
1✔
1450

1451
    def mid_prices(self, prices, version):
1✔
1452

1453
        """Format price data as a flat DataFrame, no hierarchy, calculating mid prices"""
1454

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

1458
        df = json_normalize(prices)
×
1459
        df = df.set_index("snapshotTimeUTC")
×
1460
        df.index = pd.to_datetime(df.index, format="%Y-%m-%dT%H:%M:%S")
×
1461
        df.index.name = "DateTime"
×
1462

1463
        df['Open'] = df[['openPrice.bid', 'openPrice.ask']].mean(axis=1)
×
1464
        df['High'] = df[['highPrice.bid', 'highPrice.ask']].mean(axis=1)
×
1465
        df['Low'] = df[['lowPrice.bid', 'lowPrice.ask']].mean(axis=1)
×
1466
        df['Close'] = df[['closePrice.bid', 'closePrice.ask']].mean(axis=1)
×
1467

1468
        df = df.drop(columns=['snapshotTime', 'openPrice.lastTraded', 'closePrice.lastTraded',
×
1469
                              'highPrice.lastTraded', 'lowPrice.lastTraded',
1470
                              "openPrice.bid", "openPrice.ask",
1471
                              "closePrice.bid", "closePrice.ask",
1472
                              "highPrice.bid", "highPrice.ask",
1473
                              "lowPrice.bid", "lowPrice.ask"])
1474
        df = df.rename(columns={"lastTradedVolume": "Volume"})
×
1475

1476
        return df
×
1477

1478
    def fetch_historical_prices_by_epic(
1✔
1479
        self,
1480
        epic,
1481
        resolution=None,
1482
        start_date=None,
1483
        end_date=None,
1484
        numpoints=None,
1485
        pagesize=20,
1486
        session=None,
1487
        format=None,
1488
        wait=1
1489
    ):
1490

1491
        """
1492
        Fetches historical prices for the given epic.
1493

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

1499
        If the result set spans multiple 'pages', this method will automatically
1500
        get all the results and bundle them into one object.
1501

1502
        :param epic: (str) The epic key for which historical prices are being
1503
            requested
1504
        :param resolution: (str, optional) timescale resolution. Expected values
1505
            are 1Min, 2Min, 3Min, 5Min, 10Min, 15Min, 30Min, 1H, 2H, 3H, 4H, D,
1506
            W, M. Default is 1Min
1507
        :param start_date: (datetime, optional) date range start, format
1508
            yyyy-MM-dd'T'HH:mm:ss
1509
        :param end_date: (datetime, optional) date range end, format
1510
            yyyy-MM-dd'T'HH:mm:ss
1511
        :param numpoints: (int, optional) number of data points. Default is 10
1512
        :param pagesize: (int, optional) number of data points. Default is 20
1513
        :param session: (Session, optional) session object
1514
        :param format: (function, optional) function to convert the raw
1515
            JSON response
1516
        :param wait: (int, optional) how many seconds to wait between successive
1517
            calls in a multi-page scenario. Default is 1
1518
        :returns: Pandas DataFrame if configured, otherwise a dict
1519
        :raises Exception: raises an exception if any error is encountered
1520
        """
1521

1522
        version = "3"
1✔
1523
        params = {}
1✔
1524
        if resolution and self.return_dataframe:
1✔
1525
            params["resolution"] = conv_resol(resolution)
1✔
1526
        if start_date:
1✔
1527
            params["from"] = start_date
1✔
1528
        if end_date:
1✔
1529
            params["to"] = end_date
1✔
1530
        if numpoints:
1✔
1531
            params["max"] = numpoints
1✔
1532
        params["pageSize"] = pagesize
1✔
1533
        url_params = {"epic": epic}
1✔
1534
        endpoint = "/prices/{epic}".format(**url_params)
1✔
1535
        action = "read"
1✔
1536
        prices = []
1✔
1537
        pagenumber = 1
1✔
1538
        more_results = True
1✔
1539

1540
        while more_results:
1✔
1541
            params["pageNumber"] = pagenumber
1✔
1542
            response = self._req(action, endpoint, params, session, version)
1✔
1543
            data = self.parse_response(response.text)
1✔
1544
            prices.extend(data["prices"])
1✔
1545
            page_data = data["metadata"]["pageData"]
1✔
1546
            if page_data["totalPages"] == 0 or \
1✔
1547
                    (page_data["pageNumber"] == page_data["totalPages"]):
1548
                more_results = False
1✔
1549
            else:
1550
                pagenumber += 1
1✔
1551
            time.sleep(wait)
1✔
1552

1553
        data["prices"] = prices
1✔
1554

1555
        if format is None:
1✔
1556
            format = self.format_prices
1✔
1557
        if self.return_dataframe:
1✔
1558
            data["prices"] = format(data["prices"], version)
1✔
1559
            data['prices'] = data['prices'].fillna(value=np.nan)
1✔
1560
        self.log_allowance(data["metadata"])
1✔
1561
        return data
1✔
1562

1563
    def fetch_historical_prices_by_epic_and_num_points(self, epic, resolution,
1✔
1564
                                                       numpoints, session=None,
1565
                                                       format=None):
1566
        """Returns a list of historical prices for the given epic, resolution,
1567
        number of points"""
1568
        version = "2"
1✔
1569
        if self.return_dataframe:
1✔
1570
            resolution = conv_resol(resolution)
1✔
1571
        params = {}
1✔
1572
        url_params = {"epic": epic, "resolution": resolution, "numpoints": numpoints}
1✔
1573
        endpoint = "/prices/{epic}/{resolution}/{numpoints}".format(**url_params)
1✔
1574
        action = "read"
1✔
1575
        response = self._req(action, endpoint, params, session, version)
1✔
1576
        data = self.parse_response(response.text)
1✔
1577
        if format is None:
1✔
1578
            format = self.format_prices
1✔
1579
        if self.return_dataframe:
1✔
1580
            data["prices"] = format(data["prices"], version)
1✔
1581
            data['prices'] = data['prices'].fillna(value=np.nan)
1✔
1582
        return data
1✔
1583

1584
    def fetch_historical_prices_by_epic_and_date_range(
1✔
1585
        self, epic, resolution, start_date, end_date, session=None, format=None, version='2'
1586
    ):
1587
        """
1588
        Returns a list of historical prices for the given epic, resolution, multiplier and date range. Supports
1589
        both versions 1 and 2
1590
        :param epic: IG epic
1591
        :type epic: str
1592
        :param resolution: timescale for returned data. Expected values 'M', 'D', '1H' etc
1593
        :type resolution: str
1594
        :param start_date: start date for returned data. For v1, format '2020:09:01-00:00:00', for v2 use
1595
            '2020-09-01 00:00:00'
1596
        :type start_date: str
1597
        :param end_date: end date for returned data. For v1, format '2020:09:01-00:00:00', for v2 use
1598
            '2020-09-01 00:00:00'
1599
        :type end_date: str
1600
        :param session: HTTP session
1601
        :type session: requests.Session
1602
        :param format: function defining how the historic price data should be converted into a Dataframe
1603
        :type format: function
1604
        :param version: API method version
1605
        :type version: str
1606
        :return: historic data
1607
        :rtype: dict, with 'prices' element as pandas.Dataframe
1608
        """
1609
        if self.return_dataframe:
1✔
1610
            resolution = conv_resol(resolution)
1✔
1611
        params = {}
1✔
1612
        if version == '1':
1✔
1613
            start_date = conv_datetime(start_date, version)
1✔
1614
            end_date = conv_datetime(end_date, version)
1✔
1615
            params = {"startdate": start_date, "enddate": end_date}
1✔
1616
            url_params = {"epic": epic, "resolution": resolution}
1✔
1617
            endpoint = "/prices/{epic}/{resolution}".format(**url_params)
1✔
1618
        else:
1619
            url_params = {"epic": epic, "resolution": resolution, "startDate": start_date, "endDate": end_date}
1✔
1620
            endpoint = "/prices/{epic}/{resolution}/{startDate}/{endDate}".format(**url_params)
1✔
1621
        action = "read"
1✔
1622
        response = self._req(action, endpoint, params, session, version)
1✔
1623
        del self.session.headers["VERSION"]
1✔
1624
        data = self.parse_response(response.text)
1✔
1625
        if format is None:
1✔
1626
            format = self.format_prices
1✔
1627
        if self.return_dataframe:
1✔
1628
            data["prices"] = format(data["prices"], version)
1✔
1629
            data['prices'] = data['prices'].fillna(value=np.nan)
1✔
1630
        return data
1✔
1631

1632
    def log_allowance(self, data):
1✔
1633
        remaining_allowance = data['allowance']['remainingAllowance']
1✔
1634
        allowance_expiry_secs = data['allowance']['allowanceExpiry']
1✔
1635
        allowance_expiry = datetime.today() + timedelta(seconds=allowance_expiry_secs)
1✔
1636
        logger.info("Historic price data allowance: %s remaining until %s" %
1✔
1637
                    (remaining_allowance, allowance_expiry))
1638

1639
    # -------- END -------- #
1640

1641
    # -------- WATCHLISTS -------- #
1642

1643
    def fetch_all_watchlists(self, session=None):
1✔
1644
        """Returns all watchlists belonging to the active account"""
1645
        self.non_trading_rate_limit_pause_or_pass()
1✔
1646
        version = "1"
1✔
1647
        params = {}
1✔
1648
        endpoint = "/watchlists"
1✔
1649
        action = "read"
1✔
1650
        response = self._req(action, endpoint, params, session, version)
1✔
1651
        data = self.parse_response(response.text)
1✔
1652
        if self.return_dataframe:
1✔
1653
            data = pd.DataFrame(data["watchlists"])
1✔
1654
        return data
1✔
1655

1656
    def create_watchlist(self, name, epics, session=None):
1✔
1657
        """Creates a watchlist"""
1658
        self.non_trading_rate_limit_pause_or_pass()
1✔
1659
        version = "1"
1✔
1660
        params = {"name": name, "epics": epics}
1✔
1661
        endpoint = "/watchlists"
1✔
1662
        action = "create"
1✔
1663
        response = self._req(action, endpoint, params, session, version)
1✔
1664
        data = self.parse_response(response.text)
1✔
1665
        return data
1✔
1666

1667
    def delete_watchlist(self, watchlist_id, session=None):
1✔
1668
        """Deletes a watchlist"""
1669
        self.non_trading_rate_limit_pause_or_pass()
1✔
1670
        version = "1"
1✔
1671
        params = {}
1✔
1672
        url_params = {"watchlist_id": watchlist_id}
1✔
1673
        endpoint = "/watchlists/{watchlist_id}".format(**url_params)
1✔
1674
        action = "delete"
1✔
1675
        response = self._req(action, endpoint, params, session, version)
1✔
1676
        data = self.parse_response(response.text)
1✔
1677
        return data
1✔
1678

1679
    def fetch_watchlist_markets(self, watchlist_id, session=None):
1✔
1680
        """Returns the given watchlist's markets"""
1681
        self.non_trading_rate_limit_pause_or_pass()
1✔
1682
        version = "1"
1✔
1683
        params = {}
1✔
1684
        url_params = {"watchlist_id": watchlist_id}
1✔
1685
        endpoint = "/watchlists/{watchlist_id}".format(**url_params)
1✔
1686
        action = "read"
1✔
1687
        response = self._req(action, endpoint, params, session, version)
1✔
1688
        data = self.parse_response(response.text)
1✔
1689
        if self.return_dataframe:
1✔
1690
            data = pd.DataFrame(data["markets"])
1✔
1691
        return data
1✔
1692

1693
    def add_market_to_watchlist(self, watchlist_id, epic, session=None):
1✔
1694
        """Adds a market to a watchlist"""
1695
        self.non_trading_rate_limit_pause_or_pass()
1✔
1696
        version = "1"
1✔
1697
        params = {"epic": epic}
1✔
1698
        url_params = {"watchlist_id": watchlist_id}
1✔
1699
        endpoint = "/watchlists/{watchlist_id}".format(**url_params)
1✔
1700
        action = "update"
1✔
1701
        response = self._req(action, endpoint, params, session, version)
1✔
1702
        data = self.parse_response(response.text)
1✔
1703
        return data
1✔
1704

1705
    def remove_market_from_watchlist(self, watchlist_id, epic, session=None):
1✔
1706
        """Remove a market from a watchlist"""
1707
        self.non_trading_rate_limit_pause_or_pass()
1✔
1708
        version = "1"
1✔
1709
        params = {}
1✔
1710
        url_params = {"watchlist_id": watchlist_id, "epic": epic}
1✔
1711
        endpoint = "/watchlists/{watchlist_id}/{epic}".format(**url_params)
1✔
1712
        action = "delete"
1✔
1713
        response = self._req(action, endpoint, params, session, version)
1✔
1714
        data = self.parse_response(response.text)
1✔
1715
        return data
1✔
1716

1717
    # -------- END -------- #
1718

1719
    # -------- LOGIN -------- #
1720

1721
    def logout(self, session=None):
1✔
1722
        """Log out of the current session"""
1723
        version = "1"
1✔
1724
        params = {}
1✔
1725
        endpoint = "/session"
1✔
1726
        action = "delete"
1✔
1727
        self._req(action, endpoint, params, session, version)
1✔
1728
        self.session.close()
1✔
1729
        self._exit_bucket_threads()
1✔
1730

1731
    def get_encryption_key(self, session=None):
1✔
1732
        """Get encryption key to encrypt the password"""
1733
        endpoint = "/session/encryptionKey"
1✔
1734
        session = self._get_session(session)
1✔
1735
        response = session.get(self.BASE_URL + endpoint)
1✔
1736
        if not response.ok:
1✔
1737
            raise IGException("Could not get encryption key for login.")
1✔
1738
        data = response.json()
1✔
1739
        return data["encryptionKey"], data["timeStamp"]
1✔
1740

1741
    def encrypted_password(self, session=None):
1✔
1742
        """Encrypt password for login"""
1743
        key, timestamp = self.get_encryption_key(session)
1✔
1744
        rsakey = RSA.importKey(b64decode(key))
1✔
1745
        string = self.IG_PASSWORD + "|" + str(int(timestamp))
1✔
1746
        message = b64encode(string.encode())
1✔
1747
        return b64encode(PKCS1_v1_5.new(rsakey).encrypt(message)).decode()
1✔
1748

1749
    def create_session(self, session=None, encryption=False, version='2'):
1✔
1750
        """
1751
        Creates a session, obtaining tokens for subsequent API access
1752

1753
        ** April 2021 v3 has been implemented, but is not the default for now
1754

1755
        :param session: HTTP session
1756
        :type session: requests.Session
1757
        :param encryption: whether or not the password should be encrypted. Required for some regions
1758
        :type encryption: Boolean
1759
        :param version: API method version
1760
        :type version: str
1761
        :return: JSON response body, parsed into dict
1762
        :rtype: dict
1763
        """
1764
        if version == '3' and self.ACC_NUMBER is None:
1✔
1765
            raise IGException('Account number must be set for v3 sessions')
1✔
1766

1767
        logging.info(f"Creating new v{version} session for user '{self.IG_USERNAME}' at '{self.BASE_URL}'")
1✔
1768
        password = self.encrypted_password(session) if encryption else self.IG_PASSWORD
1✔
1769
        params = {"identifier": self.IG_USERNAME, "password": password}
1✔
1770
        if encryption:
1✔
1771
            params["encryptedPassword"] = True
1✔
1772
        endpoint = "/session"
1✔
1773
        action = "create"
1✔
1774
        response = self._req(action, endpoint, params, session, version, check=False)
1✔
1775
        self._manage_headers(response)
1✔
1776
        data = self.parse_response(response.text)
1✔
1777

1778
        if self._use_rate_limiter:
1✔
1779
            self.setup_rate_limiter()
1✔
1780

1781
        return data
1✔
1782

1783
    def refresh_session(self, session=None, version='1'):
1✔
1784
        """
1785
        Refreshes a v3 session. Tokens only last for 60 seconds, so need to be renewed regularly
1786
        :param session: HTTP session object
1787
        :type session: requests.Session
1788
        :param version: API method version
1789
        :type version: str
1790
        :return: HTTP status code
1791
        :rtype: int
1792
        """
1793
        logging.info(f"Refreshing session '{self.IG_USERNAME}'")
1✔
1794
        params = {"refresh_token": self._refresh_token}
1✔
1795
        endpoint = "/session/refresh-token"
1✔
1796
        action = "create"
1✔
1797
        response = self._req(action, endpoint, params, session, version, check=False)
1✔
1798
        self._handle_oauth(json.loads(response.text))
1✔
1799
        return response.status_code
1✔
1800

1801
    def _manage_headers(self, response):
1✔
1802
        """
1803
        Manages authentication headers - different behaviour depending on the session creation version
1804
        :param response: HTTP response
1805
        :type response: requests.Response
1806
        """
1807
        # handle v1 and v2 logins
1808
        handle_session_tokens(response, self.session)
1✔
1809
        # handle v3 logins
1810
        if response.text:
1✔
1811
            self.session.headers.update({'IG-ACCOUNT-ID': self.ACC_NUMBER})
1✔
1812
            payload = json.loads(response.text)
1✔
1813
            if 'oauthToken' in payload:
1✔
1814
                self._handle_oauth(payload['oauthToken'])
1✔
1815

1816
    def _handle_oauth(self, oauth):
1✔
1817
        """
1818
        Handle the v3 headers during session creation and refresh
1819
        :param oauth: 'oauth' portion of the response body
1820
        :type oauth: dict
1821
        """
1822
        access_token = oauth['access_token']
1✔
1823
        token_type = oauth['token_type']
1✔
1824
        self.session.headers.update({'Authorization': f"{token_type} {access_token}"})
1✔
1825
        self._refresh_token = oauth['refresh_token']
1✔
1826
        validity = int(oauth['expires_in'])
1✔
1827
        self._valid_until = datetime.now() + timedelta(seconds=validity)
1✔
1828

1829
    def _check_session(self):
1✔
1830
        """
1831
        Check the v3 session status before making an API request:
1832
            - v3 tokens only last for 60 seconds
1833
            - if possible, the session can be renewed with a special refresh token
1834
            - if not, a new session will be created
1835
        """
1836
        logging.debug("Checking session status...")
1✔
1837
        if self._valid_until is not None and datetime.now() > self._valid_until:
1✔
1838
            if self._refresh_token:
1✔
1839
                # we are in a v3 session, need to refresh
1840
                try:
1✔
1841
                    logging.info("Current session has expired, refreshing...")
1✔
1842
                    self.refresh_session()
1✔
1843
                except IGException:
×
1844
                    logging.info("Refresh failed, logging in again...")
×
1845
                    self._refresh_token = None
×
1846
                    self._valid_until = None
×
1847
                    del self.session.headers['Authorization']
×
1848
                    self.create_session(version='3')
×
1849

1850
    def switch_account(self, account_id, default_account, session=None):
1✔
1851
        """Switches active accounts, optionally setting the default account"""
1852
        version = "1"
1✔
1853
        params = {"accountId": account_id, "defaultAccount": default_account}
1✔
1854
        endpoint = "/session"
1✔
1855
        action = "update"
1✔
1856
        response = self._req(action, endpoint, params, session, version)
1✔
1857
        self._manage_headers(response)
1✔
1858
        data = self.parse_response(response.text)
1✔
1859
        return data
1✔
1860

1861
    def read_session(self, fetch_session_tokens='false', session=None):
1✔
1862
        """Retrieves current session details"""
1863
        version = "1"
1✔
1864
        params = {"fetchSessionTokens": fetch_session_tokens}
1✔
1865
        endpoint = "/session"
1✔
1866
        action = "read"
1✔
1867
        response = self._req(action, endpoint, params, session, version)
1✔
1868
        if not response.ok:
1✔
1869
            raise IGException("Error in read_session() %s" % response.status_code)
×
1870
        data = self.parse_response(response.text)
1✔
1871
        return data
1✔
1872

1873
    # -------- END -------- #
1874

1875
    # -------- GENERAL -------- #
1876

1877
    def get_client_apps(self, session=None):
1✔
1878
        """Returns a list of client-owned applications"""
1879
        version = "1"
1✔
1880
        params = {}
1✔
1881
        endpoint = "/operations/application"
1✔
1882
        action = "read"
1✔
1883
        response = self._req(action, endpoint, params, session, version)
1✔
1884
        data = self.parse_response(response.text)
1✔
1885
        return data
1✔
1886

1887
    def update_client_app(
1✔
1888
        self,
1889
        allowance_account_overall,
1890
        allowance_account_trading,
1891
        api_key,
1892
        status,
1893
        session=None,
1894
    ):
1895
        """Updates an application"""
1896
        version = "1"
×
1897
        params = {
×
1898
            "allowanceAccountOverall": allowance_account_overall,
1899
            "allowanceAccountTrading": allowance_account_trading,
1900
            "apiKey": api_key,
1901
            "status": status,
1902
        }
1903
        endpoint = "/operations/application"
×
1904
        action = "update"
×
1905
        response = self._req(action, endpoint, params, session, version)
×
1906
        data = self.parse_response(response.text)
×
1907
        return data
×
1908

1909
    def disable_client_app_key(self, session=None):
1✔
1910
        """
1911
        Disables the current application key from processing further requests.
1912
        Disabled keys may be re-enabled via the My Account section on
1913
        the IG Web Dealing Platform.
1914
        """
1915
        version = "1"
×
1916
        params = {}
×
1917
        endpoint = "/operations/application/disable"
×
1918
        action = "update"
×
1919
        response = self._req(action, endpoint, params, session, version)
×
1920
        data = self.parse_response(response.text)
×
1921
        return data
×
1922

1923
    # -------- END -------- #
1924

1925

1926
def handle_session_tokens(response, session):
1✔
1927
    """
1928
    Copy session tokens from response to headers, so they will be present for all future requests
1929
    :param response: HTTP response object
1930
    :type response: requests.Response
1931
    :param session: HTTP session object
1932
    :type session: requests.Session
1933
    """
1934
    if "CST" in response.headers:
1✔
1935
        session.headers.update({'CST': response.headers['CST']})
1✔
1936
    if "X-SECURITY-TOKEN" in response.headers:
1✔
1937
        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