boosted.api.api_client

   1# Copyright (C) 2020 Gradient Boosted Investments, Inc. - All Rights Reserved
   2
   3import base64
   4import csv
   5import datetime
   6import functools
   7import io
   8import itertools
   9import json
  10import logging
  11import math
  12import mimetypes
  13import os
  14import sys
  15import tempfile
  16import time
  17from datetime import date, timedelta
  18from typing import Any, Dict, List, Literal, Optional, Tuple, Union
  19from urllib import parse
  20
  21import numpy as np
  22import pandas
  23import pandas as pd
  24import requests
  25from dateutil import parser
  26
  27import boosted.api.graphql_queries as graphql_queries
  28from boosted.api.api_type import (
  29    BoostedAPIException,
  30    BoostedDate,
  31    ChunkStatus,
  32    ColumnSubRole,
  33    DataAddType,
  34    DataSetConfig,
  35    DataSetType,
  36    DateIdentCountryCurrency,
  37    GbiIdSecurity,
  38    GbiIdTickerISIN,
  39    HedgeExperiment,
  40    HedgeExperimentDetails,
  41    HedgeExperimentScenario,
  42    Language,
  43    NewsHorizon,
  44    PortfolioSettings,
  45    Status,
  46    ThemeUniverse,
  47    hedge_experiment_type,
  48)
  49from boosted.api.api_util import (
  50    get_valid_iso_dates,
  51    infer_dataset_schema,
  52    protoCubeJsonDataToDataFrame,
  53    to_camel_case,
  54    validate_start_and_end_dates,
  55)
  56
  57logger = logging.getLogger("boosted.api.client")
  58logging.basicConfig()
  59
  60g_boosted_api_url = "https://insights.boosted.ai"
  61g_boosted_api_url_dev = "https://insights-dev.boosted.ai"
  62WATCHLIST_ROUTE_PREFIX = "/api/dal/watchlist"
  63ROUTE_PREFIX = WATCHLIST_ROUTE_PREFIX
  64DAL_WATCHLIST_ROUTE = "/api/v0/watchlist"
  65DAL_SECURITIES_ROUTE = "/api/v0/securities"
  66DAL_PA_ROUTE = "/api/v0/portfolio-analysis"
  67PORTFOLIO_GROUP_ROUTE = "/api/v0/portfolio-group"
  68
  69RISK_FACTOR = "risk-factor"
  70RISK_FACTOR_V2 = "risk-factor-v2"
  71RISK_FACTOR_COLUMNS = [
  72    "depth",
  73    "identifier",
  74    "stock_count",
  75    "volatility",
  76    "exposure",
  77    "rating",
  78    "rating_delta",
  79]
  80
  81
  82def convert_date(date: BoostedDate) -> datetime.date:
  83    if isinstance(date, str):
  84        try:
  85            return parser.parse(date).date()
  86        except Exception as e:
  87            raise BoostedAPIException(f"Unable to parse date: {str(e)}")
  88    return date
  89
  90
  91class BoostedClient:
  92    def __init__(
  93        self, api_key, override_uri=None, debug=False, proxy=None, disable_verify_ssl=False
  94    ):
  95        """
  96        Parameters
  97        ----------
  98        api_key: str
  99            Your API key provided by the Boosted application.  See your profile
 100            to generate a new key.
 101        proxy: str
 102            Your organization may require the use of a proxy for access.
 103            The address of a HTTPS proxy in the format of <address>:<port>.
 104            Examples are "123.456.789:123" or "my.proxy.com:123".
 105            Do not prepend with "https://".
 106        disable_verify_ssl: bool
 107            Your networking setup may be behind a firewall which performs SSL
 108            inspection. Either set the REQUESTS_CA_BUNDLE environment variable
 109            to point to the location of a custom certificate bundle, or set this
 110            parameter to True to disable SSL verification as a workaround.
 111        """
 112        if override_uri is None:
 113            self.base_uri = g_boosted_api_url
 114        else:
 115            self.base_uri = override_uri
 116        self.api_key = api_key
 117        self.debug = debug
 118        self._request_params: Dict = {}
 119        if debug:
 120            logger.setLevel(logging.DEBUG)
 121        else:
 122            logger.setLevel(logging.INFO)
 123        if proxy is not None:
 124            self._request_params["proxies"] = {"https": proxy}
 125        if disable_verify_ssl:
 126            self._request_params["verify"] = False
 127
 128    def __print_json_info(self, json_data, isInference=False):
 129        if "warnings" in json_data.keys():
 130            for warning in json_data["warnings"]:
 131                logger.warning("  {0}".format(warning))
 132        if "errors" in json_data.keys():
 133            for error in json_data["errors"]:
 134                logger.error("  {0}".format(error))
 135                return Status.FAIL
 136
 137        if "result" in json_data.keys():
 138            results_data = json_data["result"]
 139            if isInference:
 140                if "inferenceResultsUrl" in results_data.keys():
 141                    res_url = parse.urlparse(results_data["inferenceResultsUrl"])
 142                    logger.debug(res_url)
 143                    logger.info("Inference started.")
 144            if "updateCount" in results_data.keys():
 145                logger.info("Updated {0} rows.".format(results_data["updateCount"]))
 146            if "createCount" in results_data.keys():
 147                logger.info("Created {0} rows.".format(results_data["createCount"]))
 148            return Status.SUCCESS
 149
 150    def __to_date_obj(self, dt):
 151        if isinstance(dt, datetime.datetime):
 152            dt = dt.date()
 153        elif isinstance(dt, datetime.date):
 154            return dt
 155        elif isinstance(dt, str):
 156            try:
 157                dt = parser.parse(dt).date()
 158            except ValueError:
 159                raise ValueError('dt: "' + dt + '" is not a valid date.')
 160        return dt
 161
 162    def __iso_format(self, dt):
 163        date = self.__to_date_obj(dt)
 164        if date is not None:
 165            date = date.isoformat()
 166        return date
 167
 168    def _check_status_code(self, response, isInference=False):
 169        has_json = False
 170        try:
 171            logger.debug(response.headers)
 172            if "Content-Type" in response.headers:
 173                if response.headers["Content-Type"].startswith("application/json"):
 174                    json_data = response.json()
 175                    has_json = True
 176            else:
 177                has_json = False
 178        except json.JSONDecodeError:
 179            logger.error("ERROR: response has no JSON payload.")
 180        if response.status_code == 200 or response.status_code == 202:
 181            if has_json:
 182                self.__print_json_info(json_data, isInference)
 183            else:
 184                pass
 185            return Status.SUCCESS
 186        if response.status_code == 404:
 187            if has_json:
 188                self.__print_json_info(json_data, isInference)
 189            raise BoostedAPIException(
 190                'Server "{0}" not reachable.  Code {1}.'.format(
 191                    self.base_uri, response.status_code
 192                ),
 193                data=response,
 194            )
 195        if response.status_code == 400:
 196            if has_json:
 197                self.__print_json_info(json_data, isInference)
 198            if isInference:
 199                return Status.FAIL
 200            else:
 201                raise BoostedAPIException("Error, bad request.  Check the dataset ID.", response)
 202        if response.status_code == 401:
 203            if has_json:
 204                self.__print_json_info(json_data, isInference)
 205            raise BoostedAPIException("Authorization error.", response)
 206        else:
 207            if has_json:
 208                self.__print_json_info(json_data, isInference)
 209            raise BoostedAPIException(
 210                "Error in API response.  Status code={0} {1}\n{2}".format(
 211                    response.status_code, response.reason, response.headers
 212                ),
 213                response,
 214            )
 215
 216    def _try_extract_error_code(self, result):
 217        logger.info(result.headers)
 218        if "Content-Type" in result.headers:
 219            if result.headers["Content-Type"].startswith("application/json"):
 220                if "errors" in result.json():
 221                    return result.json()["errors"]
 222            if result.headers["Content-Type"].startswith("text/plain"):
 223                return result.text
 224        return str(result.reason)
 225
 226    def _check_ok_or_err_with_msg(self, res, potential_error_msg: str):
 227        if not res.ok:
 228            error = self._try_extract_error_code(res)
 229            logger.error(error)
 230            raise BoostedAPIException(f"{potential_error_msg}: {error}")
 231
 232    def _get_portfolio_rebalance_from_periods(
 233        self, portfolio_id: str, rel_periods: List[str]
 234    ) -> List[datetime.date]:
 235        """
 236        Returns a list of rebalance dates for a portfolio given a list of
 237        relative periods of format '1D', '1W', '3M', etc.
 238        """
 239        resp = self._get_graphql(
 240            query=graphql_queries.GET_PORTFOLIO_RELATIVE_DATES_QUERY,
 241            variables={"portfolioId": portfolio_id, "relativePeriods": rel_periods},
 242        )
 243        dates = resp["data"]["portfolio"]["relativeDates"]
 244        return [datetime.datetime.strptime(d["date"], "%Y-%m-%d").date() for d in dates]
 245
 246    def translate_text(self, language: Optional[Union[Language, str]], text: str) -> str:
 247        if not language or language == Language.ENGLISH:
 248            # By default, do not translate English
 249            return text
 250
 251        params = {"text": text, "langCode": language}
 252        url = self.base_uri + "/api/translate/translate-text"
 253        headers = {"Authorization": "ApiKey " + self.api_key}
 254        logger.info("Translating text...")
 255        res = requests.post(url, json=params, headers=headers, **self._request_params)
 256        try:
 257            result = res.json()["translatedText"]
 258        except Exception:
 259            raise BoostedAPIException("Error translating text")
 260        return result
 261
 262    def query_dataset(self, dataset_id):
 263        url = self.base_uri + "/api/datasets/{0}".format(dataset_id)
 264        headers = {"Authorization": "ApiKey " + self.api_key}
 265        res = requests.get(url, headers=headers, **self._request_params)
 266        if res.ok:
 267            return res.json()
 268        else:
 269            error_msg = self._try_extract_error_code(res)
 270            logger.error(error_msg)
 271            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 272
 273    def query_namespace_dataset_id(self, namespace, data_type):
 274        url = self.base_uri + f"/api/custom-security-dataset/{namespace}/{data_type}"
 275        headers = {"Authorization": "ApiKey " + self.api_key}
 276        res = requests.get(url, headers=headers, **self._request_params)
 277        if res.ok:
 278            return res.json()["result"]["id"]
 279        else:
 280            if res.status_code != 404:
 281                error_msg = self._try_extract_error_code(res)
 282                logger.error(error_msg)
 283                raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 284            else:
 285                return None
 286
 287    def export_global_data(
 288        self,
 289        dataset_id,
 290        start=(datetime.date.today() - timedelta(days=365 * 25)),
 291        end=datetime.date.today(),
 292        timeout=600,
 293    ):
 294        query_info = self.query_dataset(dataset_id)
 295        if DataSetType[query_info["type"]] != DataSetType.GLOBAL:
 296            raise BoostedAPIException(
 297                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.GLOBAL}"
 298            )
 299        return self.export_data(dataset_id, start, end, timeout)
 300
 301    def export_independent_data(
 302        self,
 303        dataset_id,
 304        start=(datetime.date.today() - timedelta(days=365 * 25)),
 305        end=datetime.date.today(),
 306        timeout=600,
 307    ):
 308        query_info = self.query_dataset(dataset_id)
 309        if DataSetType[query_info["type"]] != DataSetType.STRATEGY:
 310            raise BoostedAPIException(
 311                f"Incorrect dataset type: {query_info['type']}"
 312                f" - Expected {DataSetType.STRATEGY}"
 313            )
 314        return self.export_data(dataset_id, start, end, timeout)
 315
 316    def export_dependent_data(
 317        self,
 318        dataset_id,
 319        start=None,
 320        end=None,
 321        timeout=600,
 322    ):
 323        query_info = self.query_dataset(dataset_id)
 324        if DataSetType[query_info["type"]] != DataSetType.STOCK:
 325            raise BoostedAPIException(
 326                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.STOCK}"
 327            )
 328
 329        valid_date_range = self.getDatasetDates(dataset_id)
 330        validStart = valid_date_range["validFrom"]
 331        validEnd = valid_date_range["validTo"]
 332
 333        if start is None:
 334            logger.info("Since no start date provided, starting from {0}.".format(validStart))
 335            start = validStart
 336        if end is None:
 337            logger.info("Since no end date provided, ending at {0}.".format(validEnd))
 338            end = validEnd
 339        start = self.__to_date_obj(start)
 340        end = self.__to_date_obj(end)
 341        if start < validStart:
 342            logger.info("Data does not exist before {0}.".format(validStart))
 343            logger.info("Starting from {0}.".format(validStart))
 344            start = validStart
 345        if end > validEnd:
 346            logger.info("Data does not exist after {0}.".format(validEnd))
 347            logger.info("Ending at {0}.".format(validEnd))
 348            end = validEnd
 349        validate_start_and_end_dates(start, end)
 350
 351        logger.info("Data exists from {0} to {1}.".format(start, end))
 352        request_url = "/api/datasets/" + dataset_id + "/export-data"
 353        headers = {"Authorization": "ApiKey " + self.api_key}
 354
 355        data_chunks = []
 356        chunk_size_days = 90
 357        while start <= end:
 358            chunk_end = start + timedelta(days=chunk_size_days)
 359            if chunk_end > end:
 360                chunk_end = end
 361
 362            logger.info("Requesting start={0} end={1}.".format(start, chunk_end))
 363            params = {"start": self.__iso_format(start), "end": self.__iso_format(chunk_end)}
 364            logger.debug("URL={0}, headers={1}, params={2}".format(request_url, headers, params))
 365
 366            res = requests.get(
 367                self.base_uri + request_url,
 368                headers=headers,
 369                params=params,
 370                timeout=timeout,
 371                **self._request_params,
 372            )
 373
 374            if res.ok:
 375                buf = io.StringIO(res.text)
 376                df = pd.read_csv(buf, index_col=0, parse_dates=True)
 377                if "price" in df.columns:
 378                    df = df.drop("price", axis=1)
 379                data_chunks.append(df)
 380            else:
 381                error_msg = self._try_extract_error_code(res)
 382                logger.error(error_msg)
 383                raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 384
 385            start = start + timedelta(days=chunk_size_days + 1)
 386
 387        return pd.concat(data_chunks)
 388
 389    def export_custom_security_data(
 390        self,
 391        dataset_id,
 392        start=(date.today() - timedelta(days=365 * 25)),
 393        end=date.today(),
 394        timeout=600,
 395    ):
 396        query_info = self.query_dataset(dataset_id)
 397        if DataSetType[query_info["type"]] != DataSetType.SECURITIES_DAILY:
 398            raise BoostedAPIException(
 399                f"Incorrect dataset type: {query_info['type']}"
 400                f" - Expected {DataSetType.SECURITIES_DAILY}"
 401            )
 402        return self.export_data(dataset_id, start, end, timeout)
 403
 404    def export_data(
 405        self,
 406        dataset_id,
 407        start=(datetime.date.today() - timedelta(days=365 * 25)),
 408        end=datetime.date.today(),
 409        timeout=600,
 410    ):
 411        logger.info("Requesting start={0} end={1}.".format(start, end))
 412        request_url = "/api/datasets/" + dataset_id + "/export-data"
 413        headers = {"Authorization": "ApiKey " + self.api_key}
 414        start = self.__iso_format(start)
 415        end = self.__iso_format(end)
 416        params = {"start": start, "end": end}
 417        logger.debug("URL={0}, headers={1}, params={2}".format(request_url, headers, params))
 418        res = requests.get(
 419            self.base_uri + request_url,
 420            headers=headers,
 421            params=params,
 422            timeout=timeout,
 423            **self._request_params,
 424        )
 425        if res.ok or self._check_status_code(res):
 426            buf = io.StringIO(res.text)
 427            df = pd.read_csv(buf, index_col=0, parse_dates=True)
 428            if "price" in df.columns:
 429                df = df.drop("price", axis=1)
 430            return df
 431        else:
 432            error_msg = self._try_extract_error_code(res)
 433            logger.error(error_msg)
 434            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 435
 436    def _get_inference(self, model_id, inference_date=datetime.date.today()):
 437        request_url = "/api/models/" + model_id + "/inference-results"
 438        headers = {"Authorization": "ApiKey " + self.api_key}
 439        params = {}
 440        params["date"] = self.__iso_format(inference_date)
 441        logger.debug(request_url + ", " + str(headers) + ", " + str(params))
 442        res = requests.get(
 443            self.base_uri + request_url, headers=headers, params=params, **self._request_params
 444        )
 445        status = self._check_status_code(res, isInference=True)
 446        if status == Status.SUCCESS:
 447            return res, status
 448        else:
 449            return None, status
 450
 451    def get_inference(
 452        self, model_id, inference_date=datetime.date.today(), block=False, timeout_minutes=30
 453    ):
 454        start_time = datetime.datetime.now()
 455        while True:
 456            for numRetries in range(3):
 457                res, status = self._get_inference(model_id, inference_date)
 458                if res is not None:
 459                    continue
 460                else:
 461                    if status == Status.FAIL:
 462                        return Status.FAIL
 463                    logger.info("Retrying...")
 464            if res is None:
 465                logger.error("Max retries reached.  Request failed.")
 466                return None
 467
 468            json_data = res.json()
 469            if "result" in json_data.keys():
 470                if json_data["result"]["status"] == "RUNNING":
 471                    still_running = True
 472                    if not block:
 473                        logger.warn("Inference job is still running.")
 474                        return None
 475                    else:
 476                        logger.info(
 477                            "Inference job is still running.  Time elapsed={0}.".format(
 478                                datetime.datetime.now() - start_time
 479                            )
 480                        )
 481                        time.sleep(10)
 482                else:
 483                    still_running = False
 484
 485                if not still_running and json_data["result"]["status"] == "COMPLETE":
 486                    csv = json_data["result"]["signals"]
 487                    logger.info(json_data["result"])
 488                    if self._check_status_code(res, isInference=True):
 489                        logger.info(
 490                            "Total run time = {0}.".format(datetime.datetime.now() - start_time)
 491                        )
 492                        return csv
 493            else:
 494                if "errors" in json_data.keys():
 495                    logger.error(json_data["errors"])
 496                else:
 497                    logger.error("Error getting inference for date {0}.".format(inference_date))
 498                return None
 499            if (datetime.datetime.now() - start_time).total_seconds() / 60.0 > timeout_minutes:
 500                logger.error("Timeout waiting for job completion.")
 501                return None
 502
 503    def createDataset(self, schema):
 504        request_url = "/api/datasets"
 505        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
 506        s = json.dumps(schema)
 507        logger.info("Creating dataset with schema " + s)
 508        res = requests.post(
 509            self.base_uri + request_url, data=s, headers=headers, **self._request_params
 510        )
 511        if res.ok:
 512            return res.json()["result"]
 513        else:
 514            raise BoostedAPIException("Dataset creation failed.")
 515
 516    def create_custom_namespace_dataset(self, namespace, schema):
 517        request_url = f"/api/custom-security-dataset/{namespace}"
 518        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
 519        s = json.dumps(schema)
 520        logger.info("Creating dataset with schema " + s)
 521        res = requests.post(
 522            self.base_uri + request_url, data=s, headers=headers, **self._request_params
 523        )
 524        if res.ok:
 525            return res.json()["result"]
 526        else:
 527            raise BoostedAPIException("Dataset creation failed.")
 528
 529    def getUniverse(self, modelId, date=None):
 530        if date is not None:
 531            url = "/api/models/{0}/universe/{1}".format(modelId, self.__iso_format(date))
 532            logger.info("Getting universe for date: {0}.".format(date))
 533        else:
 534            url = "/api/models/{0}/universe/".format(modelId)
 535        headers = {"Authorization": "ApiKey " + self.api_key}
 536        res = requests.get(self.base_uri + url, headers=headers, **self._request_params)
 537        if res.ok:
 538            buf = io.StringIO(res.text)
 539            df = pd.read_csv(buf, index_col=0, parse_dates=True)
 540            return df
 541        else:
 542            error = self._try_extract_error_code(res)
 543            logger.error(
 544                "There was a problem getting this universe or model ID: {0}.".format(error)
 545            )
 546            raise BoostedAPIException("Failed to get universe: {0}".format(error))
 547
 548    def add_custom_security_namespace_members(
 549        self, namespace, members: Union[pandas.DataFrame, str]
 550    ) -> Tuple[pandas.DataFrame, str]:
 551        url = self.base_uri + "/api/synthetic-datasets/{0}/generate".format(namespace)
 552        headers = {"Authorization": "ApiKey " + self.api_key}
 553        headers["Content-Type"] = "application/json"
 554        logger.info("Adding custom security namespace for namespace: {0}".format(namespace))
 555        strbuf = None
 556        if isinstance(members, pandas.DataFrame):
 557            df = members
 558            df_canon = df.rename(columns={_: to_camel_case(_) for _ in df.columns})
 559            canon_cols = ["Currency", "Symbol", "Country", "Name"]
 560            if set(canon_cols).difference(df_canon.columns):
 561                raise BoostedAPIException(f"Expected columns: {canon_cols}")
 562            df_canon = df_canon.loc[:, canon_cols]
 563            buf = io.StringIO()
 564            df_canon.to_json(buf, orient="records")
 565            strbuf = buf.getvalue()
 566        elif isinstance(members, str):
 567            strbuf = members
 568        else:
 569            raise BoostedAPIException(f"Unsupported members argument type: {type(members)}")
 570        res = requests.post(url, data=strbuf, headers=headers, **self._request_params)
 571        if res.ok:
 572            res_obj = res.json()
 573            res_df = pandas.Series(res_obj["generatedISIN"]).to_frame()
 574            res_df.index.name = "Symbol"
 575            res_df.columns = ["ISIN"]
 576            logger.info("Add to custom security namespace successful.")
 577            if "warnings" in res_obj:
 578                logger.info("Warnings: {0}.".format(res.json()["warnings"]))
 579                return res_df, res.json()["warnings"]
 580            else:
 581                return res_df, "No warnings."
 582        else:
 583            error_msg = self._try_extract_error_code(res)
 584            raise BoostedAPIException("Failed to get universe: {0}.".format(error_msg))
 585
 586    def updateUniverse(self, modelId, universe_df, date=datetime.date.today() + timedelta(1)):
 587        date = self.__iso_format(date)
 588        url = self.base_uri + "/api/models/{0}/universe/{1}".format(modelId, date)
 589        headers = {"Authorization": "ApiKey " + self.api_key}
 590        logger.info("Updating universe for date {0}.".format(date))
 591        if isinstance(universe_df, pd.core.frame.DataFrame):
 592            buf = io.StringIO()
 593            universe_df.to_csv(buf)
 594            target = ("uploaded_universe.csv", buf.getvalue(), "text/csv")
 595            files_req = {}
 596            files_req["universe"] = target
 597            res = requests.post(url, files=files_req, headers=headers, **self._request_params)
 598        elif isinstance(universe_df, str):
 599            target = ("uploaded_universe.csv", universe_df, "text/csv")
 600            files_req = {}
 601            files_req["universe"] = target
 602            res = requests.post(url, files=files_req, headers=headers, **self._request_params)
 603        else:
 604            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
 605        if res.ok:
 606            logger.info("Universe update successful.")
 607            if "warnings" in res.json():
 608                logger.info("Warnings: {0}.".format(res.json()["warnings"]))
 609                return res.json()["warnings"]
 610            else:
 611                return "No warnings."
 612        else:
 613            error_msg = self._try_extract_error_code(res)
 614            raise BoostedAPIException("Failed to get universe: {0}.".format(error_msg))
 615
 616    def create_universe(
 617        self, universe: Union[pd.DataFrame, str], name: str, description: str
 618    ) -> List[str]:
 619        PRESENT = "PRESENT"
 620        ANY = "ANY"
 621        EARLIST_DATE = "1900-01-01"
 622        LATEST_DATE = "4000-01-01"
 623
 624        if isinstance(universe, (str, bytes, os.PathLike)):
 625            universe = pd.read_csv(universe)
 626
 627        universe.columns = universe.columns.str.lower()
 628
 629        # Clients are free to leave out data. Fill in some defaults here.
 630        if "from" not in universe.columns:
 631            universe["from"] = EARLIST_DATE
 632        if "to" not in universe.columns:
 633            universe["to"] = LATEST_DATE
 634        if "currency" not in universe.columns:
 635            universe["currency"] = ANY
 636        if "country" not in universe.columns:
 637            universe["country"] = ANY
 638        if "isin" not in universe.columns:
 639            universe["isin"] = None
 640        if "symbol" not in universe.columns:
 641            universe["symbol"] = None
 642
 643        # to prevent conflicts with python keywords
 644        universe.rename(columns={"from": "from_date", "to": "to_date"}, inplace=True)
 645
 646        universe = universe.replace({np.nan: None})
 647        security_country_currency_date_list = []
 648        for i, r in enumerate(universe.itertuples()):
 649            id_type = ColumnSubRole.ISIN
 650            identifier = r.isin
 651
 652            if identifier is None:
 653                id_type = ColumnSubRole.SYMBOL
 654                identifier = str(r.symbol)
 655
 656            # if identifier is still None, it means that there is no ISIN or
 657            # SYMBOL for this row, in which case we throw an error
 658            if identifier is None:
 659                raise BoostedAPIException(
 660                    (
 661                        f"Missing identifier column in universe row {i + 1}"
 662                        " should contain ISIN or Symbol"
 663                    )
 664                )
 665
 666            security_country_currency_date_list.append(
 667                DateIdentCountryCurrency(
 668                    date=r.from_date or EARLIST_DATE,
 669                    identifier=identifier,
 670                    country=r.country or ANY,
 671                    currency=r.currency or ANY,
 672                    id_type=id_type,
 673                )
 674            )
 675
 676        gbi_id_objs = self.getGbiIdFromIdentCountryCurrencyDate(security_country_currency_date_list)
 677
 678        security_list = []
 679        for i, r in enumerate(universe.itertuples()):
 680            # if we have a None here, we failed to map to a gbi id
 681            if gbi_id_objs[i] is None:
 682                raise BoostedAPIException(f"Unable to map row: {tuple(r)}")
 683
 684            security_list.append(
 685                {
 686                    "stockId": gbi_id_objs[i].gbi_id,
 687                    "fromZ": r.from_date or EARLIST_DATE,
 688                    "toZ": LATEST_DATE if r.to_date in (PRESENT, None) else r.to_date,
 689                    "removal": False,
 690                    "source": "UPLOAD",
 691                }
 692            )
 693
 694        url = self.base_uri + "/api/template-universe/save"
 695        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
 696        req = {"name": name, "description": description, "modificationDaos": security_list}
 697
 698        res = requests.post(url, json=req, headers=headers, **self._request_params)
 699        self._check_ok_or_err_with_msg(res, "Failed to create universe")
 700
 701        if "warnings" in res.json():
 702            logger.info("Warnings: {0}.".format(res.json()["warnings"]))
 703            return res.json()["warnings"].splitlines()
 704        else:
 705            return []
 706
 707    def validate_dataframe(self, df):
 708        if not isinstance(df, pd.core.frame.DataFrame):
 709            logger.error("Dataset must be of type Dataframe.")
 710            return False
 711        if type(df.index) != pd.core.indexes.datetimes.DatetimeIndex:
 712            logger.error("Index must be DatetimeIndex.")
 713            return False
 714        if len(df.columns) == 0:
 715            logger.error("No feature columns exist.")
 716            return False
 717        if len(df) == 0:
 718            logger.error("No rows exist.")
 719        return True
 720
 721    def get_dataset_schema(self, dataset_id):
 722        url = self.base_uri + "/api/datasets/{0}/schema".format(dataset_id)
 723        headers = {"Authorization": "ApiKey " + self.api_key}
 724        res = requests.get(url, headers=headers, **self._request_params)
 725        if res.ok:
 726            json_schema = res.json()
 727        else:
 728            error_msg = self._try_extract_error_code(res)
 729            logger.error(error_msg)
 730            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 731        return DataSetConfig.fromDict(json_schema["result"])
 732
 733    def add_custom_security_daily_dataset(
 734        self, namespace, dataset, schema=None, timeout=600, block=True
 735    ):
 736        result = self.add_custom_security_daily_dataset_with_warnings(
 737            namespace, dataset, schema, timeout, block
 738        )
 739        return result["dataset_id"]
 740
 741    def add_custom_security_daily_dataset_with_warnings(
 742        self,
 743        namespace,
 744        dataset,
 745        schema=None,
 746        timeout=600,
 747        block=True,
 748        no_exception_on_chunk_error=False,
 749    ):
 750        dataset_type = DataSetType.SECURITIES_DAILY
 751        dsid = self.query_namespace_dataset_id(namespace, dataset_type)
 752
 753        if not self.validate_dataframe(dataset):
 754            logger.error("dataset failed validation.")
 755            return None
 756
 757        if dsid is None:
 758            # create the dataset if not exist.
 759            schema = infer_dataset_schema(
 760                "custom_security_daily", dataset, dataset_type, infer_from_column_names=True
 761            )
 762            dsid = self.create_custom_namespace_dataset(namespace, schema.toDict())
 763            data_type = DataAddType.CREATION
 764        elif schema is not None:
 765            raise ValueError(
 766                f"Dataset schema already exists for namespace={namespace}, type={dataset_type}",
 767                ", cannot create another!",
 768            )
 769        else:
 770            data_type = DataAddType.HISTORICAL
 771
 772        logger.info("Created dataset with ID = {0}, uploading...".format(dsid))
 773        result = self.add_custom_security_daily_data(
 774            dsid,
 775            dataset,
 776            timeout,
 777            block,
 778            data_type=data_type,
 779            no_exception_on_chunk_error=no_exception_on_chunk_error,
 780        )
 781        return {
 782            "namespace": namespace,
 783            "dataset_id": dsid,
 784            "warnings": result["warnings"],
 785            "errors": result["errors"],
 786        }
 787
 788    def add_custom_security_daily_data(
 789        self,
 790        dataset_id,
 791        csv_data,
 792        timeout=600,
 793        block=True,
 794        data_type=DataAddType.HISTORICAL,
 795        no_exception_on_chunk_error=False,
 796    ):
 797        warnings = []
 798        query_info = self.query_dataset(dataset_id)
 799        if DataSetType[query_info["type"]] != DataSetType.SECURITIES_DAILY:
 800            raise BoostedAPIException(
 801                f"Incorrect dataset type: {query_info['type']}"
 802                f" - Expected {DataSetType.SECURITIES_DAILY}"
 803            )
 804        warnings, errors = self.setup_chunk_and_upload_data(
 805            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 806        )
 807        if len(warnings) > 0:
 808            logger.warning(
 809                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 810            )
 811        if len(errors) > 0:
 812            raise BoostedAPIException(
 813                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
 814                + "\n".join(errors)
 815            )
 816        return {"warnings": warnings, "errors": errors}
 817
 818    def add_dependent_dataset(
 819        self, dataset, datasetName="DependentDataset", schema=None, timeout=600, block=True
 820    ):
 821        result = self.add_dependent_dataset_with_warnings(
 822            dataset, datasetName, schema, timeout, block
 823        )
 824        return result["dataset_id"]
 825
 826    def add_dependent_dataset_with_warnings(
 827        self,
 828        dataset,
 829        datasetName="DependentDataset",
 830        schema=None,
 831        timeout=600,
 832        block=True,
 833        no_exception_on_chunk_error=False,
 834    ):
 835        if not self.validate_dataframe(dataset):
 836            logger.error("dataset failed validation.")
 837            return None
 838        if schema is None:
 839            schema = infer_dataset_schema(datasetName, dataset, DataSetType.STOCK)
 840        dsid = self.createDataset(schema.toDict())
 841        logger.info("Creating dataset with ID = {0}.".format(dsid))
 842        result = self.add_dependent_data(
 843            dsid,
 844            dataset,
 845            timeout,
 846            block,
 847            data_type=DataAddType.CREATION,
 848            no_exception_on_chunk_error=no_exception_on_chunk_error,
 849        )
 850        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
 851
 852    def add_independent_dataset(
 853        self, dataset, datasetName="IndependentDataset", schema=None, timeout=600, block=True
 854    ):
 855        result = self.add_independent_dataset_with_warnings(
 856            dataset, datasetName, schema, timeout, block
 857        )
 858        return result["dataset_id"]
 859
 860    def add_independent_dataset_with_warnings(
 861        self,
 862        dataset,
 863        datasetName="IndependentDataset",
 864        schema=None,
 865        timeout=600,
 866        block=True,
 867        no_exception_on_chunk_error=False,
 868    ):
 869        if not self.validate_dataframe(dataset):
 870            logger.error("dataset failed validation.")
 871            return None
 872        if schema is None:
 873            schema = infer_dataset_schema(datasetName, dataset, DataSetType.STRATEGY)
 874        schemaDict = schema.toDict()
 875        if "configurationDataJson" not in schemaDict:
 876            schemaDict["configurationDataJson"] = "{}"
 877        dsid = self.createDataset(schemaDict)
 878        logger.info("Creating dataset with ID = {0}.".format(dsid))
 879        result = self.add_independent_data(
 880            dsid,
 881            dataset,
 882            timeout,
 883            block,
 884            data_type=DataAddType.CREATION,
 885            no_exception_on_chunk_error=no_exception_on_chunk_error,
 886        )
 887        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
 888
 889    def add_global_dataset(
 890        self, dataset, datasetName="GlobalDataset", schema=None, timeout=600, block=True
 891    ):
 892        result = self.add_global_dataset_with_warnings(dataset, datasetName, schema, timeout, block)
 893        return result["dataset_id"]
 894
 895    def add_global_dataset_with_warnings(
 896        self,
 897        dataset,
 898        datasetName="GlobalDataset",
 899        schema=None,
 900        timeout=600,
 901        block=True,
 902        no_exception_on_chunk_error=False,
 903    ):
 904        if not self.validate_dataframe(dataset):
 905            logger.error("dataset failed validation.")
 906            return None
 907        if schema is None:
 908            schema = infer_dataset_schema(datasetName, dataset, DataSetType.GLOBAL)
 909        dsid = self.createDataset(schema.toDict())
 910        logger.info("Creating dataset with ID = {0}.".format(dsid))
 911        result = self.add_global_data(
 912            dsid,
 913            dataset,
 914            timeout,
 915            block,
 916            data_type=DataAddType.CREATION,
 917            no_exception_on_chunk_error=no_exception_on_chunk_error,
 918        )
 919        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
 920
 921    def add_independent_data(
 922        self,
 923        dataset_id,
 924        csv_data,
 925        timeout=600,
 926        block=True,
 927        data_type=DataAddType.HISTORICAL,
 928        no_exception_on_chunk_error=False,
 929    ):
 930        query_info = self.query_dataset(dataset_id)
 931        if DataSetType[query_info["type"]] != DataSetType.STRATEGY:
 932            raise BoostedAPIException(
 933                f"Incorrect dataset type: {query_info['type']}"
 934                f" - Expected {DataSetType.STRATEGY}"
 935            )
 936        warnings, errors = self.setup_chunk_and_upload_data(
 937            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 938        )
 939        if len(warnings) > 0:
 940            logger.warning(
 941                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 942            )
 943        if len(errors) > 0:
 944            raise BoostedAPIException(
 945                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
 946                + "\n".join(errors)
 947            )
 948        return {"warnings": warnings, "errors": errors}
 949
 950    def add_dependent_data(
 951        self,
 952        dataset_id,
 953        csv_data,
 954        timeout=600,
 955        block=True,
 956        data_type=DataAddType.HISTORICAL,
 957        no_exception_on_chunk_error=False,
 958    ):
 959        warnings = []
 960        query_info = self.query_dataset(dataset_id)
 961        if DataSetType[query_info["type"]] != DataSetType.STOCK:
 962            raise BoostedAPIException(
 963                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.STOCK}"
 964            )
 965        warnings, errors = self.setup_chunk_and_upload_data(
 966            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 967        )
 968        if len(warnings) > 0:
 969            logger.warning(
 970                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 971            )
 972        if len(errors) > 0:
 973            raise BoostedAPIException(
 974                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
 975                + "\n".join(errors)
 976            )
 977        return {"warnings": warnings, "errors": errors}
 978
 979    def add_global_data(
 980        self,
 981        dataset_id,
 982        csv_data,
 983        timeout=600,
 984        block=True,
 985        data_type=DataAddType.HISTORICAL,
 986        no_exception_on_chunk_error=False,
 987    ):
 988        query_info = self.query_dataset(dataset_id)
 989        if DataSetType[query_info["type"]] != DataSetType.GLOBAL:
 990            raise BoostedAPIException(
 991                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.GLOBAL}"
 992            )
 993        warnings, errors = self.setup_chunk_and_upload_data(
 994            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 995        )
 996        if len(warnings) > 0:
 997            logger.warning(
 998                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 999            )
1000        if len(errors) > 0:
1001            raise BoostedAPIException(
1002                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
1003                + "\n".join(errors)
1004            )
1005        return {"warnings": warnings, "errors": errors}
1006
1007    def get_csv_buffer(self):
1008        return io.StringIO()
1009
1010    def start_chunked_upload(self, dataset_id):
1011        url = self.base_uri + "/api/datasets/{0}/start-chunked-upload".format(dataset_id)
1012        headers = {"Authorization": "ApiKey " + self.api_key}
1013        res = requests.post(url, headers=headers, **self._request_params)
1014        if res.ok:
1015            return res.json()["result"]
1016        else:
1017            error_msg = self._try_extract_error_code(res)
1018            logger.error(error_msg)
1019            raise BoostedAPIException(
1020                "Failed to obtain dataset lock for upload: {0}.".format(error_msg)
1021            )
1022
1023    def abort_chunked_upload(self, dataset_id, chunk_id):
1024        url = self.base_uri + "/api/datasets/{0}/abort-chunked-upload".format(dataset_id)
1025        headers = {"Authorization": "ApiKey " + self.api_key}
1026        params = {"uploadGroupId": chunk_id}
1027        res = requests.post(url, headers=headers, **self._request_params, params=params)
1028        if not res.ok:
1029            error_msg = self._try_extract_error_code(res)
1030            logger.error(error_msg)
1031            raise BoostedAPIException(
1032                "Failed to abort dataset lock during error: {0}.".format(error_msg)
1033            )
1034
1035    def check_dataset_ingestion_completion(self, dataset_id, chunk_id, start_time):
1036        url = self.base_uri + "/api/datasets/{0}/upload-chunk-status".format(dataset_id)
1037        headers = {"Authorization": "ApiKey " + self.api_key}
1038        params = {"uploadGroupId": chunk_id}
1039        res = requests.get(url, headers=headers, **self._request_params, params=params)
1040        res = res.json()
1041
1042        finished = False
1043        warnings = []
1044        errors = []
1045
1046        if type(res) == dict:
1047            dataset_status = res["datasetStatus"]
1048            chunk_status = res["chunkStatus"]
1049            if chunk_status != ChunkStatus.PROCESSING.value:
1050                finished = True
1051                errors = res["errors"]
1052                warnings = res["warnings"]
1053                successful_rows = res["successfulRows"]
1054                total_rows = res["totalRows"]
1055                logger.info(
1056                    f"Successfully ingested {successful_rows} out of {total_rows} uploaded rows."
1057                )
1058                if chunk_status in [
1059                    ChunkStatus.SUCCESS.value,
1060                    ChunkStatus.WARNING.value,
1061                    ChunkStatus.ERROR.value,
1062                ]:
1063                    if dataset_status != "AVAILABLE":
1064                        raise BoostedAPIException(
1065                            "Dataset was unexpectedly unavailable after chunk upload finished."
1066                        )
1067                    else:
1068                        logger.info("Ingestion complete.  Uploaded data is ready for use.")
1069                elif chunk_status == ChunkStatus.ABORTED.value:
1070                    errors.append(
1071                        "Dataset chunk upload was aborted by server! Upload did not succeed."
1072                    )
1073                else:
1074                    errors.append("Unexpected data ingestion status: {0}.".format(chunk_status))
1075            logger.info(
1076                "Data ingestion still running.  Time elapsed={0}.".format(
1077                    datetime.datetime.now() - start_time
1078                )
1079            )
1080        else:
1081            raise BoostedAPIException("Unable to get status of dataset ingestion.")
1082        return {"finished": finished, "warnings": warnings, "errors": errors}
1083
1084    def _commit_chunked_upload(self, dataset_id, chunk_id, data_type, block=True, timeout=600):
1085        url = self.base_uri + "/api/datasets/{0}/commit-chunked-upload".format(dataset_id)
1086        headers = {"Authorization": "ApiKey " + self.api_key}
1087        params = {
1088            "uploadGroupId": chunk_id,
1089            "dataAddType": data_type,
1090            "sendCompletionEmail": not block,
1091        }
1092        res = requests.post(url, headers=headers, **self._request_params, params=params)
1093        if not res.ok:
1094            error_msg = self._try_extract_error_code(res)
1095            logger.error(error_msg)
1096            raise BoostedAPIException("Failed to commit dataset files: {0}.".format(error_msg))
1097
1098        if block:
1099            start_time = datetime.datetime.now()
1100            # Keep waiting until upload is no longer in UPDATING state...
1101            while True:
1102                result = self.check_dataset_ingestion_completion(dataset_id, chunk_id, start_time)
1103                if result["finished"]:
1104                    break
1105
1106                if (datetime.datetime.now() - start_time).total_seconds() > timeout:
1107                    err_str = (
1108                        f"Timeout waiting for commit of dataset: {dataset_id} | chunk: {chunk_id}"
1109                    )
1110                    logger.error(err_str)
1111                    return [], [err_str]
1112
1113                time.sleep(10)
1114            return result["warnings"], result["errors"]
1115        else:
1116            return [], []
1117
1118    def setup_chunk_and_upload_data(
1119        self,
1120        dataset_id,
1121        csv_data,
1122        data_type,
1123        timeout=600,
1124        block=True,
1125        no_exception_on_chunk_error=False,
1126    ):
1127        chunk_id = self.start_chunked_upload(dataset_id)
1128        logger.info("Obtained lock on dataset for upload: " + chunk_id)
1129        try:
1130            warnings, errors = self.chunk_and_upload_data(
1131                dataset_id, chunk_id, csv_data, timeout, no_exception_on_chunk_error
1132            )
1133            commit_warnings, commit_errors = self._commit_chunked_upload(
1134                dataset_id, chunk_id, data_type, block, timeout
1135            )
1136            return warnings + commit_warnings, errors + commit_errors
1137        except Exception:
1138            self.abort_chunked_upload(dataset_id, chunk_id)
1139            raise
1140
1141    def chunk_and_upload_data(
1142        self, dataset_id, chunk_id, csv_data, timeout=600, no_exception_on_chunk_error=False
1143    ):
1144        if isinstance(csv_data, pd.core.frame.DataFrame):
1145            if not isinstance(csv_data.index, pd.core.indexes.datetimes.DatetimeIndex):
1146                raise BoostedAPIException("DataFrame must have DatetimeIndex as index type.")
1147
1148            warnings = []
1149            errors = []
1150            logger.info("Uploading yearly.")
1151            for t in csv_data.index.to_period("Y").unique():
1152                if t is pd.NaT:
1153                    continue
1154
1155                # serialize bit to string
1156                buf = self.get_csv_buffer()
1157                yearly_csv = csv_data.loc[str(t)]
1158                yearly_csv.to_csv(buf, header=True)
1159                raw_csv = buf.getvalue()
1160
1161                # we are already chunking yearly... but if the csv still exceeds a healthy
1162                # limit of 50mb the final line of defence is to ignore date boundaries and
1163                # just chunk the rows. This is mostly for the cloudflare upload limit.
1164                size_lim = 50 * 1000 * 1000
1165                est_csv_size = sys.getsizeof(raw_csv)
1166                if est_csv_size > size_lim:
1167                    del raw_csv, buf
1168                    logger.info("Yearly data too large for single upload, chunking further...")
1169                    chunks = []
1170                    nchunks = math.ceil(est_csv_size / size_lim)
1171                    rows_per_chunk = math.ceil(len(yearly_csv) / nchunks)
1172                    for i in range(0, len(yearly_csv), rows_per_chunk):
1173                        buf = self.get_csv_buffer()
1174                        split_csv = yearly_csv.iloc[i : i + rows_per_chunk]
1175                        split_csv.to_csv(buf, header=True)
1176                        split_csv = buf.getvalue()
1177                        chunks.append(
1178                            (
1179                                "{0}-{1}".format(i + 1, min(len(yearly_csv), i + rows_per_chunk)),
1180                                split_csv,
1181                            )
1182                        )
1183                else:
1184                    chunks = [("all", raw_csv)]
1185
1186                for i, (rows_descriptor, chunk_csv) in enumerate(chunks):
1187                    chunk_descriptor = "{0} in yearly chunk {1}".format(rows_descriptor, t)
1188                    logger.info(
1189                        "Uploading rows:"
1190                        + chunk_descriptor
1191                        + " (chunk {0} of {1}):".format(i + 1, len(chunks))
1192                    )
1193                    _, new_warnings, new_errors = self.upload_dataset_chunk(
1194                        chunk_descriptor,
1195                        dataset_id,
1196                        chunk_id,
1197                        chunk_csv,
1198                        timeout,
1199                        no_exception_on_chunk_error,
1200                    )
1201                    warnings.extend(new_warnings)
1202                    errors.extend(new_errors)
1203            return warnings, errors
1204
1205        elif isinstance(csv_data, str):
1206            _, warnings, errors = self.upload_dataset_chunk(
1207                "all data", dataset_id, chunk_id, csv_data, timeout, no_exception_on_chunk_error
1208            )
1209            return warnings, errors
1210        else:
1211            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
1212
1213    def upload_dataset_chunk(
1214        self,
1215        chunk_descriptor,
1216        dataset_id,
1217        chunk_id,
1218        csv_data,
1219        timeout=600,
1220        no_exception_on_chunk_error=False,
1221    ):
1222        logger.info("Starting upload: " + chunk_descriptor)
1223        url = self.base_uri + "/api/datasets/{0}/upload-dataset-chunk".format(dataset_id)
1224        headers = {"Authorization": "ApiKey " + self.api_key}
1225        files_req = {}
1226        warnings = []
1227        errors = []
1228
1229        # make the network request
1230        target = ("uploaded_data.csv", csv_data, "text/csv")
1231        files_req["dataFile"] = target
1232        params = {"uploadGroupId": chunk_id}
1233        res = requests.post(
1234            url,
1235            params=params,
1236            files=files_req,
1237            headers=headers,
1238            timeout=timeout,
1239            **self._request_params,
1240        )
1241
1242        if res.ok:
1243            logger.info(
1244                (
1245                    "Chunk upload completed.  "
1246                    "Ingestion started.  "
1247                    "Please wait until the data is in AVAILABLE state."
1248                )
1249            )
1250            if "warnings" in res.json():
1251                warnings = res.json()["warnings"]
1252                if len(warnings) > 0:
1253                    logger.warning("Uploaded chunk encountered data warnings: ")
1254                for w in warnings:
1255                    logger.warning(w)
1256        else:
1257            reason = "Upload failed: {0}, {1}".format(res.text, res.reason)
1258            logger.error(reason)
1259            if no_exception_on_chunk_error:
1260                errors.append(
1261                    "Chunk {0} failed: {1}. ".format(chunk_descriptor, reason)
1262                    + "Your data was only PARTIALLY uploaded. "
1263                    + "Please reattempt the upload of this chunk."
1264                )
1265            else:
1266                raise BoostedAPIException(reason)
1267
1268        return res, warnings, errors
1269
1270    def getAllocationsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1271        date = self.__iso_format(date)
1272        endpoint = "latest-allocations" if rollback_to_last_available_date else "allocations"
1273        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1274        headers = {"Authorization": "ApiKey " + self.api_key}
1275        params = {"date": date}
1276        logger.info("Retrieving allocations information for date {0}.".format(date))
1277        res = requests.get(url, params=params, headers=headers, **self._request_params)
1278        if res.ok:
1279            logger.info("Allocations retrieval successful.")
1280            return res.json()
1281        else:
1282            error_msg = self._try_extract_error_code(res)
1283            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
1284
1285    # New API method for fetching data from portfolio_holdings.pb2 file.
1286    def getAllocationsForDateV2(self, portfolio_id, date, rollback_to_last_available_date):
1287        date = self.__iso_format(date)
1288        endpoint = "latest-allocations-v2" if rollback_to_last_available_date else "allocations-v2"
1289        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1290        headers = {"Authorization": "ApiKey " + self.api_key}
1291        params = {"date": date}
1292        logger.info("Retrieving allocations information for date {0}.".format(date))
1293        res = requests.get(url, params=params, headers=headers, **self._request_params)
1294        if res.ok:
1295            logger.info("Allocations retrieval successful.")
1296            return res.json()
1297        else:
1298            error_msg = self._try_extract_error_code(res)
1299            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
1300
1301    def getAllocationsByDates(self, portfolio_id, dates=None):
1302        url = self.base_uri + "/api/portfolios/{0}/allocationsByDate".format(portfolio_id)
1303        headers = {"Authorization": "ApiKey " + self.api_key}
1304        if dates is not None:
1305            fmt_dates = []
1306            for d in dates:
1307                fmt_dates.append(self.__iso_format(d))
1308            fmt_dates_str = ",".join(fmt_dates)
1309            params: Dict = {"dates": fmt_dates_str}
1310            logger.info("Retrieving allocations information for dates {0}.".format(fmt_dates))
1311        else:
1312            params = {"dates": None}
1313            logger.info("Retrieving allocations information for all dates")
1314        res = requests.get(url, params=params, headers=headers, **self._request_params)
1315        if res.ok:
1316            logger.info("Allocations retrieval successful.")
1317            return res.json()
1318        else:
1319            error_msg = self._try_extract_error_code(res)
1320            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
1321
1322    def getSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1323        date = self.__iso_format(date)
1324        endpoint = "latest-signals" if rollback_to_last_available_date else "signals"
1325        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1326        headers = {"Authorization": "ApiKey " + self.api_key}
1327        params = {"date": date}
1328        logger.info("Retrieving signals information for date {0}.".format(date))
1329        res = requests.get(url, params=params, headers=headers, **self._request_params)
1330        if res.ok:
1331            logger.info("Signals retrieval successful.")
1332            return res.json()
1333        else:
1334            error_msg = self._try_extract_error_code(res)
1335            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
1336
1337    def getSignalsForAllDates(self, portfolio_id, dates=None):
1338        url = self.base_uri + "/api/portfolios/{0}/signalsByDate".format(portfolio_id)
1339        headers = {"Authorization": "ApiKey " + self.api_key}
1340        params = {}
1341        if dates is not None:
1342            fmt_dates = []
1343            for d in dates:
1344                fmt_dates.append(self.__iso_format(d))
1345            fmt_dates_str = ",".join(fmt_dates)
1346            params = {"dates": fmt_dates_str}
1347            logger.info("Retrieving signals information for dates {0}.".format(fmt_dates))
1348        else:
1349            params = {"dates": None}
1350            logger.info("Retrieving signals information for all dates")
1351        res = requests.get(url, params=params, headers=headers, **self._request_params)
1352        if res.ok:
1353            logger.info("Signals retrieval successful.")
1354            return res.json()
1355        else:
1356            error_msg = self._try_extract_error_code(res)
1357            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
1358
1359    def getEquityAccuracy(
1360        self,
1361        model_id: str,
1362        portfolio_id: str,
1363        tickers: List[str],
1364        start_date: Optional[BoostedDate] = None,
1365        end_date: Optional[BoostedDate] = None,
1366    ) -> Dict[str, Dict[str, Any]]:
1367        data: Dict[str, Any] = {}
1368        if start_date is not None:
1369            start_date = convert_date(start_date)
1370            data["startDate"] = start_date.isoformat()
1371        if end_date is not None:
1372            end_date = convert_date(end_date)
1373            data["endDate"] = end_date.isoformat()
1374
1375        if start_date and end_date:
1376            validate_start_and_end_dates(start_date, end_date)
1377
1378        tickers_stream = ",".join(tickers)
1379        data["tickers"] = tickers_stream
1380        data["timestamp"] = time.strftime("%H:%M:%S")
1381        data["shouldRecalc"] = True
1382        url = self.base_uri + f"/api/analysis/equity-accuracy/{model_id}/{portfolio_id}"
1383        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1384
1385        logger.info(
1386            f"Retrieving equity accuracy data for date range {start_date} to {end_date} "
1387            f"for tickers: {tickers}."
1388        )
1389
1390        # Now create dataframes from the JSON output.
1391        metrics = [
1392            "hit_rate_mean",
1393            "hit_rate_median",
1394            "excess_return_mean",
1395            "excess_return_median",
1396            "return",
1397            "excess_return",
1398        ]
1399
1400        # send the request, retry if failed
1401        MAX_RETRIES = 10  # max of number of retries until timeout
1402        SLEEP_TIME = 3  # waiting time between requests
1403
1404        num_retries = 0
1405        success = False
1406        while not success and num_retries < MAX_RETRIES:
1407            res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
1408            if res.ok:
1409                logger.info("Equity Accuracy Data retrieval successful.")
1410                info = res.json()
1411                success = True
1412            else:
1413                data["shouldRecalc"] = False
1414                num_retries += 1
1415                time.sleep(SLEEP_TIME)
1416
1417        if not success:
1418            raise BoostedAPIException("Failed to retrieve equity accuracy: Request timeout.")
1419
1420        for ticker, accuracy_data in info.items():
1421            for metric in metrics:
1422                metric_matrix = accuracy_data[metric]
1423                if not isinstance(metric_matrix, str):
1424                    # Set the index to the quintile label, and remove it from the data
1425                    index = []
1426                    for row in metric_matrix[1:]:
1427                        index.append(row.pop(0))
1428
1429                    # columns are "1D", "5D", etc.
1430                    df = pd.DataFrame(metric_matrix[1:], columns=metric_matrix[0][1:], index=index)
1431                    accuracy_data[metric] = df
1432        return info
1433
1434    def getHistoricalTradeDates(self, portfolio_id, start_date=None, end_date=None):
1435        end_date = self.__to_date_obj(end_date or datetime.date.today())
1436        start_date = self.__iso_format(start_date or (end_date - timedelta(days=365)))
1437        end_date = self.__iso_format(end_date)
1438
1439        url = self.base_uri + "/api/portfolios/{0}/tradingDates".format(portfolio_id)
1440        headers = {"Authorization": "ApiKey " + self.api_key}
1441        params = {"startDate": start_date, "endDate": end_date}
1442
1443        logger.info(
1444            "Retrieving historical trade dates data for date range {0} to {1}.".format(
1445                start_date, end_date
1446            )
1447        )
1448        res = requests.get(url, params=params, headers=headers, **self._request_params)
1449        if res.ok:
1450            logger.info("Trading dates retrieval successful.")
1451            return res.json()["dates"]
1452        else:
1453            error_msg = self._try_extract_error_code(res)
1454            raise BoostedAPIException("Failed to retrieve trading dates: {0}.".format(error_msg))
1455
1456    def getRankingsForAllDates(self, portfolio_id, dates=None):
1457        url = self.base_uri + "/api/portfolios/{0}/rankingsByDate".format(portfolio_id)
1458        headers = {"Authorization": "ApiKey " + self.api_key}
1459        params = {}
1460        if dates is not None:
1461            fmt_dates = []
1462            for d in dates:
1463                fmt_dates.append(self.__iso_format(d))
1464            fmt_dates_str = ",".join(fmt_dates)
1465            params = {"dates": fmt_dates_str}
1466            logger.info("Retrieving rankings information for date {0}.".format(fmt_dates_str))
1467        else:
1468            params = {"dates": None}
1469            logger.info("Retrieving rankings information for all dates")
1470        res = requests.get(url, params=params, headers=headers, **self._request_params)
1471        if res.ok:
1472            logger.info("Rankings retrieval successful.")
1473            return res.json()
1474        else:
1475            error_msg = self._try_extract_error_code(res)
1476            raise BoostedAPIException("Failed to retrieve rankings: {0}.".format(error_msg))
1477
1478    def getRankingsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1479        date = self.__iso_format(date)
1480        endpoint = "latest-rankings" if rollback_to_last_available_date else "rankings"
1481        url = self.base_uri + "/api/{0}/{1}/{2}".format(endpoint, portfolio_id, date)
1482        headers = {"Authorization": "ApiKey " + self.api_key}
1483        logger.info("Retrieving rankings information for date {0}.".format(date))
1484        res = requests.get(url, headers=headers, **self._request_params)
1485        if res.ok:
1486            logger.info("Rankings retrieval successful.")
1487            return res.json()
1488        else:
1489            error_msg = self._try_extract_error_code(res)
1490            raise BoostedAPIException("Failed to retrieve rankings: {0}.".format(error_msg))
1491
1492    def sendModelRecalc(self, model_id):
1493        url = self.base_uri + "/api/models/{0}/recalc".format(model_id)
1494        logger.info("Sending model recalc request for model {0}".format(model_id))
1495        headers = {"Authorization": "ApiKey " + self.api_key}
1496        res = requests.put(url, headers=headers, **self._request_params)
1497        if not res.ok:
1498            error_msg = self._try_extract_error_code(res)
1499            logger.error(error_msg)
1500            raise BoostedAPIException(
1501                "Failed to send model recalc request - "
1502                + "the model in UI may be out of date: {0}.".format(error_msg)
1503            )
1504
1505    def sendRecalcAllModelPortfolios(self, model_id: str):
1506        """Recalculates all portfolios under a given model ID.
1507
1508        Args:
1509            model_id: the model ID
1510        Raises:
1511            BoostedAPIException: if the Boosted API request fails
1512        """
1513        url = self.base_uri + f"/api/models/{model_id}/recalc-all-portfolios"
1514        logger.info(f"Sending portfolio recalc requests for all portfolios under {model_id=}.")
1515        headers = {"Authorization": "ApiKey " + self.api_key}
1516        res = requests.put(url, headers=headers, **self._request_params)
1517        if not res.ok:
1518            error_msg = self._try_extract_error_code(res)
1519            logger.error(error_msg)
1520            raise BoostedAPIException(
1521                f"Failed to send recalc request for all portfolios under {model_id=} - {error_msg}."
1522            )
1523
1524    def sendPortfolioRecalc(self, portfolio_id: str):
1525        """Recalculates a single portfolio by its portfolio ID.
1526
1527        Args:
1528            portfolio_id: the portfolio ID to recalculate
1529        Raises:
1530            BoostedAPIException: if the Boosted API request fails
1531        """
1532        url = self.base_uri + "/api/graphql"
1533        logger.info(f"Sending portfolio recalc request for {portfolio_id=}.")
1534        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1535        qry = """
1536            mutation recalcPortfolio($input: RecalculatePortfolioInput!) {
1537                recalculatePortfolio(input: $input) {
1538                    success
1539                    errors
1540                }
1541            }
1542            """
1543        req_json = {
1544            "query": qry,
1545            "variables": {"input": {"portfolioId": f"{portfolio_id}", "allowForceRecalc": "true"}},
1546        }
1547        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
1548        if not res.ok or res.json().get("errors"):
1549            error_msg = self._try_extract_error_code(res)
1550            logger.error(error_msg)
1551            raise BoostedAPIException(
1552                f"Failed to send portfolio recalc request for {portfolio_id=} - {error_msg}."
1553            )
1554
1555    def add_uploaded_model_data(self, url, csv_data, request_data, timeout=600):
1556        logger.info("Starting upload.")
1557        headers = {"Authorization": "ApiKey " + self.api_key}
1558        files_req: Dict = {}
1559        target: Tuple[str, Any, str] = ("data.csv", None, "text/csv")
1560        warnings = []
1561        if isinstance(csv_data, pd.core.frame.DataFrame):
1562            buf = io.StringIO()
1563            csv_data.to_csv(buf, header=False)
1564            if not isinstance(csv_data.index, pd.core.indexes.datetimes.DatetimeIndex):
1565                raise BoostedAPIException("DataFrame must have DatetimeIndex as index type.")
1566            target = ("uploaded_data.csv", buf.getvalue(), "text/csv")
1567            files_req["dataFile"] = target
1568            res = requests.post(
1569                url,
1570                files=files_req,
1571                data=request_data,
1572                headers=headers,
1573                timeout=timeout,
1574                **self._request_params,
1575            )
1576        elif isinstance(csv_data, str):
1577            target = ("uploaded_data.csv", csv_data, "text/csv")
1578            files_req["dataFile"] = target
1579            res = requests.post(
1580                url,
1581                files=files_req,
1582                data=request_data,
1583                headers=headers,
1584                timeout=timeout,
1585                **self._request_params,
1586            )
1587        else:
1588            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
1589        if res.ok:
1590            logger.info("Signals upload completed.")
1591            result = res.json()["result"]
1592            if "warningMessages" in result:
1593                warnings = result["warningMessages"]
1594        else:
1595            error_str = "Signals upload failed: {0}, {1}".format(res.text, res.reason)
1596            logger.error(error_str)
1597            raise BoostedAPIException(error_str)
1598
1599        return res, warnings
1600
1601    def createSignalsModel(self, csv_data, model_name, timeout=600):
1602        warnings = []
1603        url = self.base_uri + "/api/models/upload/signals/create"
1604        request_data = {"modelName": model_name, "uploadName": model_name}
1605        res, warnings = self.add_uploaded_model_data(url, csv_data, request_data, timeout)
1606        result = res.json()["result"]
1607        model_id = result["modelId"]
1608        self.sendModelRecalc(model_id)
1609        return model_id, warnings
1610
1611    def addToUploadedModel(self, model_id, csv_data, timeout=600, recalc_model=True):
1612        warnings = []
1613        url = self.base_uri + "/api/models/{0}/upload/add-data".format(model_id)
1614        request_data: Dict = {}
1615        _, warnings = self.add_uploaded_model_data(url, csv_data, request_data, timeout)
1616        if recalc_model:
1617            self.sendModelRecalc(model_id)
1618        return warnings
1619
1620    def addSignalsToUploadedModel(
1621        self,
1622        model_id: str,
1623        csv_data: Union[pd.core.frame.DataFrame, str],
1624        timeout: int = 600,
1625        recalc_all: bool = False,
1626        recalc_portfolio_ids: Optional[List[str]] = None,
1627    ) -> List[str]:
1628        """
1629        Add signals to an uploaded model and then recalculate a random portfolio under that model.
1630
1631        Args:
1632            model_id: model ID
1633            csv_data: pandas DataFrame, or a string with signals to upload.
1634            timeout (optional): Timeout for initial upload request in seconds.
1635            recalc_all (optional): if True, recalculates all portfolios in the model.
1636            recalc_portfolio_ids (optional): List of portfolio IDs under the model to re-calculate.
1637        """
1638        warnings = self.addToUploadedModel(model_id, csv_data, timeout, recalc_model=False)
1639
1640        if recalc_all:
1641            self.sendRecalcAllModelPortfolios(model_id)
1642        elif recalc_portfolio_ids:
1643            for portfolio_id in recalc_portfolio_ids:
1644                self.sendPortfolioRecalc(portfolio_id)
1645        else:
1646            self.sendModelRecalc(model_id)
1647        return warnings
1648
1649    def getSignalsFromUploadedModel(self, model_id, date=None):
1650        date = self.__iso_format(date)
1651        url = self.base_uri + "/api/models/{0}/upload/signals".format(model_id)
1652        headers = {"Authorization": "ApiKey " + self.api_key}
1653        params = {"date": date}
1654        logger.info("Retrieving uploaded signals information")
1655        res = requests.get(url, params=params, headers=headers, **self._request_params)
1656        if res.ok:
1657            result = pd.DataFrame.from_dict(res.json()["result"])
1658            # ensure column order
1659            result = result[["date", "isin", "country", "currency", "weight"]]
1660            result["date"] = pd.to_datetime(result["date"], format="%Y-%m-%d")
1661            result = result.set_index("date")
1662            logger.info("Signals retrieval successful.")
1663            return result
1664        else:
1665            error_msg = self._try_extract_error_code(res)
1666            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
1667
1668    def getPortfolioSettings(self, portfolio_id, timeout=600):
1669        url = self.base_uri + "/api/portfolio-settings/{0}".format(portfolio_id)
1670        headers = {"Authorization": "ApiKey " + self.api_key}
1671        res = requests.get(url, headers=headers, **self._request_params)
1672        if res.ok:
1673            return PortfolioSettings(res.json())
1674        else:
1675            error_msg = self._try_extract_error_code(res)
1676            logger.error(error_msg)
1677            raise BoostedAPIException(
1678                "Failed to retrieve portfolio settings: {0}.".format(error_msg)
1679            )
1680
1681    def createPortfolioWithPortfolioSettings(
1682        self, model_id, portfolio_name, portfolio_description, portfolio_settings, timeout=600
1683    ):
1684        url = self.base_uri + "/api/models/{0}/constraints/add".format(model_id)
1685        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1686        setting_string = json.dumps(portfolio_settings.settings)
1687        logger.info("Creating new portfolio with specified setting: {}".format(setting_string))
1688        params = {
1689            "name": portfolio_name,
1690            "description": portfolio_description,
1691            "settings": setting_string,
1692            "validate": "true",
1693        }
1694        res = requests.put(url, json=params, headers=headers, **self._request_params)
1695        response = res.json()
1696        if res.ok:
1697            return response
1698        else:
1699            error_msg = self._try_extract_error_code(res)
1700            logger.error(error_msg)
1701            raise BoostedAPIException(
1702                "Failed to create portfolio with the specified settings: {0}.".format(error_msg)
1703            )
1704
1705    def getGbiIdFromIdentCountryCurrencyDate(
1706        self, ident_country_currency_dates: List[DateIdentCountryCurrency], timeout: int = 600
1707    ) -> List[Optional[GbiIdSecurity]]:
1708        url = self.base_uri + "/api/custom-stock-data/map-identifiers-simple"
1709        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1710        identifiers = [
1711            {
1712                "row": idx,
1713                "date": identifier.date,
1714                "isin": identifier.identifier if identifier.id_type == ColumnSubRole.ISIN else None,
1715                "symbol": (
1716                    identifier.identifier if identifier.id_type == ColumnSubRole.SYMBOL else None
1717                ),
1718                "countryPreference": identifier.country,
1719                "currencyPreference": identifier.currency,
1720            }
1721            for idx, identifier in enumerate(ident_country_currency_dates)
1722        ]
1723        params = json.dumps({"identifiers": identifiers})
1724        logger.info(
1725            "Retrieving GBI-ID mapping for {} identifier tuples...".format(
1726                len(ident_country_currency_dates)
1727            )
1728        )
1729        res = requests.post(url, data=params, headers=headers, **self._request_params)
1730
1731        if res.ok:
1732            result = res.json()
1733            warnings = result["warnings"]
1734            if warnings:
1735                for warning in warnings:
1736                    logger.warn(f"Mapping warning: {warning}")
1737            gbiSecurities = []
1738            for idx, ident in enumerate(result["mappedIdentifiers"]):
1739                if ident is None:
1740                    security = None
1741                else:
1742                    security = GbiIdSecurity(
1743                        ident["gbiId"],
1744                        ident_country_currency_dates[idx],
1745                        ident["symbol"],
1746                        ident["companyName"],
1747                    )
1748                gbiSecurities.append(security)
1749
1750            return gbiSecurities
1751        else:
1752            error_msg = self._try_extract_error_code(res)
1753            raise BoostedAPIException(
1754                "Failed to retrieve identifier mappings: {0}.".format(error_msg)
1755            )
1756
1757    # exists for backwards compatibility purposes.
1758    def getGbiIdFromIsinCountryCurrencyDate(self, isin_country_currency_dates, timeout=600):
1759        return self.getGbiIdFromIdentCountryCurrencyDate(
1760            ident_country_currency_dates=isin_country_currency_dates, timeout=timeout
1761        )
1762
1763    # model_id: str
1764    # returns: Dict[str, str] representing the translation from the rankings ID (feature refs)
1765    # to human readable names
1766    def __get_rankings_ref_translation(self, model_id: str) -> Dict[str, str]:
1767        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1768        feature_name_url = f"/api/models/{model_id}/advanced-explain/translate-feature-ref/"
1769        feature_name_res = requests.post(
1770            self.base_uri + feature_name_url,
1771            data=json.dumps({}),
1772            headers=headers,
1773            **self._request_params,
1774        )
1775
1776        if feature_name_res.ok:
1777            feature_name_dict = feature_name_res.json()
1778            return {
1779                id: "-".join(
1780                    [names["variable_name"], names["transform_name"], names["normalization_name"]]
1781                )
1782                for id, names in feature_name_dict.items()
1783            }
1784        else:
1785            raise Exception(
1786                """Failed to get feature names for model,
1787                    this model doesn't fully support rankings 2.0"""
1788            )
1789
1790    def getDatasetDates(self, dataset_id):
1791        url = self.base_uri + f"/api/datasets/{dataset_id}"
1792        headers = {"Authorization": "ApiKey " + self.api_key}
1793        res = requests.get(url, headers=headers, **self._request_params)
1794        if res.ok:
1795            dataset = res.json()
1796            valid_to_array = dataset.get("validTo")
1797            valid_to_date = None
1798            valid_from_array = dataset.get("validFrom")
1799            valid_from_date = None
1800            if valid_to_array:
1801                valid_to_date = datetime.date(
1802                    valid_to_array[0], valid_to_array[1], valid_to_array[2]
1803                )
1804            if valid_from_array:
1805                valid_from_date = datetime.date(
1806                    valid_from_array[0], valid_from_array[1], valid_from_array[2]
1807                )
1808            return {"validTo": valid_to_date, "validFrom": valid_from_date}
1809        else:
1810            error_msg = self._try_extract_error_code(res)
1811            logger.error(error_msg)
1812            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
1813
1814    def getRankingAnalysis(self, model_id, date):
1815        url = (
1816            self.base_uri
1817            + f"/api/explain-trades/analysis/{model_id}/{self.__iso_format(date)}/json"
1818        )
1819        headers = {"Authorization": "ApiKey " + self.api_key}
1820        analysis_res = requests.get(url, headers=headers, **self._request_params)
1821        if analysis_res.ok:
1822            ranking_dict = analysis_res.json()
1823            feature_name_dict = self.__get_rankings_ref_translation(model_id)
1824            columns = [feature_name_dict[col] for col in ranking_dict["columns"]]
1825
1826            df = protoCubeJsonDataToDataFrame(
1827                ranking_dict["data"],
1828                "Data Buckets",
1829                ranking_dict["rows"],
1830                "Feature Names",
1831                columns,
1832                ranking_dict["fields"],
1833            )
1834            return df
1835        else:
1836            error_msg = self._try_extract_error_code(analysis_res)
1837            logger.error(error_msg)
1838            raise BoostedAPIException("Failed to get ranking analysis: {0}.".format(error_msg))
1839
1840    def getExplainForPortfolio(
1841        self,
1842        model_id,
1843        portfolio_id,
1844        date,
1845        index_by_symbol: bool = False,
1846        index_by_all_metadata: bool = False,
1847    ):
1848        """
1849        Gets the ranking 2.0 explain data for the given model on the given date
1850        filtered by portfolio.
1851
1852        Parameters
1853        ----------
1854        model_id: str
1855            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
1856            button next to your model's name in the Model Summary Page in Boosted
1857            Insights.
1858        portfolio_id: str
1859            Portfolio ID.  Portfolio IDs can be retrieved from portfolio's configuration page.
1860        date: datetime.date or YYYY-MM-DD string
1861            Date of the data to retrieve.
1862        index_by_symbol: bool
1863            If true, index by stock symbol instead of ISIN.
1864        index_by_all_metadata: bool
1865            If true, index by all metadata: ISIN, stock symbol, currency, and country.
1866            Overrides index_by_symbol.
1867
1868        Returns
1869        -------
1870        pandas.DataFrame
1871            Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata
1872            and feature names, filtered by portfolio.
1873        ___
1874        """
1875        indices = ["Symbol", "ISINs", "Country", "Currency"]
1876        raw_explain_df = self.getRankingExplain(
1877            model_id, date, index_by_symbol=False, index_by_all_metadata=True
1878        )
1879        pa_ratings_dict = self.getRankingsForDate(portfolio_id, date, False)
1880
1881        ratings = pa_ratings_dict["rankings"]
1882        ratings_df = pd.DataFrame(ratings)
1883        ratings_df = ratings_df[["symbol", "isin", "country", "currency"]]
1884        ratings_df.columns = pd.Index(indices)
1885        ratings_df.set_index(indices, inplace=True)
1886
1887        # inner join to only get the securities in both data frames
1888        result_df = raw_explain_df.merge(ratings_df, left_index=True, right_index=True, how="inner")
1889
1890        # set index based on input parameters
1891        if index_by_symbol and not index_by_all_metadata:
1892            result_df = result_df.reset_index()
1893            result_df = result_df.drop(columns=["ISINs", "Currency", "Country"])
1894            result_df.set_index(["Symbol", "Feature Names"], inplace=True)
1895        elif not index_by_symbol and not index_by_all_metadata:
1896            result_df = result_df.reset_index()
1897            result_df = result_df.drop(columns=["Symbol", "Currency", "Country"])
1898            result_df.set_index(["ISINs", "Feature Names"], inplace=True)
1899
1900        return result_df
1901
1902    def getRankingExplain(
1903        self, model_id, date, index_by_symbol: bool = False, index_by_all_metadata: bool = False
1904    ):
1905        """
1906        Gets the ranking 2.0 explain data for the given model on the given date
1907
1908        Parameters
1909        ----------
1910        model_id: str
1911            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
1912            button next to your model's name in the Model Summary Page in Boosted
1913            Insights.
1914        date: datetime.date or YYYY-MM-DD string
1915            Date of the data to retrieve.
1916        index_by_symbol: bool
1917            If true, index by stock symbol instead of ISIN.
1918        index_by_all_metadata: bool
1919            If true, index by all metadata: ISIN, stock symbol, currency, and country.
1920            Overrides index_by_symbol.
1921
1922        Returns
1923        -------
1924        pandas.DataFrame
1925            Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata
1926            and feature names.
1927        ___
1928        """
1929        url = (
1930            self.base_uri + f"/api/explain-trades/explain/{model_id}/{self.__iso_format(date)}/json"
1931        )
1932        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1933        explain_res = requests.get(url, headers=headers, **self._request_params)
1934        if explain_res.ok:
1935            ranking_dict = explain_res.json()
1936            rows = ranking_dict["rows"]
1937            stock_summary_url = f"/api/stock-summaries/{model_id}"
1938            stock_summary_body = {"gbiIds": ranking_dict["rows"]}
1939            summary_res = requests.post(
1940                self.base_uri + stock_summary_url,
1941                data=json.dumps(stock_summary_body),
1942                headers=headers,
1943                **self._request_params,
1944            )
1945            if summary_res.ok:
1946                stock_summary = summary_res.json()
1947                if index_by_symbol:
1948                    rows = [stock_summary[row]["symbol"] for row in ranking_dict["rows"]]
1949                elif index_by_all_metadata:
1950                    rows = [
1951                        [
1952                            stock_summary[row]["isin"],
1953                            stock_summary[row]["symbol"],
1954                            stock_summary[row]["currency"],
1955                            stock_summary[row]["country"],
1956                        ]
1957                        for row in ranking_dict["rows"]
1958                    ]
1959                else:
1960                    rows = [stock_summary[row]["isin"] for row in ranking_dict["rows"]]
1961            else:
1962                error_msg = self._try_extract_error_code(summary_res)
1963                logger.error(error_msg)
1964                raise BoostedAPIException(
1965                    "Failed to get isin information ranking explain: {0}.".format(error_msg)
1966                )
1967
1968            feature_name_dict = self.__get_rankings_ref_translation(model_id)
1969            columns = [feature_name_dict[col] for col in ranking_dict["columns"]]
1970
1971            id_col_name = "Symbols" if index_by_symbol else "ISINs"
1972
1973            if index_by_all_metadata:
1974                pc_list = []
1975                pf = ranking_dict["data"]
1976                for row_idx, row in enumerate(rows):
1977                    for col_idx, col in enumerate(columns):
1978                        pc_list.append([row, col] + pf[row_idx]["columns"][col_idx]["fields"])
1979                df = pd.DataFrame(pc_list)
1980                df = df.set_axis(
1981                    ["Metadata", "Feature Names"] + ranking_dict["fields"], axis="columns"
1982                )
1983
1984                metadata_df = df["Metadata"].apply(pd.Series)
1985                metadata_df.columns = pd.Index(["ISINs", "Symbol", "Currency", "Country"])
1986                result_df = pd.concat([metadata_df, df], axis=1).drop("Metadata", axis=1)
1987                result_df.set_index(
1988                    ["ISINs", "Symbol", "Currency", "Country", "Feature Names"], inplace=True
1989                )
1990                return result_df
1991
1992            else:
1993                df = protoCubeJsonDataToDataFrame(
1994                    ranking_dict["data"],
1995                    id_col_name,
1996                    rows,
1997                    "Feature Names",
1998                    columns,
1999                    ranking_dict["fields"],
2000                )
2001
2002                return df
2003        else:
2004            error_msg = self._try_extract_error_code(explain_res)
2005            logger.error(error_msg)
2006            raise BoostedAPIException("Failed to get ranking explain: {0}.".format(error_msg))
2007
2008    def getDenseSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
2009        date = self.__iso_format(date)
2010        url = self.base_uri + f"/api/portfolios/{portfolio_id}/denseSignalsByDate"
2011        headers = {"Authorization": "ApiKey " + self.api_key}
2012        params = {
2013            "startDate": date,
2014            "endDate": date,
2015            "rollbackToMostRecentDate": rollback_to_last_available_date,
2016        }
2017        logger.info("Retrieving dense signals information for date {0}.".format(date))
2018        res = requests.get(url, params=params, headers=headers, **self._request_params)
2019        if res.ok:
2020            logger.info("Signals retrieval successful.")
2021            d = res.json()
2022            # reshape date to output format
2023            date = list(d["signals"].keys())[0]
2024            model_id = d["model_id"]
2025            signals_list = list(d["signals"].values())[0]
2026            return {"date": date, "signals": [{"model_id": model_id, "signals_info": signals_list}]}
2027        else:
2028            error_msg = self._try_extract_error_code(res)
2029            raise BoostedAPIException("Failed to retrieve dense signals: {0}.".format(error_msg))
2030
2031    def getDenseSignals(self, model_id, portfolio_id, file_name=None, location="./"):
2032        url = self.base_uri + f"/api/models/{model_id}/{portfolio_id}/dense-signals"
2033        headers = {"Authorization": "ApiKey " + self.api_key}
2034        res = requests.get(url, headers=headers, **self._request_params)
2035        if file_name is None:
2036            file_name = f"{model_id}-{portfolio_id}_dense_signals.csv"
2037        download_location = os.path.join(location, file_name)
2038        if res.ok:
2039            with open(download_location, "wb") as file:
2040                file.write(res.content)
2041            print("Download Complete")
2042        elif res.status_code == 404:
2043            raise BoostedAPIException(
2044                f"""Dense Singals file does not exist for model:
2045                 {model_id} - portfolio: {portfolio_id}"""
2046            )
2047        else:
2048            error_msg = self._try_extract_error_code(res)
2049            logger.error(error_msg)
2050            raise BoostedAPIException(
2051                f"""Failed to download dense singals file for model:
2052                 {model_id} - portfolio: {portfolio_id}"""
2053            )
2054
2055    def _getIsPortfolioReadyForProcessing(self, model_id, portfolio_id, formatted_date):
2056        headers = {"Authorization": "ApiKey " + self.api_key}
2057        url = (
2058            self.base_uri
2059            + f"/api/explain-trades/{model_id}/{portfolio_id}"
2060            + f"/is-ready-for-processing/{formatted_date}"
2061        )
2062        res = requests.get(url, headers=headers, **self._request_params)
2063
2064        try:
2065            if res.ok:
2066                body = res.json()
2067                if "ready" in body:
2068                    if body["ready"]:
2069                        return True, ""
2070                    else:
2071                        reason_from_api = (
2072                            body["notReadyReason"] if "notReadyReason" in body else "Unavailable"
2073                        )
2074
2075                        returned_reason = reason_from_api
2076
2077                        if returned_reason == "SKIP":
2078                            returned_reason = "holiday- market closed"
2079
2080                        if returned_reason == "WAITING":
2081                            returned_reason = "calculations pending"
2082
2083                        return False, returned_reason
2084                else:
2085                    return False, "Unavailable"
2086            else:
2087                error_msg = self._try_extract_error_code(res)
2088                logger.error(error_msg)
2089                raise BoostedAPIException(
2090                    f"""Failed to generate file for model:
2091                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2092                )
2093        except Exception as e:
2094            raise BoostedAPIException(
2095                f"""Failed to generate file for model:
2096                {model_id} - portfolio: {portfolio_id} on date: {formatted_date} {e}"""
2097            )
2098
2099    def getRanking2DateAnalysisFile(
2100        self, model_id, portfolio_id, date, file_name=None, location="./"
2101    ):
2102        formatted_date = self.__iso_format(date)
2103        s3_file_name = f"{formatted_date}_analysis.xlsx"
2104        download_url = (
2105            self.base_uri + f"/api/models/{model_id}/{portfolio_id}/ranking-file/{s3_file_name}"
2106        )
2107        headers = {"Authorization": "ApiKey " + self.api_key}
2108        if file_name is None:
2109            file_name = f"{model_id}-{portfolio_id}_statistical_analysis_{formatted_date}.xlsx"
2110        download_location = os.path.join(location, file_name)
2111
2112        res = requests.get(download_url, headers=headers, **self._request_params)
2113        if res.ok:
2114            with open(download_location, "wb") as file:
2115                file.write(res.content)
2116            print("Download Complete")
2117        elif res.status_code == 404:
2118            (
2119                is_portfolio_ready_for_processing,
2120                portfolio_ready_status,
2121            ) = self._getIsPortfolioReadyForProcessing(model_id, portfolio_id, formatted_date)
2122
2123            if not is_portfolio_ready_for_processing:
2124                logger.info(
2125                    f"""\nPortfolio {portfolio_id} for model {model_id}
2126                    on date {date} unavailable for Ranking2Date Analysis file.
2127                    Status: {portfolio_ready_status}\n"""
2128                )
2129                return
2130
2131            generate_url = (
2132                self.base_uri
2133                + f"/api/explain-trades/{model_id}/{portfolio_id}"
2134                + f"/generate/date-data/{formatted_date}"
2135            )
2136
2137            generate_res = requests.get(generate_url, headers=headers, **self._request_params)
2138            if generate_res.ok:
2139                download_res = requests.get(download_url, headers=headers, **self._request_params)
2140                while download_res.status_code == 404 or (
2141                    download_res.ok and len(download_res.content) == 0
2142                ):
2143                    print("waiting for file to be generated")
2144                    time.sleep(5)
2145                    download_res = requests.get(
2146                        download_url, headers=headers, **self._request_params
2147                    )
2148                if download_res.ok:
2149                    with open(download_location, "wb") as file:
2150                        file.write(download_res.content)
2151                    print("Download Complete")
2152            else:
2153                error_msg = self._try_extract_error_code(res)
2154                logger.error(error_msg)
2155                raise BoostedAPIException(
2156                    f"""Failed to generate ranking analysis file for model:
2157                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2158                )
2159        else:
2160            error_msg = self._try_extract_error_code(res)
2161            logger.error(error_msg)
2162            raise BoostedAPIException(
2163                f"""Failed to download ranking analysis file for model:
2164                 {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2165            )
2166
2167    def getRanking2DateExplainFile(
2168        self,
2169        model_id,
2170        portfolio_id,
2171        date,
2172        file_name=None,
2173        location="./",
2174        overwrite: bool = False,
2175        index_by_all_metadata: bool = False,
2176    ):
2177        """
2178        Downloads the ranking explain file for the provided portfolio and model.
2179        If no file exists then it will send a request to generate the file and continuously
2180        poll the server every 5 seconds to try and download the file until the file is downloaded.
2181
2182        Parameters
2183        ----------
2184        model_id: str
2185            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
2186            button next to your model's name in the Model Summary Page in Boosted
2187            Insights.
2188        portfolio_id: str
2189            Portfolio ID.  Portfolio IDs can be retrieved from portfolio's configuration page.
2190        date: datetime.date or YYYY-MM-DD string
2191            Date of the data to retrieve.
2192        file_name: str
2193            File name of the dense signals file to save as.
2194            If no file name is given the file name will be
2195            "<model_id>-<portfolio_id>_explain_data_<date>.xlsx"
2196        location: str
2197            The location to save the file to.
2198            If no location is given then it will be saved to the current directory.
2199        overwrite: bool
2200            Defaults to False, set to True to regenerate the file.
2201        index_by_all_metadata: bool
2202            If true, index by all metadata: ISIN, stock symbol, currency, and country.
2203
2204
2205        Returns
2206        -------
2207        None
2208        ___
2209        """
2210        formatted_date = self.__iso_format(date)
2211        if index_by_all_metadata:
2212            s3_file_name = f"{formatted_date}_explaindata_withmetadata.xlsx"
2213        else:
2214            s3_file_name = f"{formatted_date}_explaindata.xlsx"
2215        download_url = (
2216            self.base_uri + f"/api/models/{model_id}/{portfolio_id}/ranking-file/{s3_file_name}"
2217        )
2218        headers = {"Authorization": "ApiKey " + self.api_key}
2219        if file_name is None:
2220            file_name = f"{model_id}-{portfolio_id}_explain_data_{formatted_date}.xlsx"
2221        download_location = os.path.join(location, file_name)
2222
2223        if not overwrite:
2224            res = requests.get(download_url, headers=headers, **self._request_params)
2225        if not overwrite and res.ok:
2226            with open(download_location, "wb") as file:
2227                file.write(res.content)
2228            print("Download Complete")
2229        elif overwrite or res.status_code == 404:
2230            (
2231                is_portfolio_ready_for_processing,
2232                portfolio_ready_status,
2233            ) = self._getIsPortfolioReadyForProcessing(model_id, portfolio_id, formatted_date)
2234
2235            if not is_portfolio_ready_for_processing:
2236                logger.info(
2237                    f"""\nPortfolio {portfolio_id} for model {model_id}
2238                    on date {date} unavailable for Ranking2Date Explain file.
2239                    Status: {portfolio_ready_status}\n"""
2240                )
2241                return
2242
2243            generate_url = (
2244                self.base_uri
2245                + f"/api/explain-trades/{model_id}/{portfolio_id}"
2246                + f"/generate/date-data/{formatted_date}"
2247                + f"/{'true' if index_by_all_metadata else 'false'}"
2248            )
2249
2250            generate_res = requests.get(generate_url, headers=headers, **self._request_params)
2251            if generate_res.ok:
2252                download_res = requests.get(download_url, headers=headers, **self._request_params)
2253                while download_res.status_code == 404 or (
2254                    download_res.ok and len(download_res.content) == 0
2255                ):
2256                    print("waiting for file to be generated")
2257                    time.sleep(5)
2258                    download_res = requests.get(
2259                        download_url, headers=headers, **self._request_params
2260                    )
2261                if download_res.ok:
2262                    with open(download_location, "wb") as file:
2263                        file.write(download_res.content)
2264                    print("Download Complete")
2265            else:
2266                error_msg = self._try_extract_error_code(res)
2267                logger.error(error_msg)
2268                raise BoostedAPIException(
2269                    f"""Failed to generate ranking explain file for model:
2270                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2271                )
2272        else:
2273            error_msg = self._try_extract_error_code(res)
2274            logger.error(error_msg)
2275            raise BoostedAPIException(
2276                f"""Failed to download ranking explain file for model:
2277                 {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2278            )
2279
2280    def getRanking2DateExplain(
2281        self,
2282        model_id: str,
2283        portfolio_id: str,
2284        date: Optional[datetime.date],
2285        overwrite: bool = False,
2286    ) -> Dict[str, pd.DataFrame]:
2287        """
2288        Wrapper around getRanking2DateExplainFile, but returns a pandas
2289        dataframe instead of downloading to a path. Dataframe is indexed by
2290        symbol and should always have 'rating' and 'rating_delta' columns. Other
2291        columns will be determined by model's features.
2292        """
2293        file_name = "explaindata.xlsx"
2294        with tempfile.TemporaryDirectory() as tmpdirname:
2295            self.getRanking2DateExplainFile(
2296                model_id=model_id,
2297                portfolio_id=portfolio_id,
2298                date=date,
2299                file_name=file_name,
2300                location=tmpdirname,
2301                overwrite=overwrite,
2302            )
2303            full_path = os.path.join(tmpdirname, file_name)
2304            excel_file = pd.ExcelFile(full_path)
2305            df_map = pd.read_excel(excel_file, sheet_name=None)
2306            df_map_final = {str(sheet): df.set_index("Symbol") for (sheet, df) in df_map.items()}
2307
2308        return df_map_final
2309
2310    def getTearSheet(self, model_id, portfolio_id, start_date=None, end_date=None, block=False):
2311        if start_date is None or end_date is None:
2312            if start_date is not None or end_date is not None:
2313                raise ValueError("start_date and end_date must both be None or both be defined")
2314            return self._getCurrentTearSheet(model_id, portfolio_id)
2315
2316        start_date_obj = self.__to_date_obj(start_date)
2317        end_date_obj = self.__to_date_obj(end_date)
2318        if start_date_obj >= end_date_obj:
2319            raise ValueError("end_date must be later than the start_date")
2320
2321        # get for the given date
2322        url = self.base_uri + f"/api/analysis/keyfacts/{model_id}/{portfolio_id}"
2323        data = {
2324            "startDate": self.__iso_format(start_date),
2325            "endDate": self.__iso_format(end_date),
2326            "shouldRecalc": True,
2327        }
2328        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2329        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2330        if res.status_code == 404 and block:
2331            retries = 0
2332            data["shouldRecalc"] = False
2333            while retries < 10:
2334                time.sleep(10)
2335                retries += 1
2336                res = requests.post(
2337                    url, data=json.dumps(data), headers=headers, **self._request_params
2338                )
2339                if res.status_code != 404:
2340                    break
2341        if res.ok:
2342            return res.json()
2343        else:
2344            error_msg = self._try_extract_error_code(res)
2345            logger.error(error_msg)
2346            raise BoostedAPIException(
2347                "Failed to get tear sheet data: {0} {1}.".format(error_msg, str(res.status_code))
2348            )
2349
2350    def _getCurrentTearSheet(self, model_id, portfolio_id):
2351        url = self.base_uri + f"/api/model-summaries/{model_id}/{portfolio_id}"
2352        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2353        res = requests.get(url, headers=headers, **self._request_params)
2354        if res.ok:
2355            json = res.json()
2356            return json.get("tearSheet", {})
2357        else:
2358            error_msg = self._try_extract_error_code(res)
2359            logger.error(error_msg)
2360            raise BoostedAPIException("Failed to get tear sheet data: {0}.".format(error_msg))
2361
2362    def getPortfolioStatus(self, model_id, portfolio_id, job_date):
2363        url = (
2364            self.base_uri
2365            + f"/api/analysis/portfolioStatus/{model_id}/{portfolio_id}?jobDate={job_date}"
2366        )
2367        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2368        res = requests.get(url, headers=headers, **self._request_params)
2369        if res.ok:
2370            result = res.json()
2371            return {
2372                "is_complete": result["status"],
2373                "last_update": None if result["lastUpdate"] is None else result["lastUpdate"][:10],
2374                "next_update": None if result["nextUpdate"] is None else result["nextUpdate"][:10],
2375            }
2376        else:
2377            error_msg = self._try_extract_error_code(res)
2378            logger.error(error_msg)
2379            raise BoostedAPIException("Failed to get portfolio status: {0}".format(error_msg))
2380
2381    def _query_portfolio_factor_attribution(
2382        self,
2383        portfolio_id: str,
2384        start_date: Optional[BoostedDate] = None,
2385        end_date: Optional[BoostedDate] = None,
2386    ):
2387        response = self._get_graphql(
2388            query=graphql_queries.GET_PORTFOLIO_FACTOR_ATTRIBUTION_QUERY,
2389            variables={
2390                "portfolioId": portfolio_id,
2391                "startDate": str(start_date) if start_date else None,
2392                "endDate": str(end_date) if start_date else None,
2393            },
2394            error_msg_prefix="Failed to get factor attribution: ",
2395        )
2396        return response
2397
2398    def get_portfolio_factor_attribution(
2399        self,
2400        portfolio_id: str,
2401        start_date: Optional[BoostedDate] = None,
2402        end_date: Optional[BoostedDate] = None,
2403    ):
2404        """Get portfolio factor attribution for a portfolio
2405
2406        Args:
2407            portfolio_id (str): a valid UUID string
2408            start_date (BoostedDate, optional): The start date. Defaults to None.
2409            end_date (BoostedDate, optional): The end date. Defaults to None.
2410        """
2411        response = self._query_portfolio_factor_attribution(portfolio_id, start_date, end_date)
2412        factor_attribution = response["data"]["portfolio"]["factorAttribution"]
2413        dates = pd.DatetimeIndex(data=factor_attribution["dates"])
2414        beta = factor_attribution["factorBetas"]
2415        beta_df = pd.DataFrame(index=dates, data={x["name"]: x["data"] for x in beta})
2416        beta_df = beta_df.add_suffix("_beta")
2417        returns = factor_attribution["portfolioFactorPerformance"]
2418        returns_df = pd.DataFrame(index=dates, data={x["name"]: x["data"] for x in returns})
2419        returns_df = returns_df.add_suffix("_return")
2420        returns_df = (returns_df - 1) * 100
2421
2422        final_df = pd.concat([returns_df, beta_df], axis=1)
2423        ordered_columns = list(itertools.chain(*zip(returns_df.columns, beta_df.columns)))
2424        ordered_final_df = final_df.reindex(columns=ordered_columns)
2425
2426        # Add the column `total_return` which is the sum of returns_data
2427        ordered_final_df["total_return"] = returns_df.sum(axis=1)
2428        return ordered_final_df
2429
2430    def getBlacklist(self, blacklist_id):
2431        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2432        headers = {"Authorization": "ApiKey " + self.api_key}
2433        res = requests.get(url, headers=headers, **self._request_params)
2434        if res.ok:
2435            result = res.json()
2436            return result
2437        error_msg = self._try_extract_error_code(res)
2438        logger.error(error_msg)
2439        raise BoostedAPIException(f"Failed to get blacklist with id {blacklist_id}: {error_msg}")
2440
2441    def getBlacklists(self, model_id=None, company_id=None, last_N=None):
2442        params = {}
2443        if last_N:
2444            params["lastN"] = last_N
2445        if model_id:
2446            params["modelId"] = model_id
2447        if company_id:
2448            params["companyId"] = company_id
2449        url = self.base_uri + f"/api/blacklist"
2450        headers = {"Authorization": "ApiKey " + self.api_key}
2451        res = requests.get(url, headers=headers, params=params, **self._request_params)
2452        if res.ok:
2453            result = res.json()
2454            return result
2455        error_msg = self._try_extract_error_code(res)
2456        logger.error(error_msg)
2457        raise BoostedAPIException(
2458            f"""Failed to get blacklists with \
2459            model_id {model_id} company_id {company_id} last_N {last_N}: {error_msg}"""
2460        )
2461
2462    def createBlacklist(
2463        self,
2464        isin,
2465        long_short=2,
2466        start_date=datetime.date.today(),
2467        end_date="4000-01-01",
2468        model_id=None,
2469    ):
2470        url = self.base_uri + f"/api/blacklist"
2471        data = {
2472            "modelId": model_id,
2473            "isin": isin,
2474            "longShort": long_short,
2475            "startDate": self.__iso_format(start_date),
2476            "endDate": self.__iso_format(end_date),
2477        }
2478        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2479        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2480        if res.ok:
2481            return res.json()
2482        else:
2483            error_msg = self._try_extract_error_code(res)
2484            logger.error(error_msg)
2485            raise BoostedAPIException(
2486                f"""Failed to create the blacklist with \
2487                  isin {isin} long_short {long_short} start_date {start_date} end_date {end_date} \
2488                  model_id {model_id}: {error_msg}."""
2489            )
2490
2491    def createBlacklistsFromCSV(self, csv_name):
2492        url = self.base_uri + f"/api/blacklists"
2493        data = []
2494        with open(csv_name, mode="r") as f:
2495            csv_reader = csv.DictReader(f)
2496            for row in csv_reader:
2497                blacklist = {"modelId": row["ModelID"], "isin": row["ISIN"]}
2498                if not row.get("LongShort"):
2499                    blacklist["longShort"] = 2
2500                else:
2501                    blacklist["longShort"] = row["LongShort"]
2502
2503                if not row.get("StartDate"):
2504                    blacklist["startDate"] = self.__iso_format(datetime.date.today())
2505                else:
2506                    blacklist["startDate"] = self.__iso_format(row["StartDate"])
2507
2508                if not row.get("EndDate"):
2509                    blacklist["endDate"] = self.__iso_format("4000-01-01")
2510                else:
2511                    blacklist["endDate"] = self.__iso_format(row["EndDate"])
2512                data.append(blacklist)
2513        print(f"Processed {len(data)} blacklists.")
2514        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2515        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2516        if res.ok:
2517            return res.json()
2518        else:
2519            error_msg = self._try_extract_error_code(res)
2520            logger.error(error_msg)
2521            raise BoostedAPIException("failed to create blacklists")
2522
2523    def updateBlacklist(self, blacklist_id, long_short=None, start_date=None, end_date=None):
2524        params = {}
2525        if long_short:
2526            params["longShort"] = long_short
2527        if start_date:
2528            params["startDate"] = start_date
2529        if end_date:
2530            params["endDate"] = end_date
2531        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2532        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2533        res = requests.patch(url, json=params, headers=headers, **self._request_params)
2534        if res.ok:
2535            return res.json()
2536        else:
2537            error_msg = self._try_extract_error_code(res)
2538            logger.error(error_msg)
2539            raise BoostedAPIException(
2540                f"Failed to update blacklist with id {blacklist_id}: {error_msg}"
2541            )
2542
2543    def deleteBlacklist(self, blacklist_id):
2544        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2545        headers = {"Authorization": "ApiKey " + self.api_key}
2546        res = requests.delete(url, headers=headers, **self._request_params)
2547        if res.ok:
2548            result = res.json()
2549            return result
2550        else:
2551            error_msg = self._try_extract_error_code(res)
2552            logger.error(error_msg)
2553            raise BoostedAPIException(
2554                f"Failed to delete blacklist with id {blacklist_id}: {error_msg}"
2555            )
2556
2557    def getFeatureImportance(self, model_id, date, N=None):
2558        url = self.base_uri + f"/api/analysis/explainability/{model_id}"
2559        headers = {"Authorization": "ApiKey " + self.api_key}
2560        logger.info("Retrieving rankings information for date {0}.".format(date))
2561        res = requests.get(url, headers=headers, **self._request_params)
2562        if not res.ok:
2563            error_msg = self._try_extract_error_code(res)
2564            logger.error(error_msg)
2565            raise BoostedAPIException(
2566                f"Failed to fetch feature importance for model/portfolio {model_id}: {error_msg}"
2567            )
2568
2569        json_data = res.json()
2570        if "all" not in json_data.keys() or not json_data["all"]:
2571            raise BoostedAPIException(f"Unexpected formatting of feature importance response")
2572
2573        feature_data = json_data["all"]
2574        # find the right period (assuming returned json has dates in descending order)
2575        date_obj = self.__to_date_obj(date)
2576        start_date_for_return_data = self.__to_date_obj(feature_data[0]["date"])
2577        features_for_requested_period = None
2578
2579        if date_obj > start_date_for_return_data:
2580            features_for_requested_period = feature_data[0]["variable"]
2581        else:
2582            i = 0
2583            while i < len(feature_data) - 1:
2584                current_date = self.__to_date_obj(feature_data[i]["date"])
2585                next_date = self.__to_date_obj(feature_data[i + 1]["date"])
2586                if next_date <= date_obj <= current_date:
2587                    features_for_requested_period = feature_data[i + 1]["variable"]
2588                    start_date_for_return_data = next_date
2589                    break
2590                i += 1
2591
2592        if features_for_requested_period is None:
2593            raise BoostedAPIException(f"No feature data was found for requested date: {date_obj}")
2594
2595        features_for_requested_period.sort(key=lambda x: x["value"], reverse=True)
2596
2597        if type(N) is int and N > 0:
2598            df = pd.DataFrame.from_dict(features_for_requested_period[0:N])
2599        else:
2600            df = pd.DataFrame.from_dict(features_for_requested_period)
2601        result = df[["feature", "value"]]
2602
2603        return result.rename(columns={"feature": f"feature ({start_date_for_return_data})"})
2604
2605    def getAllModelNames(self) -> Dict[str, str]:
2606        url = f"{self.base_uri}/api/graphql"
2607        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2608        req_json = {"query": "query listOfModels {\n models { id name }}", "variables": {}}
2609        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
2610        if not res.ok:
2611            error_msg = self._try_extract_error_code(res)
2612            logger.error(error_msg)
2613            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
2614        data = res.json()
2615        if data["data"]["models"] is None:
2616            return {}
2617        return {rec["id"]: rec["name"] for rec in data["data"]["models"]}
2618
2619    def getAllModelDetails(self) -> Dict[str, Dict[str, Any]]:
2620        url = f"{self.base_uri}/api/graphql"
2621        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2622        req_json = {
2623            "query": "query listOfModels {\n models { id name lastUpdated portfolios { id name }}}",
2624            "variables": {},
2625        }
2626        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
2627        if not res.ok:
2628            error_msg = self._try_extract_error_code(res)
2629            logger.error(error_msg)
2630            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
2631        data = res.json()
2632        if data["data"]["models"] is None:
2633            return {}
2634
2635        output_data = {}
2636        for rec in data["data"]["models"]:
2637            model_id = rec["id"]
2638            output_data[model_id] = {
2639                "name": rec["name"],
2640                "last_updated": parser.parse(rec["lastUpdated"]),
2641                "portfolios": rec["portfolios"],
2642            }
2643
2644        return output_data
2645
2646    def get_hedge_experiments(self):
2647        url = self.base_uri + "/api/graphql"
2648        qry = """
2649            query getHedgeExperiments {
2650                hedgeExperiments {
2651                    hedgeExperimentId
2652                    experimentName
2653                    userId
2654                    config
2655                    description
2656                    experimentType
2657                    lastCalculated
2658                    lastModified
2659                    status
2660                    portfolioCalcStatus
2661                    targetSecurities {
2662                        gbiId
2663                        security {
2664                            gbiId
2665                            symbol
2666                            name
2667                        }
2668                        weight
2669                    }
2670                    targetPortfolios {
2671                        portfolioId
2672                    }
2673                    baselineModel {
2674                        id
2675                        name
2676
2677                    }
2678                    baselineScenario {
2679                        hedgeExperimentScenarioId
2680                        scenarioName
2681                        description
2682                        portfolioSettingsJson
2683                        hedgeExperimentPortfolios {
2684                            portfolio {
2685                                id
2686                                name
2687                                modelId
2688                                performanceGridHeader
2689                                performanceGrid
2690                                status
2691                                tearSheet {
2692                                    groupName
2693                                    members {
2694                                        name
2695                                        value
2696                                    }
2697                                }
2698                            }
2699                        }
2700                        status
2701                    }
2702                    baselineStockUniverseId
2703                }
2704            }
2705        """
2706
2707        headers = {"Authorization": "ApiKey " + self.api_key}
2708        resp = requests.post(url, json={"query": qry}, headers=headers, params=self._request_params)
2709
2710        json_resp = resp.json()
2711        # graphql endpoints typically return 200 or 400 status codes, so we must
2712        # check if we have any errors, even with a 200
2713        if (resp.ok and "errors" in json_resp) or not resp.ok:
2714            error_msg = self._try_extract_error_code(resp)
2715            logger.error(error_msg)
2716            raise BoostedAPIException(
2717                (f"Failed to get hedge experiments: {resp.status_code=}; {error_msg=}")
2718            )
2719
2720        json_experiments = resp.json()["data"]["hedgeExperiments"]
2721        experiments = [HedgeExperiment.from_json_dict(exp_json) for exp_json in json_experiments]
2722        return experiments
2723
2724    def get_hedge_experiment_details(self, experiment_id: str):
2725        url = self.base_uri + "/api/graphql"
2726        qry = """
2727            query getHedgeExperimentDetails($hedgeExperimentId: ID!) {
2728                hedgeExperiment(hedgeExperimentId: $hedgeExperimentId) {
2729                ...HedgeExperimentDetailsSummaryListFragment
2730                }
2731            }
2732
2733            fragment HedgeExperimentDetailsSummaryListFragment on HedgeExperiment {
2734                hedgeExperimentId
2735                experimentName
2736                userId
2737                config
2738                description
2739                experimentType
2740                lastCalculated
2741                lastModified
2742                status
2743                portfolioCalcStatus
2744                targetSecurities {
2745                    gbiId
2746                    security {
2747                        gbiId
2748                        symbol
2749                        name
2750                    }
2751                    weight
2752                }
2753                selectedModels {
2754                    id
2755                    name
2756                    stockUniverse {
2757                        name
2758                    }
2759                }
2760                hedgeExperimentScenarios {
2761                    ...experimentScenarioFragment
2762                }
2763                selectedDummyHedgeExperimentModels {
2764                    id
2765                    name
2766                    stockUniverse {
2767                        name
2768                    }
2769                }
2770                targetPortfolios {
2771                    portfolioId
2772                }
2773                baselineModel {
2774                    id
2775                    name
2776
2777                }
2778                baselineScenario {
2779                    hedgeExperimentScenarioId
2780                    scenarioName
2781                    description
2782                    portfolioSettingsJson
2783                    hedgeExperimentPortfolios {
2784                        portfolio {
2785                            id
2786                            name
2787                            modelId
2788                            performanceGridHeader
2789                            performanceGrid
2790                            status
2791                            tearSheet {
2792                                groupName
2793                                members {
2794                                    name
2795                                    value
2796                                }
2797                            }
2798                        }
2799                    }
2800                    status
2801                }
2802                baselineStockUniverseId
2803            }
2804
2805            fragment experimentScenarioFragment on HedgeExperimentScenario {
2806                hedgeExperimentScenarioId
2807                scenarioName
2808                status
2809                description
2810                portfolioSettingsJson
2811                hedgeExperimentPortfolios {
2812                    portfolio {
2813                        id
2814                        name
2815                        modelId
2816                        performanceGridHeader
2817                        performanceGrid
2818                        status
2819                        tearSheet {
2820                            groupName
2821                            members {
2822                                name
2823                                value
2824                            }
2825                        }
2826                    }
2827                }
2828            }
2829        """
2830        headers = {"Authorization": "ApiKey " + self.api_key}
2831        resp = requests.post(
2832            url,
2833            json={"query": qry, "variables": {"hedgeExperimentId": experiment_id}},
2834            headers=headers,
2835            params=self._request_params,
2836        )
2837
2838        json_resp = resp.json()
2839        # graphql endpoints typically return 200 or 400 status codes, so we must
2840        # check if we have any errors, even with a 200
2841        if (resp.ok and "errors" in json_resp) or not resp.ok:
2842            error_msg = self._try_extract_error_code(resp)
2843            logger.error(error_msg)
2844            raise BoostedAPIException(
2845                (
2846                    f"Failed to get hedge experiment results for {experiment_id=}: "
2847                    f"{resp.status_code=}; {error_msg=}"
2848                )
2849            )
2850
2851        json_exp_results = json_resp["data"]["hedgeExperiment"]
2852        if json_exp_results is None:
2853            return None  # issued a request with a non-existent experiment_id
2854        exp_results = HedgeExperimentDetails.from_json_dict(json_exp_results)
2855        return exp_results
2856
2857    def get_portfolio_performance(
2858        self,
2859        portfolio_id: str,
2860        start_date: Optional[datetime.date],
2861        end_date: Optional[datetime.date],
2862        daily_returns: bool,
2863    ) -> pd.DataFrame:
2864        """
2865        Get performance data for a portfolio.
2866
2867        Parameters
2868        ----------
2869        portfolio_id: str
2870            UUID corresponding to the portfolio in question.
2871        start_date: datetime.date
2872            Starting cutoff date to filter performance data
2873        end_date: datetime.date
2874            Ending cutoff date to filter performance data
2875        daily_returns: bool
2876            Flag indicating whether to add a new column with the daily return pct calculated
2877
2878        Returns
2879        -------
2880        pd.DataFrame object
2881            Portfolio and benchmark performance.
2882            -index:
2883                "date": pd.DatetimeIndex
2884            -columns:
2885                "benchmark": benchmark performance, % return
2886                "turnover": portfolio turnover, % of equity
2887                "portfolio": return since beginning of portfolio, % return
2888                "daily_returns": daily percent change in value of the portfolio, % return
2889                                (this column is optional and depends on the daily_returns flag)
2890        """
2891        url = f"{self.base_uri}/api/graphql"
2892        qry = """
2893            query getPortfolioPerformance($portfolioId: ID!) {
2894                portfolio(id: $portfolioId) {
2895                    id
2896                    modelId
2897                    name
2898                    status
2899                    performance {
2900                        benchmark
2901                        date
2902                        turnover
2903                        value
2904                    }
2905                }
2906            }
2907        """
2908
2909        headers = {"Authorization": "ApiKey " + self.api_key}
2910        resp = requests.post(
2911            url,
2912            json={"query": qry, "variables": {"portfolioId": portfolio_id}},
2913            headers=headers,
2914            params=self._request_params,
2915        )
2916
2917        json_resp = resp.json()
2918        # the webserver returns an error for non-ready portfolios, so we have to check
2919        # for this prior to the error check below
2920        pf = json_resp["data"].get("portfolio")
2921        if pf is not None and pf["status"] != "READY":
2922            return pd.DataFrame()
2923
2924        # graphql endpoints typically return 200 or 400 status codes, so we must
2925        # check if we have any errors, even with a 200
2926        if (resp.ok and "errors" in json_resp) or not resp.ok:
2927            error_msg = self._try_extract_error_code(resp)
2928            logger.error(error_msg)
2929            raise BoostedAPIException(
2930                (
2931                    f"Failed to get portfolio performance for {portfolio_id=}: "
2932                    f"{resp.status_code=}; {error_msg=}"
2933                )
2934            )
2935
2936        perf = json_resp["data"]["portfolio"]["performance"]
2937        df = pd.DataFrame(perf).set_index("date").rename(columns={"value": "portfolio"})
2938        df.index = pd.to_datetime(df.index)
2939        if daily_returns:
2940            df["daily_returns"] = pd.to_numeric(df["portfolio"]).pct_change()
2941            df = df.dropna(subset=["daily_returns"])
2942        if start_date:
2943            df = df[df.index >= pd.to_datetime(start_date)]
2944        if end_date:
2945            df = df[df.index <= pd.to_datetime(end_date)]
2946        return df.astype(float)
2947
2948    def _is_portfolio_still_running(self, error_msg: str) -> bool:
2949        # this is jank af. a proper fix of this is either at the webserver
2950        # returning a better response for a portfolio in draft HT2-226, OR
2951        # a bigger refactor of the API that moves to more OOP, which would allow us
2952        # to have this data all in one place
2953        return "Could not find a model with this ID" in error_msg
2954
2955    def get_portfolio_factors(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
2956        url = f"{self.base_uri}/api/analysis/factors/{model_id}/{portfolio_id}"
2957        headers = {"Authorization": "ApiKey " + self.api_key}
2958        resp = requests.get(url, headers=headers, params=self._request_params)
2959
2960        json_resp = resp.json()
2961        if (resp.ok and "errors" in json_resp) or not resp.ok:
2962            error_msg = json_resp["errors"][0]
2963            if self._is_portfolio_still_running(error_msg):
2964                return pd.DataFrame()
2965            logger.error(error_msg)
2966            raise BoostedAPIException(
2967                (
2968                    f"Failed to get portfolio factors for {portfolio_id=}: "
2969                    f"{resp.status_code=}; {error_msg=}"
2970                )
2971            )
2972
2973        df = pd.DataFrame(json_resp["data"], columns=json_resp["header_row"])
2974
2975        def to_lower_snake_case(s):  # why are we linting lambdas? :(
2976            return "_".join(w.lower() for w in s.split(" "))
2977
2978        df = df.rename(columns={old: to_lower_snake_case(old) for old in df.columns}).set_index(
2979            "date"
2980        )
2981        df.index = pd.to_datetime(df.index)
2982        return df
2983
2984    def get_portfolio_volatility(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
2985        url = f"{self.base_uri}/api/analysis/volatility_rolling/{model_id}/{portfolio_id}"
2986        headers = {"Authorization": "ApiKey " + self.api_key}
2987        resp = requests.get(url, headers=headers, params=self._request_params)
2988
2989        json_resp = resp.json()
2990        if (resp.ok and "errors" in json_resp) or not resp.ok:
2991            error_msg = json_resp["errors"][0]
2992            if self._is_portfolio_still_running(error_msg):
2993                return pd.DataFrame()
2994            logger.error(error_msg)
2995            raise BoostedAPIException(
2996                (
2997                    f"Failed to get portfolio volatility for {portfolio_id=}: "
2998                    f"{resp.status_code=}; {error_msg=}"
2999                )
3000            )
3001
3002        df = pd.DataFrame(json_resp["data"], columns=json_resp["headerRow"])
3003        df = df.rename(
3004            columns={old: old.lower().replace("avg", "avg_") for old in df.columns}  # type: ignore
3005        ).set_index("date")
3006        df.index = pd.to_datetime(df.index)
3007        return df
3008
3009    def get_portfolio_holdings(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
3010        url = f"{self.base_uri}/api/models/{model_id}/{portfolio_id}/basket-data"
3011        headers = {"Authorization": "ApiKey " + self.api_key}
3012        resp = requests.get(url, headers=headers, params=self._request_params)
3013
3014        # this is a classic abuse of try/except as control flow: we try to get json body
3015        # from the response so that we can error-check. if this fails, we assume we have
3016        # a legit text response (corresponding to the csv data we care about)
3017        try:
3018            json_resp = resp.json()
3019        except json.decoder.JSONDecodeError:
3020            df = pd.read_csv(io.StringIO(resp.text), header=[0])
3021        else:
3022            error_msg = json_resp["errors"][0]
3023            if self._is_portfolio_still_running(error_msg):
3024                return pd.DataFrame()
3025            else:
3026                logger.error(error_msg)
3027                raise BoostedAPIException(
3028                    (
3029                        f"Failed to get portfolio holdings for {portfolio_id=}: "
3030                        f"{resp.status_code=}; {error_msg=}"
3031                    )
3032                )
3033
3034        df = df.rename(columns={old: old.lower() for old in df.columns}).set_index("date")
3035        df.index = pd.to_datetime(df.index)
3036        return df
3037
3038    def getStockDataTableForDate(
3039        self, model_id: str, portfolio_id: str, date: datetime.date
3040    ) -> pd.DataFrame:
3041        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3042
3043        url_base = f"{self.base_uri}/api/analysis"
3044        url_params = f"{model_id}/{portfolio_id}"
3045        formatted_date = date.strftime("%Y-%m-%d")
3046
3047        stock_prices_url = f"{url_base}/stock-prices/{url_params}/{formatted_date}"
3048        stock_factors_url = f"{url_base}/stock-factors/{url_params}/date/{formatted_date}"
3049
3050        prices_params = {"useTicker": "false", "useCurrentSignals": "true"}
3051        factors_param = {"useTicker": "false", "useCurrentSignals": "true"}
3052
3053        prices_resp = requests.get(
3054            stock_prices_url, headers=headers, params=prices_params, **self._request_params
3055        )
3056        factors_resp = requests.get(
3057            stock_factors_url, headers=headers, params=factors_param, **self._request_params
3058        )
3059
3060        frames = []
3061        gbi_ids = set()
3062        for res in (prices_resp, factors_resp):
3063            if not res.ok:
3064                error_msg = self._try_extract_error_code(res)
3065                logger.error(error_msg)
3066                raise BoostedAPIException(
3067                    (
3068                        f"Failed to fetch stock data table for model {model_id}"
3069                        f" (it's possible no data is present for the given date: {date})."
3070                        f" Error message: {error_msg}"
3071                    )
3072                )
3073            result = res.json()
3074            df = pd.DataFrame(result)
3075            gbi_ids.update(df.columns.to_list())
3076            frames.append(pd.DataFrame(result))
3077
3078        all_gbiid_df = pd.concat(frames)
3079
3080        # Get the metadata of all GBI IDs
3081        gbiid_metadata_res = self._get_graphql(
3082            query=graphql_queries.GET_SEC_INFO_QRY, variables={"ids": [int(x) for x in gbi_ids]}
3083        )
3084        # Build a DF of metadata x GBI IDs
3085        gbiid_metadata_df = pd.DataFrame(
3086            {str(x["gbiId"]): x for x in gbiid_metadata_res["data"]["securities"]}
3087        )
3088        # Slice metadata we care. We'll drop "symbol" at the end.
3089        isin_country_currency_df = gbiid_metadata_df.loc[["isin", "country", "currency", "symbol"]]
3090        # Concatenate metadata to the existing stock data DF
3091        all_gbiid_with_metadata_df = pd.concat([all_gbiid_df, isin_country_currency_df])
3092        gbiid_with_symbol_df = all_gbiid_with_metadata_df.loc[
3093            :, all_gbiid_with_metadata_df.loc["symbol"].notna()
3094        ]
3095        renamed_df = gbiid_with_symbol_df.rename(
3096            index={"isin": "ISIN"}, columns=gbiid_with_symbol_df.loc["symbol"].to_dict()
3097        )
3098        output_df = renamed_df.drop(index=["symbol"])
3099        return output_df
3100
3101    def add_hedge_experiment_scenario(
3102        self,
3103        experiment_id: str,
3104        scenario_name: str,
3105        scenario_settings: PortfolioSettings,
3106        run_scenario_immediately: bool,
3107    ) -> HedgeExperimentScenario:
3108        add_scenario_input = {
3109            "hedgeExperimentId": experiment_id,
3110            "scenarioName": scenario_name,
3111            "portfolioSettingsJson": str(scenario_settings),
3112            "runExperimentOnScenario": run_scenario_immediately,
3113            "createDefaultPortfolio": "false",
3114        }
3115        qry = """
3116            mutation addHedgeExperimentScenario(
3117                $input: AddHedgeExperimentScenarioInput!
3118            ) {
3119                addHedgeExperimentScenario(input: $input) {
3120                    hedgeExperimentScenario {
3121                        hedgeExperimentScenarioId
3122                        scenarioName
3123                        description
3124                        portfolioSettingsJson
3125                    }
3126                }
3127            }
3128
3129        """
3130
3131        url = f"{self.base_uri}/api/graphql"
3132
3133        resp = requests.post(
3134            url,
3135            headers={"Authorization": "ApiKey " + self.api_key},
3136            json={"query": qry, "variables": {"input": add_scenario_input}},
3137        )
3138
3139        json_resp = resp.json()
3140        if (resp.ok and "errors" in json_resp) or not resp.ok:
3141            error_msg = self._try_extract_error_code(resp)
3142            logger.error(error_msg)
3143            raise BoostedAPIException(
3144                (f"Failed to add scenario: {resp.status_code=}; {error_msg=}")
3145            )
3146
3147        scenario_dict = json_resp["data"]["addHedgeExperimentScenario"]["hedgeExperimentScenario"]
3148        if scenario_dict is None:
3149            raise BoostedAPIException(
3150                "Failed to add scenario, likely due to bad experiment id or api key"
3151            )
3152        s = HedgeExperimentScenario.from_json_dict(scenario_dict)
3153        return s
3154
3155    # experiment life cycle has 4 steps:
3156    # 1. creation - essentially a very simple registration of a new instance, returning an id
3157    # 2. modify - populate with settings
3158    # 3. start - run the experiment
3159    # 4. delete - drop the experiment
3160    # while i would prefer to just have 2 funcs for (1,2,3) and (4) for a simpler api,
3161    # we need to expose finer-grained control becuase of how scenarios work.
3162    def create_hedge_experiment(
3163        self,
3164        name: str,
3165        description: str,
3166        experiment_type: hedge_experiment_type,
3167        target_securities: Union[Dict[GbiIdSecurity, float], str, None],
3168    ) -> HedgeExperiment:
3169        # we don't pass target_securities here (as much as id like to) because the
3170        # graphql input doesn't support it at this point
3171
3172        # note that this query returns a lot of null fields at this point, but
3173        # they are necessary for building a HE.
3174        create_qry = """
3175            mutation createDraftMutation($input: CreateHedgeExperimentDraftInput!) {
3176                createHedgeExperimentDraft(input: $input) {
3177                    hedgeExperiment {
3178                        hedgeExperimentId
3179                        experimentName
3180                        userId
3181                        config
3182                        description
3183                        experimentType
3184                        lastCalculated
3185                        lastModified
3186                        status
3187                        portfolioCalcStatus
3188                        targetSecurities {
3189                            gbiId
3190                            security {
3191                                gbiId
3192                                name
3193                                symbol
3194                            }
3195                            weight
3196                        }
3197                        baselineModel {
3198                            id
3199                            name
3200                        }
3201                        baselineScenario {
3202                            hedgeExperimentScenarioId
3203                            scenarioName
3204                            description
3205                            portfolioSettingsJson
3206                            hedgeExperimentPortfolios {
3207                                portfolio {
3208                                    id
3209                                    name
3210                                    modelId
3211                                    performanceGridHeader
3212                                    performanceGrid
3213                                    status
3214                                    tearSheet {
3215                                        groupName
3216                                        members {
3217                                            name
3218                                            value
3219                                        }
3220                                    }
3221                                }
3222                            }
3223                            status
3224                        }
3225                        baselineStockUniverseId
3226                    }
3227                }
3228            }
3229        """
3230
3231        create_input: Dict[str, Any] = {
3232            "name": name,
3233            "experimentType": experiment_type,
3234            "description": description,
3235        }
3236        if isinstance(target_securities, dict):
3237            create_input["setTargetSecurities"] = [
3238                {"gbiId": sec.gbi_id, "weight": weight}
3239                for (sec, weight) in target_securities.items()
3240            ]
3241        elif isinstance(target_securities, str):
3242            create_input["setTargetPortfolios"] = [{"portfolioId": target_securities}]
3243        elif target_securities is None:
3244            pass
3245        else:
3246            raise TypeError(
3247                "Expected value of type Union[Dict[GbiIdSecurity, str], str] for "
3248                f"argument 'target_securities'; got {type(target_securities)}"
3249            )
3250        resp = requests.post(
3251            f"{self.base_uri}/api/graphql",
3252            json={"query": create_qry, "variables": {"input": create_input}},
3253            headers={"Authorization": "ApiKey " + self.api_key},
3254            params=self._request_params,
3255        )
3256
3257        json_resp = resp.json()
3258        if (resp.ok and "errors" in json_resp) or not resp.ok:
3259            error_msg = self._try_extract_error_code(resp)
3260            logger.error(error_msg)
3261            raise BoostedAPIException(
3262                (f"Failed to create hedge experiment: {resp.status_code=}; {error_msg=}")
3263            )
3264
3265        exp_dict = json_resp["data"]["createHedgeExperimentDraft"]["hedgeExperiment"]
3266        experiment = HedgeExperiment.from_json_dict(exp_dict)
3267        return experiment
3268
3269    def modify_hedge_experiment(
3270        self,
3271        experiment_id: str,
3272        name: Optional[str] = None,
3273        description: Optional[str] = None,
3274        experiment_type: Optional[hedge_experiment_type] = None,
3275        target_securities: Union[Dict[GbiIdSecurity, float], str, None] = None,
3276        model_ids: Optional[List[str]] = None,
3277        stock_universe_ids: Optional[List[str]] = None,
3278        create_default_scenario: bool = True,
3279        baseline_model_id: Optional[str] = None,
3280        baseline_stock_universe_id: Optional[str] = None,
3281        baseline_portfolio_settings: Optional[str] = None,
3282    ) -> HedgeExperiment:
3283        mod_qry = """
3284            mutation modifyHedgeExperimentDraft(
3285                $input: ModifyHedgeExperimentDraftInput!
3286            ) {
3287                modifyHedgeExperimentDraft(input: $input) {
3288                    hedgeExperiment {
3289                    ...HedgeExperimentSelectedSecuritiesPageFragment
3290                    }
3291                }
3292            }
3293
3294            fragment HedgeExperimentSelectedSecuritiesPageFragment on HedgeExperiment {
3295                hedgeExperimentId
3296                experimentName
3297                userId
3298                config
3299                description
3300                experimentType
3301                lastCalculated
3302                lastModified
3303                status
3304                portfolioCalcStatus
3305                targetSecurities {
3306                    gbiId
3307                    security {
3308                        gbiId
3309                        name
3310                        symbol
3311                    }
3312                    weight
3313                }
3314                targetPortfolios {
3315                    portfolioId
3316                }
3317                baselineModel {
3318                    id
3319                    name
3320                }
3321                baselineScenario {
3322                    hedgeExperimentScenarioId
3323                    scenarioName
3324                    description
3325                    portfolioSettingsJson
3326                    hedgeExperimentPortfolios {
3327                        portfolio {
3328                            id
3329                            name
3330                            modelId
3331                            performanceGridHeader
3332                            performanceGrid
3333                            status
3334                            tearSheet {
3335                                groupName
3336                                members {
3337                                    name
3338                                    value
3339                                }
3340                            }
3341                        }
3342                    }
3343                    status
3344                }
3345                baselineStockUniverseId
3346            }
3347        """
3348        mod_input = {
3349            "hedgeExperimentId": experiment_id,
3350            "createDefaultScenario": create_default_scenario,
3351        }
3352        if name is not None:
3353            mod_input["newExperimentName"] = name
3354        if description is not None:
3355            mod_input["newExperimentDescription"] = description
3356        if experiment_type is not None:
3357            mod_input["newExperimentType"] = experiment_type
3358        if model_ids is not None:
3359            mod_input["setSelectdModels"] = model_ids
3360        if stock_universe_ids is not None:
3361            mod_input["selectedStockUniverseIds"] = stock_universe_ids
3362        if baseline_model_id is not None:
3363            mod_input["setBaselineModel"] = baseline_model_id
3364        if baseline_stock_universe_id is not None:
3365            mod_input["setBaselineStockUniverse"] = baseline_stock_universe_id
3366        if baseline_portfolio_settings is not None:
3367            mod_input["setBaselinePortfolioSettings"] = baseline_portfolio_settings
3368        # note that the behaviors bound to these data are mutually exclusive,
3369        # and its possible the opposite was set earlier in the DRAFT phase
3370        # of experiment creation, so when setting one, we must unset the other
3371        if isinstance(target_securities, dict):
3372            mod_input["setTargetSecurities"] = [
3373                {"gbiId": sec.gbi_id, "weight": weight}
3374                for (sec, weight) in target_securities.items()
3375            ]
3376            mod_input["setTargetPortfolios"] = None
3377        elif isinstance(target_securities, str):
3378            mod_input["setTargetPortfolios"] = [{"portfolioId": target_securities}]
3379            mod_input["setTargetSecurities"] = None
3380        elif target_securities is None:
3381            pass
3382        else:
3383            raise TypeError(
3384                "Expected value of type Union[Dict[GbiIdSecurity, str], str] "
3385                f"for argument 'target_securities'; got {type(target_securities)}"
3386            )
3387
3388        resp = requests.post(
3389            f"{self.base_uri}/api/graphql",
3390            json={"query": mod_qry, "variables": {"input": mod_input}},
3391            headers={"Authorization": "ApiKey " + self.api_key},
3392            params=self._request_params,
3393        )
3394
3395        json_resp = resp.json()
3396        if (resp.ok and "errors" in json_resp) or not resp.ok:
3397            error_msg = self._try_extract_error_code(resp)
3398            logger.error(error_msg)
3399            raise BoostedAPIException(
3400                (
3401                    f"Failed to modify hedge experiment in preparation for start {experiment_id=}: "
3402                    f"{resp.status_code=}; {error_msg=}"
3403                )
3404            )
3405
3406        exp_dict = json_resp["data"]["modifyHedgeExperimentDraft"]["hedgeExperiment"]
3407        experiment = HedgeExperiment.from_json_dict(exp_dict)
3408        return experiment
3409
3410    def start_hedge_experiment(self, experiment_id: str, *scenario_ids: str) -> HedgeExperiment:
3411        start_qry = """
3412            mutation startHedgeExperiment($input: StartHedgeExperimentInput!) {
3413                startHedgeExperiment(input: $input) {
3414                    hedgeExperiment {
3415                        hedgeExperimentId
3416                        experimentName
3417                        userId
3418                        config
3419                        description
3420                        experimentType
3421                        lastCalculated
3422                        lastModified
3423                        status
3424                        portfolioCalcStatus
3425                        targetSecurities {
3426                            gbiId
3427                            security {
3428                                gbiId
3429                                name
3430                                symbol
3431                            }
3432                            weight
3433                        }
3434                        targetPortfolios {
3435                            portfolioId
3436                        }
3437                        baselineModel {
3438                            id
3439                            name
3440                        }
3441                        baselineScenario {
3442                            hedgeExperimentScenarioId
3443                            scenarioName
3444                            description
3445                            portfolioSettingsJson
3446                            hedgeExperimentPortfolios {
3447                                portfolio {
3448                                    id
3449                                    name
3450                                    modelId
3451                                    performanceGridHeader
3452                                    performanceGrid
3453                                    status
3454                                    tearSheet {
3455                                        groupName
3456                                        members {
3457                                            name
3458                                            value
3459                                        }
3460                                    }
3461                                }
3462                            }
3463                            status
3464                        }
3465                        baselineStockUniverseId
3466                    }
3467                }
3468            }
3469        """
3470        start_input: Dict[str, Any] = {"hedgeExperimentId": experiment_id}
3471        if len(scenario_ids) > 0:
3472            start_input["hedgeExperimentScenarioIds"] = list(scenario_ids)
3473
3474        resp = requests.post(
3475            f"{self.base_uri}/api/graphql",
3476            json={"query": start_qry, "variables": {"input": start_input}},
3477            headers={"Authorization": "ApiKey " + self.api_key},
3478            params=self._request_params,
3479        )
3480
3481        json_resp = resp.json()
3482        if (resp.ok and "errors" in json_resp) or not resp.ok:
3483            error_msg = self._try_extract_error_code(resp)
3484            logger.error(error_msg)
3485            raise BoostedAPIException(
3486                (
3487                    f"Failed to start hedge experiment {experiment_id=}: "
3488                    f"{resp.status_code=}; {error_msg=}"
3489                )
3490            )
3491
3492        exp_dict = json_resp["data"]["startHedgeExperiment"]["hedgeExperiment"]
3493        experiment = HedgeExperiment.from_json_dict(exp_dict)
3494        return experiment
3495
3496    def delete_hedge_experiment(self, experiment_id: str) -> bool:
3497        delete_qry = """
3498            mutation($input: DeleteHedgeExperimentsInput!) {
3499                deleteHedgeExperiments(input: $input) {
3500                    success
3501                }
3502            }
3503        """
3504        delete_input = {"hedgeExperimentIds": [experiment_id]}
3505        resp = requests.post(
3506            f"{self.base_uri}/api/graphql",
3507            json={"query": delete_qry, "variables": {"input": delete_input}},
3508            headers={"Authorization": "ApiKey " + self.api_key},
3509            params=self._request_params,
3510        )
3511
3512        json_resp = resp.json()
3513        if (resp.ok and "errors" in json_resp) or not resp.ok:
3514            error_msg = self._try_extract_error_code(resp)
3515            logger.error(error_msg)
3516            raise BoostedAPIException(
3517                (
3518                    f"Failed to delete hedge experiment {experiment_id=}: "
3519                    + f"status_code={resp.status_code}; error_msg={error_msg}"
3520                )
3521            )
3522
3523        return json_resp["data"]["deleteHedgeExperiments"]["success"]
3524
3525    def create_hedge_basket_position_bounds_from_csv(
3526        self,
3527        filepath: str,
3528        name: str,
3529        description: Optional[str],
3530        mapping_result_filepath: Optional[str],
3531    ) -> str:
3532        DATE = "Date"
3533        ISIN = "ISIN"
3534        COUNTRY = "Country"
3535        CURRENCY = "Currency"
3536        LOWER_BOUND = "Lower Bound"
3537        UPPER_BOUND = "Upper Bound"
3538        supported_columns = {
3539            DATE,
3540            ISIN,
3541            COUNTRY,
3542            CURRENCY,
3543            LOWER_BOUND,
3544            UPPER_BOUND,
3545        }
3546        required_columns = {ISIN, LOWER_BOUND, UPPER_BOUND}
3547
3548        try:
3549            df: pd.DataFrame = pd.read_csv(filepath, parse_dates=True)
3550        except Exception as e:
3551            raise BoostedAPIException(f"Error reading {filepath=}: {e}")
3552
3553        columns = set(df.columns)
3554
3555        # First perform basic data validation
3556        missing_required_columns = required_columns - columns
3557        if missing_required_columns:
3558            raise BoostedAPIException(
3559                f"The following required columns are missing: {missing_required_columns}"
3560            )
3561        extra_columns = columns - supported_columns
3562        if extra_columns:
3563            logger.warning(
3564                f"The following columns are unsupported and will be ignored: {extra_columns}"
3565            )
3566        try:
3567            df[LOWER_BOUND] = df[LOWER_BOUND].astype(float)
3568            df[UPPER_BOUND] = df[UPPER_BOUND].astype(float)
3569            df[ISIN] = df[ISIN].astype(str)
3570        except Exception as e:
3571            raise BoostedAPIException(f"Column datatypes are incorrect: {e}")
3572        lb_gt_ub = df[df[LOWER_BOUND] > df[UPPER_BOUND]]
3573        if not lb_gt_ub.empty:
3574            raise BoostedAPIException(
3575                f"Lower Bound must be <= Upper Bound, but these are not: {lb_gt_ub[ISIN].tolist()}"
3576            )
3577        out_of_range = df[
3578            (
3579                (df[LOWER_BOUND] < 0)
3580                | (df[LOWER_BOUND] > 1)
3581                | (df[UPPER_BOUND] < 0)
3582                | (df[UPPER_BOUND] > 1)
3583            )
3584        ]
3585        if not out_of_range.empty:
3586            raise BoostedAPIException("Lower Bound and Upper Bound values must be in range [0, 1]")
3587
3588        # Now map the security info into GBI IDs
3589        rows = list(df.to_dict(orient="index").values())
3590        sec_data_list = self.getGbiIdFromIdentCountryCurrencyDate(
3591            ident_country_currency_dates=[
3592                DateIdentCountryCurrency(
3593                    date=row.get(DATE, datetime.date.today().isoformat()),
3594                    identifier=row.get(ISIN),
3595                    id_type=ColumnSubRole.ISIN,
3596                    country=row.get(COUNTRY),
3597                    currency=row.get(CURRENCY),
3598                )
3599                for row in rows
3600            ]
3601        )
3602
3603        # Now take each row and its gbi id mapping, and create the bounds list
3604        bounds = []
3605        for row, sec_data in zip(rows, sec_data_list):
3606            if sec_data is None:
3607                logger.warning(f"Failed to map {row[ISIN]}, skipping this security.")
3608            else:
3609                bounds.append(
3610                    {"gbi_id": str(sec_data.gbi_id), "lb": row[LOWER_BOUND], "ub": row[UPPER_BOUND]}
3611                )
3612
3613                # Add security metadata to see the mapping
3614                row["Mapped GBI ID"] = sec_data.gbi_id
3615                row[f"Mapped {ISIN}"] = sec_data.isin_info.identifier
3616                row[f"Mapped {COUNTRY}"] = sec_data.isin_info.country
3617                row[f"Mapped {CURRENCY}"] = sec_data.isin_info.currency
3618                row["Mapped Ticker"] = sec_data.ticker
3619                row["Mapped Company Name"] = sec_data.company_name
3620
3621        # Call endpoint to create the bounds settings template
3622        qry = """
3623              mutation CreatePartialStrategyTemplate(
3624                $portfolioSettingsKey: String!
3625                $partialSettings: String!
3626                $name: String!
3627                $description: String
3628              ) {
3629                createPartialStrategyTemplate(
3630                  portfolioSettingsKey: $portfolioSettingsKey
3631                  partialSettings: $partialSettings
3632                  name: $name
3633                  description: $description
3634                )
3635              }
3636            """
3637        variables = {
3638            "portfolioSettingsKey": "basketTrading.positionSizeBounds",
3639            "partialSettings": json.dumps(bounds),
3640            "name": name,
3641            "description": description,
3642        }
3643        resp = self._get_graphql(qry, variables=variables)
3644
3645        # Write mapped csv for reference
3646        if mapping_result_filepath is not None:
3647            pd.DataFrame(rows).to_csv(mapping_result_filepath)
3648
3649        return resp["data"]["createPartialStrategyTemplate"]
3650
3651    def get_portfolio_accuracy(
3652        self,
3653        model_id: str,
3654        portfolio_id: str,
3655        start_date: Optional[BoostedDate] = None,
3656        end_date: Optional[BoostedDate] = None,
3657    ) -> dict:
3658        if start_date and end_date:
3659            validate_start_and_end_dates(start_date=start_date, end_date=end_date)
3660            start_date = convert_date(start_date)
3661            end_date = convert_date(end_date)
3662
3663        # TODO: Later change this URI to not use the watchlist prefix. It is misnamed.
3664        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_PA_ROUTE}/get-hit-rate/"
3665        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3666        req_json = {"model_id": model_id, "portfolio_id": portfolio_id}
3667        if start_date and end_date:
3668            req_json["start_date"] = start_date.isoformat()
3669            req_json["end_date"] = end_date.isoformat()
3670        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3671
3672        if not res.ok:
3673            error_msg = self._try_extract_error_code(res)
3674            logger.error(error_msg)
3675            raise BoostedAPIException(f"Failed to get Hit Rate: {error_msg}")
3676
3677        data = res.json()
3678        return data
3679
3680    def create_watchlist(self, name: str) -> str:
3681        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/create/"
3682        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3683        req_json = {"name": name}
3684        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3685
3686        if not res.ok:
3687            error_msg = self._try_extract_error_code(res)
3688            logger.error(error_msg)
3689            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
3690
3691        data = res.json()
3692        return data["watchlist_id"]
3693
3694    def _get_graphql(
3695        self,
3696        query: str,
3697        variables: Dict,
3698        error_msg_prefix: str = "Failed to get graphql result: ",
3699        log_error: bool = True,
3700    ) -> Dict:
3701        headers = {"Authorization": "ApiKey " + self.api_key}
3702        json_req = {"query": query, "variables": variables}
3703
3704        url = self.base_uri + "/api/graphql"
3705        resp = requests.post(
3706            url,
3707            json=json_req,
3708            headers=headers,
3709            params=self._request_params,
3710        )
3711
3712        # graphql endpoints typically return 200 or 400 status codes, so we must
3713        # check if we have any errors, even with a 200
3714        if not resp.ok or (resp.ok and "errors" in resp.json()):
3715            error_msg = self._try_extract_error_code(resp)
3716            error_str = str(error_msg_prefix) + f" {resp.status_code=}; {error_msg=}"
3717            if log_error:
3718                logger.error(error_str)
3719            raise BoostedAPIException(error_str)
3720
3721        json_resp = resp.json()
3722        return json_resp
3723
3724    def _get_security_info(self, gbi_ids: List[int]) -> Dict:
3725        query = graphql_queries.GET_SEC_INFO_QRY
3726        variables = {
3727            "ids": [] if not gbi_ids else gbi_ids,
3728        }
3729
3730        error_msg_prefix = "Failed to get Security Details:"
3731        return self._get_graphql(
3732            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3733        )
3734
3735    def _get_sector_info(self) -> Dict:
3736        """
3737        Returns a list of sector objects, e.g.
3738        {
3739            "id": 1010,
3740            "parentId": 10,
3741            "name": "Energy",
3742            "topParentName": null,
3743            "spiqSectorId": -1,
3744            "legacy": false
3745        }
3746        """
3747        url = f"{self.base_uri}/api/sectors"
3748        headers = {"Authorization": "ApiKey " + self.api_key}
3749        res = requests.get(url, headers=headers, **self._request_params)
3750        self._check_ok_or_err_with_msg(res, "Failed to get sectors data")
3751        return res.json()["sectors"]
3752
3753    def _get_watchlist_analysis(
3754        self,
3755        gbi_ids: List[int],
3756        model_ids: List[str],
3757        portfolio_ids: List[str],
3758        asof_date=datetime.date.today(),
3759    ) -> Dict:
3760        query = graphql_queries.WATCHLIST_ANALYSIS_QRY
3761        variables = {
3762            "gbiIds": gbi_ids,
3763            "modelIds": model_ids,
3764            "portfolioIds": portfolio_ids,
3765            "date": self.__iso_format(asof_date),
3766        }
3767        error_msg_prefix = "Failed to get Coverage Analysis:"
3768        return self._get_graphql(
3769            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3770        )
3771
3772    def _get_models_for_portfolio(self, portfolio_ids: List[str]) -> Dict:
3773        query = graphql_queries.GET_MODELS_FOR_PORTFOLIOS_QRY
3774        variables = {"ids": portfolio_ids}
3775        error_msg_prefix = "Failed to get Models for Portfolios: "
3776        return self._get_graphql(
3777            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3778        )
3779
3780    def _get_excess_return(
3781        self, model_ids: List[str], gbi_ids: List[int], asof_date=datetime.date.today()
3782    ) -> Dict:
3783        query = graphql_queries.GET_EXCESS_RETURN_QRY
3784
3785        variables = {
3786            "modelIds": model_ids,
3787            "gbiIds": gbi_ids,
3788            "date": self.__iso_format(asof_date),
3789        }
3790        error_msg_prefix = "Failed to get Excess Return Slugging Pct: "
3791        return self._get_graphql(
3792            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3793        )
3794
3795    def _coverage_column_name_format(self, in_str) -> str:
3796        if in_str.upper() == "ISIN":
3797            return "ISIN"
3798
3799        return in_str.title()
3800
3801    def _get_model_stocks(self, model_id: str) -> List[GbiIdTickerISIN]:
3802        # first, get the universe id
3803        resp = self._get_graphql(
3804            graphql_queries.GET_MODEL_STOCK_UNIVERSE_ID_QUERY,
3805            variables={"modelId": model_id},
3806            error_msg_prefix="Failed to get model stock universe ID",
3807        )
3808        universe_id = resp["data"]["model"]["stockUniverseId"]
3809
3810        # now, query for universe stocks
3811        url = self.base_uri + f"/api/stocks/model-universe/{universe_id}"
3812        headers = {"Authorization": "ApiKey " + self.api_key}
3813        universe_resp = requests.get(url, headers=headers, **self._request_params)
3814        universe = universe_resp.json()["stockUniverse"]
3815        securities = [
3816            GbiIdTickerISIN(gbi_id=security["id"], ticker=security["symbol"], isin=security["isin"])
3817            for security in universe
3818        ]
3819        return securities
3820
3821    def get_coverage_info(self, watchlist_id: str, portfolio_group_id: str) -> pd.DataFrame:
3822        # get securities list in watchlist
3823        watchlist_details = self.get_watchlist_details(watchlist_id)
3824        security_list = watchlist_details["targets"]
3825
3826        gbi_ids = [x["gbi_id"] for x in security_list]
3827
3828        gbi_data: Dict[Any, Dict] = {x: {} for x in gbi_ids}
3829
3830        # get security info ticker, name, industry etc
3831        sec_info = self._get_security_info(gbi_ids)
3832
3833        for sec in sec_info["data"]["securities"]:
3834            gbi_id = sec["gbiId"]
3835            for k in ["symbol", "name", "isin", "country", "currency"]:
3836                gbi_data[gbi_id][self._coverage_column_name_format(k)] = sec[k]
3837
3838            gbi_data[gbi_id][self._coverage_column_name_format("Sector")] = sec["sector"][
3839                "topParentName"
3840            ]
3841
3842        # get portfolios list in portfolio_Group
3843        portfolio_group = self.get_portfolio_group(portfolio_group_id)
3844        portfolio_ids = [x["portfolio_id"] for x in portfolio_group["portfolios"]]
3845        portfolio_info = {x["portfolio_id"]: x for x in portfolio_group["portfolios"]}
3846
3847        model_resp = self._get_models_for_portfolio(portfolio_ids=portfolio_ids)
3848        for portfolio in model_resp["data"]["portfolios"]:
3849            portfolio_info[portfolio["id"]].update(portfolio)
3850
3851        model_info = {
3852            x["modelId"]: portfolio_info[x["id"]] for x in model_resp["data"]["portfolios"]
3853        }
3854
3855        # model_ids and portfolio_ids are parallel arrays
3856        model_ids = [portfolio_info[x]["modelId"] for x in portfolio_ids]
3857
3858        # graphql: get watchlist analysis
3859        wl_analysis = self._get_watchlist_analysis(
3860            gbi_ids=gbi_ids,
3861            model_ids=model_ids,
3862            portfolio_ids=portfolio_ids,
3863            asof_date=datetime.date.today(),
3864        )
3865
3866        portfolio_gbi_data: Dict[Any, Dict] = {k: {} for k in portfolio_ids}
3867        for pi, v in portfolio_gbi_data.items():
3868            v.update({k: {} for k in gbi_data.keys()})
3869
3870        equity_explorer_date = wl_analysis["data"]["watchlistAnalysis"][0]["analysisDates"][0][
3871            "date"
3872        ]
3873        for wla in wl_analysis["data"]["watchlistAnalysis"]:
3874            gbi_id = wla["gbiId"]
3875            gbi_data[gbi_id]["Composite Rating"] = wla["analysisDates"][0]["aggregateSignal"][
3876                "rating"
3877            ]
3878            gbi_data[gbi_id]["Composite Rating Delta"] = wla["analysisDates"][0]["aggregateSignal"][
3879                "ratingDelta"
3880            ]
3881
3882            for p in wla["analysisDates"][0]["portfoliosSignals"]:
3883                model_name = portfolio_info[p["portfolioId"]]["modelName"]
3884
3885                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3886                    model_name + self._coverage_column_name_format(": rank")
3887                ] = (p["rank"] + 1)
3888                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3889                    model_name + self._coverage_column_name_format(": rank delta")
3890                ] = (-1 * p["signalDelta"])
3891                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3892                    model_name + self._coverage_column_name_format(": rating")
3893                ] = p["rating"]
3894                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3895                    model_name + self._coverage_column_name_format(": rating delta")
3896                ] = p["ratingDelta"]
3897
3898        neg_rec: Dict[Any, Dict] = {k: {} for k in gbi_data.keys()}
3899        pos_rec: Dict[Any, Dict] = {k: {} for k in gbi_data.keys()}
3900        for wla in wl_analysis["data"]["watchlistAnalysis"]:
3901            gbi_id = wla["gbiId"]
3902
3903            for pid, signals in zip(portfolio_ids, wla["analysisDates"][0]["portfoliosSignals"]):
3904                model_name = portfolio_info[pid]["modelName"]
3905                neg_rec[gbi_id][
3906                    model_name + self._coverage_column_name_format(": negative recommendation")
3907                ] = signals["explainWeightNeg"]
3908                pos_rec[gbi_id][
3909                    model_name + self._coverage_column_name_format(": positive recommendation")
3910                ] = signals["explainWeightPos"]
3911
3912        # graphql: GetExcessReturn - slugging pct
3913        er_sp = self._get_excess_return(
3914            model_ids=model_ids, gbi_ids=gbi_ids, asof_date=equity_explorer_date
3915        )
3916
3917        for model in er_sp["data"]["models"]:
3918            model_name = model_info[model["id"]]["modelName"]
3919            for stat in model["equityExplorerData"]["equityExplorerSummaryStatistics"]:
3920                portfolioId = model_info[model["id"]]["id"]
3921                portfolio_gbi_data[portfolioId][int(stat["gbiId"])][
3922                    model_name + self._coverage_column_name_format(": slugging %")
3923                ] = (stat["ER"]["SP"]["sixMonthWindowOneMonthHorizon"] * 100)
3924
3925        # add rank, rating, slugging
3926        for pid, v in portfolio_gbi_data.items():
3927            for gbi_id, vv in v.items():
3928                gbi_data[gbi_id].update(vv)
3929
3930        # add neg/pos rec scores
3931        for rec in [neg_rec, pos_rec]:
3932            for k, v in rec.items():
3933                gbi_data[k].update(v)
3934
3935        df = pd.DataFrame.from_records([v for _, v in gbi_data.items()])
3936
3937        return df
3938
3939    def get_coverage_csv(
3940        self, watchlist_id: str, portfolio_group_id: str, filepath: Optional[str] = None
3941    ) -> Optional[str]:
3942        """
3943        Converts the coverage contents to CSV format
3944        Parameters
3945        ----------
3946        watchlist_id: str
3947            UUID str identifying the coverage watchlist
3948        portfolio_group_id: str
3949            UUID str identifying the group of portfolio to use for analysis
3950        filepath: Optional[str]
3951            UUID str identifying the group of portfolio to use for analysis
3952
3953        Returns:
3954        ----------
3955        None if filepath is provided, else a string with a csv's contents is returned
3956        """
3957
3958        df = self.get_coverage_info(watchlist_id, portfolio_group_id)
3959
3960        return df.to_csv(filepath, index=False, float_format="%.4f")
3961
3962    def get_watchlist_details(self, watchlist_id: str) -> Dict:
3963        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/details/"
3964        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3965        req_json = {"watchlist_id": watchlist_id}
3966        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3967
3968        if not res.ok:
3969            error_msg = self._try_extract_error_code(res)
3970            logger.error(error_msg)
3971            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
3972
3973        data = res.json()
3974        return data
3975
3976    def create_watchlist_from_file(self, name: str, filepath: str) -> str:
3977        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/create_watchlist_from_file/"
3978        headers = {"Authorization": "ApiKey " + self.api_key}
3979
3980        with open(filepath, "rb") as fp:
3981            file_bytes = fp.read()
3982
3983        file_bytes_base64 = base64.b64encode(file_bytes).decode("ascii")
3984        json_req = {
3985            "content_type": mimetypes.guess_type(filepath)[0],
3986            "file_bytes_base64": file_bytes_base64,
3987            "name": name,
3988        }
3989
3990        res = requests.post(url, json=json_req, headers=headers)
3991
3992        if not res.ok:
3993            error_msg = self._try_extract_error_code(res)
3994            logger.error(error_msg)
3995            raise BoostedAPIException(f"Failed to create watchlist from file: {error_msg}")
3996
3997        data = res.json()
3998        return data["watchlist_id"]
3999
4000    def get_watchlists(self) -> List[Dict]:
4001        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/get_user_watchlists/"
4002        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4003        req_json: Dict = {}
4004        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4005
4006        if not res.ok:
4007            error_msg = self._try_extract_error_code(res)
4008            logger.error(error_msg)
4009            raise BoostedAPIException(f"Failed to get user watchlists: {error_msg}")
4010
4011        data = res.json()
4012        return data["watchlists"]
4013
4014    def get_watchlist_contents(self, watchlist_id) -> Dict:
4015        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/contents/"
4016        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4017        req_json = {"watchlist_id": watchlist_id}
4018        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4019
4020        if not res.ok:
4021            error_msg = self._try_extract_error_code(res)
4022            logger.error(error_msg)
4023            raise BoostedAPIException(f"Failed to get watchlist contents: {error_msg}")
4024
4025        data = res.json()
4026        return data
4027
4028    def get_watchlist_contents_as_csv(self, watchlist_id, filepath) -> None:
4029        data = self.get_watchlist_contents(watchlist_id)
4030        df = pd.DataFrame(data["contents"])
4031        df.to_csv(filepath, index=False)
4032
4033    # TODO this will need to be enhanced to accept country/currency overrides
4034    def add_securities_to_watchlist(
4035        self, watchlist_id: str, identifiers: List[str], identifier_type: Literal["TICKER", "ISIN"]
4036    ) -> Dict:
4037        # should we just make the arg lower? all caps has a flag-like feel to it
4038        id_type = identifier_type.lower()
4039        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/add_{id_type}s/"
4040        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4041        req_json = {"watchlist_id": watchlist_id, id_type: identifiers}
4042        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4043
4044        if not res.ok:
4045            error_msg = self._try_extract_error_code(res)
4046            logger.error(error_msg)
4047            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
4048
4049        data = res.json()
4050        return data
4051
4052    def remove_securities_from_watchlist(
4053        self, watchlist_id: str, identifiers: List[str], identifier_type: Literal["TICKER", "ISIN"]
4054    ) -> Dict:
4055        # should we just make the arg lower? all caps has a flag-like feel to it
4056        id_type = identifier_type.lower()
4057        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/remove_{id_type}s/"
4058        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4059        req_json = {"watchlist_id": watchlist_id, id_type: identifiers}
4060        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4061
4062        if not res.ok:
4063            error_msg = self._try_extract_error_code(res)
4064            logger.error(error_msg)
4065            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
4066
4067        data = res.json()
4068        return data
4069
4070    def get_portfolio_groups(
4071        self,
4072    ) -> Dict:
4073        """
4074        Parameters: None
4075
4076
4077        Returns:
4078        ----------
4079
4080        Dict:  {
4081        user_id: str
4082        portfolio_groups: List[PortfolioGroup]
4083        }
4084        where PortfolioGroup is defined as = Dict {
4085        group_id: str
4086        group_name: str
4087        portfolios: List[PortfolioInGroup]
4088        }
4089        where PortfolioInGroup is defined as = Dict {
4090        portfolio_id: str
4091        rank_in_group: Optional[int]
4092        }
4093        """
4094        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get"
4095        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4096        req_json: Dict = {}
4097        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4098
4099        if not res.ok:
4100            error_msg = self._try_extract_error_code(res)
4101            logger.error(error_msg)
4102            raise BoostedAPIException(f"Failed to get user portfolio groups: {error_msg}")
4103
4104        data = res.json()
4105        return data
4106
4107    def get_portfolio_group(self, portfolio_group_id: str) -> Dict:
4108        """
4109        Parameters:
4110        portfolio_group_id: str
4111           UUID identifier for the portfolio group
4112
4113
4114        Returns:
4115        ----------
4116
4117        PortfolioGroup: Dict:  {
4118        group_id: str
4119        group_name: str
4120        portfolios: List[PortfolioInGroup]
4121        }
4122        where PortfolioInGroup is defined as = Dict {
4123        portfolio_id: str
4124        portfolio_name: str
4125        rank_in_group: Optional[int]
4126        }
4127        """
4128        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get-one"
4129        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4130        req_json = {"portfolio_group_id": portfolio_group_id}
4131        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4132
4133        if not res.ok:
4134            error_msg = self._try_extract_error_code(res)
4135            logger.error(error_msg)
4136            raise BoostedAPIException(f"Failed to get user portfolio groups: {error_msg}")
4137
4138        data = res.json()
4139        return data
4140
4141    def set_sticky_portfolio_group(
4142        self,
4143        portfolio_group_id: str,
4144    ) -> Dict:
4145        """
4146        Set sticky portfolio group
4147
4148        Parameters
4149        ----------
4150
4151        group_id: str,
4152           UUID str identifying a portfolio group
4153
4154        Returns:
4155        -------
4156        Dict {
4157            changed: int - 1 == success
4158        }
4159        """
4160        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/set-sticky"
4161        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4162        req_json = {"portfolio_group_id": portfolio_group_id}
4163        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4164
4165        if not res.ok:
4166            error_msg = self._try_extract_error_code(res)
4167            logger.error(error_msg)
4168            raise BoostedAPIException(f"Failed to set sticky portfolio group: {error_msg}")
4169
4170        data = res.json()
4171        return data
4172
4173    def get_sticky_portfolio_group(
4174        self,
4175    ) -> Dict:
4176        """
4177        Get sticky portfolio group for the user
4178
4179        Parameters
4180        ----------
4181
4182        Returns:
4183        -------
4184        Dict {
4185            group_id: str
4186            group_name: str
4187            portfolios: List[PortfolioInGroup(Dict)]
4188                  PortfolioInGroup(Dict):
4189                           portfolio_id: str
4190                           rank_in_group: Optional[int] = None
4191                           portfolio_name: Optional[str] = None
4192        }
4193        """
4194        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get-sticky"
4195        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4196        req_json: Dict = {}
4197        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4198
4199        if not res.ok:
4200            error_msg = self._try_extract_error_code(res)
4201            logger.error(error_msg)
4202            raise BoostedAPIException(f"Failed to get sticky portfolio group: {error_msg}")
4203
4204        data = res.json()
4205        return data
4206
4207    def create_portfolio_group(
4208        self,
4209        group_name: str,
4210        portfolios: Optional[List[Dict]] = None,
4211    ) -> Dict:
4212        """
4213        Create a new portfolio group
4214
4215        Parameters
4216        ----------
4217
4218        group_name: str
4219           name of the new group
4220
4221        portfolios: List of Dict [:
4222
4223        portfolio_id: str
4224        rank_in_group: Optional[int] = None
4225        ]
4226
4227        Returns:
4228        ----------
4229
4230        Dict: {
4231        group_id: str
4232           UUID identifier for the portfolio group
4233
4234        created: int
4235           num groups created, 1 == success
4236
4237        added: int
4238           num portfolios added to the group, should match the length of 'portfolios' argument
4239        }
4240        """
4241        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/create"
4242        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4243        req_json = {"group_name": group_name, "portfolios": portfolios}
4244
4245        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4246
4247        if not res.ok:
4248            error_msg = self._try_extract_error_code(res)
4249            logger.error(error_msg)
4250            raise BoostedAPIException(f"Failed to create portfolio group: {error_msg}")
4251
4252        data = res.json()
4253        return data
4254
4255    def rename_portfolio_group(
4256        self,
4257        group_id: str,
4258        group_name: str,
4259    ) -> Dict:
4260        """
4261        Rename a portfolio group
4262
4263        Parameters
4264        ----------
4265
4266        group_id: str,
4267           UUID str identifying a portfolio group
4268
4269        group_name: str,
4270           The new name for the porfolio
4271
4272        Returns:
4273        -------
4274        Dict {
4275            changed: int - 1 == success
4276        }
4277        """
4278        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/rename"
4279        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4280        req_json = {"group_id": group_id, "group_name": group_name}
4281        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4282
4283        if not res.ok:
4284            error_msg = self._try_extract_error_code(res)
4285            logger.error(error_msg)
4286            raise BoostedAPIException(f"Failed to rename portfolio group: {error_msg}")
4287
4288        data = res.json()
4289        return data
4290
4291    def add_to_portfolio_group(
4292        self,
4293        group_id: str,
4294        portfolios: List[Dict],
4295    ) -> Dict:
4296        """
4297        Add portfolios to a group
4298
4299        Parameters
4300        ----------
4301
4302        group_id: str,
4303           UUID str identifying a portfolio group
4304
4305        portfolios: List of Dict [:
4306            portfolio_id: str
4307            rank_in_group: Optional[int] = None
4308        ]
4309
4310
4311        Returns:
4312        -------
4313        Dict {
4314            added: int
4315               number of successful changes
4316        }
4317        """
4318        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/add-to-group"
4319        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4320        req_json = {"group_id": group_id, "portfolios": portfolios}
4321
4322        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4323
4324        if not res.ok:
4325            error_msg = self._try_extract_error_code(res)
4326            logger.error(error_msg)
4327            raise BoostedAPIException(f"Failed to add portfolios to portfolio group: {error_msg}")
4328
4329        data = res.json()
4330        return data
4331
4332    def remove_from_portfolio_group(
4333        self,
4334        group_id: str,
4335        portfolios: List[str],
4336    ) -> Dict:
4337        """
4338        Remove portfolios from a group
4339
4340        Parameters
4341        ----------
4342
4343        group_id: str,
4344           UUID str identifying a portfolio group
4345
4346        portfolios: List of str
4347
4348
4349        Returns:
4350        -------
4351        Dict {
4352            removed: int
4353               number of successful changes
4354        }
4355        """
4356        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/remove-from-group"
4357        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4358        req_json = {"group_id": group_id, "portfolios": portfolios}
4359        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4360
4361        if not res.ok:
4362            error_msg = self._try_extract_error_code(res)
4363            logger.error(error_msg)
4364            raise BoostedAPIException(
4365                f"Failed to remove portfolios from portfolio group: {error_msg}"
4366            )
4367
4368        data = res.json()
4369        return data
4370
4371    def delete_portfolio_group(
4372        self,
4373        group_id: str,
4374    ) -> Dict:
4375        """
4376        Delete a portfolio group
4377
4378        Parameters
4379        ----------
4380
4381        group_id: str,
4382           UUID str identifying a portfolio group
4383
4384
4385        Returns:
4386        -------
4387        Dict {
4388            removed_groups: int
4389               number of successful changes
4390
4391            removed_portfolios: int
4392               number of successful changes
4393        }
4394        """
4395        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/remove"
4396        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4397        req_json = {"group_id": group_id}
4398        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4399
4400        if not res.ok:
4401            error_msg = self._try_extract_error_code(res)
4402            logger.error(error_msg)
4403            raise BoostedAPIException(f"Failed to delete portfolio group: {error_msg}")
4404
4405        data = res.json()
4406        return data
4407
4408    def set_portfolio_group_for_watchlist(
4409        self,
4410        portfolio_group_id: str,
4411        watchlist_id: str,
4412    ) -> Dict:
4413        """
4414        Set portfolio group for watchlist.
4415
4416        Parameters
4417        ----------
4418
4419        portfolio_group_id: str,
4420           UUID str identifying a portfolio group
4421
4422        watchlist_id: str,
4423           UUID str identifying a watchlist
4424
4425
4426        Returns:
4427        -------
4428        Dict {
4429            success: bool
4430            errors:
4431            data: Dict
4432                changed: int
4433        }
4434        """
4435        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/set-portfolio-groups/"
4436        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4437        req_json = {"portfolio_group_id": portfolio_group_id, "watchlist_id": watchlist_id}
4438        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4439
4440        if not res.ok:
4441            error_msg = self._try_extract_error_code(res)
4442            logger.error(error_msg)
4443            raise BoostedAPIException(f"Failed to set portfolio group for watchlist: {error_msg}")
4444
4445        return res.json()
4446
4447    def get_ranking_dates(self, model_id: str, portfolio_id: str) -> List[datetime.date]:
4448        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4449        url = self.base_uri + f"/api/analysis/ranking-dates/{model_id}/{portfolio_id}"
4450        res = requests.get(url, headers=headers, **self._request_params)
4451        self._check_ok_or_err_with_msg(res, "Failed to get ranking dates")
4452        data = res.json().get("ranking_dates", [])
4453
4454        return [parser.parse(d).date() for d in data]
4455
4456    def get_prior_ranking_date(
4457        self, ranking_dates: List[datetime.date], starting_date: datetime.date
4458    ) -> datetime.date:
4459        """
4460        Given a starting date and a list of ranking dates, return the most
4461        recent previous ranking date.
4462        """
4463        # order from most recent to least
4464        ranking_dates.sort(reverse=True)
4465
4466        for d in ranking_dates:
4467            if d <= starting_date:
4468                return d
4469
4470        # if we get here, the starting date is before the earliest ranking date
4471        raise BoostedAPIException(f"No rankins exist on or before {starting_date}")
4472
4473    def _get_risk_factors_descriptors(
4474        self, model_id: str, portfolio_id: str, use_v2: bool = False
4475    ) -> Dict[int, str]:
4476        """Returns a map from descriptor id to descriptor name."""
4477        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4478
4479        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4480        url = self.base_uri + f"/api/{risk_factor}/{model_id}/{portfolio_id}/descriptors"
4481        res = requests.get(url, headers=headers, **self._request_params)
4482
4483        self._check_ok_or_err_with_msg(res, "Failed to get risk factor descriptors")
4484
4485        descriptors = {int(i): name for i, name in res.json().items() if i.isnumeric()}
4486        return descriptors
4487
4488    def get_risk_groups(
4489        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4490    ) -> List[Dict[str, Any]]:
4491        # first get the group descriptors
4492        descriptors = self._get_risk_factors_descriptors(model_id, portfolio_id, use_v2)
4493
4494        # calculate the most recent prior rankings date. This is the date
4495        # we need to use to query for risk group data.
4496        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4497        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4498        date_str = ranking_date.strftime("%Y-%m-%d")
4499
4500        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4501
4502        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4503        url = self.base_uri + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-groups/{date_str}"
4504        res = requests.get(url, headers=headers, **self._request_params)
4505
4506        self._check_ok_or_err_with_msg(
4507            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4508        )
4509
4510        # Response is a list of objects like:
4511        # [
4512        #   [
4513        #     0,
4514        #     14,
4515        #     1
4516        #   ],
4517        #   [
4518        #     25,
4519        #     12,
4520        #     13
4521        #   ],
4522        # 0.67013
4523        # ],
4524        #
4525        # Where each integer in the lists is a descriptor id.
4526
4527        groups = []
4528        for i, row in enumerate(res.json()):
4529            row_map: Dict[str, Any] = {}
4530            # map descriptor id to name
4531            row_map["machine"] = i + 1  # start at 1 not 0
4532            row_map["risk_group_a"] = [descriptors[i] for i in row[0]]
4533            row_map["risk_group_b"] = [descriptors[i] for i in row[1]]
4534            row_map["volatility_explained"] = row[2]
4535            groups.append(row_map)
4536
4537        return groups
4538
4539    def get_risk_factors_discovered_descriptors(
4540        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4541    ) -> pd.DataFrame:
4542        # first get the group descriptors
4543        descriptors = self._get_risk_factors_descriptors(model_id, portfolio_id)
4544
4545        # calculate the most recent prior rankings date. This is the date
4546        # we need to use to query for risk group data.
4547        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4548        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4549        date_str = ranking_date.strftime("%Y-%m-%d")
4550
4551        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4552
4553        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4554        url = (
4555            self.base_uri
4556            + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-descriptors/json/{date_str}"
4557        )
4558        res = requests.get(url, headers=headers, **self._request_params)
4559
4560        self._check_ok_or_err_with_msg(
4561            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4562        )
4563
4564        # Endpoint returns a nested list of floats
4565        df = pd.DataFrame(res.json(), columns=RISK_FACTOR_COLUMNS)
4566
4567        # This flat dataframe represents a potentially doubly nested structure
4568        # of Sector -> (high/low volatility) -> security. We don't care about
4569        # the high/low volatility rows, (which will have negative identifiers)
4570        # so we can filter these out.
4571        df = df[df["identifier"] >= 0]
4572
4573        # now, any values that had a depth of 2 should be set to a depth of 1,
4574        # since we removed the double nesting.
4575        df.replace(to_replace=2, value=1, inplace=True)
4576
4577        # This dataframe represents data that is nested on the UI, so the
4578        # "depth" field indicates which level of nesting each row is at. At this
4579        # point, a depth of 0 indicates a sector, and following depth 1 rows are
4580        # securities within the sector.
4581
4582        # Identifiers in rows with depth 1 will be gbi ids, need to convert to
4583        # symbols.
4584        gbi_ids = df[df["depth"] == 1]["identifier"].tolist()
4585        sec_info = self._get_security_info(gbi_ids)["data"]["securities"]
4586        sec_map = {s["gbiId"]: s["symbol"] for s in sec_info}
4587
4588        def convert_ids(row: pd.Series) -> pd.Series:
4589            # convert each row's "identifier" to the appropriate id type. If the
4590            # depth is 0, the identifier should be a sector, otherwise it should
4591            # be a ticker.
4592            ident = int(row["identifier"])
4593            row["identifier"] = (
4594                descriptors.get(ident).title() if row["depth"] == 0 else sec_map.get(ident)
4595            )
4596            return row
4597
4598        df["depth"] = df["depth"].astype(int)
4599        df["stock_count"] = df["stock_count"].astype(int)
4600        df = df.apply(convert_ids, axis=1)
4601        df = df.reset_index(drop=True)
4602        return df
4603
4604    def get_risk_factors_sectors(
4605        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4606    ) -> pd.DataFrame:
4607        # first get the group descriptors
4608        sectors = {s["id"]: s["name"] for s in self._get_sector_info()}
4609
4610        # calculate the most recent prior rankings date. This is the date
4611        # we need to use to query for risk group data.
4612        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4613        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4614        date_str = ranking_date.strftime("%Y-%m-%d")
4615
4616        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4617
4618        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4619        url = (
4620            self.base_uri
4621            + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-sectors/json/{date_str}"
4622        )
4623        res = requests.get(url, headers=headers, **self._request_params)
4624
4625        self._check_ok_or_err_with_msg(
4626            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4627        )
4628
4629        # Endpoint returns a nested list of floats
4630        df = pd.DataFrame(res.json(), columns=RISK_FACTOR_COLUMNS)
4631
4632        # identifier is a gics sector identifier
4633        df["identifier"] = df["identifier"].apply(lambda i: sectors.get(int(i), None))
4634
4635        # This dataframe represents data that is nested on the UI, so the
4636        # "depth" field indicates which level of nesting each row is at. For
4637        # risk factors sectors, each "depth" represents a level of specificity
4638        # for the sector. E.g. Energy -> Energy Equipment -> Oil & Gas Equipment
4639        df["depth"] = df["depth"].astype(int)
4640        df["stock_count"] = df["stock_count"].astype(int)
4641        df = df.reset_index(drop=True)
4642        return df
4643
4644    def download_complete_portfolio_data(
4645        self, model_id: str, portfolio_id: str, download_filepath: str
4646    ):
4647        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4648        url = self.base_uri + f"/api/models/{model_id}/{portfolio_id}/excel"
4649
4650        res = requests.get(url, headers=headers, **self._request_params)
4651        self._check_ok_or_err_with_msg(
4652            res, f"Failed to get full data for {model_id=}, {portfolio_id=}"
4653        )
4654
4655        with open(download_filepath, "wb") as f:
4656            f.write(res.content)
4657
4658    def diff_hedge_experiment_portfolio_data(
4659        self,
4660        hedge_experiment_id: str,
4661        comparison_portfolios: List[str],
4662        categories: List[str],
4663    ) -> Dict:
4664        qry = """
4665        query diffHedgeExperimentPortfolios(
4666            $input: DiffHedgeExperimentPortfoliosInput!
4667        ) {
4668            diffHedgeExperimentPortfolios(input: $input) {
4669            data {
4670                diffs {
4671                    volatility {
4672                        date
4673                        vol5D
4674                        vol10D
4675                        vol21D
4676                        vol21D
4677                        vol63D
4678                        vol126D
4679                        vol189D
4680                        vol252D
4681                        vol315D
4682                        vol378D
4683                        vol441D
4684                        vol504D
4685                    }
4686                    performance {
4687                        date
4688                        value
4689                    }
4690                    performanceGrid {
4691                        headerRow
4692                        values
4693                    }
4694                    factors {
4695                        date
4696                        momentum
4697                        growth
4698                        size
4699                        value
4700                        dividendYield
4701                        volatility
4702                    }
4703                }
4704            }
4705            errors
4706            }
4707        }
4708        """
4709        headers = {"Authorization": "ApiKey " + self.api_key}
4710        params = {
4711            "hedgeExperimentId": hedge_experiment_id,
4712            "portfolioIds": comparison_portfolios,
4713            "categories": categories,
4714        }
4715        resp = requests.post(
4716            f"{self.base_uri}/api/graphql",
4717            json={"query": qry, "variables": params},
4718            headers=headers,
4719            params=self._request_params,
4720        )
4721
4722        json_resp = resp.json()
4723
4724        # graphql endpoints typically return 200 or 400 status codes, so we must
4725        # check if we have any errors, even with a 200
4726        if (resp.ok and "errors" in json_resp) or not resp.ok:
4727            error_msg = self._try_extract_error_code(resp)
4728            logger.error(error_msg)
4729            raise BoostedAPIException(
4730                (
4731                    f"Failed to get portfolio diffs for {hedge_experiment_id=}: "
4732                    f"{resp.status_code=}; {error_msg=}"
4733                )
4734            )
4735
4736        diffs = json_resp["data"]["diffHedgeExperimentPortfolios"]["data"]["diffs"]
4737        comparisons = {}
4738        for pf, cmp in zip(comparison_portfolios, diffs):
4739            res: Dict[str, Any] = {
4740                "performance": None,
4741                "performanceGrid": None,
4742                "factors": None,
4743                "volatility": None,
4744            }
4745            if "performanceGrid" in cmp:
4746                grid = cmp["performanceGrid"]
4747                grid_df = pd.DataFrame(grid["values"], columns=grid["headerRow"])
4748                res["performanceGrid"] = grid_df
4749            if "performance" in cmp:
4750                perf_df = pd.DataFrame(cmp["performance"]).set_index("date")
4751                perf_df.index = pd.to_datetime(perf_df.index)
4752                res["performance"] = perf_df
4753            if "volatility" in cmp:
4754                vol_df = pd.DataFrame(cmp["volatility"]).set_index("date")
4755                vol_df.index = pd.to_datetime(vol_df.index)
4756                res["volatility"] = vol_df
4757            if "factors" in cmp:
4758                factors_df = pd.DataFrame(cmp["factors"]).set_index("date")
4759                factors_df.index = pd.to_datetime(factors_df.index)
4760                res["factors"] = factors_df
4761            comparisons[pf] = res
4762        return comparisons
4763
4764    def get_signal_strength(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
4765        url = self.base_uri + f"/api/analysis/signal_strength/{model_id}/{portfolio_id}"
4766        headers = {"Authorization": "ApiKey " + self.api_key}
4767
4768        logger.info(f"Retrieving portfolio signals for {model_id=}, {portfolio_id=}")
4769
4770        # Response format is a json object with a "header_row" key for column
4771        # names, and then a nested list of data.
4772        resp = requests.get(url, headers=headers, **self._request_params)
4773        self._check_ok_or_err_with_msg(
4774            resp, f"Failed to get portfolio signals for {model_id=}, {portfolio_id=}"
4775        )
4776
4777        data = resp.json()
4778
4779        df = pd.DataFrame(data=data["data"], columns=data["header_row"])
4780        df["Date"] = pd.to_datetime(df["Date"])
4781        df = df.set_index("Date")
4782        return df.astype(float)
4783
4784    def get_rolling_signal_strength(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
4785        url = self.base_uri + f"/api/analysis/signal_strength_rolling/{model_id}/{portfolio_id}"
4786        headers = {"Authorization": "ApiKey " + self.api_key}
4787
4788        logger.info(f"Retrieving rolling portfolio signals for {model_id=}, {portfolio_id=}")
4789
4790        # Response format is a json object with a "header_row" key for column
4791        # names, and then a nested list of data.
4792        resp = requests.get(url, headers=headers, **self._request_params)
4793        self._check_ok_or_err_with_msg(
4794            resp, f"Failed to get rolling portfolio signals for {model_id=}, {portfolio_id=}"
4795        )
4796
4797        data = resp.json()
4798
4799        df = pd.DataFrame(data=data["data"], columns=data["header_row"])
4800        df["Date"] = pd.to_datetime(df["Date"])
4801        df = df.set_index("Date")
4802        return df.astype(float)
4803
4804    def get_portfolio_quantiles(
4805        self,
4806        model_id: str,
4807        portfolio_id: str,
4808        id_type: Literal["TICKER", "ISIN"] = "TICKER",
4809    ):
4810        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4811        date = datetime.date.today().strftime("%Y-%m-%d")
4812
4813        payload = {
4814            "model_id": model_id,
4815            "portfolio_id": portfolio_id,
4816            "fields": ["quantile"],
4817            "min_date": date,
4818            "max_date": date,
4819            "return_format": "json",
4820        }
4821        # TODO: Later change this URI to not use the watchlist prefix. It is misnamed.
4822        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_PA_ROUTE}/get-data/"
4823
4824        res: requests.Response = requests.post(
4825            url, json=payload, headers=headers, **self._request_params
4826        )
4827        self._check_ok_or_err_with_msg(res, "Unable to get quantile data")
4828
4829        resp: Dict = res.json()
4830        quantile_index = resp["field_map"]["Quantile"]
4831        quantile_data = [[c[quantile_index] for c in r] for r in resp["data"]]
4832        date_cols = pd.to_datetime(resp["columns"])
4833
4834        # Need to map gbi id's to isins or tickers
4835        gbi_ids = [int(i) for i in resp["rows"]]
4836        security_info = self._get_security_info(gbi_ids)
4837
4838        # We now have security data, go through and create a map from internal
4839        # gbi id to client facing identifier
4840        id_key = "isin" if id_type == "ISIN" else "symbol"
4841        gbi_identifier_map = {
4842            sec["gbiId"]: sec[id_key] for sec in security_info["data"]["securities"]
4843        }
4844
4845        df = pd.DataFrame(quantile_data, index=gbi_ids, columns=date_cols).transpose()
4846        df = df.rename(columns=gbi_identifier_map)
4847        return df
4848
4849    def get_similar_stocks(
4850        self,
4851        model_id: str,
4852        portfolio_id: str,
4853        symbol_list: List[str],
4854        date: BoostedDate,
4855        identifier_type: Literal["TICKER", "ISIN"],
4856        preferred_country: Optional[str] = None,
4857        preferred_currency: Optional[str] = None,
4858    ) -> pd.DataFrame:
4859        date_str = convert_date(date).strftime("%Y-%m-%d")
4860
4861        sec_data = self.getGbiIdFromIdentCountryCurrencyDate(
4862            ident_country_currency_dates=[
4863                DateIdentCountryCurrency(
4864                    date=datetime.date.today().isoformat(),
4865                    identifier=s,
4866                    id_type=(
4867                        ColumnSubRole.SYMBOL if identifier_type == "TICKER" else ColumnSubRole.ISIN
4868                    ),
4869                    country=preferred_country,
4870                    currency=preferred_currency,
4871                )
4872                for s in symbol_list
4873            ]
4874        )
4875
4876        gbi_id_ident_map: Dict[int, str] = {}
4877        for sec in sec_data:
4878            ident = sec.ticker if identifier_type == "TICKER" else sec.isin_info.identifier
4879            gbi_id_ident_map[sec.gbi_id] = ident
4880        gbi_ids = list(gbi_id_ident_map.keys())
4881
4882        qry = """
4883          query GetSimilarStocks(
4884            $modelId: ID!
4885            $portfolioId: ID!
4886            $gbiIds: [Int]!
4887            $startDate: String!
4888            $endDate: String!
4889            $includeCorrelation: Boolean
4890          ) {
4891            similarStocks(
4892              modelId: $modelId,
4893              portfolioId: $portfolioId,
4894              gbiIds: $gbiIds,
4895              startDate: $startDate,
4896              endDate: $endDate,
4897              includeCorrelation: $includeCorrelation
4898            ) {
4899              gbiId
4900              overallSimilarityScore
4901              priceSimilarityScore
4902              factorSimilarityScore
4903              correlation
4904            }
4905          }
4906        """
4907        variables = {
4908            "startDate": date_str,
4909            "endDate": date_str,
4910            "modelId": model_id,
4911            "portfolioId": portfolio_id,
4912            "gbiIds": gbi_ids,
4913            "includeCorrelation": True,
4914        }
4915
4916        resp = self._get_graphql(
4917            qry, variables=variables, error_msg_prefix="Failed to get similar stocks result: "
4918        )
4919        df = pd.DataFrame(resp["data"]["similarStocks"])
4920
4921        # Now that we have the rest of the securities in the portfolio, we need
4922        # to map them back to the correct identifiers
4923        all_gbi_ids = df["gbiId"].tolist()
4924        sec_info = self._get_security_info(all_gbi_ids)
4925        for s in sec_info["data"]["securities"]:
4926            ident = s["symbol"] if identifier_type == "TICKER" else s["isin"]
4927            gbi_id_ident_map[s["gbiId"]] = ident
4928        df["identifier"] = df["gbiId"].map(gbi_id_ident_map)
4929        df = df.set_index("identifier")
4930        return df.drop("gbiId", axis=1)
4931
4932    def get_portfolio_trades(
4933        self,
4934        model_id: str,
4935        portfolio_id: str,
4936        start_date: Optional[BoostedDate] = None,
4937        end_date: Optional[BoostedDate] = None,
4938    ) -> pd.DataFrame:
4939        if not end_date:
4940            end_date = datetime.date.today()
4941        end_date = convert_date(end_date)
4942
4943        if not start_date:
4944            # default to a year of data
4945            start_date = end_date - datetime.timedelta(days=365)
4946        start_date = convert_date(start_date)
4947
4948        start_date_str = start_date.strftime("%Y-%m-%d")
4949        end_date_str = end_date.strftime("%Y-%m-%d")
4950
4951        if end_date - start_date > datetime.timedelta(days=365 * 7):
4952            raise BoostedAPIException(
4953                f"Date range ({start_date_str}, {end_date_str}) too large, max 7 years"
4954            )
4955
4956        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_PA_ROUTE}/get-data/"
4957        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4958        payload = {
4959            "model_id": model_id,
4960            "portfolio_id": portfolio_id,
4961            "fields": ["price", "shares_traded", "shares_owned"],
4962            "min_date": start_date_str,
4963            "max_date": end_date_str,
4964            "return_format": "json",
4965        }
4966
4967        res: requests.Response = requests.post(
4968            url, json=payload, headers=headers, **self._request_params
4969        )
4970        self._check_ok_or_err_with_msg(res, "Unable to get portfolio trades data")
4971
4972        data = res.json()
4973        gbi_ids = [int(ident) for ident in data["rows"]]
4974
4975        # need both isin and ticker to distinguish between possible duplicates
4976        isin_map = {
4977            str(s["gbiId"]): s["isin"]
4978            for s in self._get_security_info(gbi_ids)["data"]["securities"]
4979        }
4980        ticker_map = {
4981            str(s["gbiId"]): s["symbol"]
4982            for s in self._get_security_info(gbi_ids)["data"]["securities"]
4983        }
4984
4985        # construct individual dataframes for each security, then join them together
4986        dfs: List[pd.DataFrame] = []
4987        full_data = data["data"]
4988        for i, gbi_id in enumerate(data["rows"]):
4989            df = pd.DataFrame(
4990                index=pd.to_datetime(data["columns"]), columns=data["fields"], data=full_data[i]
4991            )
4992            # drop rows where no shares are owned or traded
4993            df.drop(
4994                df.loc[((df["shares_owned"] == 0.0) & (df["shares_traded"] == 0.0))].index,
4995                inplace=True,
4996            )
4997            df["isin"] = isin_map[gbi_id]
4998            df["ticker"] = ticker_map[gbi_id]
4999            dfs.append(df)
5000
5001        full_df = pd.concat(dfs)
5002        full_df["date"] = full_df.index
5003        full_df.sort_index(inplace=True)
5004        full_df.reset_index(drop=True, inplace=True)
5005
5006        # reorder the columns to match the spreadsheet
5007        columns = ["isin", "ticker", "date", *data["fields"]]
5008        return full_df[columns]
5009
5010    def get_ideas(
5011        self,
5012        model_id: str,
5013        portfolio_id: str,
5014        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5015        delta_horizon: str = "1M",
5016    ):
5017        if investment_horizon not in ("1M", "3M", "1Y"):
5018            raise BoostedAPIException(f"Invalid investment horizon: {investment_horizon}")
5019
5020        if delta_horizon not in ("1W", "1M", "3M", "6M", "9M", "1Y"):
5021            raise BoostedAPIException(f"Invalid delta horizon: {delta_horizon}")
5022
5023        # First compute dates based on the delta horizon. "0D" is the latest rebalance.
5024        try:
5025            dates = self._get_portfolio_rebalance_from_periods(
5026                portfolio_id=portfolio_id, rel_periods=["0D", delta_horizon]
5027            )
5028        except Exception:
5029            raise BoostedAPIException(
5030                f"Portfolio {portfolio_id} does not exist or you do not have permission to view it."
5031            )
5032        end_date = dates[0].strftime("%Y-%m-%d")
5033        start_date = dates[1].strftime("%Y-%m-%d")
5034
5035        resp = self._get_graphql(
5036            graphql_queries.GET_IDEAS_QUERY,
5037            variables={
5038                "modelId": model_id,
5039                "portfolioId": portfolio_id,
5040                "horizon": investment_horizon,
5041                "deltaHorizon": delta_horizon,
5042                "startDate": start_date,
5043                "endDate": end_date,
5044                # Note: market data date is needed to fetch market cap.
5045                # we don't fetch that data from this endpoint so we stub
5046                # out the mandatory parameter with the end date requested
5047                "marketDataDate": end_date,
5048            },
5049            error_msg_prefix="Failed to get ideas: ",
5050        )
5051        # rows is a list of dicts like:
5052        # {
5053        #   "category": "Strong Sell",
5054        #   "dividendYield": 0.0,
5055        #   "reason": "Boosted Insights has given this stock...",
5056        #   "rating": 0.458167,
5057        #   "ratingDelta": 0.438087,
5058        #   "risk": {
5059        #     "text": "high"
5060        #   },
5061        #   "security": {
5062        #     "symbol": "BA"
5063        #   }
5064        # }
5065        try:
5066            rows = resp["data"]["recommendations"]["recommendations"]
5067            data = [
5068                {
5069                    "symbol": r["security"]["symbol"],
5070                    "recommendation": r["category"],
5071                    "rating": r["rating"],
5072                    "rating_delta": r["ratingDelta"],
5073                    "dividend_yield": r["dividendYield"],
5074                    "predicted_excess_return_1m": r["ER"]["oneMonth"],
5075                    "predicted_excess_return_3m": r["ER"]["threeMonth"],
5076                    "predicted_excess_return_1y": r["ER"]["oneYear"],
5077                    "risk": r["risk"]["text"],
5078                    "reward": r["reward"]["text"],
5079                    "reason": r["reason"],
5080                }
5081                for r in rows
5082            ]
5083            df = pd.DataFrame(data)
5084            df.set_index("symbol", inplace=True)
5085        except Exception:
5086            # Don't show old exception info to client
5087            raise BoostedAPIException(
5088                "No recommendations found, try selecting another horizon."
5089            ) from None
5090
5091        return df
5092
5093    def get_stock_recommendations(
5094        self,
5095        model_id: str,
5096        portfolio_id: str,
5097        symbols: Optional[List[str]] = None,
5098        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5099    ) -> pd.DataFrame:
5100        model_stocks = self._get_model_stocks(model_id)
5101
5102        symbols_to_gbiids = {s.ticker: s.gbi_id for s in model_stocks}
5103        gbi_ids_to_symbols = {s.gbi_id: s.ticker for s in model_stocks}
5104
5105        variables: Dict[str, Any] = {
5106            "strategyId": portfolio_id,
5107        }
5108        if symbols:
5109            variables["gbiIds"] = [
5110                symbols_to_gbiids.get(symbol) for symbol in symbols if symbols_to_gbiids.get(symbol)
5111            ]
5112        try:
5113            recs = self._get_graphql(
5114                graphql_queries.MULTI_STOCK_RECOMMENDATION_QUERY,
5115                variables=variables,
5116                log_error=False,
5117            )["data"]["currentRecommendationsFull"]
5118        except BoostedAPIException:
5119            raise BoostedAPIException(f"Error getting recommendations for strategy {portfolio_id}")
5120
5121        data = []
5122        recommendation_key = f"recommendation{investment_horizon}"
5123        for rec in recs:
5124            # Keys to rec are:
5125            # ['ER', 'rewardCategories', 'riskCategories', 'reasons',
5126            #  'recommendation', 'rewardCategory', 'riskCategory']
5127            # need to flatten these out and add to a DF
5128            rec_data = rec[recommendation_key]
5129            reasons_dict = {r["type"]: r["text"] for r in rec_data["reasons"]}
5130            row = {
5131                "symbol": gbi_ids_to_symbols[rec["gbiId"]],
5132                "recommendation": rec_data["currentCategory"],
5133                "predicted_excess_return_1m": rec_data["ER"]["oneMonth"],
5134                "predicted_excess_return_3m": rec_data["ER"]["threeMonth"],
5135                "predicted_excess_return_1y": rec_data["ER"]["oneYear"],
5136                "risk": rec_data["risk"]["text"],
5137                "reward": rec_data["reward"]["text"],
5138                "reasons": reasons_dict,
5139            }
5140
5141            data.append(row)
5142        df = pd.DataFrame(data)
5143        df.set_index("symbol", inplace=True)
5144        return df
5145
5146    # NOTE: this could be easily expanded to the entire stockRecommendation
5147    # entity, but that only includes all horizons' excess returns and risk/reward
5148    # which we already get from getIdeas
5149    def get_stock_recommendation_reasons(
5150        self,
5151        model_id: str,
5152        portfolio_id: str,
5153        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5154        symbols: Optional[List[str]] = None,
5155    ) -> Dict[str, Optional[List[str]]]:
5156        if investment_horizon not in ("1M", "3M", "1Y"):
5157            raise BoostedAPIException(f"Invalid investment horizon: {investment_horizon}")
5158
5159        # "0D" is the latest rebalance - its all we have in terms of recs
5160        dates = self._get_portfolio_rebalance_from_periods(
5161            portfolio_id=portfolio_id, rel_periods=["0D"]
5162        )
5163        date = dates[0].strftime("%Y-%m-%d")
5164
5165        model_stocks = self._get_model_stocks(model_id)
5166
5167        symbols_to_gbiids = {s.ticker: s.gbi_id for s in model_stocks}
5168        if symbols is None:  # potentially iterate through all holdings
5169            symbols = symbols_to_gbiids.keys()  # type: ignore
5170
5171        reasons: Dict[str, Optional[List[str]]] = {}
5172        for sym in symbols:
5173            # it's possible that a passed symbol was not actually a portfolio holding
5174            try:
5175                gbi_id = symbols_to_gbiids[sym]
5176            except KeyError:
5177                logger.warning(f"Symbol={sym} not found for in universe on {date=}")
5178                reasons[sym] = None
5179                continue
5180
5181            try:
5182                recs = self._get_graphql(
5183                    graphql_queries.STOCK_RECOMMENDATION_QUERY,
5184                    variables={
5185                        "modelId": model_id,
5186                        "portfolioId": portfolio_id,
5187                        "horizon": investment_horizon,
5188                        "gbiId": gbi_id,
5189                        "date": date,
5190                    },
5191                    log_error=False,
5192                )
5193                reasons[sym] = [
5194                    reason["text"] for reason in recs["data"]["stockRecommendation"]["reasons"]
5195                ]
5196            except BoostedAPIException:
5197                logger.warning(f"No recommendation for: {sym}, skipping...")
5198        return reasons
5199
5200    def get_stock_mapping_alternatives(
5201        self,
5202        isin: Optional[str] = None,
5203        symbol: Optional[str] = None,
5204        country: Optional[str] = None,
5205        currency: Optional[str] = None,
5206        asof_date: Optional[BoostedDate] = None,
5207    ) -> Dict:
5208        """
5209        Return the stock mapping for the given criteria,
5210        also suggestions for alternate matches,
5211        if the mapping is not what is wanted
5212
5213
5214            Parameters [One of either ISIN or SYMBOL must be provided]
5215            ----------
5216            isin: Optional[str]
5217                search by ISIN
5218            symbol: Optional[str]
5219                search by Ticker Symbol
5220            country: Optional[str]
5221                Additionally filter by country code - ex: None, "ANY", "p_USA", "CAN"
5222            currency: Optional[str]
5223                Additionally filter by currency code - ex: None, "ANY", "p_USD", "CAD"
5224            asof_date: Optional[date]
5225                as of which date to perform the search, default is today()
5226
5227            Note: country/currency filter starting with "p_" indicates
5228                  only a soft preference but allows other matches
5229
5230        Returns
5231        -------
5232        Dictionary Representing this 'MapSecurityResponse' structure:
5233
5234        class MapSecurityResponse():
5235            stock_mapping: Optional[SecurityInfo]
5236               The mapping we would perform given your inputs
5237
5238            alternatives: Optional[List[SecurityInfo]]
5239               Alternative suggestions based on your input
5240
5241            error: Optional[str]
5242
5243        class SecurityInfo():
5244            gbi_id: int
5245            isin: str
5246            symbol: Optional[str]
5247            country: str
5248            currency: str
5249            name: str
5250            from_date: date
5251            to_date: date
5252            is_primary_trading_item: bool
5253
5254        """
5255
5256        url = f"{self.base_uri}/api/stock-mapping/alternatives"
5257        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
5258        req_json: Dict = {
5259            "isin": isin,
5260            "symbol": symbol,
5261            "countryPreference": country,
5262            "currencyPreference": currency,
5263        }
5264
5265        if asof_date:
5266            req_json["date"] = convert_date(asof_date).isoformat()
5267
5268        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
5269
5270        if not res.ok:
5271            error_msg = self._try_extract_error_code(res)
5272            logger.error(error_msg)
5273            raise BoostedAPIException(f"Failed to get user watchlists: {error_msg}")
5274
5275        data = res.json()
5276        return data
5277
5278    def get_pros_cons_for_stocks(
5279        self,
5280        model_id: Optional[str] = None,
5281        symbols: Optional[List[str]] = None,
5282        preferred_country: Optional[str] = None,
5283        preferred_currency: Optional[str] = None,
5284    ) -> Dict[str, Dict[str, List]]:
5285        if symbols:
5286            ident_objs = [
5287                DateIdentCountryCurrency(
5288                    date=datetime.date.today().strftime("%Y-%m-%d"),
5289                    identifier=symbol,
5290                    country=preferred_country,
5291                    currency=preferred_currency,
5292                    id_type=ColumnSubRole.SYMBOL,
5293                )
5294                for symbol in symbols
5295            ]
5296            sec_objs = self.getGbiIdFromIdentCountryCurrencyDate(
5297                ident_country_currency_dates=ident_objs
5298            )
5299            gbi_id_ticker_map = {sec.gbi_id: sec.ticker for sec in sec_objs if sec}
5300        elif model_id:
5301            gbi_id_ticker_map = {
5302                sec.gbi_id: sec.ticker for sec in self._get_model_stocks(model_id=model_id)
5303            }
5304        gbi_id_pros_cons_map = {}
5305        gbi_ids = list(gbi_id_ticker_map.keys())
5306        data = self._get_graphql(
5307            query=graphql_queries.GET_PROS_CONS_QUERY,
5308            variables={"gbiIds": gbi_ids},
5309            error_msg_prefix="Failed to get pros/cons:",
5310        )
5311        gbi_id_pros_cons_map = {
5312            row["gbiId"]: {"pros": row["pros"], "cons": row["cons"]}
5313            for row in data["data"]["bulkSecurityProsCons"]
5314        }
5315
5316        return {
5317            gbi_id_ticker_map[gbi_id]: pros_cons
5318            for gbi_id, pros_cons in gbi_id_pros_cons_map.items()
5319        }
5320
5321    def generate_theme(self, theme_name: str, stock_universes: List[ThemeUniverse]) -> str:
5322        # First get universe name and id mappings
5323        try:
5324            resp = self._get_graphql(
5325                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5326            )
5327            data = resp["data"]["getMarketTrendsUniverses"]
5328        except Exception:
5329            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5330
5331        universe_name_to_id = {u["name"]: u["id"] for u in data}
5332        universe_ids = [universe_name_to_id[u.value] for u in stock_universes]
5333        try:
5334            resp = self._get_graphql(
5335                query=graphql_queries.GENERATE_THEME_QUERY,
5336                variables={"input": {"themeName": theme_name, "universeIds": universe_ids}},
5337            )
5338            data = resp["data"]["generateTheme"]
5339        except Exception:
5340            raise BoostedAPIException(f"Failed to generate theme: {theme_name}")
5341
5342        if not data["success"]:
5343            raise BoostedAPIException(f"Failed to generate theme: {theme_name}")
5344
5345        logger.info(
5346            f"Successfully generated theme: {theme_name}. The theme ID is {data['themeId']}"
5347        )
5348        return data["themeId"]
5349
5350    def _get_stock_universe_id(self, universe: ThemeUniverse) -> str:
5351        try:
5352            resp = self._get_graphql(
5353                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5354            )
5355            data = resp["data"]["getMarketTrendsUniverses"]
5356        except Exception:
5357            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5358
5359        for u in data:
5360            if u["name"] == universe.value:
5361                universe_id = u["id"]
5362                return universe_id
5363
5364        raise BoostedAPIException(f"Failed to find universe: {universe.value}")
5365
5366    def get_themes_for_stock_universe(
5367        self,
5368        stock_universe: ThemeUniverse,
5369        start_date: Optional[BoostedDate] = None,
5370        end_date: Optional[BoostedDate] = None,
5371        language: Optional[Union[str, Language]] = None,
5372    ) -> List[Dict]:
5373        """Get all themes data for a particular stock universe
5374        (start_date, end_date) are used to calculate the theme importance for ranking purpose. If
5375        None, default to past 30 days
5376        Returns: A list of below dictionaries
5377        {
5378            themeId: str
5379            themeName: str
5380            themeImportance: float
5381            volatility: float
5382            positiveStockPerformance: float
5383            negativeStockPerformance: float
5384        }
5385        """
5386        translate = functools.partial(self.translate_text, language)
5387        # First get universe name and id mappings
5388        universe_id = self._get_stock_universe_id(stock_universe)
5389
5390        start_date_iso, end_date_iso = get_valid_iso_dates(start_date, end_date)
5391
5392        try:
5393            resp = self._get_graphql(
5394                query=graphql_queries.GET_THEMES,
5395                variables={
5396                    "type": "UNIVERSE",
5397                    "id": universe_id,
5398                    "startDate": start_date_iso,
5399                    "endDate": end_date_iso,
5400                    "deltaHorizon": "",  # not needed here
5401                },
5402            )
5403            data = resp["data"]["themes"]
5404        except Exception:
5405            raise BoostedAPIException(
5406                f"Failed to get themes for stock universe: {stock_universe.name}"
5407            )
5408
5409        for theme_data in data:
5410            theme_data["themeName"] = translate(theme_data["themeName"])
5411        return data
5412
5413    def get_themes_for_stock(
5414        self,
5415        isin: str,
5416        currency: Optional[str] = None,
5417        country: Optional[str] = None,
5418        start_date: Optional[BoostedDate] = None,
5419        end_date: Optional[BoostedDate] = None,
5420        language: Optional[Union[str, Language]] = None,
5421    ) -> List[Dict]:
5422        """Get all themes data for a particular stock
5423        (ISIN, currency, country) compose a unique identifier for a stock for us to map to GBI ID
5424        (start_date, end_date) are used to calculate the theme importance for ranking purpose. If
5425        None, default to past 30 days
5426
5427        Returns
5428        A list of below dictionaries
5429        {
5430            themeId: str
5431            themeName: str
5432            importanceScore: float
5433            similarityScore: float
5434            positiveThemeRelation: bool
5435            reason: String
5436        }
5437        """
5438        translate = functools.partial(self.translate_text, language)
5439        security_info = self.get_stock_mapping_alternatives(
5440            isin, country=country, currency=currency
5441        )
5442        gbi_id = security_info["stock_mapping"]["gbi_id"]
5443
5444        if (start_date and not end_date) or (end_date and not start_date):
5445            raise BoostedAPIException("Must provide both start and end dates or neither")
5446        elif not end_date and not start_date:
5447            end_date = datetime.date.today()
5448            start_date = end_date - datetime.timedelta(days=30)
5449            end_date = end_date.isoformat()
5450            start_date = start_date.isoformat()
5451        else:
5452            if isinstance(start_date, datetime.date):
5453                start_date = start_date.isoformat()
5454            if isinstance(end_date, datetime.date):
5455                end_date = end_date.isoformat()
5456
5457        try:
5458            resp = self._get_graphql(
5459                query=graphql_queries.GET_THEMES_FOR_STOCK_WITH_REASONS,
5460                variables={"gbiId": gbi_id, "startDate": start_date, "endDate": end_date},
5461            )
5462            data = resp["data"]["themesForStockWithReasons"]
5463        except Exception:
5464            raise BoostedAPIException(f"Failed to get themes for stock: {isin}")
5465
5466        for item in data:
5467            item["themeName"] = translate(item["themeName"])
5468            item["reason"] = translate(item["reason"])
5469        return data
5470
5471    def get_stock_news(
5472        self,
5473        time_horizon: NewsHorizon,
5474        isin: str,
5475        currency: Optional[str] = None,
5476        country: Optional[str] = None,
5477        language: Optional[Union[str, Language]] = None,
5478    ) -> Dict:
5479        """
5480        The API to get a stock's news summary for a given time horizon, the topics summarized by
5481        these news and the corresponding news to these topics
5482        Returns
5483        -------
5484        A nested dictionary in the following format:
5485        {
5486            summary: str
5487            topics: [
5488                {
5489                    topicId: str
5490                    topicLabel: str
5491                    topicDescription: str
5492                    topicPolarity: str
5493                    newsItems: [
5494                        {
5495                            newsId: str
5496                            headline: str
5497                            url: str
5498                            summary: str
5499                            source: str
5500                            publishedAt: str
5501                        }
5502                    ]
5503                }
5504            ]
5505            other_news_count: int
5506        }
5507        """
5508        translate = functools.partial(self.translate_text, language)
5509        security_info = self.get_stock_mapping_alternatives(
5510            isin, country=country, currency=currency
5511        )
5512        gbi_id = security_info["stock_mapping"]["gbi_id"]
5513
5514        try:
5515            resp = self._get_graphql(
5516                query=graphql_queries.GET_STOCK_NEWS_QUERY,
5517                variables={"gbiId": gbi_id, "deltaHorizon": time_horizon.value},
5518            )
5519            data = resp["data"]
5520        except Exception:
5521            raise BoostedAPIException(f"Failed to get themes for stock: {isin}")
5522
5523        outputs: Dict[str, Any] = {}
5524        outputs["summary"] = translate(data["getStockNewsSummary"]["summary"])
5525        # Return the top 10 topics
5526        outputs["topics"] = data["getStockNewsTopics"][:10]
5527
5528        for topic in outputs["topics"]:
5529            topic["topicLabel"] = translate(topic["topicLabel"])
5530            topic["topicDescription"] = translate(topic["topicDescription"])
5531
5532        other_news_count = 0
5533        for source_count in data["getStockNewsSummary"]["sourceCounts"]:
5534            other_news_count += source_count["count"]
5535
5536        for topic in outputs["topics"]:
5537            other_news_count -= len(topic["newsItems"])
5538
5539        outputs["other_news_count"] = other_news_count
5540
5541        return outputs
5542
5543    def get_theme_details(
5544        self,
5545        theme_id: str,
5546        universe: ThemeUniverse,
5547        language: Optional[Union[str, Language]] = None,
5548    ) -> Dict[str, Any]:
5549        translate = functools.partial(self.translate_text, language)
5550        universe_id = self._get_stock_universe_id(universe)
5551        date = datetime.date.today()
5552        prev_date = date - datetime.timedelta(days=30)
5553        result = self._get_graphql(
5554            query=graphql_queries.GET_THEME_DEEPDIVE_DETAILS,
5555            variables={
5556                "deltaHorizon": "1W",
5557                "startDate": prev_date.strftime("%Y-%m-%d"),
5558                "endDate": date.strftime("%Y-%m-%d"),
5559                "id": universe_id,
5560                "themeId": theme_id,
5561                "type": "UNIVERSE",
5562            },
5563            error_msg_prefix="Failed to get theme details",
5564        )["data"]["marketThemes"]
5565
5566        gbi_id_stock_data_map: Dict[int, Dict] = {}
5567
5568        stocks = []
5569        for stock_info in result["stockInfos"]:
5570            gbi_id_stock_data_map[stock_info["gbiId"]] = stock_info["security"]
5571            stocks.append(
5572                {
5573                    "isin": stock_info["security"]["isin"],
5574                    "name": stock_info["security"]["name"],
5575                    "reason": translate(stock_info["polarityReasonScores"]["reason"]),
5576                    "positive_theme_relation": stock_info["polarityReasonScores"][
5577                        "positiveThemeRelation"
5578                    ],
5579                    "theme_stock_impact_score": stock_info["polarityReasonScores"][
5580                        "similarityScore"
5581                    ],
5582                }
5583            )
5584
5585        impacts = []
5586        for impact in result["impactInfos"]:
5587            articles = [
5588                {
5589                    "title": newsitem["headline"],
5590                    "url": newsitem["url"],
5591                    "source": newsitem["source"],
5592                    "publish_date": newsitem["publishedAt"],
5593                }
5594                for newsitem in impact["newsItems"]
5595            ]
5596
5597            impact_stocks = []
5598            for impact_stock_data in impact["stocks"]:
5599                stock_metadata = gbi_id_stock_data_map[impact_stock_data["gbiId"]]
5600                impact_stocks.append(
5601                    {
5602                        "isin": stock_metadata["isin"],
5603                        "name": stock_metadata["name"],
5604                        "positive_impact_relation": impact_stock_data["positiveThemeRelation"],
5605                    }
5606                )
5607
5608            impact_dict = {
5609                "impact_name": translate(impact["impactName"]),
5610                "impact_description": translate(impact["impactDescription"]),
5611                "impact_score": impact["impactScore"],
5612                "articles": articles,
5613                "impact_stocks": impact_stocks,
5614            }
5615            impacts.append(impact_dict)
5616
5617        developments = []
5618        for dev in result["themeDevelopments"]:
5619            developments.append(
5620                {
5621                    "name": dev["label"],
5622                    "article_count": dev["articleCount"],
5623                    "date": parser.parse(dev["date"]).date(),
5624                    "description": dev["description"],
5625                    "is_major_development": dev["isMajorDevelopment"],
5626                    "sentiment": dev["sentiment"],
5627                    "news": [
5628                        {
5629                            "headline": entry["headline"],
5630                            "published_at": parser.parse(entry["publishedAt"]),
5631                            "source": entry["source"],
5632                            "url": entry["url"],
5633                        }
5634                        for entry in dev["news"]
5635                    ],
5636                }
5637            )
5638
5639        developments = sorted(developments, key=lambda d: d["date"], reverse=True)
5640
5641        output = {
5642            "theme_name": translate(result["themeName"]),
5643            "theme_summary": translate(result["themeDescription"]),
5644            "impacts": impacts,
5645            "stocks": stocks,
5646            "developments": developments,
5647        }
5648        return output
5649
5650    def get_all_theme_metadata(
5651        self, language: Optional[Union[str, Language]] = None
5652    ) -> List[Dict[str, Any]]:
5653        translate = functools.partial(self.translate_text, language)
5654        result = self._get_graphql(
5655            graphql_queries.GET_ALL_THEMES,
5656            variables={"universeIds": None},
5657            error_msg_prefix="Failed to fetch all themes metadata",
5658        )
5659
5660        try:
5661            resp = self._get_graphql(
5662                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5663            )
5664            data = resp["data"]["getMarketTrendsUniverses"]
5665        except Exception:
5666            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5667        universe_id_to_name = {u["id"]: u["name"] for u in data}
5668
5669        outputs = []
5670        for theme in result["data"]["getAllThemesForUser"]:
5671            # map universe ID to universe ticker
5672            universe_tickers = []
5673            for universe_id in theme["universeIds"]:
5674                if universe_id in universe_id_to_name:  # don't support unlisted universes - skip
5675                    universe_name = universe_id_to_name[universe_id]
5676                    ticker = ThemeUniverse.get_ticker_from_name(universe_name)
5677                    if ticker:
5678                        universe_tickers.append(ticker)
5679
5680            outputs.append(
5681                {
5682                    "theme_id": theme["themeId"],
5683                    "theme_name": translate(theme["themeName"]),
5684                    "universes": universe_tickers,
5685                }
5686            )
5687
5688        return outputs
5689
5690    def get_earnings_impacting_security(
5691        self,
5692        isin: str,
5693        currency: Optional[str] = None,
5694        country: Optional[str] = None,
5695        language: Optional[Union[str, Language]] = None,
5696    ) -> List[Dict[str, Any]]:
5697        translate = functools.partial(self.translate_text, language)
5698        date = datetime.date.today().strftime("%Y-%m-%d")
5699        company_data = self.getGbiIdFromIdentCountryCurrencyDate(
5700            ident_country_currency_dates=[
5701                DateIdentCountryCurrency(
5702                    date=date, identifier=isin, country=country, currency=currency
5703                )
5704            ]
5705        )
5706        try:
5707            gbi_id = company_data[0].gbi_id
5708        except Exception:
5709            raise BoostedAPIException(f"ISIN {isin} not found")
5710
5711        result = self._get_graphql(
5712            graphql_queries.EARNINGS_IMPACTS_CALENDAR_FOR_STOCK,
5713            variables={"date": date, "days": 180, "gbiId": gbi_id},
5714            error_msg_prefix="Failed to fetch earnings impacts data for stock",
5715        )
5716        earnings_events = result["data"]["earningsCalendarForStock"]
5717        output_events = []
5718        for event in earnings_events:
5719            if not event["impactedCompanies"]:
5720                continue
5721            fixed_event = {
5722                "event_date": event["eventDate"],
5723                "company_name": event["security"]["name"],
5724                "symbol": event["security"]["symbol"],
5725                "isin": event["security"]["isin"],
5726                "impact_reason": translate(event["impactedCompanies"][0]["reason"]),
5727            }
5728            output_events.append(fixed_event)
5729
5730        return output_events
5731
5732    def get_earnings_insights_for_stocks(
5733        self, isin: str, currency: Optional[str] = None, country: Optional[str] = None
5734    ) -> Dict[str, Any]:
5735        date = datetime.date.today().strftime("%Y-%m-%d")
5736        company_data = self.getGbiIdFromIdentCountryCurrencyDate(
5737            ident_country_currency_dates=[
5738                DateIdentCountryCurrency(
5739                    date=date, identifier=isin, country=country, currency=currency
5740                )
5741            ]
5742        )
5743        gbi_id_isin_map = {
5744            company.gbi_id: company.isin_info.identifier
5745            for company in company_data
5746            if company is not None
5747        }
5748        try:
5749            resp = self._get_graphql(
5750                query=graphql_queries.GET_EARNINGS_INSIGHTS_SUMMARIES,
5751                variables={"gbiIds": list(gbi_id_isin_map.keys())},
5752            )
5753            # list of objects with gbi id and data
5754            summaries = resp["data"]["getEarningsSummaries"]
5755            resp = self._get_graphql(
5756                query=graphql_queries.GET_EARNINGS_COMPARISONS,
5757                variables={"gbiIds": list(gbi_id_isin_map.keys())},
5758            )
5759            # list of objects with gbi id and data
5760            comparison = resp["data"]["getLatestEarningsChanges"]
5761        except Exception:
5762            raise BoostedAPIException(f"Failed to earnings insights data")
5763
5764        if not summaries:
5765            raise BoostedAPIException(
5766                (
5767                    f"Failed to find earnings insights data for {isin}"
5768                    ", please try with another security"
5769                )
5770            )
5771
5772        output: Dict[str, Any] = {}
5773        reports = sorted(summaries[0]["reports"], key=lambda r: r["date"], reverse=True)
5774        current_report = reports[0]
5775
5776        def is_aligned_formatter(acc: Tuple[List, List], cur: Dict[str, Any]):
5777            if cur["isAligned"]:
5778                acc[0].append({k: cur[k] for k in cur if k != "isAligned"})
5779            else:
5780                acc[1].append({k: cur[k] for k in cur if k != "isAligned"})
5781            return acc
5782
5783        current_report_common_remarks: Union[List[Dict[str, Any]], List]
5784        current_report_dropped_remarks: Union[List[Dict[str, Any]], List]
5785        current_report_common_remarks, current_report_dropped_remarks = functools.reduce(
5786            is_aligned_formatter, current_report["details"], ([], [])
5787        )
5788        prev_report_common_remarks: Union[List[Dict[str, Any]], List]
5789        prev_report_new_remarks: Union[List[Dict[str, Any]], List]
5790        prev_report_common_remarks, prev_report_new_remarks = functools.reduce(
5791            is_aligned_formatter, current_report["details"], ([], [])
5792        )
5793
5794        output["earnings_report"] = {
5795            "release_date": datetime.datetime.strptime(current_report["date"], "%Y-%m-%d").date(),
5796            "quarter": current_report["quarter"],
5797            "year": current_report["year"],
5798            "details": [
5799                {
5800                    "header": detail_obj["header"],
5801                    "detail": detail_obj["detail"],
5802                    "sentiment": detail_obj["sentiment"],
5803                }
5804                for detail_obj in current_report["details"]
5805            ],
5806            "call_summary": current_report["highlights"],
5807            "common_remarks": current_report_common_remarks,
5808            "dropped_remarks": current_report_dropped_remarks,
5809            "qa_summary": current_report["qaHighlights"],
5810            "qa_details": current_report["qaDetails"],
5811        }
5812        prev_report = summaries[0]["reports"][1]
5813        output["prior_earnings_report"] = {
5814            "release_date": datetime.datetime.strptime(prev_report["date"], "%Y-%m-%d").date(),
5815            "quarter": prev_report["quarter"],
5816            "year": prev_report["year"],
5817            "details": [
5818                {
5819                    "header": detail_obj["header"],
5820                    "detail": detail_obj["detail"],
5821                    "sentiment": detail_obj["sentiment"],
5822                }
5823                for detail_obj in prev_report["details"]
5824            ],
5825            "call_summary": prev_report["highlights"],
5826            "common_remarks": prev_report_common_remarks,
5827            "new_remarks": prev_report_new_remarks,
5828            "qa_summary": prev_report["qaHighlights"],
5829            "qa_details": prev_report["qaDetails"],
5830        }
5831
5832        if not comparison:
5833            output["report_comparison"] = []
5834        else:
5835            output["report_comparison"] = comparison[0]["changes"]
5836
5837        return output
5838
5839    def get_portfolio_inference_status(self, portfolio_id: str, inference_date: str) -> dict:
5840        url = f"{self.base_uri}/api/inference/status/{portfolio_id}/{inference_date}"
5841        headers = {"Authorization": "ApiKey " + self.api_key}
5842        res = requests.get(url, headers=headers)
5843
5844        if not res.ok:
5845            error_msg = self._try_extract_error_code(res)
5846            logger.error(error_msg)
5847            raise BoostedAPIException(
5848                f"Failed to get portfolio inference status, portfolio_id={portfolio_id}, "
5849                f"inference_date={inference_date}: {error_msg}"
5850            )
5851
5852        data = res.json()
5853        return data
logger = <Logger boosted.api.client (WARNING)>
g_boosted_api_url = 'https://insights.boosted.ai'
g_boosted_api_url_dev = 'https://insights-dev.boosted.ai'
WATCHLIST_ROUTE_PREFIX = '/api/dal/watchlist'
ROUTE_PREFIX = '/api/dal/watchlist'
DAL_WATCHLIST_ROUTE = '/api/v0/watchlist'
DAL_SECURITIES_ROUTE = '/api/v0/securities'
DAL_PA_ROUTE = '/api/v0/portfolio-analysis'
PORTFOLIO_GROUP_ROUTE = '/api/v0/portfolio-group'
RISK_FACTOR = 'risk-factor'
RISK_FACTOR_V2 = 'risk-factor-v2'
RISK_FACTOR_COLUMNS = ['depth', 'identifier', 'stock_count', 'volatility', 'exposure', 'rating', 'rating_delta']
def convert_date(date: Union[datetime.date, str]) -> datetime.date:
83def convert_date(date: BoostedDate) -> datetime.date:
84    if isinstance(date, str):
85        try:
86            return parser.parse(date).date()
87        except Exception as e:
88            raise BoostedAPIException(f"Unable to parse date: {str(e)}")
89    return date
class BoostedClient:
  92class BoostedClient:
  93    def __init__(
  94        self, api_key, override_uri=None, debug=False, proxy=None, disable_verify_ssl=False
  95    ):
  96        """
  97        Parameters
  98        ----------
  99        api_key: str
 100            Your API key provided by the Boosted application.  See your profile
 101            to generate a new key.
 102        proxy: str
 103            Your organization may require the use of a proxy for access.
 104            The address of a HTTPS proxy in the format of <address>:<port>.
 105            Examples are "123.456.789:123" or "my.proxy.com:123".
 106            Do not prepend with "https://".
 107        disable_verify_ssl: bool
 108            Your networking setup may be behind a firewall which performs SSL
 109            inspection. Either set the REQUESTS_CA_BUNDLE environment variable
 110            to point to the location of a custom certificate bundle, or set this
 111            parameter to True to disable SSL verification as a workaround.
 112        """
 113        if override_uri is None:
 114            self.base_uri = g_boosted_api_url
 115        else:
 116            self.base_uri = override_uri
 117        self.api_key = api_key
 118        self.debug = debug
 119        self._request_params: Dict = {}
 120        if debug:
 121            logger.setLevel(logging.DEBUG)
 122        else:
 123            logger.setLevel(logging.INFO)
 124        if proxy is not None:
 125            self._request_params["proxies"] = {"https": proxy}
 126        if disable_verify_ssl:
 127            self._request_params["verify"] = False
 128
 129    def __print_json_info(self, json_data, isInference=False):
 130        if "warnings" in json_data.keys():
 131            for warning in json_data["warnings"]:
 132                logger.warning("  {0}".format(warning))
 133        if "errors" in json_data.keys():
 134            for error in json_data["errors"]:
 135                logger.error("  {0}".format(error))
 136                return Status.FAIL
 137
 138        if "result" in json_data.keys():
 139            results_data = json_data["result"]
 140            if isInference:
 141                if "inferenceResultsUrl" in results_data.keys():
 142                    res_url = parse.urlparse(results_data["inferenceResultsUrl"])
 143                    logger.debug(res_url)
 144                    logger.info("Inference started.")
 145            if "updateCount" in results_data.keys():
 146                logger.info("Updated {0} rows.".format(results_data["updateCount"]))
 147            if "createCount" in results_data.keys():
 148                logger.info("Created {0} rows.".format(results_data["createCount"]))
 149            return Status.SUCCESS
 150
 151    def __to_date_obj(self, dt):
 152        if isinstance(dt, datetime.datetime):
 153            dt = dt.date()
 154        elif isinstance(dt, datetime.date):
 155            return dt
 156        elif isinstance(dt, str):
 157            try:
 158                dt = parser.parse(dt).date()
 159            except ValueError:
 160                raise ValueError('dt: "' + dt + '" is not a valid date.')
 161        return dt
 162
 163    def __iso_format(self, dt):
 164        date = self.__to_date_obj(dt)
 165        if date is not None:
 166            date = date.isoformat()
 167        return date
 168
 169    def _check_status_code(self, response, isInference=False):
 170        has_json = False
 171        try:
 172            logger.debug(response.headers)
 173            if "Content-Type" in response.headers:
 174                if response.headers["Content-Type"].startswith("application/json"):
 175                    json_data = response.json()
 176                    has_json = True
 177            else:
 178                has_json = False
 179        except json.JSONDecodeError:
 180            logger.error("ERROR: response has no JSON payload.")
 181        if response.status_code == 200 or response.status_code == 202:
 182            if has_json:
 183                self.__print_json_info(json_data, isInference)
 184            else:
 185                pass
 186            return Status.SUCCESS
 187        if response.status_code == 404:
 188            if has_json:
 189                self.__print_json_info(json_data, isInference)
 190            raise BoostedAPIException(
 191                'Server "{0}" not reachable.  Code {1}.'.format(
 192                    self.base_uri, response.status_code
 193                ),
 194                data=response,
 195            )
 196        if response.status_code == 400:
 197            if has_json:
 198                self.__print_json_info(json_data, isInference)
 199            if isInference:
 200                return Status.FAIL
 201            else:
 202                raise BoostedAPIException("Error, bad request.  Check the dataset ID.", response)
 203        if response.status_code == 401:
 204            if has_json:
 205                self.__print_json_info(json_data, isInference)
 206            raise BoostedAPIException("Authorization error.", response)
 207        else:
 208            if has_json:
 209                self.__print_json_info(json_data, isInference)
 210            raise BoostedAPIException(
 211                "Error in API response.  Status code={0} {1}\n{2}".format(
 212                    response.status_code, response.reason, response.headers
 213                ),
 214                response,
 215            )
 216
 217    def _try_extract_error_code(self, result):
 218        logger.info(result.headers)
 219        if "Content-Type" in result.headers:
 220            if result.headers["Content-Type"].startswith("application/json"):
 221                if "errors" in result.json():
 222                    return result.json()["errors"]
 223            if result.headers["Content-Type"].startswith("text/plain"):
 224                return result.text
 225        return str(result.reason)
 226
 227    def _check_ok_or_err_with_msg(self, res, potential_error_msg: str):
 228        if not res.ok:
 229            error = self._try_extract_error_code(res)
 230            logger.error(error)
 231            raise BoostedAPIException(f"{potential_error_msg}: {error}")
 232
 233    def _get_portfolio_rebalance_from_periods(
 234        self, portfolio_id: str, rel_periods: List[str]
 235    ) -> List[datetime.date]:
 236        """
 237        Returns a list of rebalance dates for a portfolio given a list of
 238        relative periods of format '1D', '1W', '3M', etc.
 239        """
 240        resp = self._get_graphql(
 241            query=graphql_queries.GET_PORTFOLIO_RELATIVE_DATES_QUERY,
 242            variables={"portfolioId": portfolio_id, "relativePeriods": rel_periods},
 243        )
 244        dates = resp["data"]["portfolio"]["relativeDates"]
 245        return [datetime.datetime.strptime(d["date"], "%Y-%m-%d").date() for d in dates]
 246
 247    def translate_text(self, language: Optional[Union[Language, str]], text: str) -> str:
 248        if not language or language == Language.ENGLISH:
 249            # By default, do not translate English
 250            return text
 251
 252        params = {"text": text, "langCode": language}
 253        url = self.base_uri + "/api/translate/translate-text"
 254        headers = {"Authorization": "ApiKey " + self.api_key}
 255        logger.info("Translating text...")
 256        res = requests.post(url, json=params, headers=headers, **self._request_params)
 257        try:
 258            result = res.json()["translatedText"]
 259        except Exception:
 260            raise BoostedAPIException("Error translating text")
 261        return result
 262
 263    def query_dataset(self, dataset_id):
 264        url = self.base_uri + "/api/datasets/{0}".format(dataset_id)
 265        headers = {"Authorization": "ApiKey " + self.api_key}
 266        res = requests.get(url, headers=headers, **self._request_params)
 267        if res.ok:
 268            return res.json()
 269        else:
 270            error_msg = self._try_extract_error_code(res)
 271            logger.error(error_msg)
 272            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 273
 274    def query_namespace_dataset_id(self, namespace, data_type):
 275        url = self.base_uri + f"/api/custom-security-dataset/{namespace}/{data_type}"
 276        headers = {"Authorization": "ApiKey " + self.api_key}
 277        res = requests.get(url, headers=headers, **self._request_params)
 278        if res.ok:
 279            return res.json()["result"]["id"]
 280        else:
 281            if res.status_code != 404:
 282                error_msg = self._try_extract_error_code(res)
 283                logger.error(error_msg)
 284                raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 285            else:
 286                return None
 287
 288    def export_global_data(
 289        self,
 290        dataset_id,
 291        start=(datetime.date.today() - timedelta(days=365 * 25)),
 292        end=datetime.date.today(),
 293        timeout=600,
 294    ):
 295        query_info = self.query_dataset(dataset_id)
 296        if DataSetType[query_info["type"]] != DataSetType.GLOBAL:
 297            raise BoostedAPIException(
 298                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.GLOBAL}"
 299            )
 300        return self.export_data(dataset_id, start, end, timeout)
 301
 302    def export_independent_data(
 303        self,
 304        dataset_id,
 305        start=(datetime.date.today() - timedelta(days=365 * 25)),
 306        end=datetime.date.today(),
 307        timeout=600,
 308    ):
 309        query_info = self.query_dataset(dataset_id)
 310        if DataSetType[query_info["type"]] != DataSetType.STRATEGY:
 311            raise BoostedAPIException(
 312                f"Incorrect dataset type: {query_info['type']}"
 313                f" - Expected {DataSetType.STRATEGY}"
 314            )
 315        return self.export_data(dataset_id, start, end, timeout)
 316
 317    def export_dependent_data(
 318        self,
 319        dataset_id,
 320        start=None,
 321        end=None,
 322        timeout=600,
 323    ):
 324        query_info = self.query_dataset(dataset_id)
 325        if DataSetType[query_info["type"]] != DataSetType.STOCK:
 326            raise BoostedAPIException(
 327                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.STOCK}"
 328            )
 329
 330        valid_date_range = self.getDatasetDates(dataset_id)
 331        validStart = valid_date_range["validFrom"]
 332        validEnd = valid_date_range["validTo"]
 333
 334        if start is None:
 335            logger.info("Since no start date provided, starting from {0}.".format(validStart))
 336            start = validStart
 337        if end is None:
 338            logger.info("Since no end date provided, ending at {0}.".format(validEnd))
 339            end = validEnd
 340        start = self.__to_date_obj(start)
 341        end = self.__to_date_obj(end)
 342        if start < validStart:
 343            logger.info("Data does not exist before {0}.".format(validStart))
 344            logger.info("Starting from {0}.".format(validStart))
 345            start = validStart
 346        if end > validEnd:
 347            logger.info("Data does not exist after {0}.".format(validEnd))
 348            logger.info("Ending at {0}.".format(validEnd))
 349            end = validEnd
 350        validate_start_and_end_dates(start, end)
 351
 352        logger.info("Data exists from {0} to {1}.".format(start, end))
 353        request_url = "/api/datasets/" + dataset_id + "/export-data"
 354        headers = {"Authorization": "ApiKey " + self.api_key}
 355
 356        data_chunks = []
 357        chunk_size_days = 90
 358        while start <= end:
 359            chunk_end = start + timedelta(days=chunk_size_days)
 360            if chunk_end > end:
 361                chunk_end = end
 362
 363            logger.info("Requesting start={0} end={1}.".format(start, chunk_end))
 364            params = {"start": self.__iso_format(start), "end": self.__iso_format(chunk_end)}
 365            logger.debug("URL={0}, headers={1}, params={2}".format(request_url, headers, params))
 366
 367            res = requests.get(
 368                self.base_uri + request_url,
 369                headers=headers,
 370                params=params,
 371                timeout=timeout,
 372                **self._request_params,
 373            )
 374
 375            if res.ok:
 376                buf = io.StringIO(res.text)
 377                df = pd.read_csv(buf, index_col=0, parse_dates=True)
 378                if "price" in df.columns:
 379                    df = df.drop("price", axis=1)
 380                data_chunks.append(df)
 381            else:
 382                error_msg = self._try_extract_error_code(res)
 383                logger.error(error_msg)
 384                raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 385
 386            start = start + timedelta(days=chunk_size_days + 1)
 387
 388        return pd.concat(data_chunks)
 389
 390    def export_custom_security_data(
 391        self,
 392        dataset_id,
 393        start=(date.today() - timedelta(days=365 * 25)),
 394        end=date.today(),
 395        timeout=600,
 396    ):
 397        query_info = self.query_dataset(dataset_id)
 398        if DataSetType[query_info["type"]] != DataSetType.SECURITIES_DAILY:
 399            raise BoostedAPIException(
 400                f"Incorrect dataset type: {query_info['type']}"
 401                f" - Expected {DataSetType.SECURITIES_DAILY}"
 402            )
 403        return self.export_data(dataset_id, start, end, timeout)
 404
 405    def export_data(
 406        self,
 407        dataset_id,
 408        start=(datetime.date.today() - timedelta(days=365 * 25)),
 409        end=datetime.date.today(),
 410        timeout=600,
 411    ):
 412        logger.info("Requesting start={0} end={1}.".format(start, end))
 413        request_url = "/api/datasets/" + dataset_id + "/export-data"
 414        headers = {"Authorization": "ApiKey " + self.api_key}
 415        start = self.__iso_format(start)
 416        end = self.__iso_format(end)
 417        params = {"start": start, "end": end}
 418        logger.debug("URL={0}, headers={1}, params={2}".format(request_url, headers, params))
 419        res = requests.get(
 420            self.base_uri + request_url,
 421            headers=headers,
 422            params=params,
 423            timeout=timeout,
 424            **self._request_params,
 425        )
 426        if res.ok or self._check_status_code(res):
 427            buf = io.StringIO(res.text)
 428            df = pd.read_csv(buf, index_col=0, parse_dates=True)
 429            if "price" in df.columns:
 430                df = df.drop("price", axis=1)
 431            return df
 432        else:
 433            error_msg = self._try_extract_error_code(res)
 434            logger.error(error_msg)
 435            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 436
 437    def _get_inference(self, model_id, inference_date=datetime.date.today()):
 438        request_url = "/api/models/" + model_id + "/inference-results"
 439        headers = {"Authorization": "ApiKey " + self.api_key}
 440        params = {}
 441        params["date"] = self.__iso_format(inference_date)
 442        logger.debug(request_url + ", " + str(headers) + ", " + str(params))
 443        res = requests.get(
 444            self.base_uri + request_url, headers=headers, params=params, **self._request_params
 445        )
 446        status = self._check_status_code(res, isInference=True)
 447        if status == Status.SUCCESS:
 448            return res, status
 449        else:
 450            return None, status
 451
 452    def get_inference(
 453        self, model_id, inference_date=datetime.date.today(), block=False, timeout_minutes=30
 454    ):
 455        start_time = datetime.datetime.now()
 456        while True:
 457            for numRetries in range(3):
 458                res, status = self._get_inference(model_id, inference_date)
 459                if res is not None:
 460                    continue
 461                else:
 462                    if status == Status.FAIL:
 463                        return Status.FAIL
 464                    logger.info("Retrying...")
 465            if res is None:
 466                logger.error("Max retries reached.  Request failed.")
 467                return None
 468
 469            json_data = res.json()
 470            if "result" in json_data.keys():
 471                if json_data["result"]["status"] == "RUNNING":
 472                    still_running = True
 473                    if not block:
 474                        logger.warn("Inference job is still running.")
 475                        return None
 476                    else:
 477                        logger.info(
 478                            "Inference job is still running.  Time elapsed={0}.".format(
 479                                datetime.datetime.now() - start_time
 480                            )
 481                        )
 482                        time.sleep(10)
 483                else:
 484                    still_running = False
 485
 486                if not still_running and json_data["result"]["status"] == "COMPLETE":
 487                    csv = json_data["result"]["signals"]
 488                    logger.info(json_data["result"])
 489                    if self._check_status_code(res, isInference=True):
 490                        logger.info(
 491                            "Total run time = {0}.".format(datetime.datetime.now() - start_time)
 492                        )
 493                        return csv
 494            else:
 495                if "errors" in json_data.keys():
 496                    logger.error(json_data["errors"])
 497                else:
 498                    logger.error("Error getting inference for date {0}.".format(inference_date))
 499                return None
 500            if (datetime.datetime.now() - start_time).total_seconds() / 60.0 > timeout_minutes:
 501                logger.error("Timeout waiting for job completion.")
 502                return None
 503
 504    def createDataset(self, schema):
 505        request_url = "/api/datasets"
 506        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
 507        s = json.dumps(schema)
 508        logger.info("Creating dataset with schema " + s)
 509        res = requests.post(
 510            self.base_uri + request_url, data=s, headers=headers, **self._request_params
 511        )
 512        if res.ok:
 513            return res.json()["result"]
 514        else:
 515            raise BoostedAPIException("Dataset creation failed.")
 516
 517    def create_custom_namespace_dataset(self, namespace, schema):
 518        request_url = f"/api/custom-security-dataset/{namespace}"
 519        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
 520        s = json.dumps(schema)
 521        logger.info("Creating dataset with schema " + s)
 522        res = requests.post(
 523            self.base_uri + request_url, data=s, headers=headers, **self._request_params
 524        )
 525        if res.ok:
 526            return res.json()["result"]
 527        else:
 528            raise BoostedAPIException("Dataset creation failed.")
 529
 530    def getUniverse(self, modelId, date=None):
 531        if date is not None:
 532            url = "/api/models/{0}/universe/{1}".format(modelId, self.__iso_format(date))
 533            logger.info("Getting universe for date: {0}.".format(date))
 534        else:
 535            url = "/api/models/{0}/universe/".format(modelId)
 536        headers = {"Authorization": "ApiKey " + self.api_key}
 537        res = requests.get(self.base_uri + url, headers=headers, **self._request_params)
 538        if res.ok:
 539            buf = io.StringIO(res.text)
 540            df = pd.read_csv(buf, index_col=0, parse_dates=True)
 541            return df
 542        else:
 543            error = self._try_extract_error_code(res)
 544            logger.error(
 545                "There was a problem getting this universe or model ID: {0}.".format(error)
 546            )
 547            raise BoostedAPIException("Failed to get universe: {0}".format(error))
 548
 549    def add_custom_security_namespace_members(
 550        self, namespace, members: Union[pandas.DataFrame, str]
 551    ) -> Tuple[pandas.DataFrame, str]:
 552        url = self.base_uri + "/api/synthetic-datasets/{0}/generate".format(namespace)
 553        headers = {"Authorization": "ApiKey " + self.api_key}
 554        headers["Content-Type"] = "application/json"
 555        logger.info("Adding custom security namespace for namespace: {0}".format(namespace))
 556        strbuf = None
 557        if isinstance(members, pandas.DataFrame):
 558            df = members
 559            df_canon = df.rename(columns={_: to_camel_case(_) for _ in df.columns})
 560            canon_cols = ["Currency", "Symbol", "Country", "Name"]
 561            if set(canon_cols).difference(df_canon.columns):
 562                raise BoostedAPIException(f"Expected columns: {canon_cols}")
 563            df_canon = df_canon.loc[:, canon_cols]
 564            buf = io.StringIO()
 565            df_canon.to_json(buf, orient="records")
 566            strbuf = buf.getvalue()
 567        elif isinstance(members, str):
 568            strbuf = members
 569        else:
 570            raise BoostedAPIException(f"Unsupported members argument type: {type(members)}")
 571        res = requests.post(url, data=strbuf, headers=headers, **self._request_params)
 572        if res.ok:
 573            res_obj = res.json()
 574            res_df = pandas.Series(res_obj["generatedISIN"]).to_frame()
 575            res_df.index.name = "Symbol"
 576            res_df.columns = ["ISIN"]
 577            logger.info("Add to custom security namespace successful.")
 578            if "warnings" in res_obj:
 579                logger.info("Warnings: {0}.".format(res.json()["warnings"]))
 580                return res_df, res.json()["warnings"]
 581            else:
 582                return res_df, "No warnings."
 583        else:
 584            error_msg = self._try_extract_error_code(res)
 585            raise BoostedAPIException("Failed to get universe: {0}.".format(error_msg))
 586
 587    def updateUniverse(self, modelId, universe_df, date=datetime.date.today() + timedelta(1)):
 588        date = self.__iso_format(date)
 589        url = self.base_uri + "/api/models/{0}/universe/{1}".format(modelId, date)
 590        headers = {"Authorization": "ApiKey " + self.api_key}
 591        logger.info("Updating universe for date {0}.".format(date))
 592        if isinstance(universe_df, pd.core.frame.DataFrame):
 593            buf = io.StringIO()
 594            universe_df.to_csv(buf)
 595            target = ("uploaded_universe.csv", buf.getvalue(), "text/csv")
 596            files_req = {}
 597            files_req["universe"] = target
 598            res = requests.post(url, files=files_req, headers=headers, **self._request_params)
 599        elif isinstance(universe_df, str):
 600            target = ("uploaded_universe.csv", universe_df, "text/csv")
 601            files_req = {}
 602            files_req["universe"] = target
 603            res = requests.post(url, files=files_req, headers=headers, **self._request_params)
 604        else:
 605            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
 606        if res.ok:
 607            logger.info("Universe update successful.")
 608            if "warnings" in res.json():
 609                logger.info("Warnings: {0}.".format(res.json()["warnings"]))
 610                return res.json()["warnings"]
 611            else:
 612                return "No warnings."
 613        else:
 614            error_msg = self._try_extract_error_code(res)
 615            raise BoostedAPIException("Failed to get universe: {0}.".format(error_msg))
 616
 617    def create_universe(
 618        self, universe: Union[pd.DataFrame, str], name: str, description: str
 619    ) -> List[str]:
 620        PRESENT = "PRESENT"
 621        ANY = "ANY"
 622        EARLIST_DATE = "1900-01-01"
 623        LATEST_DATE = "4000-01-01"
 624
 625        if isinstance(universe, (str, bytes, os.PathLike)):
 626            universe = pd.read_csv(universe)
 627
 628        universe.columns = universe.columns.str.lower()
 629
 630        # Clients are free to leave out data. Fill in some defaults here.
 631        if "from" not in universe.columns:
 632            universe["from"] = EARLIST_DATE
 633        if "to" not in universe.columns:
 634            universe["to"] = LATEST_DATE
 635        if "currency" not in universe.columns:
 636            universe["currency"] = ANY
 637        if "country" not in universe.columns:
 638            universe["country"] = ANY
 639        if "isin" not in universe.columns:
 640            universe["isin"] = None
 641        if "symbol" not in universe.columns:
 642            universe["symbol"] = None
 643
 644        # to prevent conflicts with python keywords
 645        universe.rename(columns={"from": "from_date", "to": "to_date"}, inplace=True)
 646
 647        universe = universe.replace({np.nan: None})
 648        security_country_currency_date_list = []
 649        for i, r in enumerate(universe.itertuples()):
 650            id_type = ColumnSubRole.ISIN
 651            identifier = r.isin
 652
 653            if identifier is None:
 654                id_type = ColumnSubRole.SYMBOL
 655                identifier = str(r.symbol)
 656
 657            # if identifier is still None, it means that there is no ISIN or
 658            # SYMBOL for this row, in which case we throw an error
 659            if identifier is None:
 660                raise BoostedAPIException(
 661                    (
 662                        f"Missing identifier column in universe row {i + 1}"
 663                        " should contain ISIN or Symbol"
 664                    )
 665                )
 666
 667            security_country_currency_date_list.append(
 668                DateIdentCountryCurrency(
 669                    date=r.from_date or EARLIST_DATE,
 670                    identifier=identifier,
 671                    country=r.country or ANY,
 672                    currency=r.currency or ANY,
 673                    id_type=id_type,
 674                )
 675            )
 676
 677        gbi_id_objs = self.getGbiIdFromIdentCountryCurrencyDate(security_country_currency_date_list)
 678
 679        security_list = []
 680        for i, r in enumerate(universe.itertuples()):
 681            # if we have a None here, we failed to map to a gbi id
 682            if gbi_id_objs[i] is None:
 683                raise BoostedAPIException(f"Unable to map row: {tuple(r)}")
 684
 685            security_list.append(
 686                {
 687                    "stockId": gbi_id_objs[i].gbi_id,
 688                    "fromZ": r.from_date or EARLIST_DATE,
 689                    "toZ": LATEST_DATE if r.to_date in (PRESENT, None) else r.to_date,
 690                    "removal": False,
 691                    "source": "UPLOAD",
 692                }
 693            )
 694
 695        url = self.base_uri + "/api/template-universe/save"
 696        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
 697        req = {"name": name, "description": description, "modificationDaos": security_list}
 698
 699        res = requests.post(url, json=req, headers=headers, **self._request_params)
 700        self._check_ok_or_err_with_msg(res, "Failed to create universe")
 701
 702        if "warnings" in res.json():
 703            logger.info("Warnings: {0}.".format(res.json()["warnings"]))
 704            return res.json()["warnings"].splitlines()
 705        else:
 706            return []
 707
 708    def validate_dataframe(self, df):
 709        if not isinstance(df, pd.core.frame.DataFrame):
 710            logger.error("Dataset must be of type Dataframe.")
 711            return False
 712        if type(df.index) != pd.core.indexes.datetimes.DatetimeIndex:
 713            logger.error("Index must be DatetimeIndex.")
 714            return False
 715        if len(df.columns) == 0:
 716            logger.error("No feature columns exist.")
 717            return False
 718        if len(df) == 0:
 719            logger.error("No rows exist.")
 720        return True
 721
 722    def get_dataset_schema(self, dataset_id):
 723        url = self.base_uri + "/api/datasets/{0}/schema".format(dataset_id)
 724        headers = {"Authorization": "ApiKey " + self.api_key}
 725        res = requests.get(url, headers=headers, **self._request_params)
 726        if res.ok:
 727            json_schema = res.json()
 728        else:
 729            error_msg = self._try_extract_error_code(res)
 730            logger.error(error_msg)
 731            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
 732        return DataSetConfig.fromDict(json_schema["result"])
 733
 734    def add_custom_security_daily_dataset(
 735        self, namespace, dataset, schema=None, timeout=600, block=True
 736    ):
 737        result = self.add_custom_security_daily_dataset_with_warnings(
 738            namespace, dataset, schema, timeout, block
 739        )
 740        return result["dataset_id"]
 741
 742    def add_custom_security_daily_dataset_with_warnings(
 743        self,
 744        namespace,
 745        dataset,
 746        schema=None,
 747        timeout=600,
 748        block=True,
 749        no_exception_on_chunk_error=False,
 750    ):
 751        dataset_type = DataSetType.SECURITIES_DAILY
 752        dsid = self.query_namespace_dataset_id(namespace, dataset_type)
 753
 754        if not self.validate_dataframe(dataset):
 755            logger.error("dataset failed validation.")
 756            return None
 757
 758        if dsid is None:
 759            # create the dataset if not exist.
 760            schema = infer_dataset_schema(
 761                "custom_security_daily", dataset, dataset_type, infer_from_column_names=True
 762            )
 763            dsid = self.create_custom_namespace_dataset(namespace, schema.toDict())
 764            data_type = DataAddType.CREATION
 765        elif schema is not None:
 766            raise ValueError(
 767                f"Dataset schema already exists for namespace={namespace}, type={dataset_type}",
 768                ", cannot create another!",
 769            )
 770        else:
 771            data_type = DataAddType.HISTORICAL
 772
 773        logger.info("Created dataset with ID = {0}, uploading...".format(dsid))
 774        result = self.add_custom_security_daily_data(
 775            dsid,
 776            dataset,
 777            timeout,
 778            block,
 779            data_type=data_type,
 780            no_exception_on_chunk_error=no_exception_on_chunk_error,
 781        )
 782        return {
 783            "namespace": namespace,
 784            "dataset_id": dsid,
 785            "warnings": result["warnings"],
 786            "errors": result["errors"],
 787        }
 788
 789    def add_custom_security_daily_data(
 790        self,
 791        dataset_id,
 792        csv_data,
 793        timeout=600,
 794        block=True,
 795        data_type=DataAddType.HISTORICAL,
 796        no_exception_on_chunk_error=False,
 797    ):
 798        warnings = []
 799        query_info = self.query_dataset(dataset_id)
 800        if DataSetType[query_info["type"]] != DataSetType.SECURITIES_DAILY:
 801            raise BoostedAPIException(
 802                f"Incorrect dataset type: {query_info['type']}"
 803                f" - Expected {DataSetType.SECURITIES_DAILY}"
 804            )
 805        warnings, errors = self.setup_chunk_and_upload_data(
 806            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 807        )
 808        if len(warnings) > 0:
 809            logger.warning(
 810                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 811            )
 812        if len(errors) > 0:
 813            raise BoostedAPIException(
 814                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
 815                + "\n".join(errors)
 816            )
 817        return {"warnings": warnings, "errors": errors}
 818
 819    def add_dependent_dataset(
 820        self, dataset, datasetName="DependentDataset", schema=None, timeout=600, block=True
 821    ):
 822        result = self.add_dependent_dataset_with_warnings(
 823            dataset, datasetName, schema, timeout, block
 824        )
 825        return result["dataset_id"]
 826
 827    def add_dependent_dataset_with_warnings(
 828        self,
 829        dataset,
 830        datasetName="DependentDataset",
 831        schema=None,
 832        timeout=600,
 833        block=True,
 834        no_exception_on_chunk_error=False,
 835    ):
 836        if not self.validate_dataframe(dataset):
 837            logger.error("dataset failed validation.")
 838            return None
 839        if schema is None:
 840            schema = infer_dataset_schema(datasetName, dataset, DataSetType.STOCK)
 841        dsid = self.createDataset(schema.toDict())
 842        logger.info("Creating dataset with ID = {0}.".format(dsid))
 843        result = self.add_dependent_data(
 844            dsid,
 845            dataset,
 846            timeout,
 847            block,
 848            data_type=DataAddType.CREATION,
 849            no_exception_on_chunk_error=no_exception_on_chunk_error,
 850        )
 851        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
 852
 853    def add_independent_dataset(
 854        self, dataset, datasetName="IndependentDataset", schema=None, timeout=600, block=True
 855    ):
 856        result = self.add_independent_dataset_with_warnings(
 857            dataset, datasetName, schema, timeout, block
 858        )
 859        return result["dataset_id"]
 860
 861    def add_independent_dataset_with_warnings(
 862        self,
 863        dataset,
 864        datasetName="IndependentDataset",
 865        schema=None,
 866        timeout=600,
 867        block=True,
 868        no_exception_on_chunk_error=False,
 869    ):
 870        if not self.validate_dataframe(dataset):
 871            logger.error("dataset failed validation.")
 872            return None
 873        if schema is None:
 874            schema = infer_dataset_schema(datasetName, dataset, DataSetType.STRATEGY)
 875        schemaDict = schema.toDict()
 876        if "configurationDataJson" not in schemaDict:
 877            schemaDict["configurationDataJson"] = "{}"
 878        dsid = self.createDataset(schemaDict)
 879        logger.info("Creating dataset with ID = {0}.".format(dsid))
 880        result = self.add_independent_data(
 881            dsid,
 882            dataset,
 883            timeout,
 884            block,
 885            data_type=DataAddType.CREATION,
 886            no_exception_on_chunk_error=no_exception_on_chunk_error,
 887        )
 888        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
 889
 890    def add_global_dataset(
 891        self, dataset, datasetName="GlobalDataset", schema=None, timeout=600, block=True
 892    ):
 893        result = self.add_global_dataset_with_warnings(dataset, datasetName, schema, timeout, block)
 894        return result["dataset_id"]
 895
 896    def add_global_dataset_with_warnings(
 897        self,
 898        dataset,
 899        datasetName="GlobalDataset",
 900        schema=None,
 901        timeout=600,
 902        block=True,
 903        no_exception_on_chunk_error=False,
 904    ):
 905        if not self.validate_dataframe(dataset):
 906            logger.error("dataset failed validation.")
 907            return None
 908        if schema is None:
 909            schema = infer_dataset_schema(datasetName, dataset, DataSetType.GLOBAL)
 910        dsid = self.createDataset(schema.toDict())
 911        logger.info("Creating dataset with ID = {0}.".format(dsid))
 912        result = self.add_global_data(
 913            dsid,
 914            dataset,
 915            timeout,
 916            block,
 917            data_type=DataAddType.CREATION,
 918            no_exception_on_chunk_error=no_exception_on_chunk_error,
 919        )
 920        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
 921
 922    def add_independent_data(
 923        self,
 924        dataset_id,
 925        csv_data,
 926        timeout=600,
 927        block=True,
 928        data_type=DataAddType.HISTORICAL,
 929        no_exception_on_chunk_error=False,
 930    ):
 931        query_info = self.query_dataset(dataset_id)
 932        if DataSetType[query_info["type"]] != DataSetType.STRATEGY:
 933            raise BoostedAPIException(
 934                f"Incorrect dataset type: {query_info['type']}"
 935                f" - Expected {DataSetType.STRATEGY}"
 936            )
 937        warnings, errors = self.setup_chunk_and_upload_data(
 938            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 939        )
 940        if len(warnings) > 0:
 941            logger.warning(
 942                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 943            )
 944        if len(errors) > 0:
 945            raise BoostedAPIException(
 946                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
 947                + "\n".join(errors)
 948            )
 949        return {"warnings": warnings, "errors": errors}
 950
 951    def add_dependent_data(
 952        self,
 953        dataset_id,
 954        csv_data,
 955        timeout=600,
 956        block=True,
 957        data_type=DataAddType.HISTORICAL,
 958        no_exception_on_chunk_error=False,
 959    ):
 960        warnings = []
 961        query_info = self.query_dataset(dataset_id)
 962        if DataSetType[query_info["type"]] != DataSetType.STOCK:
 963            raise BoostedAPIException(
 964                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.STOCK}"
 965            )
 966        warnings, errors = self.setup_chunk_and_upload_data(
 967            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 968        )
 969        if len(warnings) > 0:
 970            logger.warning(
 971                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
 972            )
 973        if len(errors) > 0:
 974            raise BoostedAPIException(
 975                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
 976                + "\n".join(errors)
 977            )
 978        return {"warnings": warnings, "errors": errors}
 979
 980    def add_global_data(
 981        self,
 982        dataset_id,
 983        csv_data,
 984        timeout=600,
 985        block=True,
 986        data_type=DataAddType.HISTORICAL,
 987        no_exception_on_chunk_error=False,
 988    ):
 989        query_info = self.query_dataset(dataset_id)
 990        if DataSetType[query_info["type"]] != DataSetType.GLOBAL:
 991            raise BoostedAPIException(
 992                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.GLOBAL}"
 993            )
 994        warnings, errors = self.setup_chunk_and_upload_data(
 995            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 996        )
 997        if len(warnings) > 0:
 998            logger.warning(
 999                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
1000            )
1001        if len(errors) > 0:
1002            raise BoostedAPIException(
1003                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
1004                + "\n".join(errors)
1005            )
1006        return {"warnings": warnings, "errors": errors}
1007
1008    def get_csv_buffer(self):
1009        return io.StringIO()
1010
1011    def start_chunked_upload(self, dataset_id):
1012        url = self.base_uri + "/api/datasets/{0}/start-chunked-upload".format(dataset_id)
1013        headers = {"Authorization": "ApiKey " + self.api_key}
1014        res = requests.post(url, headers=headers, **self._request_params)
1015        if res.ok:
1016            return res.json()["result"]
1017        else:
1018            error_msg = self._try_extract_error_code(res)
1019            logger.error(error_msg)
1020            raise BoostedAPIException(
1021                "Failed to obtain dataset lock for upload: {0}.".format(error_msg)
1022            )
1023
1024    def abort_chunked_upload(self, dataset_id, chunk_id):
1025        url = self.base_uri + "/api/datasets/{0}/abort-chunked-upload".format(dataset_id)
1026        headers = {"Authorization": "ApiKey " + self.api_key}
1027        params = {"uploadGroupId": chunk_id}
1028        res = requests.post(url, headers=headers, **self._request_params, params=params)
1029        if not res.ok:
1030            error_msg = self._try_extract_error_code(res)
1031            logger.error(error_msg)
1032            raise BoostedAPIException(
1033                "Failed to abort dataset lock during error: {0}.".format(error_msg)
1034            )
1035
1036    def check_dataset_ingestion_completion(self, dataset_id, chunk_id, start_time):
1037        url = self.base_uri + "/api/datasets/{0}/upload-chunk-status".format(dataset_id)
1038        headers = {"Authorization": "ApiKey " + self.api_key}
1039        params = {"uploadGroupId": chunk_id}
1040        res = requests.get(url, headers=headers, **self._request_params, params=params)
1041        res = res.json()
1042
1043        finished = False
1044        warnings = []
1045        errors = []
1046
1047        if type(res) == dict:
1048            dataset_status = res["datasetStatus"]
1049            chunk_status = res["chunkStatus"]
1050            if chunk_status != ChunkStatus.PROCESSING.value:
1051                finished = True
1052                errors = res["errors"]
1053                warnings = res["warnings"]
1054                successful_rows = res["successfulRows"]
1055                total_rows = res["totalRows"]
1056                logger.info(
1057                    f"Successfully ingested {successful_rows} out of {total_rows} uploaded rows."
1058                )
1059                if chunk_status in [
1060                    ChunkStatus.SUCCESS.value,
1061                    ChunkStatus.WARNING.value,
1062                    ChunkStatus.ERROR.value,
1063                ]:
1064                    if dataset_status != "AVAILABLE":
1065                        raise BoostedAPIException(
1066                            "Dataset was unexpectedly unavailable after chunk upload finished."
1067                        )
1068                    else:
1069                        logger.info("Ingestion complete.  Uploaded data is ready for use.")
1070                elif chunk_status == ChunkStatus.ABORTED.value:
1071                    errors.append(
1072                        "Dataset chunk upload was aborted by server! Upload did not succeed."
1073                    )
1074                else:
1075                    errors.append("Unexpected data ingestion status: {0}.".format(chunk_status))
1076            logger.info(
1077                "Data ingestion still running.  Time elapsed={0}.".format(
1078                    datetime.datetime.now() - start_time
1079                )
1080            )
1081        else:
1082            raise BoostedAPIException("Unable to get status of dataset ingestion.")
1083        return {"finished": finished, "warnings": warnings, "errors": errors}
1084
1085    def _commit_chunked_upload(self, dataset_id, chunk_id, data_type, block=True, timeout=600):
1086        url = self.base_uri + "/api/datasets/{0}/commit-chunked-upload".format(dataset_id)
1087        headers = {"Authorization": "ApiKey " + self.api_key}
1088        params = {
1089            "uploadGroupId": chunk_id,
1090            "dataAddType": data_type,
1091            "sendCompletionEmail": not block,
1092        }
1093        res = requests.post(url, headers=headers, **self._request_params, params=params)
1094        if not res.ok:
1095            error_msg = self._try_extract_error_code(res)
1096            logger.error(error_msg)
1097            raise BoostedAPIException("Failed to commit dataset files: {0}.".format(error_msg))
1098
1099        if block:
1100            start_time = datetime.datetime.now()
1101            # Keep waiting until upload is no longer in UPDATING state...
1102            while True:
1103                result = self.check_dataset_ingestion_completion(dataset_id, chunk_id, start_time)
1104                if result["finished"]:
1105                    break
1106
1107                if (datetime.datetime.now() - start_time).total_seconds() > timeout:
1108                    err_str = (
1109                        f"Timeout waiting for commit of dataset: {dataset_id} | chunk: {chunk_id}"
1110                    )
1111                    logger.error(err_str)
1112                    return [], [err_str]
1113
1114                time.sleep(10)
1115            return result["warnings"], result["errors"]
1116        else:
1117            return [], []
1118
1119    def setup_chunk_and_upload_data(
1120        self,
1121        dataset_id,
1122        csv_data,
1123        data_type,
1124        timeout=600,
1125        block=True,
1126        no_exception_on_chunk_error=False,
1127    ):
1128        chunk_id = self.start_chunked_upload(dataset_id)
1129        logger.info("Obtained lock on dataset for upload: " + chunk_id)
1130        try:
1131            warnings, errors = self.chunk_and_upload_data(
1132                dataset_id, chunk_id, csv_data, timeout, no_exception_on_chunk_error
1133            )
1134            commit_warnings, commit_errors = self._commit_chunked_upload(
1135                dataset_id, chunk_id, data_type, block, timeout
1136            )
1137            return warnings + commit_warnings, errors + commit_errors
1138        except Exception:
1139            self.abort_chunked_upload(dataset_id, chunk_id)
1140            raise
1141
1142    def chunk_and_upload_data(
1143        self, dataset_id, chunk_id, csv_data, timeout=600, no_exception_on_chunk_error=False
1144    ):
1145        if isinstance(csv_data, pd.core.frame.DataFrame):
1146            if not isinstance(csv_data.index, pd.core.indexes.datetimes.DatetimeIndex):
1147                raise BoostedAPIException("DataFrame must have DatetimeIndex as index type.")
1148
1149            warnings = []
1150            errors = []
1151            logger.info("Uploading yearly.")
1152            for t in csv_data.index.to_period("Y").unique():
1153                if t is pd.NaT:
1154                    continue
1155
1156                # serialize bit to string
1157                buf = self.get_csv_buffer()
1158                yearly_csv = csv_data.loc[str(t)]
1159                yearly_csv.to_csv(buf, header=True)
1160                raw_csv = buf.getvalue()
1161
1162                # we are already chunking yearly... but if the csv still exceeds a healthy
1163                # limit of 50mb the final line of defence is to ignore date boundaries and
1164                # just chunk the rows. This is mostly for the cloudflare upload limit.
1165                size_lim = 50 * 1000 * 1000
1166                est_csv_size = sys.getsizeof(raw_csv)
1167                if est_csv_size > size_lim:
1168                    del raw_csv, buf
1169                    logger.info("Yearly data too large for single upload, chunking further...")
1170                    chunks = []
1171                    nchunks = math.ceil(est_csv_size / size_lim)
1172                    rows_per_chunk = math.ceil(len(yearly_csv) / nchunks)
1173                    for i in range(0, len(yearly_csv), rows_per_chunk):
1174                        buf = self.get_csv_buffer()
1175                        split_csv = yearly_csv.iloc[i : i + rows_per_chunk]
1176                        split_csv.to_csv(buf, header=True)
1177                        split_csv = buf.getvalue()
1178                        chunks.append(
1179                            (
1180                                "{0}-{1}".format(i + 1, min(len(yearly_csv), i + rows_per_chunk)),
1181                                split_csv,
1182                            )
1183                        )
1184                else:
1185                    chunks = [("all", raw_csv)]
1186
1187                for i, (rows_descriptor, chunk_csv) in enumerate(chunks):
1188                    chunk_descriptor = "{0} in yearly chunk {1}".format(rows_descriptor, t)
1189                    logger.info(
1190                        "Uploading rows:"
1191                        + chunk_descriptor
1192                        + " (chunk {0} of {1}):".format(i + 1, len(chunks))
1193                    )
1194                    _, new_warnings, new_errors = self.upload_dataset_chunk(
1195                        chunk_descriptor,
1196                        dataset_id,
1197                        chunk_id,
1198                        chunk_csv,
1199                        timeout,
1200                        no_exception_on_chunk_error,
1201                    )
1202                    warnings.extend(new_warnings)
1203                    errors.extend(new_errors)
1204            return warnings, errors
1205
1206        elif isinstance(csv_data, str):
1207            _, warnings, errors = self.upload_dataset_chunk(
1208                "all data", dataset_id, chunk_id, csv_data, timeout, no_exception_on_chunk_error
1209            )
1210            return warnings, errors
1211        else:
1212            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
1213
1214    def upload_dataset_chunk(
1215        self,
1216        chunk_descriptor,
1217        dataset_id,
1218        chunk_id,
1219        csv_data,
1220        timeout=600,
1221        no_exception_on_chunk_error=False,
1222    ):
1223        logger.info("Starting upload: " + chunk_descriptor)
1224        url = self.base_uri + "/api/datasets/{0}/upload-dataset-chunk".format(dataset_id)
1225        headers = {"Authorization": "ApiKey " + self.api_key}
1226        files_req = {}
1227        warnings = []
1228        errors = []
1229
1230        # make the network request
1231        target = ("uploaded_data.csv", csv_data, "text/csv")
1232        files_req["dataFile"] = target
1233        params = {"uploadGroupId": chunk_id}
1234        res = requests.post(
1235            url,
1236            params=params,
1237            files=files_req,
1238            headers=headers,
1239            timeout=timeout,
1240            **self._request_params,
1241        )
1242
1243        if res.ok:
1244            logger.info(
1245                (
1246                    "Chunk upload completed.  "
1247                    "Ingestion started.  "
1248                    "Please wait until the data is in AVAILABLE state."
1249                )
1250            )
1251            if "warnings" in res.json():
1252                warnings = res.json()["warnings"]
1253                if len(warnings) > 0:
1254                    logger.warning("Uploaded chunk encountered data warnings: ")
1255                for w in warnings:
1256                    logger.warning(w)
1257        else:
1258            reason = "Upload failed: {0}, {1}".format(res.text, res.reason)
1259            logger.error(reason)
1260            if no_exception_on_chunk_error:
1261                errors.append(
1262                    "Chunk {0} failed: {1}. ".format(chunk_descriptor, reason)
1263                    + "Your data was only PARTIALLY uploaded. "
1264                    + "Please reattempt the upload of this chunk."
1265                )
1266            else:
1267                raise BoostedAPIException(reason)
1268
1269        return res, warnings, errors
1270
1271    def getAllocationsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1272        date = self.__iso_format(date)
1273        endpoint = "latest-allocations" if rollback_to_last_available_date else "allocations"
1274        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1275        headers = {"Authorization": "ApiKey " + self.api_key}
1276        params = {"date": date}
1277        logger.info("Retrieving allocations information for date {0}.".format(date))
1278        res = requests.get(url, params=params, headers=headers, **self._request_params)
1279        if res.ok:
1280            logger.info("Allocations retrieval successful.")
1281            return res.json()
1282        else:
1283            error_msg = self._try_extract_error_code(res)
1284            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
1285
1286    # New API method for fetching data from portfolio_holdings.pb2 file.
1287    def getAllocationsForDateV2(self, portfolio_id, date, rollback_to_last_available_date):
1288        date = self.__iso_format(date)
1289        endpoint = "latest-allocations-v2" if rollback_to_last_available_date else "allocations-v2"
1290        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1291        headers = {"Authorization": "ApiKey " + self.api_key}
1292        params = {"date": date}
1293        logger.info("Retrieving allocations information for date {0}.".format(date))
1294        res = requests.get(url, params=params, headers=headers, **self._request_params)
1295        if res.ok:
1296            logger.info("Allocations retrieval successful.")
1297            return res.json()
1298        else:
1299            error_msg = self._try_extract_error_code(res)
1300            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
1301
1302    def getAllocationsByDates(self, portfolio_id, dates=None):
1303        url = self.base_uri + "/api/portfolios/{0}/allocationsByDate".format(portfolio_id)
1304        headers = {"Authorization": "ApiKey " + self.api_key}
1305        if dates is not None:
1306            fmt_dates = []
1307            for d in dates:
1308                fmt_dates.append(self.__iso_format(d))
1309            fmt_dates_str = ",".join(fmt_dates)
1310            params: Dict = {"dates": fmt_dates_str}
1311            logger.info("Retrieving allocations information for dates {0}.".format(fmt_dates))
1312        else:
1313            params = {"dates": None}
1314            logger.info("Retrieving allocations information for all dates")
1315        res = requests.get(url, params=params, headers=headers, **self._request_params)
1316        if res.ok:
1317            logger.info("Allocations retrieval successful.")
1318            return res.json()
1319        else:
1320            error_msg = self._try_extract_error_code(res)
1321            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
1322
1323    def getSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1324        date = self.__iso_format(date)
1325        endpoint = "latest-signals" if rollback_to_last_available_date else "signals"
1326        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1327        headers = {"Authorization": "ApiKey " + self.api_key}
1328        params = {"date": date}
1329        logger.info("Retrieving signals information for date {0}.".format(date))
1330        res = requests.get(url, params=params, headers=headers, **self._request_params)
1331        if res.ok:
1332            logger.info("Signals retrieval successful.")
1333            return res.json()
1334        else:
1335            error_msg = self._try_extract_error_code(res)
1336            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
1337
1338    def getSignalsForAllDates(self, portfolio_id, dates=None):
1339        url = self.base_uri + "/api/portfolios/{0}/signalsByDate".format(portfolio_id)
1340        headers = {"Authorization": "ApiKey " + self.api_key}
1341        params = {}
1342        if dates is not None:
1343            fmt_dates = []
1344            for d in dates:
1345                fmt_dates.append(self.__iso_format(d))
1346            fmt_dates_str = ",".join(fmt_dates)
1347            params = {"dates": fmt_dates_str}
1348            logger.info("Retrieving signals information for dates {0}.".format(fmt_dates))
1349        else:
1350            params = {"dates": None}
1351            logger.info("Retrieving signals information for all dates")
1352        res = requests.get(url, params=params, headers=headers, **self._request_params)
1353        if res.ok:
1354            logger.info("Signals retrieval successful.")
1355            return res.json()
1356        else:
1357            error_msg = self._try_extract_error_code(res)
1358            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
1359
1360    def getEquityAccuracy(
1361        self,
1362        model_id: str,
1363        portfolio_id: str,
1364        tickers: List[str],
1365        start_date: Optional[BoostedDate] = None,
1366        end_date: Optional[BoostedDate] = None,
1367    ) -> Dict[str, Dict[str, Any]]:
1368        data: Dict[str, Any] = {}
1369        if start_date is not None:
1370            start_date = convert_date(start_date)
1371            data["startDate"] = start_date.isoformat()
1372        if end_date is not None:
1373            end_date = convert_date(end_date)
1374            data["endDate"] = end_date.isoformat()
1375
1376        if start_date and end_date:
1377            validate_start_and_end_dates(start_date, end_date)
1378
1379        tickers_stream = ",".join(tickers)
1380        data["tickers"] = tickers_stream
1381        data["timestamp"] = time.strftime("%H:%M:%S")
1382        data["shouldRecalc"] = True
1383        url = self.base_uri + f"/api/analysis/equity-accuracy/{model_id}/{portfolio_id}"
1384        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1385
1386        logger.info(
1387            f"Retrieving equity accuracy data for date range {start_date} to {end_date} "
1388            f"for tickers: {tickers}."
1389        )
1390
1391        # Now create dataframes from the JSON output.
1392        metrics = [
1393            "hit_rate_mean",
1394            "hit_rate_median",
1395            "excess_return_mean",
1396            "excess_return_median",
1397            "return",
1398            "excess_return",
1399        ]
1400
1401        # send the request, retry if failed
1402        MAX_RETRIES = 10  # max of number of retries until timeout
1403        SLEEP_TIME = 3  # waiting time between requests
1404
1405        num_retries = 0
1406        success = False
1407        while not success and num_retries < MAX_RETRIES:
1408            res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
1409            if res.ok:
1410                logger.info("Equity Accuracy Data retrieval successful.")
1411                info = res.json()
1412                success = True
1413            else:
1414                data["shouldRecalc"] = False
1415                num_retries += 1
1416                time.sleep(SLEEP_TIME)
1417
1418        if not success:
1419            raise BoostedAPIException("Failed to retrieve equity accuracy: Request timeout.")
1420
1421        for ticker, accuracy_data in info.items():
1422            for metric in metrics:
1423                metric_matrix = accuracy_data[metric]
1424                if not isinstance(metric_matrix, str):
1425                    # Set the index to the quintile label, and remove it from the data
1426                    index = []
1427                    for row in metric_matrix[1:]:
1428                        index.append(row.pop(0))
1429
1430                    # columns are "1D", "5D", etc.
1431                    df = pd.DataFrame(metric_matrix[1:], columns=metric_matrix[0][1:], index=index)
1432                    accuracy_data[metric] = df
1433        return info
1434
1435    def getHistoricalTradeDates(self, portfolio_id, start_date=None, end_date=None):
1436        end_date = self.__to_date_obj(end_date or datetime.date.today())
1437        start_date = self.__iso_format(start_date or (end_date - timedelta(days=365)))
1438        end_date = self.__iso_format(end_date)
1439
1440        url = self.base_uri + "/api/portfolios/{0}/tradingDates".format(portfolio_id)
1441        headers = {"Authorization": "ApiKey " + self.api_key}
1442        params = {"startDate": start_date, "endDate": end_date}
1443
1444        logger.info(
1445            "Retrieving historical trade dates data for date range {0} to {1}.".format(
1446                start_date, end_date
1447            )
1448        )
1449        res = requests.get(url, params=params, headers=headers, **self._request_params)
1450        if res.ok:
1451            logger.info("Trading dates retrieval successful.")
1452            return res.json()["dates"]
1453        else:
1454            error_msg = self._try_extract_error_code(res)
1455            raise BoostedAPIException("Failed to retrieve trading dates: {0}.".format(error_msg))
1456
1457    def getRankingsForAllDates(self, portfolio_id, dates=None):
1458        url = self.base_uri + "/api/portfolios/{0}/rankingsByDate".format(portfolio_id)
1459        headers = {"Authorization": "ApiKey " + self.api_key}
1460        params = {}
1461        if dates is not None:
1462            fmt_dates = []
1463            for d in dates:
1464                fmt_dates.append(self.__iso_format(d))
1465            fmt_dates_str = ",".join(fmt_dates)
1466            params = {"dates": fmt_dates_str}
1467            logger.info("Retrieving rankings information for date {0}.".format(fmt_dates_str))
1468        else:
1469            params = {"dates": None}
1470            logger.info("Retrieving rankings information for all dates")
1471        res = requests.get(url, params=params, headers=headers, **self._request_params)
1472        if res.ok:
1473            logger.info("Rankings retrieval successful.")
1474            return res.json()
1475        else:
1476            error_msg = self._try_extract_error_code(res)
1477            raise BoostedAPIException("Failed to retrieve rankings: {0}.".format(error_msg))
1478
1479    def getRankingsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1480        date = self.__iso_format(date)
1481        endpoint = "latest-rankings" if rollback_to_last_available_date else "rankings"
1482        url = self.base_uri + "/api/{0}/{1}/{2}".format(endpoint, portfolio_id, date)
1483        headers = {"Authorization": "ApiKey " + self.api_key}
1484        logger.info("Retrieving rankings information for date {0}.".format(date))
1485        res = requests.get(url, headers=headers, **self._request_params)
1486        if res.ok:
1487            logger.info("Rankings retrieval successful.")
1488            return res.json()
1489        else:
1490            error_msg = self._try_extract_error_code(res)
1491            raise BoostedAPIException("Failed to retrieve rankings: {0}.".format(error_msg))
1492
1493    def sendModelRecalc(self, model_id):
1494        url = self.base_uri + "/api/models/{0}/recalc".format(model_id)
1495        logger.info("Sending model recalc request for model {0}".format(model_id))
1496        headers = {"Authorization": "ApiKey " + self.api_key}
1497        res = requests.put(url, headers=headers, **self._request_params)
1498        if not res.ok:
1499            error_msg = self._try_extract_error_code(res)
1500            logger.error(error_msg)
1501            raise BoostedAPIException(
1502                "Failed to send model recalc request - "
1503                + "the model in UI may be out of date: {0}.".format(error_msg)
1504            )
1505
1506    def sendRecalcAllModelPortfolios(self, model_id: str):
1507        """Recalculates all portfolios under a given model ID.
1508
1509        Args:
1510            model_id: the model ID
1511        Raises:
1512            BoostedAPIException: if the Boosted API request fails
1513        """
1514        url = self.base_uri + f"/api/models/{model_id}/recalc-all-portfolios"
1515        logger.info(f"Sending portfolio recalc requests for all portfolios under {model_id=}.")
1516        headers = {"Authorization": "ApiKey " + self.api_key}
1517        res = requests.put(url, headers=headers, **self._request_params)
1518        if not res.ok:
1519            error_msg = self._try_extract_error_code(res)
1520            logger.error(error_msg)
1521            raise BoostedAPIException(
1522                f"Failed to send recalc request for all portfolios under {model_id=} - {error_msg}."
1523            )
1524
1525    def sendPortfolioRecalc(self, portfolio_id: str):
1526        """Recalculates a single portfolio by its portfolio ID.
1527
1528        Args:
1529            portfolio_id: the portfolio ID to recalculate
1530        Raises:
1531            BoostedAPIException: if the Boosted API request fails
1532        """
1533        url = self.base_uri + "/api/graphql"
1534        logger.info(f"Sending portfolio recalc request for {portfolio_id=}.")
1535        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1536        qry = """
1537            mutation recalcPortfolio($input: RecalculatePortfolioInput!) {
1538                recalculatePortfolio(input: $input) {
1539                    success
1540                    errors
1541                }
1542            }
1543            """
1544        req_json = {
1545            "query": qry,
1546            "variables": {"input": {"portfolioId": f"{portfolio_id}", "allowForceRecalc": "true"}},
1547        }
1548        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
1549        if not res.ok or res.json().get("errors"):
1550            error_msg = self._try_extract_error_code(res)
1551            logger.error(error_msg)
1552            raise BoostedAPIException(
1553                f"Failed to send portfolio recalc request for {portfolio_id=} - {error_msg}."
1554            )
1555
1556    def add_uploaded_model_data(self, url, csv_data, request_data, timeout=600):
1557        logger.info("Starting upload.")
1558        headers = {"Authorization": "ApiKey " + self.api_key}
1559        files_req: Dict = {}
1560        target: Tuple[str, Any, str] = ("data.csv", None, "text/csv")
1561        warnings = []
1562        if isinstance(csv_data, pd.core.frame.DataFrame):
1563            buf = io.StringIO()
1564            csv_data.to_csv(buf, header=False)
1565            if not isinstance(csv_data.index, pd.core.indexes.datetimes.DatetimeIndex):
1566                raise BoostedAPIException("DataFrame must have DatetimeIndex as index type.")
1567            target = ("uploaded_data.csv", buf.getvalue(), "text/csv")
1568            files_req["dataFile"] = target
1569            res = requests.post(
1570                url,
1571                files=files_req,
1572                data=request_data,
1573                headers=headers,
1574                timeout=timeout,
1575                **self._request_params,
1576            )
1577        elif isinstance(csv_data, str):
1578            target = ("uploaded_data.csv", csv_data, "text/csv")
1579            files_req["dataFile"] = target
1580            res = requests.post(
1581                url,
1582                files=files_req,
1583                data=request_data,
1584                headers=headers,
1585                timeout=timeout,
1586                **self._request_params,
1587            )
1588        else:
1589            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
1590        if res.ok:
1591            logger.info("Signals upload completed.")
1592            result = res.json()["result"]
1593            if "warningMessages" in result:
1594                warnings = result["warningMessages"]
1595        else:
1596            error_str = "Signals upload failed: {0}, {1}".format(res.text, res.reason)
1597            logger.error(error_str)
1598            raise BoostedAPIException(error_str)
1599
1600        return res, warnings
1601
1602    def createSignalsModel(self, csv_data, model_name, timeout=600):
1603        warnings = []
1604        url = self.base_uri + "/api/models/upload/signals/create"
1605        request_data = {"modelName": model_name, "uploadName": model_name}
1606        res, warnings = self.add_uploaded_model_data(url, csv_data, request_data, timeout)
1607        result = res.json()["result"]
1608        model_id = result["modelId"]
1609        self.sendModelRecalc(model_id)
1610        return model_id, warnings
1611
1612    def addToUploadedModel(self, model_id, csv_data, timeout=600, recalc_model=True):
1613        warnings = []
1614        url = self.base_uri + "/api/models/{0}/upload/add-data".format(model_id)
1615        request_data: Dict = {}
1616        _, warnings = self.add_uploaded_model_data(url, csv_data, request_data, timeout)
1617        if recalc_model:
1618            self.sendModelRecalc(model_id)
1619        return warnings
1620
1621    def addSignalsToUploadedModel(
1622        self,
1623        model_id: str,
1624        csv_data: Union[pd.core.frame.DataFrame, str],
1625        timeout: int = 600,
1626        recalc_all: bool = False,
1627        recalc_portfolio_ids: Optional[List[str]] = None,
1628    ) -> List[str]:
1629        """
1630        Add signals to an uploaded model and then recalculate a random portfolio under that model.
1631
1632        Args:
1633            model_id: model ID
1634            csv_data: pandas DataFrame, or a string with signals to upload.
1635            timeout (optional): Timeout for initial upload request in seconds.
1636            recalc_all (optional): if True, recalculates all portfolios in the model.
1637            recalc_portfolio_ids (optional): List of portfolio IDs under the model to re-calculate.
1638        """
1639        warnings = self.addToUploadedModel(model_id, csv_data, timeout, recalc_model=False)
1640
1641        if recalc_all:
1642            self.sendRecalcAllModelPortfolios(model_id)
1643        elif recalc_portfolio_ids:
1644            for portfolio_id in recalc_portfolio_ids:
1645                self.sendPortfolioRecalc(portfolio_id)
1646        else:
1647            self.sendModelRecalc(model_id)
1648        return warnings
1649
1650    def getSignalsFromUploadedModel(self, model_id, date=None):
1651        date = self.__iso_format(date)
1652        url = self.base_uri + "/api/models/{0}/upload/signals".format(model_id)
1653        headers = {"Authorization": "ApiKey " + self.api_key}
1654        params = {"date": date}
1655        logger.info("Retrieving uploaded signals information")
1656        res = requests.get(url, params=params, headers=headers, **self._request_params)
1657        if res.ok:
1658            result = pd.DataFrame.from_dict(res.json()["result"])
1659            # ensure column order
1660            result = result[["date", "isin", "country", "currency", "weight"]]
1661            result["date"] = pd.to_datetime(result["date"], format="%Y-%m-%d")
1662            result = result.set_index("date")
1663            logger.info("Signals retrieval successful.")
1664            return result
1665        else:
1666            error_msg = self._try_extract_error_code(res)
1667            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
1668
1669    def getPortfolioSettings(self, portfolio_id, timeout=600):
1670        url = self.base_uri + "/api/portfolio-settings/{0}".format(portfolio_id)
1671        headers = {"Authorization": "ApiKey " + self.api_key}
1672        res = requests.get(url, headers=headers, **self._request_params)
1673        if res.ok:
1674            return PortfolioSettings(res.json())
1675        else:
1676            error_msg = self._try_extract_error_code(res)
1677            logger.error(error_msg)
1678            raise BoostedAPIException(
1679                "Failed to retrieve portfolio settings: {0}.".format(error_msg)
1680            )
1681
1682    def createPortfolioWithPortfolioSettings(
1683        self, model_id, portfolio_name, portfolio_description, portfolio_settings, timeout=600
1684    ):
1685        url = self.base_uri + "/api/models/{0}/constraints/add".format(model_id)
1686        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1687        setting_string = json.dumps(portfolio_settings.settings)
1688        logger.info("Creating new portfolio with specified setting: {}".format(setting_string))
1689        params = {
1690            "name": portfolio_name,
1691            "description": portfolio_description,
1692            "settings": setting_string,
1693            "validate": "true",
1694        }
1695        res = requests.put(url, json=params, headers=headers, **self._request_params)
1696        response = res.json()
1697        if res.ok:
1698            return response
1699        else:
1700            error_msg = self._try_extract_error_code(res)
1701            logger.error(error_msg)
1702            raise BoostedAPIException(
1703                "Failed to create portfolio with the specified settings: {0}.".format(error_msg)
1704            )
1705
1706    def getGbiIdFromIdentCountryCurrencyDate(
1707        self, ident_country_currency_dates: List[DateIdentCountryCurrency], timeout: int = 600
1708    ) -> List[Optional[GbiIdSecurity]]:
1709        url = self.base_uri + "/api/custom-stock-data/map-identifiers-simple"
1710        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1711        identifiers = [
1712            {
1713                "row": idx,
1714                "date": identifier.date,
1715                "isin": identifier.identifier if identifier.id_type == ColumnSubRole.ISIN else None,
1716                "symbol": (
1717                    identifier.identifier if identifier.id_type == ColumnSubRole.SYMBOL else None
1718                ),
1719                "countryPreference": identifier.country,
1720                "currencyPreference": identifier.currency,
1721            }
1722            for idx, identifier in enumerate(ident_country_currency_dates)
1723        ]
1724        params = json.dumps({"identifiers": identifiers})
1725        logger.info(
1726            "Retrieving GBI-ID mapping for {} identifier tuples...".format(
1727                len(ident_country_currency_dates)
1728            )
1729        )
1730        res = requests.post(url, data=params, headers=headers, **self._request_params)
1731
1732        if res.ok:
1733            result = res.json()
1734            warnings = result["warnings"]
1735            if warnings:
1736                for warning in warnings:
1737                    logger.warn(f"Mapping warning: {warning}")
1738            gbiSecurities = []
1739            for idx, ident in enumerate(result["mappedIdentifiers"]):
1740                if ident is None:
1741                    security = None
1742                else:
1743                    security = GbiIdSecurity(
1744                        ident["gbiId"],
1745                        ident_country_currency_dates[idx],
1746                        ident["symbol"],
1747                        ident["companyName"],
1748                    )
1749                gbiSecurities.append(security)
1750
1751            return gbiSecurities
1752        else:
1753            error_msg = self._try_extract_error_code(res)
1754            raise BoostedAPIException(
1755                "Failed to retrieve identifier mappings: {0}.".format(error_msg)
1756            )
1757
1758    # exists for backwards compatibility purposes.
1759    def getGbiIdFromIsinCountryCurrencyDate(self, isin_country_currency_dates, timeout=600):
1760        return self.getGbiIdFromIdentCountryCurrencyDate(
1761            ident_country_currency_dates=isin_country_currency_dates, timeout=timeout
1762        )
1763
1764    # model_id: str
1765    # returns: Dict[str, str] representing the translation from the rankings ID (feature refs)
1766    # to human readable names
1767    def __get_rankings_ref_translation(self, model_id: str) -> Dict[str, str]:
1768        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1769        feature_name_url = f"/api/models/{model_id}/advanced-explain/translate-feature-ref/"
1770        feature_name_res = requests.post(
1771            self.base_uri + feature_name_url,
1772            data=json.dumps({}),
1773            headers=headers,
1774            **self._request_params,
1775        )
1776
1777        if feature_name_res.ok:
1778            feature_name_dict = feature_name_res.json()
1779            return {
1780                id: "-".join(
1781                    [names["variable_name"], names["transform_name"], names["normalization_name"]]
1782                )
1783                for id, names in feature_name_dict.items()
1784            }
1785        else:
1786            raise Exception(
1787                """Failed to get feature names for model,
1788                    this model doesn't fully support rankings 2.0"""
1789            )
1790
1791    def getDatasetDates(self, dataset_id):
1792        url = self.base_uri + f"/api/datasets/{dataset_id}"
1793        headers = {"Authorization": "ApiKey " + self.api_key}
1794        res = requests.get(url, headers=headers, **self._request_params)
1795        if res.ok:
1796            dataset = res.json()
1797            valid_to_array = dataset.get("validTo")
1798            valid_to_date = None
1799            valid_from_array = dataset.get("validFrom")
1800            valid_from_date = None
1801            if valid_to_array:
1802                valid_to_date = datetime.date(
1803                    valid_to_array[0], valid_to_array[1], valid_to_array[2]
1804                )
1805            if valid_from_array:
1806                valid_from_date = datetime.date(
1807                    valid_from_array[0], valid_from_array[1], valid_from_array[2]
1808                )
1809            return {"validTo": valid_to_date, "validFrom": valid_from_date}
1810        else:
1811            error_msg = self._try_extract_error_code(res)
1812            logger.error(error_msg)
1813            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
1814
1815    def getRankingAnalysis(self, model_id, date):
1816        url = (
1817            self.base_uri
1818            + f"/api/explain-trades/analysis/{model_id}/{self.__iso_format(date)}/json"
1819        )
1820        headers = {"Authorization": "ApiKey " + self.api_key}
1821        analysis_res = requests.get(url, headers=headers, **self._request_params)
1822        if analysis_res.ok:
1823            ranking_dict = analysis_res.json()
1824            feature_name_dict = self.__get_rankings_ref_translation(model_id)
1825            columns = [feature_name_dict[col] for col in ranking_dict["columns"]]
1826
1827            df = protoCubeJsonDataToDataFrame(
1828                ranking_dict["data"],
1829                "Data Buckets",
1830                ranking_dict["rows"],
1831                "Feature Names",
1832                columns,
1833                ranking_dict["fields"],
1834            )
1835            return df
1836        else:
1837            error_msg = self._try_extract_error_code(analysis_res)
1838            logger.error(error_msg)
1839            raise BoostedAPIException("Failed to get ranking analysis: {0}.".format(error_msg))
1840
1841    def getExplainForPortfolio(
1842        self,
1843        model_id,
1844        portfolio_id,
1845        date,
1846        index_by_symbol: bool = False,
1847        index_by_all_metadata: bool = False,
1848    ):
1849        """
1850        Gets the ranking 2.0 explain data for the given model on the given date
1851        filtered by portfolio.
1852
1853        Parameters
1854        ----------
1855        model_id: str
1856            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
1857            button next to your model's name in the Model Summary Page in Boosted
1858            Insights.
1859        portfolio_id: str
1860            Portfolio ID.  Portfolio IDs can be retrieved from portfolio's configuration page.
1861        date: datetime.date or YYYY-MM-DD string
1862            Date of the data to retrieve.
1863        index_by_symbol: bool
1864            If true, index by stock symbol instead of ISIN.
1865        index_by_all_metadata: bool
1866            If true, index by all metadata: ISIN, stock symbol, currency, and country.
1867            Overrides index_by_symbol.
1868
1869        Returns
1870        -------
1871        pandas.DataFrame
1872            Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata
1873            and feature names, filtered by portfolio.
1874        ___
1875        """
1876        indices = ["Symbol", "ISINs", "Country", "Currency"]
1877        raw_explain_df = self.getRankingExplain(
1878            model_id, date, index_by_symbol=False, index_by_all_metadata=True
1879        )
1880        pa_ratings_dict = self.getRankingsForDate(portfolio_id, date, False)
1881
1882        ratings = pa_ratings_dict["rankings"]
1883        ratings_df = pd.DataFrame(ratings)
1884        ratings_df = ratings_df[["symbol", "isin", "country", "currency"]]
1885        ratings_df.columns = pd.Index(indices)
1886        ratings_df.set_index(indices, inplace=True)
1887
1888        # inner join to only get the securities in both data frames
1889        result_df = raw_explain_df.merge(ratings_df, left_index=True, right_index=True, how="inner")
1890
1891        # set index based on input parameters
1892        if index_by_symbol and not index_by_all_metadata:
1893            result_df = result_df.reset_index()
1894            result_df = result_df.drop(columns=["ISINs", "Currency", "Country"])
1895            result_df.set_index(["Symbol", "Feature Names"], inplace=True)
1896        elif not index_by_symbol and not index_by_all_metadata:
1897            result_df = result_df.reset_index()
1898            result_df = result_df.drop(columns=["Symbol", "Currency", "Country"])
1899            result_df.set_index(["ISINs", "Feature Names"], inplace=True)
1900
1901        return result_df
1902
1903    def getRankingExplain(
1904        self, model_id, date, index_by_symbol: bool = False, index_by_all_metadata: bool = False
1905    ):
1906        """
1907        Gets the ranking 2.0 explain data for the given model on the given date
1908
1909        Parameters
1910        ----------
1911        model_id: str
1912            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
1913            button next to your model's name in the Model Summary Page in Boosted
1914            Insights.
1915        date: datetime.date or YYYY-MM-DD string
1916            Date of the data to retrieve.
1917        index_by_symbol: bool
1918            If true, index by stock symbol instead of ISIN.
1919        index_by_all_metadata: bool
1920            If true, index by all metadata: ISIN, stock symbol, currency, and country.
1921            Overrides index_by_symbol.
1922
1923        Returns
1924        -------
1925        pandas.DataFrame
1926            Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata
1927            and feature names.
1928        ___
1929        """
1930        url = (
1931            self.base_uri + f"/api/explain-trades/explain/{model_id}/{self.__iso_format(date)}/json"
1932        )
1933        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1934        explain_res = requests.get(url, headers=headers, **self._request_params)
1935        if explain_res.ok:
1936            ranking_dict = explain_res.json()
1937            rows = ranking_dict["rows"]
1938            stock_summary_url = f"/api/stock-summaries/{model_id}"
1939            stock_summary_body = {"gbiIds": ranking_dict["rows"]}
1940            summary_res = requests.post(
1941                self.base_uri + stock_summary_url,
1942                data=json.dumps(stock_summary_body),
1943                headers=headers,
1944                **self._request_params,
1945            )
1946            if summary_res.ok:
1947                stock_summary = summary_res.json()
1948                if index_by_symbol:
1949                    rows = [stock_summary[row]["symbol"] for row in ranking_dict["rows"]]
1950                elif index_by_all_metadata:
1951                    rows = [
1952                        [
1953                            stock_summary[row]["isin"],
1954                            stock_summary[row]["symbol"],
1955                            stock_summary[row]["currency"],
1956                            stock_summary[row]["country"],
1957                        ]
1958                        for row in ranking_dict["rows"]
1959                    ]
1960                else:
1961                    rows = [stock_summary[row]["isin"] for row in ranking_dict["rows"]]
1962            else:
1963                error_msg = self._try_extract_error_code(summary_res)
1964                logger.error(error_msg)
1965                raise BoostedAPIException(
1966                    "Failed to get isin information ranking explain: {0}.".format(error_msg)
1967                )
1968
1969            feature_name_dict = self.__get_rankings_ref_translation(model_id)
1970            columns = [feature_name_dict[col] for col in ranking_dict["columns"]]
1971
1972            id_col_name = "Symbols" if index_by_symbol else "ISINs"
1973
1974            if index_by_all_metadata:
1975                pc_list = []
1976                pf = ranking_dict["data"]
1977                for row_idx, row in enumerate(rows):
1978                    for col_idx, col in enumerate(columns):
1979                        pc_list.append([row, col] + pf[row_idx]["columns"][col_idx]["fields"])
1980                df = pd.DataFrame(pc_list)
1981                df = df.set_axis(
1982                    ["Metadata", "Feature Names"] + ranking_dict["fields"], axis="columns"
1983                )
1984
1985                metadata_df = df["Metadata"].apply(pd.Series)
1986                metadata_df.columns = pd.Index(["ISINs", "Symbol", "Currency", "Country"])
1987                result_df = pd.concat([metadata_df, df], axis=1).drop("Metadata", axis=1)
1988                result_df.set_index(
1989                    ["ISINs", "Symbol", "Currency", "Country", "Feature Names"], inplace=True
1990                )
1991                return result_df
1992
1993            else:
1994                df = protoCubeJsonDataToDataFrame(
1995                    ranking_dict["data"],
1996                    id_col_name,
1997                    rows,
1998                    "Feature Names",
1999                    columns,
2000                    ranking_dict["fields"],
2001                )
2002
2003                return df
2004        else:
2005            error_msg = self._try_extract_error_code(explain_res)
2006            logger.error(error_msg)
2007            raise BoostedAPIException("Failed to get ranking explain: {0}.".format(error_msg))
2008
2009    def getDenseSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
2010        date = self.__iso_format(date)
2011        url = self.base_uri + f"/api/portfolios/{portfolio_id}/denseSignalsByDate"
2012        headers = {"Authorization": "ApiKey " + self.api_key}
2013        params = {
2014            "startDate": date,
2015            "endDate": date,
2016            "rollbackToMostRecentDate": rollback_to_last_available_date,
2017        }
2018        logger.info("Retrieving dense signals information for date {0}.".format(date))
2019        res = requests.get(url, params=params, headers=headers, **self._request_params)
2020        if res.ok:
2021            logger.info("Signals retrieval successful.")
2022            d = res.json()
2023            # reshape date to output format
2024            date = list(d["signals"].keys())[0]
2025            model_id = d["model_id"]
2026            signals_list = list(d["signals"].values())[0]
2027            return {"date": date, "signals": [{"model_id": model_id, "signals_info": signals_list}]}
2028        else:
2029            error_msg = self._try_extract_error_code(res)
2030            raise BoostedAPIException("Failed to retrieve dense signals: {0}.".format(error_msg))
2031
2032    def getDenseSignals(self, model_id, portfolio_id, file_name=None, location="./"):
2033        url = self.base_uri + f"/api/models/{model_id}/{portfolio_id}/dense-signals"
2034        headers = {"Authorization": "ApiKey " + self.api_key}
2035        res = requests.get(url, headers=headers, **self._request_params)
2036        if file_name is None:
2037            file_name = f"{model_id}-{portfolio_id}_dense_signals.csv"
2038        download_location = os.path.join(location, file_name)
2039        if res.ok:
2040            with open(download_location, "wb") as file:
2041                file.write(res.content)
2042            print("Download Complete")
2043        elif res.status_code == 404:
2044            raise BoostedAPIException(
2045                f"""Dense Singals file does not exist for model:
2046                 {model_id} - portfolio: {portfolio_id}"""
2047            )
2048        else:
2049            error_msg = self._try_extract_error_code(res)
2050            logger.error(error_msg)
2051            raise BoostedAPIException(
2052                f"""Failed to download dense singals file for model:
2053                 {model_id} - portfolio: {portfolio_id}"""
2054            )
2055
2056    def _getIsPortfolioReadyForProcessing(self, model_id, portfolio_id, formatted_date):
2057        headers = {"Authorization": "ApiKey " + self.api_key}
2058        url = (
2059            self.base_uri
2060            + f"/api/explain-trades/{model_id}/{portfolio_id}"
2061            + f"/is-ready-for-processing/{formatted_date}"
2062        )
2063        res = requests.get(url, headers=headers, **self._request_params)
2064
2065        try:
2066            if res.ok:
2067                body = res.json()
2068                if "ready" in body:
2069                    if body["ready"]:
2070                        return True, ""
2071                    else:
2072                        reason_from_api = (
2073                            body["notReadyReason"] if "notReadyReason" in body else "Unavailable"
2074                        )
2075
2076                        returned_reason = reason_from_api
2077
2078                        if returned_reason == "SKIP":
2079                            returned_reason = "holiday- market closed"
2080
2081                        if returned_reason == "WAITING":
2082                            returned_reason = "calculations pending"
2083
2084                        return False, returned_reason
2085                else:
2086                    return False, "Unavailable"
2087            else:
2088                error_msg = self._try_extract_error_code(res)
2089                logger.error(error_msg)
2090                raise BoostedAPIException(
2091                    f"""Failed to generate file for model:
2092                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2093                )
2094        except Exception as e:
2095            raise BoostedAPIException(
2096                f"""Failed to generate file for model:
2097                {model_id} - portfolio: {portfolio_id} on date: {formatted_date} {e}"""
2098            )
2099
2100    def getRanking2DateAnalysisFile(
2101        self, model_id, portfolio_id, date, file_name=None, location="./"
2102    ):
2103        formatted_date = self.__iso_format(date)
2104        s3_file_name = f"{formatted_date}_analysis.xlsx"
2105        download_url = (
2106            self.base_uri + f"/api/models/{model_id}/{portfolio_id}/ranking-file/{s3_file_name}"
2107        )
2108        headers = {"Authorization": "ApiKey " + self.api_key}
2109        if file_name is None:
2110            file_name = f"{model_id}-{portfolio_id}_statistical_analysis_{formatted_date}.xlsx"
2111        download_location = os.path.join(location, file_name)
2112
2113        res = requests.get(download_url, headers=headers, **self._request_params)
2114        if res.ok:
2115            with open(download_location, "wb") as file:
2116                file.write(res.content)
2117            print("Download Complete")
2118        elif res.status_code == 404:
2119            (
2120                is_portfolio_ready_for_processing,
2121                portfolio_ready_status,
2122            ) = self._getIsPortfolioReadyForProcessing(model_id, portfolio_id, formatted_date)
2123
2124            if not is_portfolio_ready_for_processing:
2125                logger.info(
2126                    f"""\nPortfolio {portfolio_id} for model {model_id}
2127                    on date {date} unavailable for Ranking2Date Analysis file.
2128                    Status: {portfolio_ready_status}\n"""
2129                )
2130                return
2131
2132            generate_url = (
2133                self.base_uri
2134                + f"/api/explain-trades/{model_id}/{portfolio_id}"
2135                + f"/generate/date-data/{formatted_date}"
2136            )
2137
2138            generate_res = requests.get(generate_url, headers=headers, **self._request_params)
2139            if generate_res.ok:
2140                download_res = requests.get(download_url, headers=headers, **self._request_params)
2141                while download_res.status_code == 404 or (
2142                    download_res.ok and len(download_res.content) == 0
2143                ):
2144                    print("waiting for file to be generated")
2145                    time.sleep(5)
2146                    download_res = requests.get(
2147                        download_url, headers=headers, **self._request_params
2148                    )
2149                if download_res.ok:
2150                    with open(download_location, "wb") as file:
2151                        file.write(download_res.content)
2152                    print("Download Complete")
2153            else:
2154                error_msg = self._try_extract_error_code(res)
2155                logger.error(error_msg)
2156                raise BoostedAPIException(
2157                    f"""Failed to generate ranking analysis file for model:
2158                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2159                )
2160        else:
2161            error_msg = self._try_extract_error_code(res)
2162            logger.error(error_msg)
2163            raise BoostedAPIException(
2164                f"""Failed to download ranking analysis file for model:
2165                 {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2166            )
2167
2168    def getRanking2DateExplainFile(
2169        self,
2170        model_id,
2171        portfolio_id,
2172        date,
2173        file_name=None,
2174        location="./",
2175        overwrite: bool = False,
2176        index_by_all_metadata: bool = False,
2177    ):
2178        """
2179        Downloads the ranking explain file for the provided portfolio and model.
2180        If no file exists then it will send a request to generate the file and continuously
2181        poll the server every 5 seconds to try and download the file until the file is downloaded.
2182
2183        Parameters
2184        ----------
2185        model_id: str
2186            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
2187            button next to your model's name in the Model Summary Page in Boosted
2188            Insights.
2189        portfolio_id: str
2190            Portfolio ID.  Portfolio IDs can be retrieved from portfolio's configuration page.
2191        date: datetime.date or YYYY-MM-DD string
2192            Date of the data to retrieve.
2193        file_name: str
2194            File name of the dense signals file to save as.
2195            If no file name is given the file name will be
2196            "<model_id>-<portfolio_id>_explain_data_<date>.xlsx"
2197        location: str
2198            The location to save the file to.
2199            If no location is given then it will be saved to the current directory.
2200        overwrite: bool
2201            Defaults to False, set to True to regenerate the file.
2202        index_by_all_metadata: bool
2203            If true, index by all metadata: ISIN, stock symbol, currency, and country.
2204
2205
2206        Returns
2207        -------
2208        None
2209        ___
2210        """
2211        formatted_date = self.__iso_format(date)
2212        if index_by_all_metadata:
2213            s3_file_name = f"{formatted_date}_explaindata_withmetadata.xlsx"
2214        else:
2215            s3_file_name = f"{formatted_date}_explaindata.xlsx"
2216        download_url = (
2217            self.base_uri + f"/api/models/{model_id}/{portfolio_id}/ranking-file/{s3_file_name}"
2218        )
2219        headers = {"Authorization": "ApiKey " + self.api_key}
2220        if file_name is None:
2221            file_name = f"{model_id}-{portfolio_id}_explain_data_{formatted_date}.xlsx"
2222        download_location = os.path.join(location, file_name)
2223
2224        if not overwrite:
2225            res = requests.get(download_url, headers=headers, **self._request_params)
2226        if not overwrite and res.ok:
2227            with open(download_location, "wb") as file:
2228                file.write(res.content)
2229            print("Download Complete")
2230        elif overwrite or res.status_code == 404:
2231            (
2232                is_portfolio_ready_for_processing,
2233                portfolio_ready_status,
2234            ) = self._getIsPortfolioReadyForProcessing(model_id, portfolio_id, formatted_date)
2235
2236            if not is_portfolio_ready_for_processing:
2237                logger.info(
2238                    f"""\nPortfolio {portfolio_id} for model {model_id}
2239                    on date {date} unavailable for Ranking2Date Explain file.
2240                    Status: {portfolio_ready_status}\n"""
2241                )
2242                return
2243
2244            generate_url = (
2245                self.base_uri
2246                + f"/api/explain-trades/{model_id}/{portfolio_id}"
2247                + f"/generate/date-data/{formatted_date}"
2248                + f"/{'true' if index_by_all_metadata else 'false'}"
2249            )
2250
2251            generate_res = requests.get(generate_url, headers=headers, **self._request_params)
2252            if generate_res.ok:
2253                download_res = requests.get(download_url, headers=headers, **self._request_params)
2254                while download_res.status_code == 404 or (
2255                    download_res.ok and len(download_res.content) == 0
2256                ):
2257                    print("waiting for file to be generated")
2258                    time.sleep(5)
2259                    download_res = requests.get(
2260                        download_url, headers=headers, **self._request_params
2261                    )
2262                if download_res.ok:
2263                    with open(download_location, "wb") as file:
2264                        file.write(download_res.content)
2265                    print("Download Complete")
2266            else:
2267                error_msg = self._try_extract_error_code(res)
2268                logger.error(error_msg)
2269                raise BoostedAPIException(
2270                    f"""Failed to generate ranking explain file for model:
2271                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2272                )
2273        else:
2274            error_msg = self._try_extract_error_code(res)
2275            logger.error(error_msg)
2276            raise BoostedAPIException(
2277                f"""Failed to download ranking explain file for model:
2278                 {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2279            )
2280
2281    def getRanking2DateExplain(
2282        self,
2283        model_id: str,
2284        portfolio_id: str,
2285        date: Optional[datetime.date],
2286        overwrite: bool = False,
2287    ) -> Dict[str, pd.DataFrame]:
2288        """
2289        Wrapper around getRanking2DateExplainFile, but returns a pandas
2290        dataframe instead of downloading to a path. Dataframe is indexed by
2291        symbol and should always have 'rating' and 'rating_delta' columns. Other
2292        columns will be determined by model's features.
2293        """
2294        file_name = "explaindata.xlsx"
2295        with tempfile.TemporaryDirectory() as tmpdirname:
2296            self.getRanking2DateExplainFile(
2297                model_id=model_id,
2298                portfolio_id=portfolio_id,
2299                date=date,
2300                file_name=file_name,
2301                location=tmpdirname,
2302                overwrite=overwrite,
2303            )
2304            full_path = os.path.join(tmpdirname, file_name)
2305            excel_file = pd.ExcelFile(full_path)
2306            df_map = pd.read_excel(excel_file, sheet_name=None)
2307            df_map_final = {str(sheet): df.set_index("Symbol") for (sheet, df) in df_map.items()}
2308
2309        return df_map_final
2310
2311    def getTearSheet(self, model_id, portfolio_id, start_date=None, end_date=None, block=False):
2312        if start_date is None or end_date is None:
2313            if start_date is not None or end_date is not None:
2314                raise ValueError("start_date and end_date must both be None or both be defined")
2315            return self._getCurrentTearSheet(model_id, portfolio_id)
2316
2317        start_date_obj = self.__to_date_obj(start_date)
2318        end_date_obj = self.__to_date_obj(end_date)
2319        if start_date_obj >= end_date_obj:
2320            raise ValueError("end_date must be later than the start_date")
2321
2322        # get for the given date
2323        url = self.base_uri + f"/api/analysis/keyfacts/{model_id}/{portfolio_id}"
2324        data = {
2325            "startDate": self.__iso_format(start_date),
2326            "endDate": self.__iso_format(end_date),
2327            "shouldRecalc": True,
2328        }
2329        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2330        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2331        if res.status_code == 404 and block:
2332            retries = 0
2333            data["shouldRecalc"] = False
2334            while retries < 10:
2335                time.sleep(10)
2336                retries += 1
2337                res = requests.post(
2338                    url, data=json.dumps(data), headers=headers, **self._request_params
2339                )
2340                if res.status_code != 404:
2341                    break
2342        if res.ok:
2343            return res.json()
2344        else:
2345            error_msg = self._try_extract_error_code(res)
2346            logger.error(error_msg)
2347            raise BoostedAPIException(
2348                "Failed to get tear sheet data: {0} {1}.".format(error_msg, str(res.status_code))
2349            )
2350
2351    def _getCurrentTearSheet(self, model_id, portfolio_id):
2352        url = self.base_uri + f"/api/model-summaries/{model_id}/{portfolio_id}"
2353        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2354        res = requests.get(url, headers=headers, **self._request_params)
2355        if res.ok:
2356            json = res.json()
2357            return json.get("tearSheet", {})
2358        else:
2359            error_msg = self._try_extract_error_code(res)
2360            logger.error(error_msg)
2361            raise BoostedAPIException("Failed to get tear sheet data: {0}.".format(error_msg))
2362
2363    def getPortfolioStatus(self, model_id, portfolio_id, job_date):
2364        url = (
2365            self.base_uri
2366            + f"/api/analysis/portfolioStatus/{model_id}/{portfolio_id}?jobDate={job_date}"
2367        )
2368        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2369        res = requests.get(url, headers=headers, **self._request_params)
2370        if res.ok:
2371            result = res.json()
2372            return {
2373                "is_complete": result["status"],
2374                "last_update": None if result["lastUpdate"] is None else result["lastUpdate"][:10],
2375                "next_update": None if result["nextUpdate"] is None else result["nextUpdate"][:10],
2376            }
2377        else:
2378            error_msg = self._try_extract_error_code(res)
2379            logger.error(error_msg)
2380            raise BoostedAPIException("Failed to get portfolio status: {0}".format(error_msg))
2381
2382    def _query_portfolio_factor_attribution(
2383        self,
2384        portfolio_id: str,
2385        start_date: Optional[BoostedDate] = None,
2386        end_date: Optional[BoostedDate] = None,
2387    ):
2388        response = self._get_graphql(
2389            query=graphql_queries.GET_PORTFOLIO_FACTOR_ATTRIBUTION_QUERY,
2390            variables={
2391                "portfolioId": portfolio_id,
2392                "startDate": str(start_date) if start_date else None,
2393                "endDate": str(end_date) if start_date else None,
2394            },
2395            error_msg_prefix="Failed to get factor attribution: ",
2396        )
2397        return response
2398
2399    def get_portfolio_factor_attribution(
2400        self,
2401        portfolio_id: str,
2402        start_date: Optional[BoostedDate] = None,
2403        end_date: Optional[BoostedDate] = None,
2404    ):
2405        """Get portfolio factor attribution for a portfolio
2406
2407        Args:
2408            portfolio_id (str): a valid UUID string
2409            start_date (BoostedDate, optional): The start date. Defaults to None.
2410            end_date (BoostedDate, optional): The end date. Defaults to None.
2411        """
2412        response = self._query_portfolio_factor_attribution(portfolio_id, start_date, end_date)
2413        factor_attribution = response["data"]["portfolio"]["factorAttribution"]
2414        dates = pd.DatetimeIndex(data=factor_attribution["dates"])
2415        beta = factor_attribution["factorBetas"]
2416        beta_df = pd.DataFrame(index=dates, data={x["name"]: x["data"] for x in beta})
2417        beta_df = beta_df.add_suffix("_beta")
2418        returns = factor_attribution["portfolioFactorPerformance"]
2419        returns_df = pd.DataFrame(index=dates, data={x["name"]: x["data"] for x in returns})
2420        returns_df = returns_df.add_suffix("_return")
2421        returns_df = (returns_df - 1) * 100
2422
2423        final_df = pd.concat([returns_df, beta_df], axis=1)
2424        ordered_columns = list(itertools.chain(*zip(returns_df.columns, beta_df.columns)))
2425        ordered_final_df = final_df.reindex(columns=ordered_columns)
2426
2427        # Add the column `total_return` which is the sum of returns_data
2428        ordered_final_df["total_return"] = returns_df.sum(axis=1)
2429        return ordered_final_df
2430
2431    def getBlacklist(self, blacklist_id):
2432        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2433        headers = {"Authorization": "ApiKey " + self.api_key}
2434        res = requests.get(url, headers=headers, **self._request_params)
2435        if res.ok:
2436            result = res.json()
2437            return result
2438        error_msg = self._try_extract_error_code(res)
2439        logger.error(error_msg)
2440        raise BoostedAPIException(f"Failed to get blacklist with id {blacklist_id}: {error_msg}")
2441
2442    def getBlacklists(self, model_id=None, company_id=None, last_N=None):
2443        params = {}
2444        if last_N:
2445            params["lastN"] = last_N
2446        if model_id:
2447            params["modelId"] = model_id
2448        if company_id:
2449            params["companyId"] = company_id
2450        url = self.base_uri + f"/api/blacklist"
2451        headers = {"Authorization": "ApiKey " + self.api_key}
2452        res = requests.get(url, headers=headers, params=params, **self._request_params)
2453        if res.ok:
2454            result = res.json()
2455            return result
2456        error_msg = self._try_extract_error_code(res)
2457        logger.error(error_msg)
2458        raise BoostedAPIException(
2459            f"""Failed to get blacklists with \
2460            model_id {model_id} company_id {company_id} last_N {last_N}: {error_msg}"""
2461        )
2462
2463    def createBlacklist(
2464        self,
2465        isin,
2466        long_short=2,
2467        start_date=datetime.date.today(),
2468        end_date="4000-01-01",
2469        model_id=None,
2470    ):
2471        url = self.base_uri + f"/api/blacklist"
2472        data = {
2473            "modelId": model_id,
2474            "isin": isin,
2475            "longShort": long_short,
2476            "startDate": self.__iso_format(start_date),
2477            "endDate": self.__iso_format(end_date),
2478        }
2479        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2480        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2481        if res.ok:
2482            return res.json()
2483        else:
2484            error_msg = self._try_extract_error_code(res)
2485            logger.error(error_msg)
2486            raise BoostedAPIException(
2487                f"""Failed to create the blacklist with \
2488                  isin {isin} long_short {long_short} start_date {start_date} end_date {end_date} \
2489                  model_id {model_id}: {error_msg}."""
2490            )
2491
2492    def createBlacklistsFromCSV(self, csv_name):
2493        url = self.base_uri + f"/api/blacklists"
2494        data = []
2495        with open(csv_name, mode="r") as f:
2496            csv_reader = csv.DictReader(f)
2497            for row in csv_reader:
2498                blacklist = {"modelId": row["ModelID"], "isin": row["ISIN"]}
2499                if not row.get("LongShort"):
2500                    blacklist["longShort"] = 2
2501                else:
2502                    blacklist["longShort"] = row["LongShort"]
2503
2504                if not row.get("StartDate"):
2505                    blacklist["startDate"] = self.__iso_format(datetime.date.today())
2506                else:
2507                    blacklist["startDate"] = self.__iso_format(row["StartDate"])
2508
2509                if not row.get("EndDate"):
2510                    blacklist["endDate"] = self.__iso_format("4000-01-01")
2511                else:
2512                    blacklist["endDate"] = self.__iso_format(row["EndDate"])
2513                data.append(blacklist)
2514        print(f"Processed {len(data)} blacklists.")
2515        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2516        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2517        if res.ok:
2518            return res.json()
2519        else:
2520            error_msg = self._try_extract_error_code(res)
2521            logger.error(error_msg)
2522            raise BoostedAPIException("failed to create blacklists")
2523
2524    def updateBlacklist(self, blacklist_id, long_short=None, start_date=None, end_date=None):
2525        params = {}
2526        if long_short:
2527            params["longShort"] = long_short
2528        if start_date:
2529            params["startDate"] = start_date
2530        if end_date:
2531            params["endDate"] = end_date
2532        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2533        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2534        res = requests.patch(url, json=params, headers=headers, **self._request_params)
2535        if res.ok:
2536            return res.json()
2537        else:
2538            error_msg = self._try_extract_error_code(res)
2539            logger.error(error_msg)
2540            raise BoostedAPIException(
2541                f"Failed to update blacklist with id {blacklist_id}: {error_msg}"
2542            )
2543
2544    def deleteBlacklist(self, blacklist_id):
2545        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2546        headers = {"Authorization": "ApiKey " + self.api_key}
2547        res = requests.delete(url, headers=headers, **self._request_params)
2548        if res.ok:
2549            result = res.json()
2550            return result
2551        else:
2552            error_msg = self._try_extract_error_code(res)
2553            logger.error(error_msg)
2554            raise BoostedAPIException(
2555                f"Failed to delete blacklist with id {blacklist_id}: {error_msg}"
2556            )
2557
2558    def getFeatureImportance(self, model_id, date, N=None):
2559        url = self.base_uri + f"/api/analysis/explainability/{model_id}"
2560        headers = {"Authorization": "ApiKey " + self.api_key}
2561        logger.info("Retrieving rankings information for date {0}.".format(date))
2562        res = requests.get(url, headers=headers, **self._request_params)
2563        if not res.ok:
2564            error_msg = self._try_extract_error_code(res)
2565            logger.error(error_msg)
2566            raise BoostedAPIException(
2567                f"Failed to fetch feature importance for model/portfolio {model_id}: {error_msg}"
2568            )
2569
2570        json_data = res.json()
2571        if "all" not in json_data.keys() or not json_data["all"]:
2572            raise BoostedAPIException(f"Unexpected formatting of feature importance response")
2573
2574        feature_data = json_data["all"]
2575        # find the right period (assuming returned json has dates in descending order)
2576        date_obj = self.__to_date_obj(date)
2577        start_date_for_return_data = self.__to_date_obj(feature_data[0]["date"])
2578        features_for_requested_period = None
2579
2580        if date_obj > start_date_for_return_data:
2581            features_for_requested_period = feature_data[0]["variable"]
2582        else:
2583            i = 0
2584            while i < len(feature_data) - 1:
2585                current_date = self.__to_date_obj(feature_data[i]["date"])
2586                next_date = self.__to_date_obj(feature_data[i + 1]["date"])
2587                if next_date <= date_obj <= current_date:
2588                    features_for_requested_period = feature_data[i + 1]["variable"]
2589                    start_date_for_return_data = next_date
2590                    break
2591                i += 1
2592
2593        if features_for_requested_period is None:
2594            raise BoostedAPIException(f"No feature data was found for requested date: {date_obj}")
2595
2596        features_for_requested_period.sort(key=lambda x: x["value"], reverse=True)
2597
2598        if type(N) is int and N > 0:
2599            df = pd.DataFrame.from_dict(features_for_requested_period[0:N])
2600        else:
2601            df = pd.DataFrame.from_dict(features_for_requested_period)
2602        result = df[["feature", "value"]]
2603
2604        return result.rename(columns={"feature": f"feature ({start_date_for_return_data})"})
2605
2606    def getAllModelNames(self) -> Dict[str, str]:
2607        url = f"{self.base_uri}/api/graphql"
2608        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2609        req_json = {"query": "query listOfModels {\n models { id name }}", "variables": {}}
2610        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
2611        if not res.ok:
2612            error_msg = self._try_extract_error_code(res)
2613            logger.error(error_msg)
2614            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
2615        data = res.json()
2616        if data["data"]["models"] is None:
2617            return {}
2618        return {rec["id"]: rec["name"] for rec in data["data"]["models"]}
2619
2620    def getAllModelDetails(self) -> Dict[str, Dict[str, Any]]:
2621        url = f"{self.base_uri}/api/graphql"
2622        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2623        req_json = {
2624            "query": "query listOfModels {\n models { id name lastUpdated portfolios { id name }}}",
2625            "variables": {},
2626        }
2627        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
2628        if not res.ok:
2629            error_msg = self._try_extract_error_code(res)
2630            logger.error(error_msg)
2631            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
2632        data = res.json()
2633        if data["data"]["models"] is None:
2634            return {}
2635
2636        output_data = {}
2637        for rec in data["data"]["models"]:
2638            model_id = rec["id"]
2639            output_data[model_id] = {
2640                "name": rec["name"],
2641                "last_updated": parser.parse(rec["lastUpdated"]),
2642                "portfolios": rec["portfolios"],
2643            }
2644
2645        return output_data
2646
2647    def get_hedge_experiments(self):
2648        url = self.base_uri + "/api/graphql"
2649        qry = """
2650            query getHedgeExperiments {
2651                hedgeExperiments {
2652                    hedgeExperimentId
2653                    experimentName
2654                    userId
2655                    config
2656                    description
2657                    experimentType
2658                    lastCalculated
2659                    lastModified
2660                    status
2661                    portfolioCalcStatus
2662                    targetSecurities {
2663                        gbiId
2664                        security {
2665                            gbiId
2666                            symbol
2667                            name
2668                        }
2669                        weight
2670                    }
2671                    targetPortfolios {
2672                        portfolioId
2673                    }
2674                    baselineModel {
2675                        id
2676                        name
2677
2678                    }
2679                    baselineScenario {
2680                        hedgeExperimentScenarioId
2681                        scenarioName
2682                        description
2683                        portfolioSettingsJson
2684                        hedgeExperimentPortfolios {
2685                            portfolio {
2686                                id
2687                                name
2688                                modelId
2689                                performanceGridHeader
2690                                performanceGrid
2691                                status
2692                                tearSheet {
2693                                    groupName
2694                                    members {
2695                                        name
2696                                        value
2697                                    }
2698                                }
2699                            }
2700                        }
2701                        status
2702                    }
2703                    baselineStockUniverseId
2704                }
2705            }
2706        """
2707
2708        headers = {"Authorization": "ApiKey " + self.api_key}
2709        resp = requests.post(url, json={"query": qry}, headers=headers, params=self._request_params)
2710
2711        json_resp = resp.json()
2712        # graphql endpoints typically return 200 or 400 status codes, so we must
2713        # check if we have any errors, even with a 200
2714        if (resp.ok and "errors" in json_resp) or not resp.ok:
2715            error_msg = self._try_extract_error_code(resp)
2716            logger.error(error_msg)
2717            raise BoostedAPIException(
2718                (f"Failed to get hedge experiments: {resp.status_code=}; {error_msg=}")
2719            )
2720
2721        json_experiments = resp.json()["data"]["hedgeExperiments"]
2722        experiments = [HedgeExperiment.from_json_dict(exp_json) for exp_json in json_experiments]
2723        return experiments
2724
2725    def get_hedge_experiment_details(self, experiment_id: str):
2726        url = self.base_uri + "/api/graphql"
2727        qry = """
2728            query getHedgeExperimentDetails($hedgeExperimentId: ID!) {
2729                hedgeExperiment(hedgeExperimentId: $hedgeExperimentId) {
2730                ...HedgeExperimentDetailsSummaryListFragment
2731                }
2732            }
2733
2734            fragment HedgeExperimentDetailsSummaryListFragment on HedgeExperiment {
2735                hedgeExperimentId
2736                experimentName
2737                userId
2738                config
2739                description
2740                experimentType
2741                lastCalculated
2742                lastModified
2743                status
2744                portfolioCalcStatus
2745                targetSecurities {
2746                    gbiId
2747                    security {
2748                        gbiId
2749                        symbol
2750                        name
2751                    }
2752                    weight
2753                }
2754                selectedModels {
2755                    id
2756                    name
2757                    stockUniverse {
2758                        name
2759                    }
2760                }
2761                hedgeExperimentScenarios {
2762                    ...experimentScenarioFragment
2763                }
2764                selectedDummyHedgeExperimentModels {
2765                    id
2766                    name
2767                    stockUniverse {
2768                        name
2769                    }
2770                }
2771                targetPortfolios {
2772                    portfolioId
2773                }
2774                baselineModel {
2775                    id
2776                    name
2777
2778                }
2779                baselineScenario {
2780                    hedgeExperimentScenarioId
2781                    scenarioName
2782                    description
2783                    portfolioSettingsJson
2784                    hedgeExperimentPortfolios {
2785                        portfolio {
2786                            id
2787                            name
2788                            modelId
2789                            performanceGridHeader
2790                            performanceGrid
2791                            status
2792                            tearSheet {
2793                                groupName
2794                                members {
2795                                    name
2796                                    value
2797                                }
2798                            }
2799                        }
2800                    }
2801                    status
2802                }
2803                baselineStockUniverseId
2804            }
2805
2806            fragment experimentScenarioFragment on HedgeExperimentScenario {
2807                hedgeExperimentScenarioId
2808                scenarioName
2809                status
2810                description
2811                portfolioSettingsJson
2812                hedgeExperimentPortfolios {
2813                    portfolio {
2814                        id
2815                        name
2816                        modelId
2817                        performanceGridHeader
2818                        performanceGrid
2819                        status
2820                        tearSheet {
2821                            groupName
2822                            members {
2823                                name
2824                                value
2825                            }
2826                        }
2827                    }
2828                }
2829            }
2830        """
2831        headers = {"Authorization": "ApiKey " + self.api_key}
2832        resp = requests.post(
2833            url,
2834            json={"query": qry, "variables": {"hedgeExperimentId": experiment_id}},
2835            headers=headers,
2836            params=self._request_params,
2837        )
2838
2839        json_resp = resp.json()
2840        # graphql endpoints typically return 200 or 400 status codes, so we must
2841        # check if we have any errors, even with a 200
2842        if (resp.ok and "errors" in json_resp) or not resp.ok:
2843            error_msg = self._try_extract_error_code(resp)
2844            logger.error(error_msg)
2845            raise BoostedAPIException(
2846                (
2847                    f"Failed to get hedge experiment results for {experiment_id=}: "
2848                    f"{resp.status_code=}; {error_msg=}"
2849                )
2850            )
2851
2852        json_exp_results = json_resp["data"]["hedgeExperiment"]
2853        if json_exp_results is None:
2854            return None  # issued a request with a non-existent experiment_id
2855        exp_results = HedgeExperimentDetails.from_json_dict(json_exp_results)
2856        return exp_results
2857
2858    def get_portfolio_performance(
2859        self,
2860        portfolio_id: str,
2861        start_date: Optional[datetime.date],
2862        end_date: Optional[datetime.date],
2863        daily_returns: bool,
2864    ) -> pd.DataFrame:
2865        """
2866        Get performance data for a portfolio.
2867
2868        Parameters
2869        ----------
2870        portfolio_id: str
2871            UUID corresponding to the portfolio in question.
2872        start_date: datetime.date
2873            Starting cutoff date to filter performance data
2874        end_date: datetime.date
2875            Ending cutoff date to filter performance data
2876        daily_returns: bool
2877            Flag indicating whether to add a new column with the daily return pct calculated
2878
2879        Returns
2880        -------
2881        pd.DataFrame object
2882            Portfolio and benchmark performance.
2883            -index:
2884                "date": pd.DatetimeIndex
2885            -columns:
2886                "benchmark": benchmark performance, % return
2887                "turnover": portfolio turnover, % of equity
2888                "portfolio": return since beginning of portfolio, % return
2889                "daily_returns": daily percent change in value of the portfolio, % return
2890                                (this column is optional and depends on the daily_returns flag)
2891        """
2892        url = f"{self.base_uri}/api/graphql"
2893        qry = """
2894            query getPortfolioPerformance($portfolioId: ID!) {
2895                portfolio(id: $portfolioId) {
2896                    id
2897                    modelId
2898                    name
2899                    status
2900                    performance {
2901                        benchmark
2902                        date
2903                        turnover
2904                        value
2905                    }
2906                }
2907            }
2908        """
2909
2910        headers = {"Authorization": "ApiKey " + self.api_key}
2911        resp = requests.post(
2912            url,
2913            json={"query": qry, "variables": {"portfolioId": portfolio_id}},
2914            headers=headers,
2915            params=self._request_params,
2916        )
2917
2918        json_resp = resp.json()
2919        # the webserver returns an error for non-ready portfolios, so we have to check
2920        # for this prior to the error check below
2921        pf = json_resp["data"].get("portfolio")
2922        if pf is not None and pf["status"] != "READY":
2923            return pd.DataFrame()
2924
2925        # graphql endpoints typically return 200 or 400 status codes, so we must
2926        # check if we have any errors, even with a 200
2927        if (resp.ok and "errors" in json_resp) or not resp.ok:
2928            error_msg = self._try_extract_error_code(resp)
2929            logger.error(error_msg)
2930            raise BoostedAPIException(
2931                (
2932                    f"Failed to get portfolio performance for {portfolio_id=}: "
2933                    f"{resp.status_code=}; {error_msg=}"
2934                )
2935            )
2936
2937        perf = json_resp["data"]["portfolio"]["performance"]
2938        df = pd.DataFrame(perf).set_index("date").rename(columns={"value": "portfolio"})
2939        df.index = pd.to_datetime(df.index)
2940        if daily_returns:
2941            df["daily_returns"] = pd.to_numeric(df["portfolio"]).pct_change()
2942            df = df.dropna(subset=["daily_returns"])
2943        if start_date:
2944            df = df[df.index >= pd.to_datetime(start_date)]
2945        if end_date:
2946            df = df[df.index <= pd.to_datetime(end_date)]
2947        return df.astype(float)
2948
2949    def _is_portfolio_still_running(self, error_msg: str) -> bool:
2950        # this is jank af. a proper fix of this is either at the webserver
2951        # returning a better response for a portfolio in draft HT2-226, OR
2952        # a bigger refactor of the API that moves to more OOP, which would allow us
2953        # to have this data all in one place
2954        return "Could not find a model with this ID" in error_msg
2955
2956    def get_portfolio_factors(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
2957        url = f"{self.base_uri}/api/analysis/factors/{model_id}/{portfolio_id}"
2958        headers = {"Authorization": "ApiKey " + self.api_key}
2959        resp = requests.get(url, headers=headers, params=self._request_params)
2960
2961        json_resp = resp.json()
2962        if (resp.ok and "errors" in json_resp) or not resp.ok:
2963            error_msg = json_resp["errors"][0]
2964            if self._is_portfolio_still_running(error_msg):
2965                return pd.DataFrame()
2966            logger.error(error_msg)
2967            raise BoostedAPIException(
2968                (
2969                    f"Failed to get portfolio factors for {portfolio_id=}: "
2970                    f"{resp.status_code=}; {error_msg=}"
2971                )
2972            )
2973
2974        df = pd.DataFrame(json_resp["data"], columns=json_resp["header_row"])
2975
2976        def to_lower_snake_case(s):  # why are we linting lambdas? :(
2977            return "_".join(w.lower() for w in s.split(" "))
2978
2979        df = df.rename(columns={old: to_lower_snake_case(old) for old in df.columns}).set_index(
2980            "date"
2981        )
2982        df.index = pd.to_datetime(df.index)
2983        return df
2984
2985    def get_portfolio_volatility(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
2986        url = f"{self.base_uri}/api/analysis/volatility_rolling/{model_id}/{portfolio_id}"
2987        headers = {"Authorization": "ApiKey " + self.api_key}
2988        resp = requests.get(url, headers=headers, params=self._request_params)
2989
2990        json_resp = resp.json()
2991        if (resp.ok and "errors" in json_resp) or not resp.ok:
2992            error_msg = json_resp["errors"][0]
2993            if self._is_portfolio_still_running(error_msg):
2994                return pd.DataFrame()
2995            logger.error(error_msg)
2996            raise BoostedAPIException(
2997                (
2998                    f"Failed to get portfolio volatility for {portfolio_id=}: "
2999                    f"{resp.status_code=}; {error_msg=}"
3000                )
3001            )
3002
3003        df = pd.DataFrame(json_resp["data"], columns=json_resp["headerRow"])
3004        df = df.rename(
3005            columns={old: old.lower().replace("avg", "avg_") for old in df.columns}  # type: ignore
3006        ).set_index("date")
3007        df.index = pd.to_datetime(df.index)
3008        return df
3009
3010    def get_portfolio_holdings(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
3011        url = f"{self.base_uri}/api/models/{model_id}/{portfolio_id}/basket-data"
3012        headers = {"Authorization": "ApiKey " + self.api_key}
3013        resp = requests.get(url, headers=headers, params=self._request_params)
3014
3015        # this is a classic abuse of try/except as control flow: we try to get json body
3016        # from the response so that we can error-check. if this fails, we assume we have
3017        # a legit text response (corresponding to the csv data we care about)
3018        try:
3019            json_resp = resp.json()
3020        except json.decoder.JSONDecodeError:
3021            df = pd.read_csv(io.StringIO(resp.text), header=[0])
3022        else:
3023            error_msg = json_resp["errors"][0]
3024            if self._is_portfolio_still_running(error_msg):
3025                return pd.DataFrame()
3026            else:
3027                logger.error(error_msg)
3028                raise BoostedAPIException(
3029                    (
3030                        f"Failed to get portfolio holdings for {portfolio_id=}: "
3031                        f"{resp.status_code=}; {error_msg=}"
3032                    )
3033                )
3034
3035        df = df.rename(columns={old: old.lower() for old in df.columns}).set_index("date")
3036        df.index = pd.to_datetime(df.index)
3037        return df
3038
3039    def getStockDataTableForDate(
3040        self, model_id: str, portfolio_id: str, date: datetime.date
3041    ) -> pd.DataFrame:
3042        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3043
3044        url_base = f"{self.base_uri}/api/analysis"
3045        url_params = f"{model_id}/{portfolio_id}"
3046        formatted_date = date.strftime("%Y-%m-%d")
3047
3048        stock_prices_url = f"{url_base}/stock-prices/{url_params}/{formatted_date}"
3049        stock_factors_url = f"{url_base}/stock-factors/{url_params}/date/{formatted_date}"
3050
3051        prices_params = {"useTicker": "false", "useCurrentSignals": "true"}
3052        factors_param = {"useTicker": "false", "useCurrentSignals": "true"}
3053
3054        prices_resp = requests.get(
3055            stock_prices_url, headers=headers, params=prices_params, **self._request_params
3056        )
3057        factors_resp = requests.get(
3058            stock_factors_url, headers=headers, params=factors_param, **self._request_params
3059        )
3060
3061        frames = []
3062        gbi_ids = set()
3063        for res in (prices_resp, factors_resp):
3064            if not res.ok:
3065                error_msg = self._try_extract_error_code(res)
3066                logger.error(error_msg)
3067                raise BoostedAPIException(
3068                    (
3069                        f"Failed to fetch stock data table for model {model_id}"
3070                        f" (it's possible no data is present for the given date: {date})."
3071                        f" Error message: {error_msg}"
3072                    )
3073                )
3074            result = res.json()
3075            df = pd.DataFrame(result)
3076            gbi_ids.update(df.columns.to_list())
3077            frames.append(pd.DataFrame(result))
3078
3079        all_gbiid_df = pd.concat(frames)
3080
3081        # Get the metadata of all GBI IDs
3082        gbiid_metadata_res = self._get_graphql(
3083            query=graphql_queries.GET_SEC_INFO_QRY, variables={"ids": [int(x) for x in gbi_ids]}
3084        )
3085        # Build a DF of metadata x GBI IDs
3086        gbiid_metadata_df = pd.DataFrame(
3087            {str(x["gbiId"]): x for x in gbiid_metadata_res["data"]["securities"]}
3088        )
3089        # Slice metadata we care. We'll drop "symbol" at the end.
3090        isin_country_currency_df = gbiid_metadata_df.loc[["isin", "country", "currency", "symbol"]]
3091        # Concatenate metadata to the existing stock data DF
3092        all_gbiid_with_metadata_df = pd.concat([all_gbiid_df, isin_country_currency_df])
3093        gbiid_with_symbol_df = all_gbiid_with_metadata_df.loc[
3094            :, all_gbiid_with_metadata_df.loc["symbol"].notna()
3095        ]
3096        renamed_df = gbiid_with_symbol_df.rename(
3097            index={"isin": "ISIN"}, columns=gbiid_with_symbol_df.loc["symbol"].to_dict()
3098        )
3099        output_df = renamed_df.drop(index=["symbol"])
3100        return output_df
3101
3102    def add_hedge_experiment_scenario(
3103        self,
3104        experiment_id: str,
3105        scenario_name: str,
3106        scenario_settings: PortfolioSettings,
3107        run_scenario_immediately: bool,
3108    ) -> HedgeExperimentScenario:
3109        add_scenario_input = {
3110            "hedgeExperimentId": experiment_id,
3111            "scenarioName": scenario_name,
3112            "portfolioSettingsJson": str(scenario_settings),
3113            "runExperimentOnScenario": run_scenario_immediately,
3114            "createDefaultPortfolio": "false",
3115        }
3116        qry = """
3117            mutation addHedgeExperimentScenario(
3118                $input: AddHedgeExperimentScenarioInput!
3119            ) {
3120                addHedgeExperimentScenario(input: $input) {
3121                    hedgeExperimentScenario {
3122                        hedgeExperimentScenarioId
3123                        scenarioName
3124                        description
3125                        portfolioSettingsJson
3126                    }
3127                }
3128            }
3129
3130        """
3131
3132        url = f"{self.base_uri}/api/graphql"
3133
3134        resp = requests.post(
3135            url,
3136            headers={"Authorization": "ApiKey " + self.api_key},
3137            json={"query": qry, "variables": {"input": add_scenario_input}},
3138        )
3139
3140        json_resp = resp.json()
3141        if (resp.ok and "errors" in json_resp) or not resp.ok:
3142            error_msg = self._try_extract_error_code(resp)
3143            logger.error(error_msg)
3144            raise BoostedAPIException(
3145                (f"Failed to add scenario: {resp.status_code=}; {error_msg=}")
3146            )
3147
3148        scenario_dict = json_resp["data"]["addHedgeExperimentScenario"]["hedgeExperimentScenario"]
3149        if scenario_dict is None:
3150            raise BoostedAPIException(
3151                "Failed to add scenario, likely due to bad experiment id or api key"
3152            )
3153        s = HedgeExperimentScenario.from_json_dict(scenario_dict)
3154        return s
3155
3156    # experiment life cycle has 4 steps:
3157    # 1. creation - essentially a very simple registration of a new instance, returning an id
3158    # 2. modify - populate with settings
3159    # 3. start - run the experiment
3160    # 4. delete - drop the experiment
3161    # while i would prefer to just have 2 funcs for (1,2,3) and (4) for a simpler api,
3162    # we need to expose finer-grained control becuase of how scenarios work.
3163    def create_hedge_experiment(
3164        self,
3165        name: str,
3166        description: str,
3167        experiment_type: hedge_experiment_type,
3168        target_securities: Union[Dict[GbiIdSecurity, float], str, None],
3169    ) -> HedgeExperiment:
3170        # we don't pass target_securities here (as much as id like to) because the
3171        # graphql input doesn't support it at this point
3172
3173        # note that this query returns a lot of null fields at this point, but
3174        # they are necessary for building a HE.
3175        create_qry = """
3176            mutation createDraftMutation($input: CreateHedgeExperimentDraftInput!) {
3177                createHedgeExperimentDraft(input: $input) {
3178                    hedgeExperiment {
3179                        hedgeExperimentId
3180                        experimentName
3181                        userId
3182                        config
3183                        description
3184                        experimentType
3185                        lastCalculated
3186                        lastModified
3187                        status
3188                        portfolioCalcStatus
3189                        targetSecurities {
3190                            gbiId
3191                            security {
3192                                gbiId
3193                                name
3194                                symbol
3195                            }
3196                            weight
3197                        }
3198                        baselineModel {
3199                            id
3200                            name
3201                        }
3202                        baselineScenario {
3203                            hedgeExperimentScenarioId
3204                            scenarioName
3205                            description
3206                            portfolioSettingsJson
3207                            hedgeExperimentPortfolios {
3208                                portfolio {
3209                                    id
3210                                    name
3211                                    modelId
3212                                    performanceGridHeader
3213                                    performanceGrid
3214                                    status
3215                                    tearSheet {
3216                                        groupName
3217                                        members {
3218                                            name
3219                                            value
3220                                        }
3221                                    }
3222                                }
3223                            }
3224                            status
3225                        }
3226                        baselineStockUniverseId
3227                    }
3228                }
3229            }
3230        """
3231
3232        create_input: Dict[str, Any] = {
3233            "name": name,
3234            "experimentType": experiment_type,
3235            "description": description,
3236        }
3237        if isinstance(target_securities, dict):
3238            create_input["setTargetSecurities"] = [
3239                {"gbiId": sec.gbi_id, "weight": weight}
3240                for (sec, weight) in target_securities.items()
3241            ]
3242        elif isinstance(target_securities, str):
3243            create_input["setTargetPortfolios"] = [{"portfolioId": target_securities}]
3244        elif target_securities is None:
3245            pass
3246        else:
3247            raise TypeError(
3248                "Expected value of type Union[Dict[GbiIdSecurity, str], str] for "
3249                f"argument 'target_securities'; got {type(target_securities)}"
3250            )
3251        resp = requests.post(
3252            f"{self.base_uri}/api/graphql",
3253            json={"query": create_qry, "variables": {"input": create_input}},
3254            headers={"Authorization": "ApiKey " + self.api_key},
3255            params=self._request_params,
3256        )
3257
3258        json_resp = resp.json()
3259        if (resp.ok and "errors" in json_resp) or not resp.ok:
3260            error_msg = self._try_extract_error_code(resp)
3261            logger.error(error_msg)
3262            raise BoostedAPIException(
3263                (f"Failed to create hedge experiment: {resp.status_code=}; {error_msg=}")
3264            )
3265
3266        exp_dict = json_resp["data"]["createHedgeExperimentDraft"]["hedgeExperiment"]
3267        experiment = HedgeExperiment.from_json_dict(exp_dict)
3268        return experiment
3269
3270    def modify_hedge_experiment(
3271        self,
3272        experiment_id: str,
3273        name: Optional[str] = None,
3274        description: Optional[str] = None,
3275        experiment_type: Optional[hedge_experiment_type] = None,
3276        target_securities: Union[Dict[GbiIdSecurity, float], str, None] = None,
3277        model_ids: Optional[List[str]] = None,
3278        stock_universe_ids: Optional[List[str]] = None,
3279        create_default_scenario: bool = True,
3280        baseline_model_id: Optional[str] = None,
3281        baseline_stock_universe_id: Optional[str] = None,
3282        baseline_portfolio_settings: Optional[str] = None,
3283    ) -> HedgeExperiment:
3284        mod_qry = """
3285            mutation modifyHedgeExperimentDraft(
3286                $input: ModifyHedgeExperimentDraftInput!
3287            ) {
3288                modifyHedgeExperimentDraft(input: $input) {
3289                    hedgeExperiment {
3290                    ...HedgeExperimentSelectedSecuritiesPageFragment
3291                    }
3292                }
3293            }
3294
3295            fragment HedgeExperimentSelectedSecuritiesPageFragment on HedgeExperiment {
3296                hedgeExperimentId
3297                experimentName
3298                userId
3299                config
3300                description
3301                experimentType
3302                lastCalculated
3303                lastModified
3304                status
3305                portfolioCalcStatus
3306                targetSecurities {
3307                    gbiId
3308                    security {
3309                        gbiId
3310                        name
3311                        symbol
3312                    }
3313                    weight
3314                }
3315                targetPortfolios {
3316                    portfolioId
3317                }
3318                baselineModel {
3319                    id
3320                    name
3321                }
3322                baselineScenario {
3323                    hedgeExperimentScenarioId
3324                    scenarioName
3325                    description
3326                    portfolioSettingsJson
3327                    hedgeExperimentPortfolios {
3328                        portfolio {
3329                            id
3330                            name
3331                            modelId
3332                            performanceGridHeader
3333                            performanceGrid
3334                            status
3335                            tearSheet {
3336                                groupName
3337                                members {
3338                                    name
3339                                    value
3340                                }
3341                            }
3342                        }
3343                    }
3344                    status
3345                }
3346                baselineStockUniverseId
3347            }
3348        """
3349        mod_input = {
3350            "hedgeExperimentId": experiment_id,
3351            "createDefaultScenario": create_default_scenario,
3352        }
3353        if name is not None:
3354            mod_input["newExperimentName"] = name
3355        if description is not None:
3356            mod_input["newExperimentDescription"] = description
3357        if experiment_type is not None:
3358            mod_input["newExperimentType"] = experiment_type
3359        if model_ids is not None:
3360            mod_input["setSelectdModels"] = model_ids
3361        if stock_universe_ids is not None:
3362            mod_input["selectedStockUniverseIds"] = stock_universe_ids
3363        if baseline_model_id is not None:
3364            mod_input["setBaselineModel"] = baseline_model_id
3365        if baseline_stock_universe_id is not None:
3366            mod_input["setBaselineStockUniverse"] = baseline_stock_universe_id
3367        if baseline_portfolio_settings is not None:
3368            mod_input["setBaselinePortfolioSettings"] = baseline_portfolio_settings
3369        # note that the behaviors bound to these data are mutually exclusive,
3370        # and its possible the opposite was set earlier in the DRAFT phase
3371        # of experiment creation, so when setting one, we must unset the other
3372        if isinstance(target_securities, dict):
3373            mod_input["setTargetSecurities"] = [
3374                {"gbiId": sec.gbi_id, "weight": weight}
3375                for (sec, weight) in target_securities.items()
3376            ]
3377            mod_input["setTargetPortfolios"] = None
3378        elif isinstance(target_securities, str):
3379            mod_input["setTargetPortfolios"] = [{"portfolioId": target_securities}]
3380            mod_input["setTargetSecurities"] = None
3381        elif target_securities is None:
3382            pass
3383        else:
3384            raise TypeError(
3385                "Expected value of type Union[Dict[GbiIdSecurity, str], str] "
3386                f"for argument 'target_securities'; got {type(target_securities)}"
3387            )
3388
3389        resp = requests.post(
3390            f"{self.base_uri}/api/graphql",
3391            json={"query": mod_qry, "variables": {"input": mod_input}},
3392            headers={"Authorization": "ApiKey " + self.api_key},
3393            params=self._request_params,
3394        )
3395
3396        json_resp = resp.json()
3397        if (resp.ok and "errors" in json_resp) or not resp.ok:
3398            error_msg = self._try_extract_error_code(resp)
3399            logger.error(error_msg)
3400            raise BoostedAPIException(
3401                (
3402                    f"Failed to modify hedge experiment in preparation for start {experiment_id=}: "
3403                    f"{resp.status_code=}; {error_msg=}"
3404                )
3405            )
3406
3407        exp_dict = json_resp["data"]["modifyHedgeExperimentDraft"]["hedgeExperiment"]
3408        experiment = HedgeExperiment.from_json_dict(exp_dict)
3409        return experiment
3410
3411    def start_hedge_experiment(self, experiment_id: str, *scenario_ids: str) -> HedgeExperiment:
3412        start_qry = """
3413            mutation startHedgeExperiment($input: StartHedgeExperimentInput!) {
3414                startHedgeExperiment(input: $input) {
3415                    hedgeExperiment {
3416                        hedgeExperimentId
3417                        experimentName
3418                        userId
3419                        config
3420                        description
3421                        experimentType
3422                        lastCalculated
3423                        lastModified
3424                        status
3425                        portfolioCalcStatus
3426                        targetSecurities {
3427                            gbiId
3428                            security {
3429                                gbiId
3430                                name
3431                                symbol
3432                            }
3433                            weight
3434                        }
3435                        targetPortfolios {
3436                            portfolioId
3437                        }
3438                        baselineModel {
3439                            id
3440                            name
3441                        }
3442                        baselineScenario {
3443                            hedgeExperimentScenarioId
3444                            scenarioName
3445                            description
3446                            portfolioSettingsJson
3447                            hedgeExperimentPortfolios {
3448                                portfolio {
3449                                    id
3450                                    name
3451                                    modelId
3452                                    performanceGridHeader
3453                                    performanceGrid
3454                                    status
3455                                    tearSheet {
3456                                        groupName
3457                                        members {
3458                                            name
3459                                            value
3460                                        }
3461                                    }
3462                                }
3463                            }
3464                            status
3465                        }
3466                        baselineStockUniverseId
3467                    }
3468                }
3469            }
3470        """
3471        start_input: Dict[str, Any] = {"hedgeExperimentId": experiment_id}
3472        if len(scenario_ids) > 0:
3473            start_input["hedgeExperimentScenarioIds"] = list(scenario_ids)
3474
3475        resp = requests.post(
3476            f"{self.base_uri}/api/graphql",
3477            json={"query": start_qry, "variables": {"input": start_input}},
3478            headers={"Authorization": "ApiKey " + self.api_key},
3479            params=self._request_params,
3480        )
3481
3482        json_resp = resp.json()
3483        if (resp.ok and "errors" in json_resp) or not resp.ok:
3484            error_msg = self._try_extract_error_code(resp)
3485            logger.error(error_msg)
3486            raise BoostedAPIException(
3487                (
3488                    f"Failed to start hedge experiment {experiment_id=}: "
3489                    f"{resp.status_code=}; {error_msg=}"
3490                )
3491            )
3492
3493        exp_dict = json_resp["data"]["startHedgeExperiment"]["hedgeExperiment"]
3494        experiment = HedgeExperiment.from_json_dict(exp_dict)
3495        return experiment
3496
3497    def delete_hedge_experiment(self, experiment_id: str) -> bool:
3498        delete_qry = """
3499            mutation($input: DeleteHedgeExperimentsInput!) {
3500                deleteHedgeExperiments(input: $input) {
3501                    success
3502                }
3503            }
3504        """
3505        delete_input = {"hedgeExperimentIds": [experiment_id]}
3506        resp = requests.post(
3507            f"{self.base_uri}/api/graphql",
3508            json={"query": delete_qry, "variables": {"input": delete_input}},
3509            headers={"Authorization": "ApiKey " + self.api_key},
3510            params=self._request_params,
3511        )
3512
3513        json_resp = resp.json()
3514        if (resp.ok and "errors" in json_resp) or not resp.ok:
3515            error_msg = self._try_extract_error_code(resp)
3516            logger.error(error_msg)
3517            raise BoostedAPIException(
3518                (
3519                    f"Failed to delete hedge experiment {experiment_id=}: "
3520                    + f"status_code={resp.status_code}; error_msg={error_msg}"
3521                )
3522            )
3523
3524        return json_resp["data"]["deleteHedgeExperiments"]["success"]
3525
3526    def create_hedge_basket_position_bounds_from_csv(
3527        self,
3528        filepath: str,
3529        name: str,
3530        description: Optional[str],
3531        mapping_result_filepath: Optional[str],
3532    ) -> str:
3533        DATE = "Date"
3534        ISIN = "ISIN"
3535        COUNTRY = "Country"
3536        CURRENCY = "Currency"
3537        LOWER_BOUND = "Lower Bound"
3538        UPPER_BOUND = "Upper Bound"
3539        supported_columns = {
3540            DATE,
3541            ISIN,
3542            COUNTRY,
3543            CURRENCY,
3544            LOWER_BOUND,
3545            UPPER_BOUND,
3546        }
3547        required_columns = {ISIN, LOWER_BOUND, UPPER_BOUND}
3548
3549        try:
3550            df: pd.DataFrame = pd.read_csv(filepath, parse_dates=True)
3551        except Exception as e:
3552            raise BoostedAPIException(f"Error reading {filepath=}: {e}")
3553
3554        columns = set(df.columns)
3555
3556        # First perform basic data validation
3557        missing_required_columns = required_columns - columns
3558        if missing_required_columns:
3559            raise BoostedAPIException(
3560                f"The following required columns are missing: {missing_required_columns}"
3561            )
3562        extra_columns = columns - supported_columns
3563        if extra_columns:
3564            logger.warning(
3565                f"The following columns are unsupported and will be ignored: {extra_columns}"
3566            )
3567        try:
3568            df[LOWER_BOUND] = df[LOWER_BOUND].astype(float)
3569            df[UPPER_BOUND] = df[UPPER_BOUND].astype(float)
3570            df[ISIN] = df[ISIN].astype(str)
3571        except Exception as e:
3572            raise BoostedAPIException(f"Column datatypes are incorrect: {e}")
3573        lb_gt_ub = df[df[LOWER_BOUND] > df[UPPER_BOUND]]
3574        if not lb_gt_ub.empty:
3575            raise BoostedAPIException(
3576                f"Lower Bound must be <= Upper Bound, but these are not: {lb_gt_ub[ISIN].tolist()}"
3577            )
3578        out_of_range = df[
3579            (
3580                (df[LOWER_BOUND] < 0)
3581                | (df[LOWER_BOUND] > 1)
3582                | (df[UPPER_BOUND] < 0)
3583                | (df[UPPER_BOUND] > 1)
3584            )
3585        ]
3586        if not out_of_range.empty:
3587            raise BoostedAPIException("Lower Bound and Upper Bound values must be in range [0, 1]")
3588
3589        # Now map the security info into GBI IDs
3590        rows = list(df.to_dict(orient="index").values())
3591        sec_data_list = self.getGbiIdFromIdentCountryCurrencyDate(
3592            ident_country_currency_dates=[
3593                DateIdentCountryCurrency(
3594                    date=row.get(DATE, datetime.date.today().isoformat()),
3595                    identifier=row.get(ISIN),
3596                    id_type=ColumnSubRole.ISIN,
3597                    country=row.get(COUNTRY),
3598                    currency=row.get(CURRENCY),
3599                )
3600                for row in rows
3601            ]
3602        )
3603
3604        # Now take each row and its gbi id mapping, and create the bounds list
3605        bounds = []
3606        for row, sec_data in zip(rows, sec_data_list):
3607            if sec_data is None:
3608                logger.warning(f"Failed to map {row[ISIN]}, skipping this security.")
3609            else:
3610                bounds.append(
3611                    {"gbi_id": str(sec_data.gbi_id), "lb": row[LOWER_BOUND], "ub": row[UPPER_BOUND]}
3612                )
3613
3614                # Add security metadata to see the mapping
3615                row["Mapped GBI ID"] = sec_data.gbi_id
3616                row[f"Mapped {ISIN}"] = sec_data.isin_info.identifier
3617                row[f"Mapped {COUNTRY}"] = sec_data.isin_info.country
3618                row[f"Mapped {CURRENCY}"] = sec_data.isin_info.currency
3619                row["Mapped Ticker"] = sec_data.ticker
3620                row["Mapped Company Name"] = sec_data.company_name
3621
3622        # Call endpoint to create the bounds settings template
3623        qry = """
3624              mutation CreatePartialStrategyTemplate(
3625                $portfolioSettingsKey: String!
3626                $partialSettings: String!
3627                $name: String!
3628                $description: String
3629              ) {
3630                createPartialStrategyTemplate(
3631                  portfolioSettingsKey: $portfolioSettingsKey
3632                  partialSettings: $partialSettings
3633                  name: $name
3634                  description: $description
3635                )
3636              }
3637            """
3638        variables = {
3639            "portfolioSettingsKey": "basketTrading.positionSizeBounds",
3640            "partialSettings": json.dumps(bounds),
3641            "name": name,
3642            "description": description,
3643        }
3644        resp = self._get_graphql(qry, variables=variables)
3645
3646        # Write mapped csv for reference
3647        if mapping_result_filepath is not None:
3648            pd.DataFrame(rows).to_csv(mapping_result_filepath)
3649
3650        return resp["data"]["createPartialStrategyTemplate"]
3651
3652    def get_portfolio_accuracy(
3653        self,
3654        model_id: str,
3655        portfolio_id: str,
3656        start_date: Optional[BoostedDate] = None,
3657        end_date: Optional[BoostedDate] = None,
3658    ) -> dict:
3659        if start_date and end_date:
3660            validate_start_and_end_dates(start_date=start_date, end_date=end_date)
3661            start_date = convert_date(start_date)
3662            end_date = convert_date(end_date)
3663
3664        # TODO: Later change this URI to not use the watchlist prefix. It is misnamed.
3665        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_PA_ROUTE}/get-hit-rate/"
3666        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3667        req_json = {"model_id": model_id, "portfolio_id": portfolio_id}
3668        if start_date and end_date:
3669            req_json["start_date"] = start_date.isoformat()
3670            req_json["end_date"] = end_date.isoformat()
3671        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3672
3673        if not res.ok:
3674            error_msg = self._try_extract_error_code(res)
3675            logger.error(error_msg)
3676            raise BoostedAPIException(f"Failed to get Hit Rate: {error_msg}")
3677
3678        data = res.json()
3679        return data
3680
3681    def create_watchlist(self, name: str) -> str:
3682        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/create/"
3683        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3684        req_json = {"name": name}
3685        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3686
3687        if not res.ok:
3688            error_msg = self._try_extract_error_code(res)
3689            logger.error(error_msg)
3690            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
3691
3692        data = res.json()
3693        return data["watchlist_id"]
3694
3695    def _get_graphql(
3696        self,
3697        query: str,
3698        variables: Dict,
3699        error_msg_prefix: str = "Failed to get graphql result: ",
3700        log_error: bool = True,
3701    ) -> Dict:
3702        headers = {"Authorization": "ApiKey " + self.api_key}
3703        json_req = {"query": query, "variables": variables}
3704
3705        url = self.base_uri + "/api/graphql"
3706        resp = requests.post(
3707            url,
3708            json=json_req,
3709            headers=headers,
3710            params=self._request_params,
3711        )
3712
3713        # graphql endpoints typically return 200 or 400 status codes, so we must
3714        # check if we have any errors, even with a 200
3715        if not resp.ok or (resp.ok and "errors" in resp.json()):
3716            error_msg = self._try_extract_error_code(resp)
3717            error_str = str(error_msg_prefix) + f" {resp.status_code=}; {error_msg=}"
3718            if log_error:
3719                logger.error(error_str)
3720            raise BoostedAPIException(error_str)
3721
3722        json_resp = resp.json()
3723        return json_resp
3724
3725    def _get_security_info(self, gbi_ids: List[int]) -> Dict:
3726        query = graphql_queries.GET_SEC_INFO_QRY
3727        variables = {
3728            "ids": [] if not gbi_ids else gbi_ids,
3729        }
3730
3731        error_msg_prefix = "Failed to get Security Details:"
3732        return self._get_graphql(
3733            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3734        )
3735
3736    def _get_sector_info(self) -> Dict:
3737        """
3738        Returns a list of sector objects, e.g.
3739        {
3740            "id": 1010,
3741            "parentId": 10,
3742            "name": "Energy",
3743            "topParentName": null,
3744            "spiqSectorId": -1,
3745            "legacy": false
3746        }
3747        """
3748        url = f"{self.base_uri}/api/sectors"
3749        headers = {"Authorization": "ApiKey " + self.api_key}
3750        res = requests.get(url, headers=headers, **self._request_params)
3751        self._check_ok_or_err_with_msg(res, "Failed to get sectors data")
3752        return res.json()["sectors"]
3753
3754    def _get_watchlist_analysis(
3755        self,
3756        gbi_ids: List[int],
3757        model_ids: List[str],
3758        portfolio_ids: List[str],
3759        asof_date=datetime.date.today(),
3760    ) -> Dict:
3761        query = graphql_queries.WATCHLIST_ANALYSIS_QRY
3762        variables = {
3763            "gbiIds": gbi_ids,
3764            "modelIds": model_ids,
3765            "portfolioIds": portfolio_ids,
3766            "date": self.__iso_format(asof_date),
3767        }
3768        error_msg_prefix = "Failed to get Coverage Analysis:"
3769        return self._get_graphql(
3770            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3771        )
3772
3773    def _get_models_for_portfolio(self, portfolio_ids: List[str]) -> Dict:
3774        query = graphql_queries.GET_MODELS_FOR_PORTFOLIOS_QRY
3775        variables = {"ids": portfolio_ids}
3776        error_msg_prefix = "Failed to get Models for Portfolios: "
3777        return self._get_graphql(
3778            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3779        )
3780
3781    def _get_excess_return(
3782        self, model_ids: List[str], gbi_ids: List[int], asof_date=datetime.date.today()
3783    ) -> Dict:
3784        query = graphql_queries.GET_EXCESS_RETURN_QRY
3785
3786        variables = {
3787            "modelIds": model_ids,
3788            "gbiIds": gbi_ids,
3789            "date": self.__iso_format(asof_date),
3790        }
3791        error_msg_prefix = "Failed to get Excess Return Slugging Pct: "
3792        return self._get_graphql(
3793            query=query, variables=variables, error_msg_prefix=error_msg_prefix
3794        )
3795
3796    def _coverage_column_name_format(self, in_str) -> str:
3797        if in_str.upper() == "ISIN":
3798            return "ISIN"
3799
3800        return in_str.title()
3801
3802    def _get_model_stocks(self, model_id: str) -> List[GbiIdTickerISIN]:
3803        # first, get the universe id
3804        resp = self._get_graphql(
3805            graphql_queries.GET_MODEL_STOCK_UNIVERSE_ID_QUERY,
3806            variables={"modelId": model_id},
3807            error_msg_prefix="Failed to get model stock universe ID",
3808        )
3809        universe_id = resp["data"]["model"]["stockUniverseId"]
3810
3811        # now, query for universe stocks
3812        url = self.base_uri + f"/api/stocks/model-universe/{universe_id}"
3813        headers = {"Authorization": "ApiKey " + self.api_key}
3814        universe_resp = requests.get(url, headers=headers, **self._request_params)
3815        universe = universe_resp.json()["stockUniverse"]
3816        securities = [
3817            GbiIdTickerISIN(gbi_id=security["id"], ticker=security["symbol"], isin=security["isin"])
3818            for security in universe
3819        ]
3820        return securities
3821
3822    def get_coverage_info(self, watchlist_id: str, portfolio_group_id: str) -> pd.DataFrame:
3823        # get securities list in watchlist
3824        watchlist_details = self.get_watchlist_details(watchlist_id)
3825        security_list = watchlist_details["targets"]
3826
3827        gbi_ids = [x["gbi_id"] for x in security_list]
3828
3829        gbi_data: Dict[Any, Dict] = {x: {} for x in gbi_ids}
3830
3831        # get security info ticker, name, industry etc
3832        sec_info = self._get_security_info(gbi_ids)
3833
3834        for sec in sec_info["data"]["securities"]:
3835            gbi_id = sec["gbiId"]
3836            for k in ["symbol", "name", "isin", "country", "currency"]:
3837                gbi_data[gbi_id][self._coverage_column_name_format(k)] = sec[k]
3838
3839            gbi_data[gbi_id][self._coverage_column_name_format("Sector")] = sec["sector"][
3840                "topParentName"
3841            ]
3842
3843        # get portfolios list in portfolio_Group
3844        portfolio_group = self.get_portfolio_group(portfolio_group_id)
3845        portfolio_ids = [x["portfolio_id"] for x in portfolio_group["portfolios"]]
3846        portfolio_info = {x["portfolio_id"]: x for x in portfolio_group["portfolios"]}
3847
3848        model_resp = self._get_models_for_portfolio(portfolio_ids=portfolio_ids)
3849        for portfolio in model_resp["data"]["portfolios"]:
3850            portfolio_info[portfolio["id"]].update(portfolio)
3851
3852        model_info = {
3853            x["modelId"]: portfolio_info[x["id"]] for x in model_resp["data"]["portfolios"]
3854        }
3855
3856        # model_ids and portfolio_ids are parallel arrays
3857        model_ids = [portfolio_info[x]["modelId"] for x in portfolio_ids]
3858
3859        # graphql: get watchlist analysis
3860        wl_analysis = self._get_watchlist_analysis(
3861            gbi_ids=gbi_ids,
3862            model_ids=model_ids,
3863            portfolio_ids=portfolio_ids,
3864            asof_date=datetime.date.today(),
3865        )
3866
3867        portfolio_gbi_data: Dict[Any, Dict] = {k: {} for k in portfolio_ids}
3868        for pi, v in portfolio_gbi_data.items():
3869            v.update({k: {} for k in gbi_data.keys()})
3870
3871        equity_explorer_date = wl_analysis["data"]["watchlistAnalysis"][0]["analysisDates"][0][
3872            "date"
3873        ]
3874        for wla in wl_analysis["data"]["watchlistAnalysis"]:
3875            gbi_id = wla["gbiId"]
3876            gbi_data[gbi_id]["Composite Rating"] = wla["analysisDates"][0]["aggregateSignal"][
3877                "rating"
3878            ]
3879            gbi_data[gbi_id]["Composite Rating Delta"] = wla["analysisDates"][0]["aggregateSignal"][
3880                "ratingDelta"
3881            ]
3882
3883            for p in wla["analysisDates"][0]["portfoliosSignals"]:
3884                model_name = portfolio_info[p["portfolioId"]]["modelName"]
3885
3886                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3887                    model_name + self._coverage_column_name_format(": rank")
3888                ] = (p["rank"] + 1)
3889                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3890                    model_name + self._coverage_column_name_format(": rank delta")
3891                ] = (-1 * p["signalDelta"])
3892                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3893                    model_name + self._coverage_column_name_format(": rating")
3894                ] = p["rating"]
3895                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3896                    model_name + self._coverage_column_name_format(": rating delta")
3897                ] = p["ratingDelta"]
3898
3899        neg_rec: Dict[Any, Dict] = {k: {} for k in gbi_data.keys()}
3900        pos_rec: Dict[Any, Dict] = {k: {} for k in gbi_data.keys()}
3901        for wla in wl_analysis["data"]["watchlistAnalysis"]:
3902            gbi_id = wla["gbiId"]
3903
3904            for pid, signals in zip(portfolio_ids, wla["analysisDates"][0]["portfoliosSignals"]):
3905                model_name = portfolio_info[pid]["modelName"]
3906                neg_rec[gbi_id][
3907                    model_name + self._coverage_column_name_format(": negative recommendation")
3908                ] = signals["explainWeightNeg"]
3909                pos_rec[gbi_id][
3910                    model_name + self._coverage_column_name_format(": positive recommendation")
3911                ] = signals["explainWeightPos"]
3912
3913        # graphql: GetExcessReturn - slugging pct
3914        er_sp = self._get_excess_return(
3915            model_ids=model_ids, gbi_ids=gbi_ids, asof_date=equity_explorer_date
3916        )
3917
3918        for model in er_sp["data"]["models"]:
3919            model_name = model_info[model["id"]]["modelName"]
3920            for stat in model["equityExplorerData"]["equityExplorerSummaryStatistics"]:
3921                portfolioId = model_info[model["id"]]["id"]
3922                portfolio_gbi_data[portfolioId][int(stat["gbiId"])][
3923                    model_name + self._coverage_column_name_format(": slugging %")
3924                ] = (stat["ER"]["SP"]["sixMonthWindowOneMonthHorizon"] * 100)
3925
3926        # add rank, rating, slugging
3927        for pid, v in portfolio_gbi_data.items():
3928            for gbi_id, vv in v.items():
3929                gbi_data[gbi_id].update(vv)
3930
3931        # add neg/pos rec scores
3932        for rec in [neg_rec, pos_rec]:
3933            for k, v in rec.items():
3934                gbi_data[k].update(v)
3935
3936        df = pd.DataFrame.from_records([v for _, v in gbi_data.items()])
3937
3938        return df
3939
3940    def get_coverage_csv(
3941        self, watchlist_id: str, portfolio_group_id: str, filepath: Optional[str] = None
3942    ) -> Optional[str]:
3943        """
3944        Converts the coverage contents to CSV format
3945        Parameters
3946        ----------
3947        watchlist_id: str
3948            UUID str identifying the coverage watchlist
3949        portfolio_group_id: str
3950            UUID str identifying the group of portfolio to use for analysis
3951        filepath: Optional[str]
3952            UUID str identifying the group of portfolio to use for analysis
3953
3954        Returns:
3955        ----------
3956        None if filepath is provided, else a string with a csv's contents is returned
3957        """
3958
3959        df = self.get_coverage_info(watchlist_id, portfolio_group_id)
3960
3961        return df.to_csv(filepath, index=False, float_format="%.4f")
3962
3963    def get_watchlist_details(self, watchlist_id: str) -> Dict:
3964        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/details/"
3965        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3966        req_json = {"watchlist_id": watchlist_id}
3967        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3968
3969        if not res.ok:
3970            error_msg = self._try_extract_error_code(res)
3971            logger.error(error_msg)
3972            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
3973
3974        data = res.json()
3975        return data
3976
3977    def create_watchlist_from_file(self, name: str, filepath: str) -> str:
3978        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/create_watchlist_from_file/"
3979        headers = {"Authorization": "ApiKey " + self.api_key}
3980
3981        with open(filepath, "rb") as fp:
3982            file_bytes = fp.read()
3983
3984        file_bytes_base64 = base64.b64encode(file_bytes).decode("ascii")
3985        json_req = {
3986            "content_type": mimetypes.guess_type(filepath)[0],
3987            "file_bytes_base64": file_bytes_base64,
3988            "name": name,
3989        }
3990
3991        res = requests.post(url, json=json_req, headers=headers)
3992
3993        if not res.ok:
3994            error_msg = self._try_extract_error_code(res)
3995            logger.error(error_msg)
3996            raise BoostedAPIException(f"Failed to create watchlist from file: {error_msg}")
3997
3998        data = res.json()
3999        return data["watchlist_id"]
4000
4001    def get_watchlists(self) -> List[Dict]:
4002        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/get_user_watchlists/"
4003        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4004        req_json: Dict = {}
4005        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4006
4007        if not res.ok:
4008            error_msg = self._try_extract_error_code(res)
4009            logger.error(error_msg)
4010            raise BoostedAPIException(f"Failed to get user watchlists: {error_msg}")
4011
4012        data = res.json()
4013        return data["watchlists"]
4014
4015    def get_watchlist_contents(self, watchlist_id) -> Dict:
4016        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/contents/"
4017        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4018        req_json = {"watchlist_id": watchlist_id}
4019        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4020
4021        if not res.ok:
4022            error_msg = self._try_extract_error_code(res)
4023            logger.error(error_msg)
4024            raise BoostedAPIException(f"Failed to get watchlist contents: {error_msg}")
4025
4026        data = res.json()
4027        return data
4028
4029    def get_watchlist_contents_as_csv(self, watchlist_id, filepath) -> None:
4030        data = self.get_watchlist_contents(watchlist_id)
4031        df = pd.DataFrame(data["contents"])
4032        df.to_csv(filepath, index=False)
4033
4034    # TODO this will need to be enhanced to accept country/currency overrides
4035    def add_securities_to_watchlist(
4036        self, watchlist_id: str, identifiers: List[str], identifier_type: Literal["TICKER", "ISIN"]
4037    ) -> Dict:
4038        # should we just make the arg lower? all caps has a flag-like feel to it
4039        id_type = identifier_type.lower()
4040        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/add_{id_type}s/"
4041        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4042        req_json = {"watchlist_id": watchlist_id, id_type: identifiers}
4043        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4044
4045        if not res.ok:
4046            error_msg = self._try_extract_error_code(res)
4047            logger.error(error_msg)
4048            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
4049
4050        data = res.json()
4051        return data
4052
4053    def remove_securities_from_watchlist(
4054        self, watchlist_id: str, identifiers: List[str], identifier_type: Literal["TICKER", "ISIN"]
4055    ) -> Dict:
4056        # should we just make the arg lower? all caps has a flag-like feel to it
4057        id_type = identifier_type.lower()
4058        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/remove_{id_type}s/"
4059        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4060        req_json = {"watchlist_id": watchlist_id, id_type: identifiers}
4061        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4062
4063        if not res.ok:
4064            error_msg = self._try_extract_error_code(res)
4065            logger.error(error_msg)
4066            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
4067
4068        data = res.json()
4069        return data
4070
4071    def get_portfolio_groups(
4072        self,
4073    ) -> Dict:
4074        """
4075        Parameters: None
4076
4077
4078        Returns:
4079        ----------
4080
4081        Dict:  {
4082        user_id: str
4083        portfolio_groups: List[PortfolioGroup]
4084        }
4085        where PortfolioGroup is defined as = Dict {
4086        group_id: str
4087        group_name: str
4088        portfolios: List[PortfolioInGroup]
4089        }
4090        where PortfolioInGroup is defined as = Dict {
4091        portfolio_id: str
4092        rank_in_group: Optional[int]
4093        }
4094        """
4095        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get"
4096        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4097        req_json: Dict = {}
4098        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4099
4100        if not res.ok:
4101            error_msg = self._try_extract_error_code(res)
4102            logger.error(error_msg)
4103            raise BoostedAPIException(f"Failed to get user portfolio groups: {error_msg}")
4104
4105        data = res.json()
4106        return data
4107
4108    def get_portfolio_group(self, portfolio_group_id: str) -> Dict:
4109        """
4110        Parameters:
4111        portfolio_group_id: str
4112           UUID identifier for the portfolio group
4113
4114
4115        Returns:
4116        ----------
4117
4118        PortfolioGroup: Dict:  {
4119        group_id: str
4120        group_name: str
4121        portfolios: List[PortfolioInGroup]
4122        }
4123        where PortfolioInGroup is defined as = Dict {
4124        portfolio_id: str
4125        portfolio_name: str
4126        rank_in_group: Optional[int]
4127        }
4128        """
4129        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get-one"
4130        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4131        req_json = {"portfolio_group_id": portfolio_group_id}
4132        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4133
4134        if not res.ok:
4135            error_msg = self._try_extract_error_code(res)
4136            logger.error(error_msg)
4137            raise BoostedAPIException(f"Failed to get user portfolio groups: {error_msg}")
4138
4139        data = res.json()
4140        return data
4141
4142    def set_sticky_portfolio_group(
4143        self,
4144        portfolio_group_id: str,
4145    ) -> Dict:
4146        """
4147        Set sticky portfolio group
4148
4149        Parameters
4150        ----------
4151
4152        group_id: str,
4153           UUID str identifying a portfolio group
4154
4155        Returns:
4156        -------
4157        Dict {
4158            changed: int - 1 == success
4159        }
4160        """
4161        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/set-sticky"
4162        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4163        req_json = {"portfolio_group_id": portfolio_group_id}
4164        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4165
4166        if not res.ok:
4167            error_msg = self._try_extract_error_code(res)
4168            logger.error(error_msg)
4169            raise BoostedAPIException(f"Failed to set sticky portfolio group: {error_msg}")
4170
4171        data = res.json()
4172        return data
4173
4174    def get_sticky_portfolio_group(
4175        self,
4176    ) -> Dict:
4177        """
4178        Get sticky portfolio group for the user
4179
4180        Parameters
4181        ----------
4182
4183        Returns:
4184        -------
4185        Dict {
4186            group_id: str
4187            group_name: str
4188            portfolios: List[PortfolioInGroup(Dict)]
4189                  PortfolioInGroup(Dict):
4190                           portfolio_id: str
4191                           rank_in_group: Optional[int] = None
4192                           portfolio_name: Optional[str] = None
4193        }
4194        """
4195        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get-sticky"
4196        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4197        req_json: Dict = {}
4198        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4199
4200        if not res.ok:
4201            error_msg = self._try_extract_error_code(res)
4202            logger.error(error_msg)
4203            raise BoostedAPIException(f"Failed to get sticky portfolio group: {error_msg}")
4204
4205        data = res.json()
4206        return data
4207
4208    def create_portfolio_group(
4209        self,
4210        group_name: str,
4211        portfolios: Optional[List[Dict]] = None,
4212    ) -> Dict:
4213        """
4214        Create a new portfolio group
4215
4216        Parameters
4217        ----------
4218
4219        group_name: str
4220           name of the new group
4221
4222        portfolios: List of Dict [:
4223
4224        portfolio_id: str
4225        rank_in_group: Optional[int] = None
4226        ]
4227
4228        Returns:
4229        ----------
4230
4231        Dict: {
4232        group_id: str
4233           UUID identifier for the portfolio group
4234
4235        created: int
4236           num groups created, 1 == success
4237
4238        added: int
4239           num portfolios added to the group, should match the length of 'portfolios' argument
4240        }
4241        """
4242        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/create"
4243        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4244        req_json = {"group_name": group_name, "portfolios": portfolios}
4245
4246        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4247
4248        if not res.ok:
4249            error_msg = self._try_extract_error_code(res)
4250            logger.error(error_msg)
4251            raise BoostedAPIException(f"Failed to create portfolio group: {error_msg}")
4252
4253        data = res.json()
4254        return data
4255
4256    def rename_portfolio_group(
4257        self,
4258        group_id: str,
4259        group_name: str,
4260    ) -> Dict:
4261        """
4262        Rename a portfolio group
4263
4264        Parameters
4265        ----------
4266
4267        group_id: str,
4268           UUID str identifying a portfolio group
4269
4270        group_name: str,
4271           The new name for the porfolio
4272
4273        Returns:
4274        -------
4275        Dict {
4276            changed: int - 1 == success
4277        }
4278        """
4279        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/rename"
4280        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4281        req_json = {"group_id": group_id, "group_name": group_name}
4282        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4283
4284        if not res.ok:
4285            error_msg = self._try_extract_error_code(res)
4286            logger.error(error_msg)
4287            raise BoostedAPIException(f"Failed to rename portfolio group: {error_msg}")
4288
4289        data = res.json()
4290        return data
4291
4292    def add_to_portfolio_group(
4293        self,
4294        group_id: str,
4295        portfolios: List[Dict],
4296    ) -> Dict:
4297        """
4298        Add portfolios to a group
4299
4300        Parameters
4301        ----------
4302
4303        group_id: str,
4304           UUID str identifying a portfolio group
4305
4306        portfolios: List of Dict [:
4307            portfolio_id: str
4308            rank_in_group: Optional[int] = None
4309        ]
4310
4311
4312        Returns:
4313        -------
4314        Dict {
4315            added: int
4316               number of successful changes
4317        }
4318        """
4319        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/add-to-group"
4320        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4321        req_json = {"group_id": group_id, "portfolios": portfolios}
4322
4323        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4324
4325        if not res.ok:
4326            error_msg = self._try_extract_error_code(res)
4327            logger.error(error_msg)
4328            raise BoostedAPIException(f"Failed to add portfolios to portfolio group: {error_msg}")
4329
4330        data = res.json()
4331        return data
4332
4333    def remove_from_portfolio_group(
4334        self,
4335        group_id: str,
4336        portfolios: List[str],
4337    ) -> Dict:
4338        """
4339        Remove portfolios from a group
4340
4341        Parameters
4342        ----------
4343
4344        group_id: str,
4345           UUID str identifying a portfolio group
4346
4347        portfolios: List of str
4348
4349
4350        Returns:
4351        -------
4352        Dict {
4353            removed: int
4354               number of successful changes
4355        }
4356        """
4357        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/remove-from-group"
4358        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4359        req_json = {"group_id": group_id, "portfolios": portfolios}
4360        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4361
4362        if not res.ok:
4363            error_msg = self._try_extract_error_code(res)
4364            logger.error(error_msg)
4365            raise BoostedAPIException(
4366                f"Failed to remove portfolios from portfolio group: {error_msg}"
4367            )
4368
4369        data = res.json()
4370        return data
4371
4372    def delete_portfolio_group(
4373        self,
4374        group_id: str,
4375    ) -> Dict:
4376        """
4377        Delete a portfolio group
4378
4379        Parameters
4380        ----------
4381
4382        group_id: str,
4383           UUID str identifying a portfolio group
4384
4385
4386        Returns:
4387        -------
4388        Dict {
4389            removed_groups: int
4390               number of successful changes
4391
4392            removed_portfolios: int
4393               number of successful changes
4394        }
4395        """
4396        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/remove"
4397        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4398        req_json = {"group_id": group_id}
4399        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4400
4401        if not res.ok:
4402            error_msg = self._try_extract_error_code(res)
4403            logger.error(error_msg)
4404            raise BoostedAPIException(f"Failed to delete portfolio group: {error_msg}")
4405
4406        data = res.json()
4407        return data
4408
4409    def set_portfolio_group_for_watchlist(
4410        self,
4411        portfolio_group_id: str,
4412        watchlist_id: str,
4413    ) -> Dict:
4414        """
4415        Set portfolio group for watchlist.
4416
4417        Parameters
4418        ----------
4419
4420        portfolio_group_id: str,
4421           UUID str identifying a portfolio group
4422
4423        watchlist_id: str,
4424           UUID str identifying a watchlist
4425
4426
4427        Returns:
4428        -------
4429        Dict {
4430            success: bool
4431            errors:
4432            data: Dict
4433                changed: int
4434        }
4435        """
4436        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/set-portfolio-groups/"
4437        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4438        req_json = {"portfolio_group_id": portfolio_group_id, "watchlist_id": watchlist_id}
4439        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4440
4441        if not res.ok:
4442            error_msg = self._try_extract_error_code(res)
4443            logger.error(error_msg)
4444            raise BoostedAPIException(f"Failed to set portfolio group for watchlist: {error_msg}")
4445
4446        return res.json()
4447
4448    def get_ranking_dates(self, model_id: str, portfolio_id: str) -> List[datetime.date]:
4449        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4450        url = self.base_uri + f"/api/analysis/ranking-dates/{model_id}/{portfolio_id}"
4451        res = requests.get(url, headers=headers, **self._request_params)
4452        self._check_ok_or_err_with_msg(res, "Failed to get ranking dates")
4453        data = res.json().get("ranking_dates", [])
4454
4455        return [parser.parse(d).date() for d in data]
4456
4457    def get_prior_ranking_date(
4458        self, ranking_dates: List[datetime.date], starting_date: datetime.date
4459    ) -> datetime.date:
4460        """
4461        Given a starting date and a list of ranking dates, return the most
4462        recent previous ranking date.
4463        """
4464        # order from most recent to least
4465        ranking_dates.sort(reverse=True)
4466
4467        for d in ranking_dates:
4468            if d <= starting_date:
4469                return d
4470
4471        # if we get here, the starting date is before the earliest ranking date
4472        raise BoostedAPIException(f"No rankins exist on or before {starting_date}")
4473
4474    def _get_risk_factors_descriptors(
4475        self, model_id: str, portfolio_id: str, use_v2: bool = False
4476    ) -> Dict[int, str]:
4477        """Returns a map from descriptor id to descriptor name."""
4478        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4479
4480        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4481        url = self.base_uri + f"/api/{risk_factor}/{model_id}/{portfolio_id}/descriptors"
4482        res = requests.get(url, headers=headers, **self._request_params)
4483
4484        self._check_ok_or_err_with_msg(res, "Failed to get risk factor descriptors")
4485
4486        descriptors = {int(i): name for i, name in res.json().items() if i.isnumeric()}
4487        return descriptors
4488
4489    def get_risk_groups(
4490        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4491    ) -> List[Dict[str, Any]]:
4492        # first get the group descriptors
4493        descriptors = self._get_risk_factors_descriptors(model_id, portfolio_id, use_v2)
4494
4495        # calculate the most recent prior rankings date. This is the date
4496        # we need to use to query for risk group data.
4497        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4498        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4499        date_str = ranking_date.strftime("%Y-%m-%d")
4500
4501        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4502
4503        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4504        url = self.base_uri + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-groups/{date_str}"
4505        res = requests.get(url, headers=headers, **self._request_params)
4506
4507        self._check_ok_or_err_with_msg(
4508            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4509        )
4510
4511        # Response is a list of objects like:
4512        # [
4513        #   [
4514        #     0,
4515        #     14,
4516        #     1
4517        #   ],
4518        #   [
4519        #     25,
4520        #     12,
4521        #     13
4522        #   ],
4523        # 0.67013
4524        # ],
4525        #
4526        # Where each integer in the lists is a descriptor id.
4527
4528        groups = []
4529        for i, row in enumerate(res.json()):
4530            row_map: Dict[str, Any] = {}
4531            # map descriptor id to name
4532            row_map["machine"] = i + 1  # start at 1 not 0
4533            row_map["risk_group_a"] = [descriptors[i] for i in row[0]]
4534            row_map["risk_group_b"] = [descriptors[i] for i in row[1]]
4535            row_map["volatility_explained"] = row[2]
4536            groups.append(row_map)
4537
4538        return groups
4539
4540    def get_risk_factors_discovered_descriptors(
4541        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4542    ) -> pd.DataFrame:
4543        # first get the group descriptors
4544        descriptors = self._get_risk_factors_descriptors(model_id, portfolio_id)
4545
4546        # calculate the most recent prior rankings date. This is the date
4547        # we need to use to query for risk group data.
4548        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4549        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4550        date_str = ranking_date.strftime("%Y-%m-%d")
4551
4552        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4553
4554        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4555        url = (
4556            self.base_uri
4557            + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-descriptors/json/{date_str}"
4558        )
4559        res = requests.get(url, headers=headers, **self._request_params)
4560
4561        self._check_ok_or_err_with_msg(
4562            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4563        )
4564
4565        # Endpoint returns a nested list of floats
4566        df = pd.DataFrame(res.json(), columns=RISK_FACTOR_COLUMNS)
4567
4568        # This flat dataframe represents a potentially doubly nested structure
4569        # of Sector -> (high/low volatility) -> security. We don't care about
4570        # the high/low volatility rows, (which will have negative identifiers)
4571        # so we can filter these out.
4572        df = df[df["identifier"] >= 0]
4573
4574        # now, any values that had a depth of 2 should be set to a depth of 1,
4575        # since we removed the double nesting.
4576        df.replace(to_replace=2, value=1, inplace=True)
4577
4578        # This dataframe represents data that is nested on the UI, so the
4579        # "depth" field indicates which level of nesting each row is at. At this
4580        # point, a depth of 0 indicates a sector, and following depth 1 rows are
4581        # securities within the sector.
4582
4583        # Identifiers in rows with depth 1 will be gbi ids, need to convert to
4584        # symbols.
4585        gbi_ids = df[df["depth"] == 1]["identifier"].tolist()
4586        sec_info = self._get_security_info(gbi_ids)["data"]["securities"]
4587        sec_map = {s["gbiId"]: s["symbol"] for s in sec_info}
4588
4589        def convert_ids(row: pd.Series) -> pd.Series:
4590            # convert each row's "identifier" to the appropriate id type. If the
4591            # depth is 0, the identifier should be a sector, otherwise it should
4592            # be a ticker.
4593            ident = int(row["identifier"])
4594            row["identifier"] = (
4595                descriptors.get(ident).title() if row["depth"] == 0 else sec_map.get(ident)
4596            )
4597            return row
4598
4599        df["depth"] = df["depth"].astype(int)
4600        df["stock_count"] = df["stock_count"].astype(int)
4601        df = df.apply(convert_ids, axis=1)
4602        df = df.reset_index(drop=True)
4603        return df
4604
4605    def get_risk_factors_sectors(
4606        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4607    ) -> pd.DataFrame:
4608        # first get the group descriptors
4609        sectors = {s["id"]: s["name"] for s in self._get_sector_info()}
4610
4611        # calculate the most recent prior rankings date. This is the date
4612        # we need to use to query for risk group data.
4613        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4614        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4615        date_str = ranking_date.strftime("%Y-%m-%d")
4616
4617        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4618
4619        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4620        url = (
4621            self.base_uri
4622            + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-sectors/json/{date_str}"
4623        )
4624        res = requests.get(url, headers=headers, **self._request_params)
4625
4626        self._check_ok_or_err_with_msg(
4627            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4628        )
4629
4630        # Endpoint returns a nested list of floats
4631        df = pd.DataFrame(res.json(), columns=RISK_FACTOR_COLUMNS)
4632
4633        # identifier is a gics sector identifier
4634        df["identifier"] = df["identifier"].apply(lambda i: sectors.get(int(i), None))
4635
4636        # This dataframe represents data that is nested on the UI, so the
4637        # "depth" field indicates which level of nesting each row is at. For
4638        # risk factors sectors, each "depth" represents a level of specificity
4639        # for the sector. E.g. Energy -> Energy Equipment -> Oil & Gas Equipment
4640        df["depth"] = df["depth"].astype(int)
4641        df["stock_count"] = df["stock_count"].astype(int)
4642        df = df.reset_index(drop=True)
4643        return df
4644
4645    def download_complete_portfolio_data(
4646        self, model_id: str, portfolio_id: str, download_filepath: str
4647    ):
4648        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4649        url = self.base_uri + f"/api/models/{model_id}/{portfolio_id}/excel"
4650
4651        res = requests.get(url, headers=headers, **self._request_params)
4652        self._check_ok_or_err_with_msg(
4653            res, f"Failed to get full data for {model_id=}, {portfolio_id=}"
4654        )
4655
4656        with open(download_filepath, "wb") as f:
4657            f.write(res.content)
4658
4659    def diff_hedge_experiment_portfolio_data(
4660        self,
4661        hedge_experiment_id: str,
4662        comparison_portfolios: List[str],
4663        categories: List[str],
4664    ) -> Dict:
4665        qry = """
4666        query diffHedgeExperimentPortfolios(
4667            $input: DiffHedgeExperimentPortfoliosInput!
4668        ) {
4669            diffHedgeExperimentPortfolios(input: $input) {
4670            data {
4671                diffs {
4672                    volatility {
4673                        date
4674                        vol5D
4675                        vol10D
4676                        vol21D
4677                        vol21D
4678                        vol63D
4679                        vol126D
4680                        vol189D
4681                        vol252D
4682                        vol315D
4683                        vol378D
4684                        vol441D
4685                        vol504D
4686                    }
4687                    performance {
4688                        date
4689                        value
4690                    }
4691                    performanceGrid {
4692                        headerRow
4693                        values
4694                    }
4695                    factors {
4696                        date
4697                        momentum
4698                        growth
4699                        size
4700                        value
4701                        dividendYield
4702                        volatility
4703                    }
4704                }
4705            }
4706            errors
4707            }
4708        }
4709        """
4710        headers = {"Authorization": "ApiKey " + self.api_key}
4711        params = {
4712            "hedgeExperimentId": hedge_experiment_id,
4713            "portfolioIds": comparison_portfolios,
4714            "categories": categories,
4715        }
4716        resp = requests.post(
4717            f"{self.base_uri}/api/graphql",
4718            json={"query": qry, "variables": params},
4719            headers=headers,
4720            params=self._request_params,
4721        )
4722
4723        json_resp = resp.json()
4724
4725        # graphql endpoints typically return 200 or 400 status codes, so we must
4726        # check if we have any errors, even with a 200
4727        if (resp.ok and "errors" in json_resp) or not resp.ok:
4728            error_msg = self._try_extract_error_code(resp)
4729            logger.error(error_msg)
4730            raise BoostedAPIException(
4731                (
4732                    f"Failed to get portfolio diffs for {hedge_experiment_id=}: "
4733                    f"{resp.status_code=}; {error_msg=}"
4734                )
4735            )
4736
4737        diffs = json_resp["data"]["diffHedgeExperimentPortfolios"]["data"]["diffs"]
4738        comparisons = {}
4739        for pf, cmp in zip(comparison_portfolios, diffs):
4740            res: Dict[str, Any] = {
4741                "performance": None,
4742                "performanceGrid": None,
4743                "factors": None,
4744                "volatility": None,
4745            }
4746            if "performanceGrid" in cmp:
4747                grid = cmp["performanceGrid"]
4748                grid_df = pd.DataFrame(grid["values"], columns=grid["headerRow"])
4749                res["performanceGrid"] = grid_df
4750            if "performance" in cmp:
4751                perf_df = pd.DataFrame(cmp["performance"]).set_index("date")
4752                perf_df.index = pd.to_datetime(perf_df.index)
4753                res["performance"] = perf_df
4754            if "volatility" in cmp:
4755                vol_df = pd.DataFrame(cmp["volatility"]).set_index("date")
4756                vol_df.index = pd.to_datetime(vol_df.index)
4757                res["volatility"] = vol_df
4758            if "factors" in cmp:
4759                factors_df = pd.DataFrame(cmp["factors"]).set_index("date")
4760                factors_df.index = pd.to_datetime(factors_df.index)
4761                res["factors"] = factors_df
4762            comparisons[pf] = res
4763        return comparisons
4764
4765    def get_signal_strength(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
4766        url = self.base_uri + f"/api/analysis/signal_strength/{model_id}/{portfolio_id}"
4767        headers = {"Authorization": "ApiKey " + self.api_key}
4768
4769        logger.info(f"Retrieving portfolio signals for {model_id=}, {portfolio_id=}")
4770
4771        # Response format is a json object with a "header_row" key for column
4772        # names, and then a nested list of data.
4773        resp = requests.get(url, headers=headers, **self._request_params)
4774        self._check_ok_or_err_with_msg(
4775            resp, f"Failed to get portfolio signals for {model_id=}, {portfolio_id=}"
4776        )
4777
4778        data = resp.json()
4779
4780        df = pd.DataFrame(data=data["data"], columns=data["header_row"])
4781        df["Date"] = pd.to_datetime(df["Date"])
4782        df = df.set_index("Date")
4783        return df.astype(float)
4784
4785    def get_rolling_signal_strength(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
4786        url = self.base_uri + f"/api/analysis/signal_strength_rolling/{model_id}/{portfolio_id}"
4787        headers = {"Authorization": "ApiKey " + self.api_key}
4788
4789        logger.info(f"Retrieving rolling portfolio signals for {model_id=}, {portfolio_id=}")
4790
4791        # Response format is a json object with a "header_row" key for column
4792        # names, and then a nested list of data.
4793        resp = requests.get(url, headers=headers, **self._request_params)
4794        self._check_ok_or_err_with_msg(
4795            resp, f"Failed to get rolling portfolio signals for {model_id=}, {portfolio_id=}"
4796        )
4797
4798        data = resp.json()
4799
4800        df = pd.DataFrame(data=data["data"], columns=data["header_row"])
4801        df["Date"] = pd.to_datetime(df["Date"])
4802        df = df.set_index("Date")
4803        return df.astype(float)
4804
4805    def get_portfolio_quantiles(
4806        self,
4807        model_id: str,
4808        portfolio_id: str,
4809        id_type: Literal["TICKER", "ISIN"] = "TICKER",
4810    ):
4811        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4812        date = datetime.date.today().strftime("%Y-%m-%d")
4813
4814        payload = {
4815            "model_id": model_id,
4816            "portfolio_id": portfolio_id,
4817            "fields": ["quantile"],
4818            "min_date": date,
4819            "max_date": date,
4820            "return_format": "json",
4821        }
4822        # TODO: Later change this URI to not use the watchlist prefix. It is misnamed.
4823        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_PA_ROUTE}/get-data/"
4824
4825        res: requests.Response = requests.post(
4826            url, json=payload, headers=headers, **self._request_params
4827        )
4828        self._check_ok_or_err_with_msg(res, "Unable to get quantile data")
4829
4830        resp: Dict = res.json()
4831        quantile_index = resp["field_map"]["Quantile"]
4832        quantile_data = [[c[quantile_index] for c in r] for r in resp["data"]]
4833        date_cols = pd.to_datetime(resp["columns"])
4834
4835        # Need to map gbi id's to isins or tickers
4836        gbi_ids = [int(i) for i in resp["rows"]]
4837        security_info = self._get_security_info(gbi_ids)
4838
4839        # We now have security data, go through and create a map from internal
4840        # gbi id to client facing identifier
4841        id_key = "isin" if id_type == "ISIN" else "symbol"
4842        gbi_identifier_map = {
4843            sec["gbiId"]: sec[id_key] for sec in security_info["data"]["securities"]
4844        }
4845
4846        df = pd.DataFrame(quantile_data, index=gbi_ids, columns=date_cols).transpose()
4847        df = df.rename(columns=gbi_identifier_map)
4848        return df
4849
4850    def get_similar_stocks(
4851        self,
4852        model_id: str,
4853        portfolio_id: str,
4854        symbol_list: List[str],
4855        date: BoostedDate,
4856        identifier_type: Literal["TICKER", "ISIN"],
4857        preferred_country: Optional[str] = None,
4858        preferred_currency: Optional[str] = None,
4859    ) -> pd.DataFrame:
4860        date_str = convert_date(date).strftime("%Y-%m-%d")
4861
4862        sec_data = self.getGbiIdFromIdentCountryCurrencyDate(
4863            ident_country_currency_dates=[
4864                DateIdentCountryCurrency(
4865                    date=datetime.date.today().isoformat(),
4866                    identifier=s,
4867                    id_type=(
4868                        ColumnSubRole.SYMBOL if identifier_type == "TICKER" else ColumnSubRole.ISIN
4869                    ),
4870                    country=preferred_country,
4871                    currency=preferred_currency,
4872                )
4873                for s in symbol_list
4874            ]
4875        )
4876
4877        gbi_id_ident_map: Dict[int, str] = {}
4878        for sec in sec_data:
4879            ident = sec.ticker if identifier_type == "TICKER" else sec.isin_info.identifier
4880            gbi_id_ident_map[sec.gbi_id] = ident
4881        gbi_ids = list(gbi_id_ident_map.keys())
4882
4883        qry = """
4884          query GetSimilarStocks(
4885            $modelId: ID!
4886            $portfolioId: ID!
4887            $gbiIds: [Int]!
4888            $startDate: String!
4889            $endDate: String!
4890            $includeCorrelation: Boolean
4891          ) {
4892            similarStocks(
4893              modelId: $modelId,
4894              portfolioId: $portfolioId,
4895              gbiIds: $gbiIds,
4896              startDate: $startDate,
4897              endDate: $endDate,
4898              includeCorrelation: $includeCorrelation
4899            ) {
4900              gbiId
4901              overallSimilarityScore
4902              priceSimilarityScore
4903              factorSimilarityScore
4904              correlation
4905            }
4906          }
4907        """
4908        variables = {
4909            "startDate": date_str,
4910            "endDate": date_str,
4911            "modelId": model_id,
4912            "portfolioId": portfolio_id,
4913            "gbiIds": gbi_ids,
4914            "includeCorrelation": True,
4915        }
4916
4917        resp = self._get_graphql(
4918            qry, variables=variables, error_msg_prefix="Failed to get similar stocks result: "
4919        )
4920        df = pd.DataFrame(resp["data"]["similarStocks"])
4921
4922        # Now that we have the rest of the securities in the portfolio, we need
4923        # to map them back to the correct identifiers
4924        all_gbi_ids = df["gbiId"].tolist()
4925        sec_info = self._get_security_info(all_gbi_ids)
4926        for s in sec_info["data"]["securities"]:
4927            ident = s["symbol"] if identifier_type == "TICKER" else s["isin"]
4928            gbi_id_ident_map[s["gbiId"]] = ident
4929        df["identifier"] = df["gbiId"].map(gbi_id_ident_map)
4930        df = df.set_index("identifier")
4931        return df.drop("gbiId", axis=1)
4932
4933    def get_portfolio_trades(
4934        self,
4935        model_id: str,
4936        portfolio_id: str,
4937        start_date: Optional[BoostedDate] = None,
4938        end_date: Optional[BoostedDate] = None,
4939    ) -> pd.DataFrame:
4940        if not end_date:
4941            end_date = datetime.date.today()
4942        end_date = convert_date(end_date)
4943
4944        if not start_date:
4945            # default to a year of data
4946            start_date = end_date - datetime.timedelta(days=365)
4947        start_date = convert_date(start_date)
4948
4949        start_date_str = start_date.strftime("%Y-%m-%d")
4950        end_date_str = end_date.strftime("%Y-%m-%d")
4951
4952        if end_date - start_date > datetime.timedelta(days=365 * 7):
4953            raise BoostedAPIException(
4954                f"Date range ({start_date_str}, {end_date_str}) too large, max 7 years"
4955            )
4956
4957        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_PA_ROUTE}/get-data/"
4958        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4959        payload = {
4960            "model_id": model_id,
4961            "portfolio_id": portfolio_id,
4962            "fields": ["price", "shares_traded", "shares_owned"],
4963            "min_date": start_date_str,
4964            "max_date": end_date_str,
4965            "return_format": "json",
4966        }
4967
4968        res: requests.Response = requests.post(
4969            url, json=payload, headers=headers, **self._request_params
4970        )
4971        self._check_ok_or_err_with_msg(res, "Unable to get portfolio trades data")
4972
4973        data = res.json()
4974        gbi_ids = [int(ident) for ident in data["rows"]]
4975
4976        # need both isin and ticker to distinguish between possible duplicates
4977        isin_map = {
4978            str(s["gbiId"]): s["isin"]
4979            for s in self._get_security_info(gbi_ids)["data"]["securities"]
4980        }
4981        ticker_map = {
4982            str(s["gbiId"]): s["symbol"]
4983            for s in self._get_security_info(gbi_ids)["data"]["securities"]
4984        }
4985
4986        # construct individual dataframes for each security, then join them together
4987        dfs: List[pd.DataFrame] = []
4988        full_data = data["data"]
4989        for i, gbi_id in enumerate(data["rows"]):
4990            df = pd.DataFrame(
4991                index=pd.to_datetime(data["columns"]), columns=data["fields"], data=full_data[i]
4992            )
4993            # drop rows where no shares are owned or traded
4994            df.drop(
4995                df.loc[((df["shares_owned"] == 0.0) & (df["shares_traded"] == 0.0))].index,
4996                inplace=True,
4997            )
4998            df["isin"] = isin_map[gbi_id]
4999            df["ticker"] = ticker_map[gbi_id]
5000            dfs.append(df)
5001
5002        full_df = pd.concat(dfs)
5003        full_df["date"] = full_df.index
5004        full_df.sort_index(inplace=True)
5005        full_df.reset_index(drop=True, inplace=True)
5006
5007        # reorder the columns to match the spreadsheet
5008        columns = ["isin", "ticker", "date", *data["fields"]]
5009        return full_df[columns]
5010
5011    def get_ideas(
5012        self,
5013        model_id: str,
5014        portfolio_id: str,
5015        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5016        delta_horizon: str = "1M",
5017    ):
5018        if investment_horizon not in ("1M", "3M", "1Y"):
5019            raise BoostedAPIException(f"Invalid investment horizon: {investment_horizon}")
5020
5021        if delta_horizon not in ("1W", "1M", "3M", "6M", "9M", "1Y"):
5022            raise BoostedAPIException(f"Invalid delta horizon: {delta_horizon}")
5023
5024        # First compute dates based on the delta horizon. "0D" is the latest rebalance.
5025        try:
5026            dates = self._get_portfolio_rebalance_from_periods(
5027                portfolio_id=portfolio_id, rel_periods=["0D", delta_horizon]
5028            )
5029        except Exception:
5030            raise BoostedAPIException(
5031                f"Portfolio {portfolio_id} does not exist or you do not have permission to view it."
5032            )
5033        end_date = dates[0].strftime("%Y-%m-%d")
5034        start_date = dates[1].strftime("%Y-%m-%d")
5035
5036        resp = self._get_graphql(
5037            graphql_queries.GET_IDEAS_QUERY,
5038            variables={
5039                "modelId": model_id,
5040                "portfolioId": portfolio_id,
5041                "horizon": investment_horizon,
5042                "deltaHorizon": delta_horizon,
5043                "startDate": start_date,
5044                "endDate": end_date,
5045                # Note: market data date is needed to fetch market cap.
5046                # we don't fetch that data from this endpoint so we stub
5047                # out the mandatory parameter with the end date requested
5048                "marketDataDate": end_date,
5049            },
5050            error_msg_prefix="Failed to get ideas: ",
5051        )
5052        # rows is a list of dicts like:
5053        # {
5054        #   "category": "Strong Sell",
5055        #   "dividendYield": 0.0,
5056        #   "reason": "Boosted Insights has given this stock...",
5057        #   "rating": 0.458167,
5058        #   "ratingDelta": 0.438087,
5059        #   "risk": {
5060        #     "text": "high"
5061        #   },
5062        #   "security": {
5063        #     "symbol": "BA"
5064        #   }
5065        # }
5066        try:
5067            rows = resp["data"]["recommendations"]["recommendations"]
5068            data = [
5069                {
5070                    "symbol": r["security"]["symbol"],
5071                    "recommendation": r["category"],
5072                    "rating": r["rating"],
5073                    "rating_delta": r["ratingDelta"],
5074                    "dividend_yield": r["dividendYield"],
5075                    "predicted_excess_return_1m": r["ER"]["oneMonth"],
5076                    "predicted_excess_return_3m": r["ER"]["threeMonth"],
5077                    "predicted_excess_return_1y": r["ER"]["oneYear"],
5078                    "risk": r["risk"]["text"],
5079                    "reward": r["reward"]["text"],
5080                    "reason": r["reason"],
5081                }
5082                for r in rows
5083            ]
5084            df = pd.DataFrame(data)
5085            df.set_index("symbol", inplace=True)
5086        except Exception:
5087            # Don't show old exception info to client
5088            raise BoostedAPIException(
5089                "No recommendations found, try selecting another horizon."
5090            ) from None
5091
5092        return df
5093
5094    def get_stock_recommendations(
5095        self,
5096        model_id: str,
5097        portfolio_id: str,
5098        symbols: Optional[List[str]] = None,
5099        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5100    ) -> pd.DataFrame:
5101        model_stocks = self._get_model_stocks(model_id)
5102
5103        symbols_to_gbiids = {s.ticker: s.gbi_id for s in model_stocks}
5104        gbi_ids_to_symbols = {s.gbi_id: s.ticker for s in model_stocks}
5105
5106        variables: Dict[str, Any] = {
5107            "strategyId": portfolio_id,
5108        }
5109        if symbols:
5110            variables["gbiIds"] = [
5111                symbols_to_gbiids.get(symbol) for symbol in symbols if symbols_to_gbiids.get(symbol)
5112            ]
5113        try:
5114            recs = self._get_graphql(
5115                graphql_queries.MULTI_STOCK_RECOMMENDATION_QUERY,
5116                variables=variables,
5117                log_error=False,
5118            )["data"]["currentRecommendationsFull"]
5119        except BoostedAPIException:
5120            raise BoostedAPIException(f"Error getting recommendations for strategy {portfolio_id}")
5121
5122        data = []
5123        recommendation_key = f"recommendation{investment_horizon}"
5124        for rec in recs:
5125            # Keys to rec are:
5126            # ['ER', 'rewardCategories', 'riskCategories', 'reasons',
5127            #  'recommendation', 'rewardCategory', 'riskCategory']
5128            # need to flatten these out and add to a DF
5129            rec_data = rec[recommendation_key]
5130            reasons_dict = {r["type"]: r["text"] for r in rec_data["reasons"]}
5131            row = {
5132                "symbol": gbi_ids_to_symbols[rec["gbiId"]],
5133                "recommendation": rec_data["currentCategory"],
5134                "predicted_excess_return_1m": rec_data["ER"]["oneMonth"],
5135                "predicted_excess_return_3m": rec_data["ER"]["threeMonth"],
5136                "predicted_excess_return_1y": rec_data["ER"]["oneYear"],
5137                "risk": rec_data["risk"]["text"],
5138                "reward": rec_data["reward"]["text"],
5139                "reasons": reasons_dict,
5140            }
5141
5142            data.append(row)
5143        df = pd.DataFrame(data)
5144        df.set_index("symbol", inplace=True)
5145        return df
5146
5147    # NOTE: this could be easily expanded to the entire stockRecommendation
5148    # entity, but that only includes all horizons' excess returns and risk/reward
5149    # which we already get from getIdeas
5150    def get_stock_recommendation_reasons(
5151        self,
5152        model_id: str,
5153        portfolio_id: str,
5154        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5155        symbols: Optional[List[str]] = None,
5156    ) -> Dict[str, Optional[List[str]]]:
5157        if investment_horizon not in ("1M", "3M", "1Y"):
5158            raise BoostedAPIException(f"Invalid investment horizon: {investment_horizon}")
5159
5160        # "0D" is the latest rebalance - its all we have in terms of recs
5161        dates = self._get_portfolio_rebalance_from_periods(
5162            portfolio_id=portfolio_id, rel_periods=["0D"]
5163        )
5164        date = dates[0].strftime("%Y-%m-%d")
5165
5166        model_stocks = self._get_model_stocks(model_id)
5167
5168        symbols_to_gbiids = {s.ticker: s.gbi_id for s in model_stocks}
5169        if symbols is None:  # potentially iterate through all holdings
5170            symbols = symbols_to_gbiids.keys()  # type: ignore
5171
5172        reasons: Dict[str, Optional[List[str]]] = {}
5173        for sym in symbols:
5174            # it's possible that a passed symbol was not actually a portfolio holding
5175            try:
5176                gbi_id = symbols_to_gbiids[sym]
5177            except KeyError:
5178                logger.warning(f"Symbol={sym} not found for in universe on {date=}")
5179                reasons[sym] = None
5180                continue
5181
5182            try:
5183                recs = self._get_graphql(
5184                    graphql_queries.STOCK_RECOMMENDATION_QUERY,
5185                    variables={
5186                        "modelId": model_id,
5187                        "portfolioId": portfolio_id,
5188                        "horizon": investment_horizon,
5189                        "gbiId": gbi_id,
5190                        "date": date,
5191                    },
5192                    log_error=False,
5193                )
5194                reasons[sym] = [
5195                    reason["text"] for reason in recs["data"]["stockRecommendation"]["reasons"]
5196                ]
5197            except BoostedAPIException:
5198                logger.warning(f"No recommendation for: {sym}, skipping...")
5199        return reasons
5200
5201    def get_stock_mapping_alternatives(
5202        self,
5203        isin: Optional[str] = None,
5204        symbol: Optional[str] = None,
5205        country: Optional[str] = None,
5206        currency: Optional[str] = None,
5207        asof_date: Optional[BoostedDate] = None,
5208    ) -> Dict:
5209        """
5210        Return the stock mapping for the given criteria,
5211        also suggestions for alternate matches,
5212        if the mapping is not what is wanted
5213
5214
5215            Parameters [One of either ISIN or SYMBOL must be provided]
5216            ----------
5217            isin: Optional[str]
5218                search by ISIN
5219            symbol: Optional[str]
5220                search by Ticker Symbol
5221            country: Optional[str]
5222                Additionally filter by country code - ex: None, "ANY", "p_USA", "CAN"
5223            currency: Optional[str]
5224                Additionally filter by currency code - ex: None, "ANY", "p_USD", "CAD"
5225            asof_date: Optional[date]
5226                as of which date to perform the search, default is today()
5227
5228            Note: country/currency filter starting with "p_" indicates
5229                  only a soft preference but allows other matches
5230
5231        Returns
5232        -------
5233        Dictionary Representing this 'MapSecurityResponse' structure:
5234
5235        class MapSecurityResponse():
5236            stock_mapping: Optional[SecurityInfo]
5237               The mapping we would perform given your inputs
5238
5239            alternatives: Optional[List[SecurityInfo]]
5240               Alternative suggestions based on your input
5241
5242            error: Optional[str]
5243
5244        class SecurityInfo():
5245            gbi_id: int
5246            isin: str
5247            symbol: Optional[str]
5248            country: str
5249            currency: str
5250            name: str
5251            from_date: date
5252            to_date: date
5253            is_primary_trading_item: bool
5254
5255        """
5256
5257        url = f"{self.base_uri}/api/stock-mapping/alternatives"
5258        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
5259        req_json: Dict = {
5260            "isin": isin,
5261            "symbol": symbol,
5262            "countryPreference": country,
5263            "currencyPreference": currency,
5264        }
5265
5266        if asof_date:
5267            req_json["date"] = convert_date(asof_date).isoformat()
5268
5269        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
5270
5271        if not res.ok:
5272            error_msg = self._try_extract_error_code(res)
5273            logger.error(error_msg)
5274            raise BoostedAPIException(f"Failed to get user watchlists: {error_msg}")
5275
5276        data = res.json()
5277        return data
5278
5279    def get_pros_cons_for_stocks(
5280        self,
5281        model_id: Optional[str] = None,
5282        symbols: Optional[List[str]] = None,
5283        preferred_country: Optional[str] = None,
5284        preferred_currency: Optional[str] = None,
5285    ) -> Dict[str, Dict[str, List]]:
5286        if symbols:
5287            ident_objs = [
5288                DateIdentCountryCurrency(
5289                    date=datetime.date.today().strftime("%Y-%m-%d"),
5290                    identifier=symbol,
5291                    country=preferred_country,
5292                    currency=preferred_currency,
5293                    id_type=ColumnSubRole.SYMBOL,
5294                )
5295                for symbol in symbols
5296            ]
5297            sec_objs = self.getGbiIdFromIdentCountryCurrencyDate(
5298                ident_country_currency_dates=ident_objs
5299            )
5300            gbi_id_ticker_map = {sec.gbi_id: sec.ticker for sec in sec_objs if sec}
5301        elif model_id:
5302            gbi_id_ticker_map = {
5303                sec.gbi_id: sec.ticker for sec in self._get_model_stocks(model_id=model_id)
5304            }
5305        gbi_id_pros_cons_map = {}
5306        gbi_ids = list(gbi_id_ticker_map.keys())
5307        data = self._get_graphql(
5308            query=graphql_queries.GET_PROS_CONS_QUERY,
5309            variables={"gbiIds": gbi_ids},
5310            error_msg_prefix="Failed to get pros/cons:",
5311        )
5312        gbi_id_pros_cons_map = {
5313            row["gbiId"]: {"pros": row["pros"], "cons": row["cons"]}
5314            for row in data["data"]["bulkSecurityProsCons"]
5315        }
5316
5317        return {
5318            gbi_id_ticker_map[gbi_id]: pros_cons
5319            for gbi_id, pros_cons in gbi_id_pros_cons_map.items()
5320        }
5321
5322    def generate_theme(self, theme_name: str, stock_universes: List[ThemeUniverse]) -> str:
5323        # First get universe name and id mappings
5324        try:
5325            resp = self._get_graphql(
5326                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5327            )
5328            data = resp["data"]["getMarketTrendsUniverses"]
5329        except Exception:
5330            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5331
5332        universe_name_to_id = {u["name"]: u["id"] for u in data}
5333        universe_ids = [universe_name_to_id[u.value] for u in stock_universes]
5334        try:
5335            resp = self._get_graphql(
5336                query=graphql_queries.GENERATE_THEME_QUERY,
5337                variables={"input": {"themeName": theme_name, "universeIds": universe_ids}},
5338            )
5339            data = resp["data"]["generateTheme"]
5340        except Exception:
5341            raise BoostedAPIException(f"Failed to generate theme: {theme_name}")
5342
5343        if not data["success"]:
5344            raise BoostedAPIException(f"Failed to generate theme: {theme_name}")
5345
5346        logger.info(
5347            f"Successfully generated theme: {theme_name}. The theme ID is {data['themeId']}"
5348        )
5349        return data["themeId"]
5350
5351    def _get_stock_universe_id(self, universe: ThemeUniverse) -> str:
5352        try:
5353            resp = self._get_graphql(
5354                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5355            )
5356            data = resp["data"]["getMarketTrendsUniverses"]
5357        except Exception:
5358            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5359
5360        for u in data:
5361            if u["name"] == universe.value:
5362                universe_id = u["id"]
5363                return universe_id
5364
5365        raise BoostedAPIException(f"Failed to find universe: {universe.value}")
5366
5367    def get_themes_for_stock_universe(
5368        self,
5369        stock_universe: ThemeUniverse,
5370        start_date: Optional[BoostedDate] = None,
5371        end_date: Optional[BoostedDate] = None,
5372        language: Optional[Union[str, Language]] = None,
5373    ) -> List[Dict]:
5374        """Get all themes data for a particular stock universe
5375        (start_date, end_date) are used to calculate the theme importance for ranking purpose. If
5376        None, default to past 30 days
5377        Returns: A list of below dictionaries
5378        {
5379            themeId: str
5380            themeName: str
5381            themeImportance: float
5382            volatility: float
5383            positiveStockPerformance: float
5384            negativeStockPerformance: float
5385        }
5386        """
5387        translate = functools.partial(self.translate_text, language)
5388        # First get universe name and id mappings
5389        universe_id = self._get_stock_universe_id(stock_universe)
5390
5391        start_date_iso, end_date_iso = get_valid_iso_dates(start_date, end_date)
5392
5393        try:
5394            resp = self._get_graphql(
5395                query=graphql_queries.GET_THEMES,
5396                variables={
5397                    "type": "UNIVERSE",
5398                    "id": universe_id,
5399                    "startDate": start_date_iso,
5400                    "endDate": end_date_iso,
5401                    "deltaHorizon": "",  # not needed here
5402                },
5403            )
5404            data = resp["data"]["themes"]
5405        except Exception:
5406            raise BoostedAPIException(
5407                f"Failed to get themes for stock universe: {stock_universe.name}"
5408            )
5409
5410        for theme_data in data:
5411            theme_data["themeName"] = translate(theme_data["themeName"])
5412        return data
5413
5414    def get_themes_for_stock(
5415        self,
5416        isin: str,
5417        currency: Optional[str] = None,
5418        country: Optional[str] = None,
5419        start_date: Optional[BoostedDate] = None,
5420        end_date: Optional[BoostedDate] = None,
5421        language: Optional[Union[str, Language]] = None,
5422    ) -> List[Dict]:
5423        """Get all themes data for a particular stock
5424        (ISIN, currency, country) compose a unique identifier for a stock for us to map to GBI ID
5425        (start_date, end_date) are used to calculate the theme importance for ranking purpose. If
5426        None, default to past 30 days
5427
5428        Returns
5429        A list of below dictionaries
5430        {
5431            themeId: str
5432            themeName: str
5433            importanceScore: float
5434            similarityScore: float
5435            positiveThemeRelation: bool
5436            reason: String
5437        }
5438        """
5439        translate = functools.partial(self.translate_text, language)
5440        security_info = self.get_stock_mapping_alternatives(
5441            isin, country=country, currency=currency
5442        )
5443        gbi_id = security_info["stock_mapping"]["gbi_id"]
5444
5445        if (start_date and not end_date) or (end_date and not start_date):
5446            raise BoostedAPIException("Must provide both start and end dates or neither")
5447        elif not end_date and not start_date:
5448            end_date = datetime.date.today()
5449            start_date = end_date - datetime.timedelta(days=30)
5450            end_date = end_date.isoformat()
5451            start_date = start_date.isoformat()
5452        else:
5453            if isinstance(start_date, datetime.date):
5454                start_date = start_date.isoformat()
5455            if isinstance(end_date, datetime.date):
5456                end_date = end_date.isoformat()
5457
5458        try:
5459            resp = self._get_graphql(
5460                query=graphql_queries.GET_THEMES_FOR_STOCK_WITH_REASONS,
5461                variables={"gbiId": gbi_id, "startDate": start_date, "endDate": end_date},
5462            )
5463            data = resp["data"]["themesForStockWithReasons"]
5464        except Exception:
5465            raise BoostedAPIException(f"Failed to get themes for stock: {isin}")
5466
5467        for item in data:
5468            item["themeName"] = translate(item["themeName"])
5469            item["reason"] = translate(item["reason"])
5470        return data
5471
5472    def get_stock_news(
5473        self,
5474        time_horizon: NewsHorizon,
5475        isin: str,
5476        currency: Optional[str] = None,
5477        country: Optional[str] = None,
5478        language: Optional[Union[str, Language]] = None,
5479    ) -> Dict:
5480        """
5481        The API to get a stock's news summary for a given time horizon, the topics summarized by
5482        these news and the corresponding news to these topics
5483        Returns
5484        -------
5485        A nested dictionary in the following format:
5486        {
5487            summary: str
5488            topics: [
5489                {
5490                    topicId: str
5491                    topicLabel: str
5492                    topicDescription: str
5493                    topicPolarity: str
5494                    newsItems: [
5495                        {
5496                            newsId: str
5497                            headline: str
5498                            url: str
5499                            summary: str
5500                            source: str
5501                            publishedAt: str
5502                        }
5503                    ]
5504                }
5505            ]
5506            other_news_count: int
5507        }
5508        """
5509        translate = functools.partial(self.translate_text, language)
5510        security_info = self.get_stock_mapping_alternatives(
5511            isin, country=country, currency=currency
5512        )
5513        gbi_id = security_info["stock_mapping"]["gbi_id"]
5514
5515        try:
5516            resp = self._get_graphql(
5517                query=graphql_queries.GET_STOCK_NEWS_QUERY,
5518                variables={"gbiId": gbi_id, "deltaHorizon": time_horizon.value},
5519            )
5520            data = resp["data"]
5521        except Exception:
5522            raise BoostedAPIException(f"Failed to get themes for stock: {isin}")
5523
5524        outputs: Dict[str, Any] = {}
5525        outputs["summary"] = translate(data["getStockNewsSummary"]["summary"])
5526        # Return the top 10 topics
5527        outputs["topics"] = data["getStockNewsTopics"][:10]
5528
5529        for topic in outputs["topics"]:
5530            topic["topicLabel"] = translate(topic["topicLabel"])
5531            topic["topicDescription"] = translate(topic["topicDescription"])
5532
5533        other_news_count = 0
5534        for source_count in data["getStockNewsSummary"]["sourceCounts"]:
5535            other_news_count += source_count["count"]
5536
5537        for topic in outputs["topics"]:
5538            other_news_count -= len(topic["newsItems"])
5539
5540        outputs["other_news_count"] = other_news_count
5541
5542        return outputs
5543
5544    def get_theme_details(
5545        self,
5546        theme_id: str,
5547        universe: ThemeUniverse,
5548        language: Optional[Union[str, Language]] = None,
5549    ) -> Dict[str, Any]:
5550        translate = functools.partial(self.translate_text, language)
5551        universe_id = self._get_stock_universe_id(universe)
5552        date = datetime.date.today()
5553        prev_date = date - datetime.timedelta(days=30)
5554        result = self._get_graphql(
5555            query=graphql_queries.GET_THEME_DEEPDIVE_DETAILS,
5556            variables={
5557                "deltaHorizon": "1W",
5558                "startDate": prev_date.strftime("%Y-%m-%d"),
5559                "endDate": date.strftime("%Y-%m-%d"),
5560                "id": universe_id,
5561                "themeId": theme_id,
5562                "type": "UNIVERSE",
5563            },
5564            error_msg_prefix="Failed to get theme details",
5565        )["data"]["marketThemes"]
5566
5567        gbi_id_stock_data_map: Dict[int, Dict] = {}
5568
5569        stocks = []
5570        for stock_info in result["stockInfos"]:
5571            gbi_id_stock_data_map[stock_info["gbiId"]] = stock_info["security"]
5572            stocks.append(
5573                {
5574                    "isin": stock_info["security"]["isin"],
5575                    "name": stock_info["security"]["name"],
5576                    "reason": translate(stock_info["polarityReasonScores"]["reason"]),
5577                    "positive_theme_relation": stock_info["polarityReasonScores"][
5578                        "positiveThemeRelation"
5579                    ],
5580                    "theme_stock_impact_score": stock_info["polarityReasonScores"][
5581                        "similarityScore"
5582                    ],
5583                }
5584            )
5585
5586        impacts = []
5587        for impact in result["impactInfos"]:
5588            articles = [
5589                {
5590                    "title": newsitem["headline"],
5591                    "url": newsitem["url"],
5592                    "source": newsitem["source"],
5593                    "publish_date": newsitem["publishedAt"],
5594                }
5595                for newsitem in impact["newsItems"]
5596            ]
5597
5598            impact_stocks = []
5599            for impact_stock_data in impact["stocks"]:
5600                stock_metadata = gbi_id_stock_data_map[impact_stock_data["gbiId"]]
5601                impact_stocks.append(
5602                    {
5603                        "isin": stock_metadata["isin"],
5604                        "name": stock_metadata["name"],
5605                        "positive_impact_relation": impact_stock_data["positiveThemeRelation"],
5606                    }
5607                )
5608
5609            impact_dict = {
5610                "impact_name": translate(impact["impactName"]),
5611                "impact_description": translate(impact["impactDescription"]),
5612                "impact_score": impact["impactScore"],
5613                "articles": articles,
5614                "impact_stocks": impact_stocks,
5615            }
5616            impacts.append(impact_dict)
5617
5618        developments = []
5619        for dev in result["themeDevelopments"]:
5620            developments.append(
5621                {
5622                    "name": dev["label"],
5623                    "article_count": dev["articleCount"],
5624                    "date": parser.parse(dev["date"]).date(),
5625                    "description": dev["description"],
5626                    "is_major_development": dev["isMajorDevelopment"],
5627                    "sentiment": dev["sentiment"],
5628                    "news": [
5629                        {
5630                            "headline": entry["headline"],
5631                            "published_at": parser.parse(entry["publishedAt"]),
5632                            "source": entry["source"],
5633                            "url": entry["url"],
5634                        }
5635                        for entry in dev["news"]
5636                    ],
5637                }
5638            )
5639
5640        developments = sorted(developments, key=lambda d: d["date"], reverse=True)
5641
5642        output = {
5643            "theme_name": translate(result["themeName"]),
5644            "theme_summary": translate(result["themeDescription"]),
5645            "impacts": impacts,
5646            "stocks": stocks,
5647            "developments": developments,
5648        }
5649        return output
5650
5651    def get_all_theme_metadata(
5652        self, language: Optional[Union[str, Language]] = None
5653    ) -> List[Dict[str, Any]]:
5654        translate = functools.partial(self.translate_text, language)
5655        result = self._get_graphql(
5656            graphql_queries.GET_ALL_THEMES,
5657            variables={"universeIds": None},
5658            error_msg_prefix="Failed to fetch all themes metadata",
5659        )
5660
5661        try:
5662            resp = self._get_graphql(
5663                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5664            )
5665            data = resp["data"]["getMarketTrendsUniverses"]
5666        except Exception:
5667            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5668        universe_id_to_name = {u["id"]: u["name"] for u in data}
5669
5670        outputs = []
5671        for theme in result["data"]["getAllThemesForUser"]:
5672            # map universe ID to universe ticker
5673            universe_tickers = []
5674            for universe_id in theme["universeIds"]:
5675                if universe_id in universe_id_to_name:  # don't support unlisted universes - skip
5676                    universe_name = universe_id_to_name[universe_id]
5677                    ticker = ThemeUniverse.get_ticker_from_name(universe_name)
5678                    if ticker:
5679                        universe_tickers.append(ticker)
5680
5681            outputs.append(
5682                {
5683                    "theme_id": theme["themeId"],
5684                    "theme_name": translate(theme["themeName"]),
5685                    "universes": universe_tickers,
5686                }
5687            )
5688
5689        return outputs
5690
5691    def get_earnings_impacting_security(
5692        self,
5693        isin: str,
5694        currency: Optional[str] = None,
5695        country: Optional[str] = None,
5696        language: Optional[Union[str, Language]] = None,
5697    ) -> List[Dict[str, Any]]:
5698        translate = functools.partial(self.translate_text, language)
5699        date = datetime.date.today().strftime("%Y-%m-%d")
5700        company_data = self.getGbiIdFromIdentCountryCurrencyDate(
5701            ident_country_currency_dates=[
5702                DateIdentCountryCurrency(
5703                    date=date, identifier=isin, country=country, currency=currency
5704                )
5705            ]
5706        )
5707        try:
5708            gbi_id = company_data[0].gbi_id
5709        except Exception:
5710            raise BoostedAPIException(f"ISIN {isin} not found")
5711
5712        result = self._get_graphql(
5713            graphql_queries.EARNINGS_IMPACTS_CALENDAR_FOR_STOCK,
5714            variables={"date": date, "days": 180, "gbiId": gbi_id},
5715            error_msg_prefix="Failed to fetch earnings impacts data for stock",
5716        )
5717        earnings_events = result["data"]["earningsCalendarForStock"]
5718        output_events = []
5719        for event in earnings_events:
5720            if not event["impactedCompanies"]:
5721                continue
5722            fixed_event = {
5723                "event_date": event["eventDate"],
5724                "company_name": event["security"]["name"],
5725                "symbol": event["security"]["symbol"],
5726                "isin": event["security"]["isin"],
5727                "impact_reason": translate(event["impactedCompanies"][0]["reason"]),
5728            }
5729            output_events.append(fixed_event)
5730
5731        return output_events
5732
5733    def get_earnings_insights_for_stocks(
5734        self, isin: str, currency: Optional[str] = None, country: Optional[str] = None
5735    ) -> Dict[str, Any]:
5736        date = datetime.date.today().strftime("%Y-%m-%d")
5737        company_data = self.getGbiIdFromIdentCountryCurrencyDate(
5738            ident_country_currency_dates=[
5739                DateIdentCountryCurrency(
5740                    date=date, identifier=isin, country=country, currency=currency
5741                )
5742            ]
5743        )
5744        gbi_id_isin_map = {
5745            company.gbi_id: company.isin_info.identifier
5746            for company in company_data
5747            if company is not None
5748        }
5749        try:
5750            resp = self._get_graphql(
5751                query=graphql_queries.GET_EARNINGS_INSIGHTS_SUMMARIES,
5752                variables={"gbiIds": list(gbi_id_isin_map.keys())},
5753            )
5754            # list of objects with gbi id and data
5755            summaries = resp["data"]["getEarningsSummaries"]
5756            resp = self._get_graphql(
5757                query=graphql_queries.GET_EARNINGS_COMPARISONS,
5758                variables={"gbiIds": list(gbi_id_isin_map.keys())},
5759            )
5760            # list of objects with gbi id and data
5761            comparison = resp["data"]["getLatestEarningsChanges"]
5762        except Exception:
5763            raise BoostedAPIException(f"Failed to earnings insights data")
5764
5765        if not summaries:
5766            raise BoostedAPIException(
5767                (
5768                    f"Failed to find earnings insights data for {isin}"
5769                    ", please try with another security"
5770                )
5771            )
5772
5773        output: Dict[str, Any] = {}
5774        reports = sorted(summaries[0]["reports"], key=lambda r: r["date"], reverse=True)
5775        current_report = reports[0]
5776
5777        def is_aligned_formatter(acc: Tuple[List, List], cur: Dict[str, Any]):
5778            if cur["isAligned"]:
5779                acc[0].append({k: cur[k] for k in cur if k != "isAligned"})
5780            else:
5781                acc[1].append({k: cur[k] for k in cur if k != "isAligned"})
5782            return acc
5783
5784        current_report_common_remarks: Union[List[Dict[str, Any]], List]
5785        current_report_dropped_remarks: Union[List[Dict[str, Any]], List]
5786        current_report_common_remarks, current_report_dropped_remarks = functools.reduce(
5787            is_aligned_formatter, current_report["details"], ([], [])
5788        )
5789        prev_report_common_remarks: Union[List[Dict[str, Any]], List]
5790        prev_report_new_remarks: Union[List[Dict[str, Any]], List]
5791        prev_report_common_remarks, prev_report_new_remarks = functools.reduce(
5792            is_aligned_formatter, current_report["details"], ([], [])
5793        )
5794
5795        output["earnings_report"] = {
5796            "release_date": datetime.datetime.strptime(current_report["date"], "%Y-%m-%d").date(),
5797            "quarter": current_report["quarter"],
5798            "year": current_report["year"],
5799            "details": [
5800                {
5801                    "header": detail_obj["header"],
5802                    "detail": detail_obj["detail"],
5803                    "sentiment": detail_obj["sentiment"],
5804                }
5805                for detail_obj in current_report["details"]
5806            ],
5807            "call_summary": current_report["highlights"],
5808            "common_remarks": current_report_common_remarks,
5809            "dropped_remarks": current_report_dropped_remarks,
5810            "qa_summary": current_report["qaHighlights"],
5811            "qa_details": current_report["qaDetails"],
5812        }
5813        prev_report = summaries[0]["reports"][1]
5814        output["prior_earnings_report"] = {
5815            "release_date": datetime.datetime.strptime(prev_report["date"], "%Y-%m-%d").date(),
5816            "quarter": prev_report["quarter"],
5817            "year": prev_report["year"],
5818            "details": [
5819                {
5820                    "header": detail_obj["header"],
5821                    "detail": detail_obj["detail"],
5822                    "sentiment": detail_obj["sentiment"],
5823                }
5824                for detail_obj in prev_report["details"]
5825            ],
5826            "call_summary": prev_report["highlights"],
5827            "common_remarks": prev_report_common_remarks,
5828            "new_remarks": prev_report_new_remarks,
5829            "qa_summary": prev_report["qaHighlights"],
5830            "qa_details": prev_report["qaDetails"],
5831        }
5832
5833        if not comparison:
5834            output["report_comparison"] = []
5835        else:
5836            output["report_comparison"] = comparison[0]["changes"]
5837
5838        return output
5839
5840    def get_portfolio_inference_status(self, portfolio_id: str, inference_date: str) -> dict:
5841        url = f"{self.base_uri}/api/inference/status/{portfolio_id}/{inference_date}"
5842        headers = {"Authorization": "ApiKey " + self.api_key}
5843        res = requests.get(url, headers=headers)
5844
5845        if not res.ok:
5846            error_msg = self._try_extract_error_code(res)
5847            logger.error(error_msg)
5848            raise BoostedAPIException(
5849                f"Failed to get portfolio inference status, portfolio_id={portfolio_id}, "
5850                f"inference_date={inference_date}: {error_msg}"
5851            )
5852
5853        data = res.json()
5854        return data
BoostedClient( api_key, override_uri=None, debug=False, proxy=None, disable_verify_ssl=False)
 93    def __init__(
 94        self, api_key, override_uri=None, debug=False, proxy=None, disable_verify_ssl=False
 95    ):
 96        """
 97        Parameters
 98        ----------
 99        api_key: str
100            Your API key provided by the Boosted application.  See your profile
101            to generate a new key.
102        proxy: str
103            Your organization may require the use of a proxy for access.
104            The address of a HTTPS proxy in the format of <address>:<port>.
105            Examples are "123.456.789:123" or "my.proxy.com:123".
106            Do not prepend with "https://".
107        disable_verify_ssl: bool
108            Your networking setup may be behind a firewall which performs SSL
109            inspection. Either set the REQUESTS_CA_BUNDLE environment variable
110            to point to the location of a custom certificate bundle, or set this
111            parameter to True to disable SSL verification as a workaround.
112        """
113        if override_uri is None:
114            self.base_uri = g_boosted_api_url
115        else:
116            self.base_uri = override_uri
117        self.api_key = api_key
118        self.debug = debug
119        self._request_params: Dict = {}
120        if debug:
121            logger.setLevel(logging.DEBUG)
122        else:
123            logger.setLevel(logging.INFO)
124        if proxy is not None:
125            self._request_params["proxies"] = {"https": proxy}
126        if disable_verify_ssl:
127            self._request_params["verify"] = False

Parameters

api_key: str Your API key provided by the Boosted application. See your profile to generate a new key. proxy: str Your organization may require the use of a proxy for access. The address of a HTTPS proxy in the format of

:. Examples are "123.456.789:123" or "my.proxy.com:123". Do not prepend with "https://". disable_verify_ssl: bool Your networking setup may be behind a firewall which performs SSL inspection. Either set the REQUESTS_CA_BUNDLE environment variable to point to the location of a custom certificate bundle, or set this parameter to True to disable SSL verification as a workaround.

api_key
debug
def translate_text( self, language: Union[boosted.api.api_type.Language, str, NoneType], text: str) -> str:
247    def translate_text(self, language: Optional[Union[Language, str]], text: str) -> str:
248        if not language or language == Language.ENGLISH:
249            # By default, do not translate English
250            return text
251
252        params = {"text": text, "langCode": language}
253        url = self.base_uri + "/api/translate/translate-text"
254        headers = {"Authorization": "ApiKey " + self.api_key}
255        logger.info("Translating text...")
256        res = requests.post(url, json=params, headers=headers, **self._request_params)
257        try:
258            result = res.json()["translatedText"]
259        except Exception:
260            raise BoostedAPIException("Error translating text")
261        return result
def query_dataset(self, dataset_id):
263    def query_dataset(self, dataset_id):
264        url = self.base_uri + "/api/datasets/{0}".format(dataset_id)
265        headers = {"Authorization": "ApiKey " + self.api_key}
266        res = requests.get(url, headers=headers, **self._request_params)
267        if res.ok:
268            return res.json()
269        else:
270            error_msg = self._try_extract_error_code(res)
271            logger.error(error_msg)
272            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
def query_namespace_dataset_id(self, namespace, data_type):
274    def query_namespace_dataset_id(self, namespace, data_type):
275        url = self.base_uri + f"/api/custom-security-dataset/{namespace}/{data_type}"
276        headers = {"Authorization": "ApiKey " + self.api_key}
277        res = requests.get(url, headers=headers, **self._request_params)
278        if res.ok:
279            return res.json()["result"]["id"]
280        else:
281            if res.status_code != 404:
282                error_msg = self._try_extract_error_code(res)
283                logger.error(error_msg)
284                raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
285            else:
286                return None
def export_global_data( self, dataset_id, start=datetime.date(1999, 4, 24), end=datetime.date(2024, 4, 17), timeout=600):
288    def export_global_data(
289        self,
290        dataset_id,
291        start=(datetime.date.today() - timedelta(days=365 * 25)),
292        end=datetime.date.today(),
293        timeout=600,
294    ):
295        query_info = self.query_dataset(dataset_id)
296        if DataSetType[query_info["type"]] != DataSetType.GLOBAL:
297            raise BoostedAPIException(
298                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.GLOBAL}"
299            )
300        return self.export_data(dataset_id, start, end, timeout)
def export_independent_data( self, dataset_id, start=datetime.date(1999, 4, 24), end=datetime.date(2024, 4, 17), timeout=600):
302    def export_independent_data(
303        self,
304        dataset_id,
305        start=(datetime.date.today() - timedelta(days=365 * 25)),
306        end=datetime.date.today(),
307        timeout=600,
308    ):
309        query_info = self.query_dataset(dataset_id)
310        if DataSetType[query_info["type"]] != DataSetType.STRATEGY:
311            raise BoostedAPIException(
312                f"Incorrect dataset type: {query_info['type']}"
313                f" - Expected {DataSetType.STRATEGY}"
314            )
315        return self.export_data(dataset_id, start, end, timeout)
def export_dependent_data(self, dataset_id, start=None, end=None, timeout=600):
317    def export_dependent_data(
318        self,
319        dataset_id,
320        start=None,
321        end=None,
322        timeout=600,
323    ):
324        query_info = self.query_dataset(dataset_id)
325        if DataSetType[query_info["type"]] != DataSetType.STOCK:
326            raise BoostedAPIException(
327                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.STOCK}"
328            )
329
330        valid_date_range = self.getDatasetDates(dataset_id)
331        validStart = valid_date_range["validFrom"]
332        validEnd = valid_date_range["validTo"]
333
334        if start is None:
335            logger.info("Since no start date provided, starting from {0}.".format(validStart))
336            start = validStart
337        if end is None:
338            logger.info("Since no end date provided, ending at {0}.".format(validEnd))
339            end = validEnd
340        start = self.__to_date_obj(start)
341        end = self.__to_date_obj(end)
342        if start < validStart:
343            logger.info("Data does not exist before {0}.".format(validStart))
344            logger.info("Starting from {0}.".format(validStart))
345            start = validStart
346        if end > validEnd:
347            logger.info("Data does not exist after {0}.".format(validEnd))
348            logger.info("Ending at {0}.".format(validEnd))
349            end = validEnd
350        validate_start_and_end_dates(start, end)
351
352        logger.info("Data exists from {0} to {1}.".format(start, end))
353        request_url = "/api/datasets/" + dataset_id + "/export-data"
354        headers = {"Authorization": "ApiKey " + self.api_key}
355
356        data_chunks = []
357        chunk_size_days = 90
358        while start <= end:
359            chunk_end = start + timedelta(days=chunk_size_days)
360            if chunk_end > end:
361                chunk_end = end
362
363            logger.info("Requesting start={0} end={1}.".format(start, chunk_end))
364            params = {"start": self.__iso_format(start), "end": self.__iso_format(chunk_end)}
365            logger.debug("URL={0}, headers={1}, params={2}".format(request_url, headers, params))
366
367            res = requests.get(
368                self.base_uri + request_url,
369                headers=headers,
370                params=params,
371                timeout=timeout,
372                **self._request_params,
373            )
374
375            if res.ok:
376                buf = io.StringIO(res.text)
377                df = pd.read_csv(buf, index_col=0, parse_dates=True)
378                if "price" in df.columns:
379                    df = df.drop("price", axis=1)
380                data_chunks.append(df)
381            else:
382                error_msg = self._try_extract_error_code(res)
383                logger.error(error_msg)
384                raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
385
386            start = start + timedelta(days=chunk_size_days + 1)
387
388        return pd.concat(data_chunks)
def export_custom_security_data( self, dataset_id, start=datetime.date(1999, 4, 24), end=datetime.date(2024, 4, 17), timeout=600):
390    def export_custom_security_data(
391        self,
392        dataset_id,
393        start=(date.today() - timedelta(days=365 * 25)),
394        end=date.today(),
395        timeout=600,
396    ):
397        query_info = self.query_dataset(dataset_id)
398        if DataSetType[query_info["type"]] != DataSetType.SECURITIES_DAILY:
399            raise BoostedAPIException(
400                f"Incorrect dataset type: {query_info['type']}"
401                f" - Expected {DataSetType.SECURITIES_DAILY}"
402            )
403        return self.export_data(dataset_id, start, end, timeout)
def export_data( self, dataset_id, start=datetime.date(1999, 4, 24), end=datetime.date(2024, 4, 17), timeout=600):
405    def export_data(
406        self,
407        dataset_id,
408        start=(datetime.date.today() - timedelta(days=365 * 25)),
409        end=datetime.date.today(),
410        timeout=600,
411    ):
412        logger.info("Requesting start={0} end={1}.".format(start, end))
413        request_url = "/api/datasets/" + dataset_id + "/export-data"
414        headers = {"Authorization": "ApiKey " + self.api_key}
415        start = self.__iso_format(start)
416        end = self.__iso_format(end)
417        params = {"start": start, "end": end}
418        logger.debug("URL={0}, headers={1}, params={2}".format(request_url, headers, params))
419        res = requests.get(
420            self.base_uri + request_url,
421            headers=headers,
422            params=params,
423            timeout=timeout,
424            **self._request_params,
425        )
426        if res.ok or self._check_status_code(res):
427            buf = io.StringIO(res.text)
428            df = pd.read_csv(buf, index_col=0, parse_dates=True)
429            if "price" in df.columns:
430                df = df.drop("price", axis=1)
431            return df
432        else:
433            error_msg = self._try_extract_error_code(res)
434            logger.error(error_msg)
435            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
def get_inference( self, model_id, inference_date=datetime.date(2024, 4, 17), block=False, timeout_minutes=30):
452    def get_inference(
453        self, model_id, inference_date=datetime.date.today(), block=False, timeout_minutes=30
454    ):
455        start_time = datetime.datetime.now()
456        while True:
457            for numRetries in range(3):
458                res, status = self._get_inference(model_id, inference_date)
459                if res is not None:
460                    continue
461                else:
462                    if status == Status.FAIL:
463                        return Status.FAIL
464                    logger.info("Retrying...")
465            if res is None:
466                logger.error("Max retries reached.  Request failed.")
467                return None
468
469            json_data = res.json()
470            if "result" in json_data.keys():
471                if json_data["result"]["status"] == "RUNNING":
472                    still_running = True
473                    if not block:
474                        logger.warn("Inference job is still running.")
475                        return None
476                    else:
477                        logger.info(
478                            "Inference job is still running.  Time elapsed={0}.".format(
479                                datetime.datetime.now() - start_time
480                            )
481                        )
482                        time.sleep(10)
483                else:
484                    still_running = False
485
486                if not still_running and json_data["result"]["status"] == "COMPLETE":
487                    csv = json_data["result"]["signals"]
488                    logger.info(json_data["result"])
489                    if self._check_status_code(res, isInference=True):
490                        logger.info(
491                            "Total run time = {0}.".format(datetime.datetime.now() - start_time)
492                        )
493                        return csv
494            else:
495                if "errors" in json_data.keys():
496                    logger.error(json_data["errors"])
497                else:
498                    logger.error("Error getting inference for date {0}.".format(inference_date))
499                return None
500            if (datetime.datetime.now() - start_time).total_seconds() / 60.0 > timeout_minutes:
501                logger.error("Timeout waiting for job completion.")
502                return None
def createDataset(self, schema):
504    def createDataset(self, schema):
505        request_url = "/api/datasets"
506        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
507        s = json.dumps(schema)
508        logger.info("Creating dataset with schema " + s)
509        res = requests.post(
510            self.base_uri + request_url, data=s, headers=headers, **self._request_params
511        )
512        if res.ok:
513            return res.json()["result"]
514        else:
515            raise BoostedAPIException("Dataset creation failed.")
def create_custom_namespace_dataset(self, namespace, schema):
517    def create_custom_namespace_dataset(self, namespace, schema):
518        request_url = f"/api/custom-security-dataset/{namespace}"
519        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
520        s = json.dumps(schema)
521        logger.info("Creating dataset with schema " + s)
522        res = requests.post(
523            self.base_uri + request_url, data=s, headers=headers, **self._request_params
524        )
525        if res.ok:
526            return res.json()["result"]
527        else:
528            raise BoostedAPIException("Dataset creation failed.")
def getUniverse(self, modelId, date=None):
530    def getUniverse(self, modelId, date=None):
531        if date is not None:
532            url = "/api/models/{0}/universe/{1}".format(modelId, self.__iso_format(date))
533            logger.info("Getting universe for date: {0}.".format(date))
534        else:
535            url = "/api/models/{0}/universe/".format(modelId)
536        headers = {"Authorization": "ApiKey " + self.api_key}
537        res = requests.get(self.base_uri + url, headers=headers, **self._request_params)
538        if res.ok:
539            buf = io.StringIO(res.text)
540            df = pd.read_csv(buf, index_col=0, parse_dates=True)
541            return df
542        else:
543            error = self._try_extract_error_code(res)
544            logger.error(
545                "There was a problem getting this universe or model ID: {0}.".format(error)
546            )
547            raise BoostedAPIException("Failed to get universe: {0}".format(error))
def add_custom_security_namespace_members( self, namespace, members: Union[pandas.core.frame.DataFrame, str]) -> Tuple[pandas.core.frame.DataFrame, str]:
549    def add_custom_security_namespace_members(
550        self, namespace, members: Union[pandas.DataFrame, str]
551    ) -> Tuple[pandas.DataFrame, str]:
552        url = self.base_uri + "/api/synthetic-datasets/{0}/generate".format(namespace)
553        headers = {"Authorization": "ApiKey " + self.api_key}
554        headers["Content-Type"] = "application/json"
555        logger.info("Adding custom security namespace for namespace: {0}".format(namespace))
556        strbuf = None
557        if isinstance(members, pandas.DataFrame):
558            df = members
559            df_canon = df.rename(columns={_: to_camel_case(_) for _ in df.columns})
560            canon_cols = ["Currency", "Symbol", "Country", "Name"]
561            if set(canon_cols).difference(df_canon.columns):
562                raise BoostedAPIException(f"Expected columns: {canon_cols}")
563            df_canon = df_canon.loc[:, canon_cols]
564            buf = io.StringIO()
565            df_canon.to_json(buf, orient="records")
566            strbuf = buf.getvalue()
567        elif isinstance(members, str):
568            strbuf = members
569        else:
570            raise BoostedAPIException(f"Unsupported members argument type: {type(members)}")
571        res = requests.post(url, data=strbuf, headers=headers, **self._request_params)
572        if res.ok:
573            res_obj = res.json()
574            res_df = pandas.Series(res_obj["generatedISIN"]).to_frame()
575            res_df.index.name = "Symbol"
576            res_df.columns = ["ISIN"]
577            logger.info("Add to custom security namespace successful.")
578            if "warnings" in res_obj:
579                logger.info("Warnings: {0}.".format(res.json()["warnings"]))
580                return res_df, res.json()["warnings"]
581            else:
582                return res_df, "No warnings."
583        else:
584            error_msg = self._try_extract_error_code(res)
585            raise BoostedAPIException("Failed to get universe: {0}.".format(error_msg))
def updateUniverse(self, modelId, universe_df, date=datetime.date(2024, 4, 18)):
587    def updateUniverse(self, modelId, universe_df, date=datetime.date.today() + timedelta(1)):
588        date = self.__iso_format(date)
589        url = self.base_uri + "/api/models/{0}/universe/{1}".format(modelId, date)
590        headers = {"Authorization": "ApiKey " + self.api_key}
591        logger.info("Updating universe for date {0}.".format(date))
592        if isinstance(universe_df, pd.core.frame.DataFrame):
593            buf = io.StringIO()
594            universe_df.to_csv(buf)
595            target = ("uploaded_universe.csv", buf.getvalue(), "text/csv")
596            files_req = {}
597            files_req["universe"] = target
598            res = requests.post(url, files=files_req, headers=headers, **self._request_params)
599        elif isinstance(universe_df, str):
600            target = ("uploaded_universe.csv", universe_df, "text/csv")
601            files_req = {}
602            files_req["universe"] = target
603            res = requests.post(url, files=files_req, headers=headers, **self._request_params)
604        else:
605            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
606        if res.ok:
607            logger.info("Universe update successful.")
608            if "warnings" in res.json():
609                logger.info("Warnings: {0}.".format(res.json()["warnings"]))
610                return res.json()["warnings"]
611            else:
612                return "No warnings."
613        else:
614            error_msg = self._try_extract_error_code(res)
615            raise BoostedAPIException("Failed to get universe: {0}.".format(error_msg))
def create_universe( self, universe: Union[pandas.core.frame.DataFrame, str], name: str, description: str) -> List[str]:
617    def create_universe(
618        self, universe: Union[pd.DataFrame, str], name: str, description: str
619    ) -> List[str]:
620        PRESENT = "PRESENT"
621        ANY = "ANY"
622        EARLIST_DATE = "1900-01-01"
623        LATEST_DATE = "4000-01-01"
624
625        if isinstance(universe, (str, bytes, os.PathLike)):
626            universe = pd.read_csv(universe)
627
628        universe.columns = universe.columns.str.lower()
629
630        # Clients are free to leave out data. Fill in some defaults here.
631        if "from" not in universe.columns:
632            universe["from"] = EARLIST_DATE
633        if "to" not in universe.columns:
634            universe["to"] = LATEST_DATE
635        if "currency" not in universe.columns:
636            universe["currency"] = ANY
637        if "country" not in universe.columns:
638            universe["country"] = ANY
639        if "isin" not in universe.columns:
640            universe["isin"] = None
641        if "symbol" not in universe.columns:
642            universe["symbol"] = None
643
644        # to prevent conflicts with python keywords
645        universe.rename(columns={"from": "from_date", "to": "to_date"}, inplace=True)
646
647        universe = universe.replace({np.nan: None})
648        security_country_currency_date_list = []
649        for i, r in enumerate(universe.itertuples()):
650            id_type = ColumnSubRole.ISIN
651            identifier = r.isin
652
653            if identifier is None:
654                id_type = ColumnSubRole.SYMBOL
655                identifier = str(r.symbol)
656
657            # if identifier is still None, it means that there is no ISIN or
658            # SYMBOL for this row, in which case we throw an error
659            if identifier is None:
660                raise BoostedAPIException(
661                    (
662                        f"Missing identifier column in universe row {i + 1}"
663                        " should contain ISIN or Symbol"
664                    )
665                )
666
667            security_country_currency_date_list.append(
668                DateIdentCountryCurrency(
669                    date=r.from_date or EARLIST_DATE,
670                    identifier=identifier,
671                    country=r.country or ANY,
672                    currency=r.currency or ANY,
673                    id_type=id_type,
674                )
675            )
676
677        gbi_id_objs = self.getGbiIdFromIdentCountryCurrencyDate(security_country_currency_date_list)
678
679        security_list = []
680        for i, r in enumerate(universe.itertuples()):
681            # if we have a None here, we failed to map to a gbi id
682            if gbi_id_objs[i] is None:
683                raise BoostedAPIException(f"Unable to map row: {tuple(r)}")
684
685            security_list.append(
686                {
687                    "stockId": gbi_id_objs[i].gbi_id,
688                    "fromZ": r.from_date or EARLIST_DATE,
689                    "toZ": LATEST_DATE if r.to_date in (PRESENT, None) else r.to_date,
690                    "removal": False,
691                    "source": "UPLOAD",
692                }
693            )
694
695        url = self.base_uri + "/api/template-universe/save"
696        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
697        req = {"name": name, "description": description, "modificationDaos": security_list}
698
699        res = requests.post(url, json=req, headers=headers, **self._request_params)
700        self._check_ok_or_err_with_msg(res, "Failed to create universe")
701
702        if "warnings" in res.json():
703            logger.info("Warnings: {0}.".format(res.json()["warnings"]))
704            return res.json()["warnings"].splitlines()
705        else:
706            return []
def validate_dataframe(self, df):
708    def validate_dataframe(self, df):
709        if not isinstance(df, pd.core.frame.DataFrame):
710            logger.error("Dataset must be of type Dataframe.")
711            return False
712        if type(df.index) != pd.core.indexes.datetimes.DatetimeIndex:
713            logger.error("Index must be DatetimeIndex.")
714            return False
715        if len(df.columns) == 0:
716            logger.error("No feature columns exist.")
717            return False
718        if len(df) == 0:
719            logger.error("No rows exist.")
720        return True
def get_dataset_schema(self, dataset_id):
722    def get_dataset_schema(self, dataset_id):
723        url = self.base_uri + "/api/datasets/{0}/schema".format(dataset_id)
724        headers = {"Authorization": "ApiKey " + self.api_key}
725        res = requests.get(url, headers=headers, **self._request_params)
726        if res.ok:
727            json_schema = res.json()
728        else:
729            error_msg = self._try_extract_error_code(res)
730            logger.error(error_msg)
731            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
732        return DataSetConfig.fromDict(json_schema["result"])
def add_custom_security_daily_dataset(self, namespace, dataset, schema=None, timeout=600, block=True):
734    def add_custom_security_daily_dataset(
735        self, namespace, dataset, schema=None, timeout=600, block=True
736    ):
737        result = self.add_custom_security_daily_dataset_with_warnings(
738            namespace, dataset, schema, timeout, block
739        )
740        return result["dataset_id"]
def add_custom_security_daily_dataset_with_warnings( self, namespace, dataset, schema=None, timeout=600, block=True, no_exception_on_chunk_error=False):
742    def add_custom_security_daily_dataset_with_warnings(
743        self,
744        namespace,
745        dataset,
746        schema=None,
747        timeout=600,
748        block=True,
749        no_exception_on_chunk_error=False,
750    ):
751        dataset_type = DataSetType.SECURITIES_DAILY
752        dsid = self.query_namespace_dataset_id(namespace, dataset_type)
753
754        if not self.validate_dataframe(dataset):
755            logger.error("dataset failed validation.")
756            return None
757
758        if dsid is None:
759            # create the dataset if not exist.
760            schema = infer_dataset_schema(
761                "custom_security_daily", dataset, dataset_type, infer_from_column_names=True
762            )
763            dsid = self.create_custom_namespace_dataset(namespace, schema.toDict())
764            data_type = DataAddType.CREATION
765        elif schema is not None:
766            raise ValueError(
767                f"Dataset schema already exists for namespace={namespace}, type={dataset_type}",
768                ", cannot create another!",
769            )
770        else:
771            data_type = DataAddType.HISTORICAL
772
773        logger.info("Created dataset with ID = {0}, uploading...".format(dsid))
774        result = self.add_custom_security_daily_data(
775            dsid,
776            dataset,
777            timeout,
778            block,
779            data_type=data_type,
780            no_exception_on_chunk_error=no_exception_on_chunk_error,
781        )
782        return {
783            "namespace": namespace,
784            "dataset_id": dsid,
785            "warnings": result["warnings"],
786            "errors": result["errors"],
787        }
def add_custom_security_daily_data( self, dataset_id, csv_data, timeout=600, block=True, data_type=<DataAddType.HISTORICAL: 2>, no_exception_on_chunk_error=False):
789    def add_custom_security_daily_data(
790        self,
791        dataset_id,
792        csv_data,
793        timeout=600,
794        block=True,
795        data_type=DataAddType.HISTORICAL,
796        no_exception_on_chunk_error=False,
797    ):
798        warnings = []
799        query_info = self.query_dataset(dataset_id)
800        if DataSetType[query_info["type"]] != DataSetType.SECURITIES_DAILY:
801            raise BoostedAPIException(
802                f"Incorrect dataset type: {query_info['type']}"
803                f" - Expected {DataSetType.SECURITIES_DAILY}"
804            )
805        warnings, errors = self.setup_chunk_and_upload_data(
806            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
807        )
808        if len(warnings) > 0:
809            logger.warning(
810                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
811            )
812        if len(errors) > 0:
813            raise BoostedAPIException(
814                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
815                + "\n".join(errors)
816            )
817        return {"warnings": warnings, "errors": errors}
def add_dependent_dataset( self, dataset, datasetName='DependentDataset', schema=None, timeout=600, block=True):
819    def add_dependent_dataset(
820        self, dataset, datasetName="DependentDataset", schema=None, timeout=600, block=True
821    ):
822        result = self.add_dependent_dataset_with_warnings(
823            dataset, datasetName, schema, timeout, block
824        )
825        return result["dataset_id"]
def add_dependent_dataset_with_warnings( self, dataset, datasetName='DependentDataset', schema=None, timeout=600, block=True, no_exception_on_chunk_error=False):
827    def add_dependent_dataset_with_warnings(
828        self,
829        dataset,
830        datasetName="DependentDataset",
831        schema=None,
832        timeout=600,
833        block=True,
834        no_exception_on_chunk_error=False,
835    ):
836        if not self.validate_dataframe(dataset):
837            logger.error("dataset failed validation.")
838            return None
839        if schema is None:
840            schema = infer_dataset_schema(datasetName, dataset, DataSetType.STOCK)
841        dsid = self.createDataset(schema.toDict())
842        logger.info("Creating dataset with ID = {0}.".format(dsid))
843        result = self.add_dependent_data(
844            dsid,
845            dataset,
846            timeout,
847            block,
848            data_type=DataAddType.CREATION,
849            no_exception_on_chunk_error=no_exception_on_chunk_error,
850        )
851        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
def add_independent_dataset( self, dataset, datasetName='IndependentDataset', schema=None, timeout=600, block=True):
853    def add_independent_dataset(
854        self, dataset, datasetName="IndependentDataset", schema=None, timeout=600, block=True
855    ):
856        result = self.add_independent_dataset_with_warnings(
857            dataset, datasetName, schema, timeout, block
858        )
859        return result["dataset_id"]
def add_independent_dataset_with_warnings( self, dataset, datasetName='IndependentDataset', schema=None, timeout=600, block=True, no_exception_on_chunk_error=False):
861    def add_independent_dataset_with_warnings(
862        self,
863        dataset,
864        datasetName="IndependentDataset",
865        schema=None,
866        timeout=600,
867        block=True,
868        no_exception_on_chunk_error=False,
869    ):
870        if not self.validate_dataframe(dataset):
871            logger.error("dataset failed validation.")
872            return None
873        if schema is None:
874            schema = infer_dataset_schema(datasetName, dataset, DataSetType.STRATEGY)
875        schemaDict = schema.toDict()
876        if "configurationDataJson" not in schemaDict:
877            schemaDict["configurationDataJson"] = "{}"
878        dsid = self.createDataset(schemaDict)
879        logger.info("Creating dataset with ID = {0}.".format(dsid))
880        result = self.add_independent_data(
881            dsid,
882            dataset,
883            timeout,
884            block,
885            data_type=DataAddType.CREATION,
886            no_exception_on_chunk_error=no_exception_on_chunk_error,
887        )
888        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
def add_global_dataset( self, dataset, datasetName='GlobalDataset', schema=None, timeout=600, block=True):
890    def add_global_dataset(
891        self, dataset, datasetName="GlobalDataset", schema=None, timeout=600, block=True
892    ):
893        result = self.add_global_dataset_with_warnings(dataset, datasetName, schema, timeout, block)
894        return result["dataset_id"]
def add_global_dataset_with_warnings( self, dataset, datasetName='GlobalDataset', schema=None, timeout=600, block=True, no_exception_on_chunk_error=False):
896    def add_global_dataset_with_warnings(
897        self,
898        dataset,
899        datasetName="GlobalDataset",
900        schema=None,
901        timeout=600,
902        block=True,
903        no_exception_on_chunk_error=False,
904    ):
905        if not self.validate_dataframe(dataset):
906            logger.error("dataset failed validation.")
907            return None
908        if schema is None:
909            schema = infer_dataset_schema(datasetName, dataset, DataSetType.GLOBAL)
910        dsid = self.createDataset(schema.toDict())
911        logger.info("Creating dataset with ID = {0}.".format(dsid))
912        result = self.add_global_data(
913            dsid,
914            dataset,
915            timeout,
916            block,
917            data_type=DataAddType.CREATION,
918            no_exception_on_chunk_error=no_exception_on_chunk_error,
919        )
920        return {"dataset_id": dsid, "warnings": result["warnings"], "errors": result["errors"]}
def add_independent_data( self, dataset_id, csv_data, timeout=600, block=True, data_type=<DataAddType.HISTORICAL: 2>, no_exception_on_chunk_error=False):
922    def add_independent_data(
923        self,
924        dataset_id,
925        csv_data,
926        timeout=600,
927        block=True,
928        data_type=DataAddType.HISTORICAL,
929        no_exception_on_chunk_error=False,
930    ):
931        query_info = self.query_dataset(dataset_id)
932        if DataSetType[query_info["type"]] != DataSetType.STRATEGY:
933            raise BoostedAPIException(
934                f"Incorrect dataset type: {query_info['type']}"
935                f" - Expected {DataSetType.STRATEGY}"
936            )
937        warnings, errors = self.setup_chunk_and_upload_data(
938            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
939        )
940        if len(warnings) > 0:
941            logger.warning(
942                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
943            )
944        if len(errors) > 0:
945            raise BoostedAPIException(
946                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
947                + "\n".join(errors)
948            )
949        return {"warnings": warnings, "errors": errors}
def add_dependent_data( self, dataset_id, csv_data, timeout=600, block=True, data_type=<DataAddType.HISTORICAL: 2>, no_exception_on_chunk_error=False):
951    def add_dependent_data(
952        self,
953        dataset_id,
954        csv_data,
955        timeout=600,
956        block=True,
957        data_type=DataAddType.HISTORICAL,
958        no_exception_on_chunk_error=False,
959    ):
960        warnings = []
961        query_info = self.query_dataset(dataset_id)
962        if DataSetType[query_info["type"]] != DataSetType.STOCK:
963            raise BoostedAPIException(
964                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.STOCK}"
965            )
966        warnings, errors = self.setup_chunk_and_upload_data(
967            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
968        )
969        if len(warnings) > 0:
970            logger.warning(
971                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
972            )
973        if len(errors) > 0:
974            raise BoostedAPIException(
975                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
976                + "\n".join(errors)
977            )
978        return {"warnings": warnings, "errors": errors}
def add_global_data( self, dataset_id, csv_data, timeout=600, block=True, data_type=<DataAddType.HISTORICAL: 2>, no_exception_on_chunk_error=False):
 980    def add_global_data(
 981        self,
 982        dataset_id,
 983        csv_data,
 984        timeout=600,
 985        block=True,
 986        data_type=DataAddType.HISTORICAL,
 987        no_exception_on_chunk_error=False,
 988    ):
 989        query_info = self.query_dataset(dataset_id)
 990        if DataSetType[query_info["type"]] != DataSetType.GLOBAL:
 991            raise BoostedAPIException(
 992                f"Incorrect dataset type: {query_info['type']}" f" - Expected {DataSetType.GLOBAL}"
 993            )
 994        warnings, errors = self.setup_chunk_and_upload_data(
 995            dataset_id, csv_data, data_type, timeout, block, no_exception_on_chunk_error
 996        )
 997        if len(warnings) > 0:
 998            logger.warning(
 999                "Encountered {0} total warnings while uploading dataset.".format(len(warnings))
1000            )
1001        if len(errors) > 0:
1002            raise BoostedAPIException(
1003                "Encountered {0} total ERRORS while uploading dataset".format(len(errors))
1004                + "\n".join(errors)
1005            )
1006        return {"warnings": warnings, "errors": errors}
def get_csv_buffer(self):
1008    def get_csv_buffer(self):
1009        return io.StringIO()
def start_chunked_upload(self, dataset_id):
1011    def start_chunked_upload(self, dataset_id):
1012        url = self.base_uri + "/api/datasets/{0}/start-chunked-upload".format(dataset_id)
1013        headers = {"Authorization": "ApiKey " + self.api_key}
1014        res = requests.post(url, headers=headers, **self._request_params)
1015        if res.ok:
1016            return res.json()["result"]
1017        else:
1018            error_msg = self._try_extract_error_code(res)
1019            logger.error(error_msg)
1020            raise BoostedAPIException(
1021                "Failed to obtain dataset lock for upload: {0}.".format(error_msg)
1022            )
def abort_chunked_upload(self, dataset_id, chunk_id):
1024    def abort_chunked_upload(self, dataset_id, chunk_id):
1025        url = self.base_uri + "/api/datasets/{0}/abort-chunked-upload".format(dataset_id)
1026        headers = {"Authorization": "ApiKey " + self.api_key}
1027        params = {"uploadGroupId": chunk_id}
1028        res = requests.post(url, headers=headers, **self._request_params, params=params)
1029        if not res.ok:
1030            error_msg = self._try_extract_error_code(res)
1031            logger.error(error_msg)
1032            raise BoostedAPIException(
1033                "Failed to abort dataset lock during error: {0}.".format(error_msg)
1034            )
def check_dataset_ingestion_completion(self, dataset_id, chunk_id, start_time):
1036    def check_dataset_ingestion_completion(self, dataset_id, chunk_id, start_time):
1037        url = self.base_uri + "/api/datasets/{0}/upload-chunk-status".format(dataset_id)
1038        headers = {"Authorization": "ApiKey " + self.api_key}
1039        params = {"uploadGroupId": chunk_id}
1040        res = requests.get(url, headers=headers, **self._request_params, params=params)
1041        res = res.json()
1042
1043        finished = False
1044        warnings = []
1045        errors = []
1046
1047        if type(res) == dict:
1048            dataset_status = res["datasetStatus"]
1049            chunk_status = res["chunkStatus"]
1050            if chunk_status != ChunkStatus.PROCESSING.value:
1051                finished = True
1052                errors = res["errors"]
1053                warnings = res["warnings"]
1054                successful_rows = res["successfulRows"]
1055                total_rows = res["totalRows"]
1056                logger.info(
1057                    f"Successfully ingested {successful_rows} out of {total_rows} uploaded rows."
1058                )
1059                if chunk_status in [
1060                    ChunkStatus.SUCCESS.value,
1061                    ChunkStatus.WARNING.value,
1062                    ChunkStatus.ERROR.value,
1063                ]:
1064                    if dataset_status != "AVAILABLE":
1065                        raise BoostedAPIException(
1066                            "Dataset was unexpectedly unavailable after chunk upload finished."
1067                        )
1068                    else:
1069                        logger.info("Ingestion complete.  Uploaded data is ready for use.")
1070                elif chunk_status == ChunkStatus.ABORTED.value:
1071                    errors.append(
1072                        "Dataset chunk upload was aborted by server! Upload did not succeed."
1073                    )
1074                else:
1075                    errors.append("Unexpected data ingestion status: {0}.".format(chunk_status))
1076            logger.info(
1077                "Data ingestion still running.  Time elapsed={0}.".format(
1078                    datetime.datetime.now() - start_time
1079                )
1080            )
1081        else:
1082            raise BoostedAPIException("Unable to get status of dataset ingestion.")
1083        return {"finished": finished, "warnings": warnings, "errors": errors}
def setup_chunk_and_upload_data( self, dataset_id, csv_data, data_type, timeout=600, block=True, no_exception_on_chunk_error=False):
1119    def setup_chunk_and_upload_data(
1120        self,
1121        dataset_id,
1122        csv_data,
1123        data_type,
1124        timeout=600,
1125        block=True,
1126        no_exception_on_chunk_error=False,
1127    ):
1128        chunk_id = self.start_chunked_upload(dataset_id)
1129        logger.info("Obtained lock on dataset for upload: " + chunk_id)
1130        try:
1131            warnings, errors = self.chunk_and_upload_data(
1132                dataset_id, chunk_id, csv_data, timeout, no_exception_on_chunk_error
1133            )
1134            commit_warnings, commit_errors = self._commit_chunked_upload(
1135                dataset_id, chunk_id, data_type, block, timeout
1136            )
1137            return warnings + commit_warnings, errors + commit_errors
1138        except Exception:
1139            self.abort_chunked_upload(dataset_id, chunk_id)
1140            raise
def chunk_and_upload_data( self, dataset_id, chunk_id, csv_data, timeout=600, no_exception_on_chunk_error=False):
1142    def chunk_and_upload_data(
1143        self, dataset_id, chunk_id, csv_data, timeout=600, no_exception_on_chunk_error=False
1144    ):
1145        if isinstance(csv_data, pd.core.frame.DataFrame):
1146            if not isinstance(csv_data.index, pd.core.indexes.datetimes.DatetimeIndex):
1147                raise BoostedAPIException("DataFrame must have DatetimeIndex as index type.")
1148
1149            warnings = []
1150            errors = []
1151            logger.info("Uploading yearly.")
1152            for t in csv_data.index.to_period("Y").unique():
1153                if t is pd.NaT:
1154                    continue
1155
1156                # serialize bit to string
1157                buf = self.get_csv_buffer()
1158                yearly_csv = csv_data.loc[str(t)]
1159                yearly_csv.to_csv(buf, header=True)
1160                raw_csv = buf.getvalue()
1161
1162                # we are already chunking yearly... but if the csv still exceeds a healthy
1163                # limit of 50mb the final line of defence is to ignore date boundaries and
1164                # just chunk the rows. This is mostly for the cloudflare upload limit.
1165                size_lim = 50 * 1000 * 1000
1166                est_csv_size = sys.getsizeof(raw_csv)
1167                if est_csv_size > size_lim:
1168                    del raw_csv, buf
1169                    logger.info("Yearly data too large for single upload, chunking further...")
1170                    chunks = []
1171                    nchunks = math.ceil(est_csv_size / size_lim)
1172                    rows_per_chunk = math.ceil(len(yearly_csv) / nchunks)
1173                    for i in range(0, len(yearly_csv), rows_per_chunk):
1174                        buf = self.get_csv_buffer()
1175                        split_csv = yearly_csv.iloc[i : i + rows_per_chunk]
1176                        split_csv.to_csv(buf, header=True)
1177                        split_csv = buf.getvalue()
1178                        chunks.append(
1179                            (
1180                                "{0}-{1}".format(i + 1, min(len(yearly_csv), i + rows_per_chunk)),
1181                                split_csv,
1182                            )
1183                        )
1184                else:
1185                    chunks = [("all", raw_csv)]
1186
1187                for i, (rows_descriptor, chunk_csv) in enumerate(chunks):
1188                    chunk_descriptor = "{0} in yearly chunk {1}".format(rows_descriptor, t)
1189                    logger.info(
1190                        "Uploading rows:"
1191                        + chunk_descriptor
1192                        + " (chunk {0} of {1}):".format(i + 1, len(chunks))
1193                    )
1194                    _, new_warnings, new_errors = self.upload_dataset_chunk(
1195                        chunk_descriptor,
1196                        dataset_id,
1197                        chunk_id,
1198                        chunk_csv,
1199                        timeout,
1200                        no_exception_on_chunk_error,
1201                    )
1202                    warnings.extend(new_warnings)
1203                    errors.extend(new_errors)
1204            return warnings, errors
1205
1206        elif isinstance(csv_data, str):
1207            _, warnings, errors = self.upload_dataset_chunk(
1208                "all data", dataset_id, chunk_id, csv_data, timeout, no_exception_on_chunk_error
1209            )
1210            return warnings, errors
1211        else:
1212            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
def upload_dataset_chunk( self, chunk_descriptor, dataset_id, chunk_id, csv_data, timeout=600, no_exception_on_chunk_error=False):
1214    def upload_dataset_chunk(
1215        self,
1216        chunk_descriptor,
1217        dataset_id,
1218        chunk_id,
1219        csv_data,
1220        timeout=600,
1221        no_exception_on_chunk_error=False,
1222    ):
1223        logger.info("Starting upload: " + chunk_descriptor)
1224        url = self.base_uri + "/api/datasets/{0}/upload-dataset-chunk".format(dataset_id)
1225        headers = {"Authorization": "ApiKey " + self.api_key}
1226        files_req = {}
1227        warnings = []
1228        errors = []
1229
1230        # make the network request
1231        target = ("uploaded_data.csv", csv_data, "text/csv")
1232        files_req["dataFile"] = target
1233        params = {"uploadGroupId": chunk_id}
1234        res = requests.post(
1235            url,
1236            params=params,
1237            files=files_req,
1238            headers=headers,
1239            timeout=timeout,
1240            **self._request_params,
1241        )
1242
1243        if res.ok:
1244            logger.info(
1245                (
1246                    "Chunk upload completed.  "
1247                    "Ingestion started.  "
1248                    "Please wait until the data is in AVAILABLE state."
1249                )
1250            )
1251            if "warnings" in res.json():
1252                warnings = res.json()["warnings"]
1253                if len(warnings) > 0:
1254                    logger.warning("Uploaded chunk encountered data warnings: ")
1255                for w in warnings:
1256                    logger.warning(w)
1257        else:
1258            reason = "Upload failed: {0}, {1}".format(res.text, res.reason)
1259            logger.error(reason)
1260            if no_exception_on_chunk_error:
1261                errors.append(
1262                    "Chunk {0} failed: {1}. ".format(chunk_descriptor, reason)
1263                    + "Your data was only PARTIALLY uploaded. "
1264                    + "Please reattempt the upload of this chunk."
1265                )
1266            else:
1267                raise BoostedAPIException(reason)
1268
1269        return res, warnings, errors
def getAllocationsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1271    def getAllocationsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1272        date = self.__iso_format(date)
1273        endpoint = "latest-allocations" if rollback_to_last_available_date else "allocations"
1274        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1275        headers = {"Authorization": "ApiKey " + self.api_key}
1276        params = {"date": date}
1277        logger.info("Retrieving allocations information for date {0}.".format(date))
1278        res = requests.get(url, params=params, headers=headers, **self._request_params)
1279        if res.ok:
1280            logger.info("Allocations retrieval successful.")
1281            return res.json()
1282        else:
1283            error_msg = self._try_extract_error_code(res)
1284            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
def getAllocationsForDateV2(self, portfolio_id, date, rollback_to_last_available_date):
1287    def getAllocationsForDateV2(self, portfolio_id, date, rollback_to_last_available_date):
1288        date = self.__iso_format(date)
1289        endpoint = "latest-allocations-v2" if rollback_to_last_available_date else "allocations-v2"
1290        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1291        headers = {"Authorization": "ApiKey " + self.api_key}
1292        params = {"date": date}
1293        logger.info("Retrieving allocations information for date {0}.".format(date))
1294        res = requests.get(url, params=params, headers=headers, **self._request_params)
1295        if res.ok:
1296            logger.info("Allocations retrieval successful.")
1297            return res.json()
1298        else:
1299            error_msg = self._try_extract_error_code(res)
1300            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
def getAllocationsByDates(self, portfolio_id, dates=None):
1302    def getAllocationsByDates(self, portfolio_id, dates=None):
1303        url = self.base_uri + "/api/portfolios/{0}/allocationsByDate".format(portfolio_id)
1304        headers = {"Authorization": "ApiKey " + self.api_key}
1305        if dates is not None:
1306            fmt_dates = []
1307            for d in dates:
1308                fmt_dates.append(self.__iso_format(d))
1309            fmt_dates_str = ",".join(fmt_dates)
1310            params: Dict = {"dates": fmt_dates_str}
1311            logger.info("Retrieving allocations information for dates {0}.".format(fmt_dates))
1312        else:
1313            params = {"dates": None}
1314            logger.info("Retrieving allocations information for all dates")
1315        res = requests.get(url, params=params, headers=headers, **self._request_params)
1316        if res.ok:
1317            logger.info("Allocations retrieval successful.")
1318            return res.json()
1319        else:
1320            error_msg = self._try_extract_error_code(res)
1321            raise BoostedAPIException("Failed to retrieve allocations: {0}.".format(error_msg))
def getSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1323    def getSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1324        date = self.__iso_format(date)
1325        endpoint = "latest-signals" if rollback_to_last_available_date else "signals"
1326        url = self.base_uri + "/api/portfolios/{0}/{1}".format(portfolio_id, endpoint)
1327        headers = {"Authorization": "ApiKey " + self.api_key}
1328        params = {"date": date}
1329        logger.info("Retrieving signals information for date {0}.".format(date))
1330        res = requests.get(url, params=params, headers=headers, **self._request_params)
1331        if res.ok:
1332            logger.info("Signals retrieval successful.")
1333            return res.json()
1334        else:
1335            error_msg = self._try_extract_error_code(res)
1336            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
def getSignalsForAllDates(self, portfolio_id, dates=None):
1338    def getSignalsForAllDates(self, portfolio_id, dates=None):
1339        url = self.base_uri + "/api/portfolios/{0}/signalsByDate".format(portfolio_id)
1340        headers = {"Authorization": "ApiKey " + self.api_key}
1341        params = {}
1342        if dates is not None:
1343            fmt_dates = []
1344            for d in dates:
1345                fmt_dates.append(self.__iso_format(d))
1346            fmt_dates_str = ",".join(fmt_dates)
1347            params = {"dates": fmt_dates_str}
1348            logger.info("Retrieving signals information for dates {0}.".format(fmt_dates))
1349        else:
1350            params = {"dates": None}
1351            logger.info("Retrieving signals information for all dates")
1352        res = requests.get(url, params=params, headers=headers, **self._request_params)
1353        if res.ok:
1354            logger.info("Signals retrieval successful.")
1355            return res.json()
1356        else:
1357            error_msg = self._try_extract_error_code(res)
1358            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
def getEquityAccuracy( self, model_id: str, portfolio_id: str, tickers: List[str], start_date: Union[datetime.date, str, NoneType] = None, end_date: Union[datetime.date, str, NoneType] = None) -> Dict[str, Dict[str, Any]]:
1360    def getEquityAccuracy(
1361        self,
1362        model_id: str,
1363        portfolio_id: str,
1364        tickers: List[str],
1365        start_date: Optional[BoostedDate] = None,
1366        end_date: Optional[BoostedDate] = None,
1367    ) -> Dict[str, Dict[str, Any]]:
1368        data: Dict[str, Any] = {}
1369        if start_date is not None:
1370            start_date = convert_date(start_date)
1371            data["startDate"] = start_date.isoformat()
1372        if end_date is not None:
1373            end_date = convert_date(end_date)
1374            data["endDate"] = end_date.isoformat()
1375
1376        if start_date and end_date:
1377            validate_start_and_end_dates(start_date, end_date)
1378
1379        tickers_stream = ",".join(tickers)
1380        data["tickers"] = tickers_stream
1381        data["timestamp"] = time.strftime("%H:%M:%S")
1382        data["shouldRecalc"] = True
1383        url = self.base_uri + f"/api/analysis/equity-accuracy/{model_id}/{portfolio_id}"
1384        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1385
1386        logger.info(
1387            f"Retrieving equity accuracy data for date range {start_date} to {end_date} "
1388            f"for tickers: {tickers}."
1389        )
1390
1391        # Now create dataframes from the JSON output.
1392        metrics = [
1393            "hit_rate_mean",
1394            "hit_rate_median",
1395            "excess_return_mean",
1396            "excess_return_median",
1397            "return",
1398            "excess_return",
1399        ]
1400
1401        # send the request, retry if failed
1402        MAX_RETRIES = 10  # max of number of retries until timeout
1403        SLEEP_TIME = 3  # waiting time between requests
1404
1405        num_retries = 0
1406        success = False
1407        while not success and num_retries < MAX_RETRIES:
1408            res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
1409            if res.ok:
1410                logger.info("Equity Accuracy Data retrieval successful.")
1411                info = res.json()
1412                success = True
1413            else:
1414                data["shouldRecalc"] = False
1415                num_retries += 1
1416                time.sleep(SLEEP_TIME)
1417
1418        if not success:
1419            raise BoostedAPIException("Failed to retrieve equity accuracy: Request timeout.")
1420
1421        for ticker, accuracy_data in info.items():
1422            for metric in metrics:
1423                metric_matrix = accuracy_data[metric]
1424                if not isinstance(metric_matrix, str):
1425                    # Set the index to the quintile label, and remove it from the data
1426                    index = []
1427                    for row in metric_matrix[1:]:
1428                        index.append(row.pop(0))
1429
1430                    # columns are "1D", "5D", etc.
1431                    df = pd.DataFrame(metric_matrix[1:], columns=metric_matrix[0][1:], index=index)
1432                    accuracy_data[metric] = df
1433        return info
def getHistoricalTradeDates(self, portfolio_id, start_date=None, end_date=None):
1435    def getHistoricalTradeDates(self, portfolio_id, start_date=None, end_date=None):
1436        end_date = self.__to_date_obj(end_date or datetime.date.today())
1437        start_date = self.__iso_format(start_date or (end_date - timedelta(days=365)))
1438        end_date = self.__iso_format(end_date)
1439
1440        url = self.base_uri + "/api/portfolios/{0}/tradingDates".format(portfolio_id)
1441        headers = {"Authorization": "ApiKey " + self.api_key}
1442        params = {"startDate": start_date, "endDate": end_date}
1443
1444        logger.info(
1445            "Retrieving historical trade dates data for date range {0} to {1}.".format(
1446                start_date, end_date
1447            )
1448        )
1449        res = requests.get(url, params=params, headers=headers, **self._request_params)
1450        if res.ok:
1451            logger.info("Trading dates retrieval successful.")
1452            return res.json()["dates"]
1453        else:
1454            error_msg = self._try_extract_error_code(res)
1455            raise BoostedAPIException("Failed to retrieve trading dates: {0}.".format(error_msg))
def getRankingsForAllDates(self, portfolio_id, dates=None):
1457    def getRankingsForAllDates(self, portfolio_id, dates=None):
1458        url = self.base_uri + "/api/portfolios/{0}/rankingsByDate".format(portfolio_id)
1459        headers = {"Authorization": "ApiKey " + self.api_key}
1460        params = {}
1461        if dates is not None:
1462            fmt_dates = []
1463            for d in dates:
1464                fmt_dates.append(self.__iso_format(d))
1465            fmt_dates_str = ",".join(fmt_dates)
1466            params = {"dates": fmt_dates_str}
1467            logger.info("Retrieving rankings information for date {0}.".format(fmt_dates_str))
1468        else:
1469            params = {"dates": None}
1470            logger.info("Retrieving rankings information for all dates")
1471        res = requests.get(url, params=params, headers=headers, **self._request_params)
1472        if res.ok:
1473            logger.info("Rankings retrieval successful.")
1474            return res.json()
1475        else:
1476            error_msg = self._try_extract_error_code(res)
1477            raise BoostedAPIException("Failed to retrieve rankings: {0}.".format(error_msg))
def getRankingsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1479    def getRankingsForDate(self, portfolio_id, date, rollback_to_last_available_date):
1480        date = self.__iso_format(date)
1481        endpoint = "latest-rankings" if rollback_to_last_available_date else "rankings"
1482        url = self.base_uri + "/api/{0}/{1}/{2}".format(endpoint, portfolio_id, date)
1483        headers = {"Authorization": "ApiKey " + self.api_key}
1484        logger.info("Retrieving rankings information for date {0}.".format(date))
1485        res = requests.get(url, headers=headers, **self._request_params)
1486        if res.ok:
1487            logger.info("Rankings retrieval successful.")
1488            return res.json()
1489        else:
1490            error_msg = self._try_extract_error_code(res)
1491            raise BoostedAPIException("Failed to retrieve rankings: {0}.".format(error_msg))
def sendModelRecalc(self, model_id):
1493    def sendModelRecalc(self, model_id):
1494        url = self.base_uri + "/api/models/{0}/recalc".format(model_id)
1495        logger.info("Sending model recalc request for model {0}".format(model_id))
1496        headers = {"Authorization": "ApiKey " + self.api_key}
1497        res = requests.put(url, headers=headers, **self._request_params)
1498        if not res.ok:
1499            error_msg = self._try_extract_error_code(res)
1500            logger.error(error_msg)
1501            raise BoostedAPIException(
1502                "Failed to send model recalc request - "
1503                + "the model in UI may be out of date: {0}.".format(error_msg)
1504            )
def sendRecalcAllModelPortfolios(self, model_id: str):
1506    def sendRecalcAllModelPortfolios(self, model_id: str):
1507        """Recalculates all portfolios under a given model ID.
1508
1509        Args:
1510            model_id: the model ID
1511        Raises:
1512            BoostedAPIException: if the Boosted API request fails
1513        """
1514        url = self.base_uri + f"/api/models/{model_id}/recalc-all-portfolios"
1515        logger.info(f"Sending portfolio recalc requests for all portfolios under {model_id=}.")
1516        headers = {"Authorization": "ApiKey " + self.api_key}
1517        res = requests.put(url, headers=headers, **self._request_params)
1518        if not res.ok:
1519            error_msg = self._try_extract_error_code(res)
1520            logger.error(error_msg)
1521            raise BoostedAPIException(
1522                f"Failed to send recalc request for all portfolios under {model_id=} - {error_msg}."
1523            )

Recalculates all portfolios under a given model ID.

Args: model_id: the model ID Raises: BoostedAPIException: if the Boosted API request fails

def sendPortfolioRecalc(self, portfolio_id: str):
1525    def sendPortfolioRecalc(self, portfolio_id: str):
1526        """Recalculates a single portfolio by its portfolio ID.
1527
1528        Args:
1529            portfolio_id: the portfolio ID to recalculate
1530        Raises:
1531            BoostedAPIException: if the Boosted API request fails
1532        """
1533        url = self.base_uri + "/api/graphql"
1534        logger.info(f"Sending portfolio recalc request for {portfolio_id=}.")
1535        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1536        qry = """
1537            mutation recalcPortfolio($input: RecalculatePortfolioInput!) {
1538                recalculatePortfolio(input: $input) {
1539                    success
1540                    errors
1541                }
1542            }
1543            """
1544        req_json = {
1545            "query": qry,
1546            "variables": {"input": {"portfolioId": f"{portfolio_id}", "allowForceRecalc": "true"}},
1547        }
1548        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
1549        if not res.ok or res.json().get("errors"):
1550            error_msg = self._try_extract_error_code(res)
1551            logger.error(error_msg)
1552            raise BoostedAPIException(
1553                f"Failed to send portfolio recalc request for {portfolio_id=} - {error_msg}."
1554            )

Recalculates a single portfolio by its portfolio ID.

Args: portfolio_id: the portfolio ID to recalculate Raises: BoostedAPIException: if the Boosted API request fails

def add_uploaded_model_data(self, url, csv_data, request_data, timeout=600):
1556    def add_uploaded_model_data(self, url, csv_data, request_data, timeout=600):
1557        logger.info("Starting upload.")
1558        headers = {"Authorization": "ApiKey " + self.api_key}
1559        files_req: Dict = {}
1560        target: Tuple[str, Any, str] = ("data.csv", None, "text/csv")
1561        warnings = []
1562        if isinstance(csv_data, pd.core.frame.DataFrame):
1563            buf = io.StringIO()
1564            csv_data.to_csv(buf, header=False)
1565            if not isinstance(csv_data.index, pd.core.indexes.datetimes.DatetimeIndex):
1566                raise BoostedAPIException("DataFrame must have DatetimeIndex as index type.")
1567            target = ("uploaded_data.csv", buf.getvalue(), "text/csv")
1568            files_req["dataFile"] = target
1569            res = requests.post(
1570                url,
1571                files=files_req,
1572                data=request_data,
1573                headers=headers,
1574                timeout=timeout,
1575                **self._request_params,
1576            )
1577        elif isinstance(csv_data, str):
1578            target = ("uploaded_data.csv", csv_data, "text/csv")
1579            files_req["dataFile"] = target
1580            res = requests.post(
1581                url,
1582                files=files_req,
1583                data=request_data,
1584                headers=headers,
1585                timeout=timeout,
1586                **self._request_params,
1587            )
1588        else:
1589            raise BoostedAPIException("Expected CSV as str or Pandas DataFrame.")
1590        if res.ok:
1591            logger.info("Signals upload completed.")
1592            result = res.json()["result"]
1593            if "warningMessages" in result:
1594                warnings = result["warningMessages"]
1595        else:
1596            error_str = "Signals upload failed: {0}, {1}".format(res.text, res.reason)
1597            logger.error(error_str)
1598            raise BoostedAPIException(error_str)
1599
1600        return res, warnings
def createSignalsModel(self, csv_data, model_name, timeout=600):
1602    def createSignalsModel(self, csv_data, model_name, timeout=600):
1603        warnings = []
1604        url = self.base_uri + "/api/models/upload/signals/create"
1605        request_data = {"modelName": model_name, "uploadName": model_name}
1606        res, warnings = self.add_uploaded_model_data(url, csv_data, request_data, timeout)
1607        result = res.json()["result"]
1608        model_id = result["modelId"]
1609        self.sendModelRecalc(model_id)
1610        return model_id, warnings
def addToUploadedModel(self, model_id, csv_data, timeout=600, recalc_model=True):
1612    def addToUploadedModel(self, model_id, csv_data, timeout=600, recalc_model=True):
1613        warnings = []
1614        url = self.base_uri + "/api/models/{0}/upload/add-data".format(model_id)
1615        request_data: Dict = {}
1616        _, warnings = self.add_uploaded_model_data(url, csv_data, request_data, timeout)
1617        if recalc_model:
1618            self.sendModelRecalc(model_id)
1619        return warnings
def addSignalsToUploadedModel( self, model_id: str, csv_data: Union[pandas.core.frame.DataFrame, str], timeout: int = 600, recalc_all: bool = False, recalc_portfolio_ids: Union[List[str], NoneType] = None) -> List[str]:
1621    def addSignalsToUploadedModel(
1622        self,
1623        model_id: str,
1624        csv_data: Union[pd.core.frame.DataFrame, str],
1625        timeout: int = 600,
1626        recalc_all: bool = False,
1627        recalc_portfolio_ids: Optional[List[str]] = None,
1628    ) -> List[str]:
1629        """
1630        Add signals to an uploaded model and then recalculate a random portfolio under that model.
1631
1632        Args:
1633            model_id: model ID
1634            csv_data: pandas DataFrame, or a string with signals to upload.
1635            timeout (optional): Timeout for initial upload request in seconds.
1636            recalc_all (optional): if True, recalculates all portfolios in the model.
1637            recalc_portfolio_ids (optional): List of portfolio IDs under the model to re-calculate.
1638        """
1639        warnings = self.addToUploadedModel(model_id, csv_data, timeout, recalc_model=False)
1640
1641        if recalc_all:
1642            self.sendRecalcAllModelPortfolios(model_id)
1643        elif recalc_portfolio_ids:
1644            for portfolio_id in recalc_portfolio_ids:
1645                self.sendPortfolioRecalc(portfolio_id)
1646        else:
1647            self.sendModelRecalc(model_id)
1648        return warnings

Add signals to an uploaded model and then recalculate a random portfolio under that model.

Args: model_id: model ID csv_data: pandas DataFrame, or a string with signals to upload. timeout (optional): Timeout for initial upload request in seconds. recalc_all (optional): if True, recalculates all portfolios in the model. recalc_portfolio_ids (optional): List of portfolio IDs under the model to re-calculate.

def getSignalsFromUploadedModel(self, model_id, date=None):
1650    def getSignalsFromUploadedModel(self, model_id, date=None):
1651        date = self.__iso_format(date)
1652        url = self.base_uri + "/api/models/{0}/upload/signals".format(model_id)
1653        headers = {"Authorization": "ApiKey " + self.api_key}
1654        params = {"date": date}
1655        logger.info("Retrieving uploaded signals information")
1656        res = requests.get(url, params=params, headers=headers, **self._request_params)
1657        if res.ok:
1658            result = pd.DataFrame.from_dict(res.json()["result"])
1659            # ensure column order
1660            result = result[["date", "isin", "country", "currency", "weight"]]
1661            result["date"] = pd.to_datetime(result["date"], format="%Y-%m-%d")
1662            result = result.set_index("date")
1663            logger.info("Signals retrieval successful.")
1664            return result
1665        else:
1666            error_msg = self._try_extract_error_code(res)
1667            raise BoostedAPIException("Failed to retrieve signals: {0}.".format(error_msg))
def getPortfolioSettings(self, portfolio_id, timeout=600):
1669    def getPortfolioSettings(self, portfolio_id, timeout=600):
1670        url = self.base_uri + "/api/portfolio-settings/{0}".format(portfolio_id)
1671        headers = {"Authorization": "ApiKey " + self.api_key}
1672        res = requests.get(url, headers=headers, **self._request_params)
1673        if res.ok:
1674            return PortfolioSettings(res.json())
1675        else:
1676            error_msg = self._try_extract_error_code(res)
1677            logger.error(error_msg)
1678            raise BoostedAPIException(
1679                "Failed to retrieve portfolio settings: {0}.".format(error_msg)
1680            )
def createPortfolioWithPortfolioSettings( self, model_id, portfolio_name, portfolio_description, portfolio_settings, timeout=600):
1682    def createPortfolioWithPortfolioSettings(
1683        self, model_id, portfolio_name, portfolio_description, portfolio_settings, timeout=600
1684    ):
1685        url = self.base_uri + "/api/models/{0}/constraints/add".format(model_id)
1686        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1687        setting_string = json.dumps(portfolio_settings.settings)
1688        logger.info("Creating new portfolio with specified setting: {}".format(setting_string))
1689        params = {
1690            "name": portfolio_name,
1691            "description": portfolio_description,
1692            "settings": setting_string,
1693            "validate": "true",
1694        }
1695        res = requests.put(url, json=params, headers=headers, **self._request_params)
1696        response = res.json()
1697        if res.ok:
1698            return response
1699        else:
1700            error_msg = self._try_extract_error_code(res)
1701            logger.error(error_msg)
1702            raise BoostedAPIException(
1703                "Failed to create portfolio with the specified settings: {0}.".format(error_msg)
1704            )
def getGbiIdFromIdentCountryCurrencyDate( self, ident_country_currency_dates: List[boosted.api.api_type.DateIdentCountryCurrency], timeout: int = 600) -> List[Union[boosted.api.api_type.GbiIdSecurity, NoneType]]:
1706    def getGbiIdFromIdentCountryCurrencyDate(
1707        self, ident_country_currency_dates: List[DateIdentCountryCurrency], timeout: int = 600
1708    ) -> List[Optional[GbiIdSecurity]]:
1709        url = self.base_uri + "/api/custom-stock-data/map-identifiers-simple"
1710        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1711        identifiers = [
1712            {
1713                "row": idx,
1714                "date": identifier.date,
1715                "isin": identifier.identifier if identifier.id_type == ColumnSubRole.ISIN else None,
1716                "symbol": (
1717                    identifier.identifier if identifier.id_type == ColumnSubRole.SYMBOL else None
1718                ),
1719                "countryPreference": identifier.country,
1720                "currencyPreference": identifier.currency,
1721            }
1722            for idx, identifier in enumerate(ident_country_currency_dates)
1723        ]
1724        params = json.dumps({"identifiers": identifiers})
1725        logger.info(
1726            "Retrieving GBI-ID mapping for {} identifier tuples...".format(
1727                len(ident_country_currency_dates)
1728            )
1729        )
1730        res = requests.post(url, data=params, headers=headers, **self._request_params)
1731
1732        if res.ok:
1733            result = res.json()
1734            warnings = result["warnings"]
1735            if warnings:
1736                for warning in warnings:
1737                    logger.warn(f"Mapping warning: {warning}")
1738            gbiSecurities = []
1739            for idx, ident in enumerate(result["mappedIdentifiers"]):
1740                if ident is None:
1741                    security = None
1742                else:
1743                    security = GbiIdSecurity(
1744                        ident["gbiId"],
1745                        ident_country_currency_dates[idx],
1746                        ident["symbol"],
1747                        ident["companyName"],
1748                    )
1749                gbiSecurities.append(security)
1750
1751            return gbiSecurities
1752        else:
1753            error_msg = self._try_extract_error_code(res)
1754            raise BoostedAPIException(
1755                "Failed to retrieve identifier mappings: {0}.".format(error_msg)
1756            )
def getGbiIdFromIsinCountryCurrencyDate(self, isin_country_currency_dates, timeout=600):
1759    def getGbiIdFromIsinCountryCurrencyDate(self, isin_country_currency_dates, timeout=600):
1760        return self.getGbiIdFromIdentCountryCurrencyDate(
1761            ident_country_currency_dates=isin_country_currency_dates, timeout=timeout
1762        )
def getDatasetDates(self, dataset_id):
1791    def getDatasetDates(self, dataset_id):
1792        url = self.base_uri + f"/api/datasets/{dataset_id}"
1793        headers = {"Authorization": "ApiKey " + self.api_key}
1794        res = requests.get(url, headers=headers, **self._request_params)
1795        if res.ok:
1796            dataset = res.json()
1797            valid_to_array = dataset.get("validTo")
1798            valid_to_date = None
1799            valid_from_array = dataset.get("validFrom")
1800            valid_from_date = None
1801            if valid_to_array:
1802                valid_to_date = datetime.date(
1803                    valid_to_array[0], valid_to_array[1], valid_to_array[2]
1804                )
1805            if valid_from_array:
1806                valid_from_date = datetime.date(
1807                    valid_from_array[0], valid_from_array[1], valid_from_array[2]
1808                )
1809            return {"validTo": valid_to_date, "validFrom": valid_from_date}
1810        else:
1811            error_msg = self._try_extract_error_code(res)
1812            logger.error(error_msg)
1813            raise BoostedAPIException("Failed to query dataset: {0}.".format(error_msg))
def getRankingAnalysis(self, model_id, date):
1815    def getRankingAnalysis(self, model_id, date):
1816        url = (
1817            self.base_uri
1818            + f"/api/explain-trades/analysis/{model_id}/{self.__iso_format(date)}/json"
1819        )
1820        headers = {"Authorization": "ApiKey " + self.api_key}
1821        analysis_res = requests.get(url, headers=headers, **self._request_params)
1822        if analysis_res.ok:
1823            ranking_dict = analysis_res.json()
1824            feature_name_dict = self.__get_rankings_ref_translation(model_id)
1825            columns = [feature_name_dict[col] for col in ranking_dict["columns"]]
1826
1827            df = protoCubeJsonDataToDataFrame(
1828                ranking_dict["data"],
1829                "Data Buckets",
1830                ranking_dict["rows"],
1831                "Feature Names",
1832                columns,
1833                ranking_dict["fields"],
1834            )
1835            return df
1836        else:
1837            error_msg = self._try_extract_error_code(analysis_res)
1838            logger.error(error_msg)
1839            raise BoostedAPIException("Failed to get ranking analysis: {0}.".format(error_msg))
def getExplainForPortfolio( self, model_id, portfolio_id, date, index_by_symbol: bool = False, index_by_all_metadata: bool = False):
1841    def getExplainForPortfolio(
1842        self,
1843        model_id,
1844        portfolio_id,
1845        date,
1846        index_by_symbol: bool = False,
1847        index_by_all_metadata: bool = False,
1848    ):
1849        """
1850        Gets the ranking 2.0 explain data for the given model on the given date
1851        filtered by portfolio.
1852
1853        Parameters
1854        ----------
1855        model_id: str
1856            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
1857            button next to your model's name in the Model Summary Page in Boosted
1858            Insights.
1859        portfolio_id: str
1860            Portfolio ID.  Portfolio IDs can be retrieved from portfolio's configuration page.
1861        date: datetime.date or YYYY-MM-DD string
1862            Date of the data to retrieve.
1863        index_by_symbol: bool
1864            If true, index by stock symbol instead of ISIN.
1865        index_by_all_metadata: bool
1866            If true, index by all metadata: ISIN, stock symbol, currency, and country.
1867            Overrides index_by_symbol.
1868
1869        Returns
1870        -------
1871        pandas.DataFrame
1872            Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata
1873            and feature names, filtered by portfolio.
1874        ___
1875        """
1876        indices = ["Symbol", "ISINs", "Country", "Currency"]
1877        raw_explain_df = self.getRankingExplain(
1878            model_id, date, index_by_symbol=False, index_by_all_metadata=True
1879        )
1880        pa_ratings_dict = self.getRankingsForDate(portfolio_id, date, False)
1881
1882        ratings = pa_ratings_dict["rankings"]
1883        ratings_df = pd.DataFrame(ratings)
1884        ratings_df = ratings_df[["symbol", "isin", "country", "currency"]]
1885        ratings_df.columns = pd.Index(indices)
1886        ratings_df.set_index(indices, inplace=True)
1887
1888        # inner join to only get the securities in both data frames
1889        result_df = raw_explain_df.merge(ratings_df, left_index=True, right_index=True, how="inner")
1890
1891        # set index based on input parameters
1892        if index_by_symbol and not index_by_all_metadata:
1893            result_df = result_df.reset_index()
1894            result_df = result_df.drop(columns=["ISINs", "Currency", "Country"])
1895            result_df.set_index(["Symbol", "Feature Names"], inplace=True)
1896        elif not index_by_symbol and not index_by_all_metadata:
1897            result_df = result_df.reset_index()
1898            result_df = result_df.drop(columns=["Symbol", "Currency", "Country"])
1899            result_df.set_index(["ISINs", "Feature Names"], inplace=True)
1900
1901        return result_df

Gets the ranking 2.0 explain data for the given model on the given date filtered by portfolio.

Parameters

model_id: str Model ID. Model IDs can be retrieved by clicking on the copy to clipboard button next to your model's name in the Model Summary Page in Boosted Insights. portfolio_id: str Portfolio ID. Portfolio IDs can be retrieved from portfolio's configuration page. date: datetime.date or YYYY-MM-DD string Date of the data to retrieve. index_by_symbol: bool If true, index by stock symbol instead of ISIN. index_by_all_metadata: bool If true, index by all metadata: ISIN, stock symbol, currency, and country. Overrides index_by_symbol.

Returns

pandas.DataFrame Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata and feature names, filtered by portfolio.


def getRankingExplain( self, model_id, date, index_by_symbol: bool = False, index_by_all_metadata: bool = False):
1903    def getRankingExplain(
1904        self, model_id, date, index_by_symbol: bool = False, index_by_all_metadata: bool = False
1905    ):
1906        """
1907        Gets the ranking 2.0 explain data for the given model on the given date
1908
1909        Parameters
1910        ----------
1911        model_id: str
1912            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
1913            button next to your model's name in the Model Summary Page in Boosted
1914            Insights.
1915        date: datetime.date or YYYY-MM-DD string
1916            Date of the data to retrieve.
1917        index_by_symbol: bool
1918            If true, index by stock symbol instead of ISIN.
1919        index_by_all_metadata: bool
1920            If true, index by all metadata: ISIN, stock symbol, currency, and country.
1921            Overrides index_by_symbol.
1922
1923        Returns
1924        -------
1925        pandas.DataFrame
1926            Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata
1927            and feature names.
1928        ___
1929        """
1930        url = (
1931            self.base_uri + f"/api/explain-trades/explain/{model_id}/{self.__iso_format(date)}/json"
1932        )
1933        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
1934        explain_res = requests.get(url, headers=headers, **self._request_params)
1935        if explain_res.ok:
1936            ranking_dict = explain_res.json()
1937            rows = ranking_dict["rows"]
1938            stock_summary_url = f"/api/stock-summaries/{model_id}"
1939            stock_summary_body = {"gbiIds": ranking_dict["rows"]}
1940            summary_res = requests.post(
1941                self.base_uri + stock_summary_url,
1942                data=json.dumps(stock_summary_body),
1943                headers=headers,
1944                **self._request_params,
1945            )
1946            if summary_res.ok:
1947                stock_summary = summary_res.json()
1948                if index_by_symbol:
1949                    rows = [stock_summary[row]["symbol"] for row in ranking_dict["rows"]]
1950                elif index_by_all_metadata:
1951                    rows = [
1952                        [
1953                            stock_summary[row]["isin"],
1954                            stock_summary[row]["symbol"],
1955                            stock_summary[row]["currency"],
1956                            stock_summary[row]["country"],
1957                        ]
1958                        for row in ranking_dict["rows"]
1959                    ]
1960                else:
1961                    rows = [stock_summary[row]["isin"] for row in ranking_dict["rows"]]
1962            else:
1963                error_msg = self._try_extract_error_code(summary_res)
1964                logger.error(error_msg)
1965                raise BoostedAPIException(
1966                    "Failed to get isin information ranking explain: {0}.".format(error_msg)
1967                )
1968
1969            feature_name_dict = self.__get_rankings_ref_translation(model_id)
1970            columns = [feature_name_dict[col] for col in ranking_dict["columns"]]
1971
1972            id_col_name = "Symbols" if index_by_symbol else "ISINs"
1973
1974            if index_by_all_metadata:
1975                pc_list = []
1976                pf = ranking_dict["data"]
1977                for row_idx, row in enumerate(rows):
1978                    for col_idx, col in enumerate(columns):
1979                        pc_list.append([row, col] + pf[row_idx]["columns"][col_idx]["fields"])
1980                df = pd.DataFrame(pc_list)
1981                df = df.set_axis(
1982                    ["Metadata", "Feature Names"] + ranking_dict["fields"], axis="columns"
1983                )
1984
1985                metadata_df = df["Metadata"].apply(pd.Series)
1986                metadata_df.columns = pd.Index(["ISINs", "Symbol", "Currency", "Country"])
1987                result_df = pd.concat([metadata_df, df], axis=1).drop("Metadata", axis=1)
1988                result_df.set_index(
1989                    ["ISINs", "Symbol", "Currency", "Country", "Feature Names"], inplace=True
1990                )
1991                return result_df
1992
1993            else:
1994                df = protoCubeJsonDataToDataFrame(
1995                    ranking_dict["data"],
1996                    id_col_name,
1997                    rows,
1998                    "Feature Names",
1999                    columns,
2000                    ranking_dict["fields"],
2001                )
2002
2003                return df
2004        else:
2005            error_msg = self._try_extract_error_code(explain_res)
2006            logger.error(error_msg)
2007            raise BoostedAPIException("Failed to get ranking explain: {0}.".format(error_msg))

Gets the ranking 2.0 explain data for the given model on the given date

Parameters

model_id: str Model ID. Model IDs can be retrieved by clicking on the copy to clipboard button next to your model's name in the Model Summary Page in Boosted Insights. date: datetime.date or YYYY-MM-DD string Date of the data to retrieve. index_by_symbol: bool If true, index by stock symbol instead of ISIN. index_by_all_metadata: bool If true, index by all metadata: ISIN, stock symbol, currency, and country. Overrides index_by_symbol.

Returns

pandas.DataFrame Pandas DataFrame containing your data indexed by ISINs/Symbol/all metadata and feature names.


def getDenseSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
2009    def getDenseSignalsForDate(self, portfolio_id, date, rollback_to_last_available_date):
2010        date = self.__iso_format(date)
2011        url = self.base_uri + f"/api/portfolios/{portfolio_id}/denseSignalsByDate"
2012        headers = {"Authorization": "ApiKey " + self.api_key}
2013        params = {
2014            "startDate": date,
2015            "endDate": date,
2016            "rollbackToMostRecentDate": rollback_to_last_available_date,
2017        }
2018        logger.info("Retrieving dense signals information for date {0}.".format(date))
2019        res = requests.get(url, params=params, headers=headers, **self._request_params)
2020        if res.ok:
2021            logger.info("Signals retrieval successful.")
2022            d = res.json()
2023            # reshape date to output format
2024            date = list(d["signals"].keys())[0]
2025            model_id = d["model_id"]
2026            signals_list = list(d["signals"].values())[0]
2027            return {"date": date, "signals": [{"model_id": model_id, "signals_info": signals_list}]}
2028        else:
2029            error_msg = self._try_extract_error_code(res)
2030            raise BoostedAPIException("Failed to retrieve dense signals: {0}.".format(error_msg))
def getDenseSignals(self, model_id, portfolio_id, file_name=None, location='./'):
2032    def getDenseSignals(self, model_id, portfolio_id, file_name=None, location="./"):
2033        url = self.base_uri + f"/api/models/{model_id}/{portfolio_id}/dense-signals"
2034        headers = {"Authorization": "ApiKey " + self.api_key}
2035        res = requests.get(url, headers=headers, **self._request_params)
2036        if file_name is None:
2037            file_name = f"{model_id}-{portfolio_id}_dense_signals.csv"
2038        download_location = os.path.join(location, file_name)
2039        if res.ok:
2040            with open(download_location, "wb") as file:
2041                file.write(res.content)
2042            print("Download Complete")
2043        elif res.status_code == 404:
2044            raise BoostedAPIException(
2045                f"""Dense Singals file does not exist for model:
2046                 {model_id} - portfolio: {portfolio_id}"""
2047            )
2048        else:
2049            error_msg = self._try_extract_error_code(res)
2050            logger.error(error_msg)
2051            raise BoostedAPIException(
2052                f"""Failed to download dense singals file for model:
2053                 {model_id} - portfolio: {portfolio_id}"""
2054            )
def getRanking2DateAnalysisFile(self, model_id, portfolio_id, date, file_name=None, location='./'):
2100    def getRanking2DateAnalysisFile(
2101        self, model_id, portfolio_id, date, file_name=None, location="./"
2102    ):
2103        formatted_date = self.__iso_format(date)
2104        s3_file_name = f"{formatted_date}_analysis.xlsx"
2105        download_url = (
2106            self.base_uri + f"/api/models/{model_id}/{portfolio_id}/ranking-file/{s3_file_name}"
2107        )
2108        headers = {"Authorization": "ApiKey " + self.api_key}
2109        if file_name is None:
2110            file_name = f"{model_id}-{portfolio_id}_statistical_analysis_{formatted_date}.xlsx"
2111        download_location = os.path.join(location, file_name)
2112
2113        res = requests.get(download_url, headers=headers, **self._request_params)
2114        if res.ok:
2115            with open(download_location, "wb") as file:
2116                file.write(res.content)
2117            print("Download Complete")
2118        elif res.status_code == 404:
2119            (
2120                is_portfolio_ready_for_processing,
2121                portfolio_ready_status,
2122            ) = self._getIsPortfolioReadyForProcessing(model_id, portfolio_id, formatted_date)
2123
2124            if not is_portfolio_ready_for_processing:
2125                logger.info(
2126                    f"""\nPortfolio {portfolio_id} for model {model_id}
2127                    on date {date} unavailable for Ranking2Date Analysis file.
2128                    Status: {portfolio_ready_status}\n"""
2129                )
2130                return
2131
2132            generate_url = (
2133                self.base_uri
2134                + f"/api/explain-trades/{model_id}/{portfolio_id}"
2135                + f"/generate/date-data/{formatted_date}"
2136            )
2137
2138            generate_res = requests.get(generate_url, headers=headers, **self._request_params)
2139            if generate_res.ok:
2140                download_res = requests.get(download_url, headers=headers, **self._request_params)
2141                while download_res.status_code == 404 or (
2142                    download_res.ok and len(download_res.content) == 0
2143                ):
2144                    print("waiting for file to be generated")
2145                    time.sleep(5)
2146                    download_res = requests.get(
2147                        download_url, headers=headers, **self._request_params
2148                    )
2149                if download_res.ok:
2150                    with open(download_location, "wb") as file:
2151                        file.write(download_res.content)
2152                    print("Download Complete")
2153            else:
2154                error_msg = self._try_extract_error_code(res)
2155                logger.error(error_msg)
2156                raise BoostedAPIException(
2157                    f"""Failed to generate ranking analysis file for model:
2158                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2159                )
2160        else:
2161            error_msg = self._try_extract_error_code(res)
2162            logger.error(error_msg)
2163            raise BoostedAPIException(
2164                f"""Failed to download ranking analysis file for model:
2165                 {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2166            )
def getRanking2DateExplainFile( self, model_id, portfolio_id, date, file_name=None, location='./', overwrite: bool = False, index_by_all_metadata: bool = False):
2168    def getRanking2DateExplainFile(
2169        self,
2170        model_id,
2171        portfolio_id,
2172        date,
2173        file_name=None,
2174        location="./",
2175        overwrite: bool = False,
2176        index_by_all_metadata: bool = False,
2177    ):
2178        """
2179        Downloads the ranking explain file for the provided portfolio and model.
2180        If no file exists then it will send a request to generate the file and continuously
2181        poll the server every 5 seconds to try and download the file until the file is downloaded.
2182
2183        Parameters
2184        ----------
2185        model_id: str
2186            Model ID.  Model IDs can be retrieved by clicking on the copy to clipboard
2187            button next to your model's name in the Model Summary Page in Boosted
2188            Insights.
2189        portfolio_id: str
2190            Portfolio ID.  Portfolio IDs can be retrieved from portfolio's configuration page.
2191        date: datetime.date or YYYY-MM-DD string
2192            Date of the data to retrieve.
2193        file_name: str
2194            File name of the dense signals file to save as.
2195            If no file name is given the file name will be
2196            "<model_id>-<portfolio_id>_explain_data_<date>.xlsx"
2197        location: str
2198            The location to save the file to.
2199            If no location is given then it will be saved to the current directory.
2200        overwrite: bool
2201            Defaults to False, set to True to regenerate the file.
2202        index_by_all_metadata: bool
2203            If true, index by all metadata: ISIN, stock symbol, currency, and country.
2204
2205
2206        Returns
2207        -------
2208        None
2209        ___
2210        """
2211        formatted_date = self.__iso_format(date)
2212        if index_by_all_metadata:
2213            s3_file_name = f"{formatted_date}_explaindata_withmetadata.xlsx"
2214        else:
2215            s3_file_name = f"{formatted_date}_explaindata.xlsx"
2216        download_url = (
2217            self.base_uri + f"/api/models/{model_id}/{portfolio_id}/ranking-file/{s3_file_name}"
2218        )
2219        headers = {"Authorization": "ApiKey " + self.api_key}
2220        if file_name is None:
2221            file_name = f"{model_id}-{portfolio_id}_explain_data_{formatted_date}.xlsx"
2222        download_location = os.path.join(location, file_name)
2223
2224        if not overwrite:
2225            res = requests.get(download_url, headers=headers, **self._request_params)
2226        if not overwrite and res.ok:
2227            with open(download_location, "wb") as file:
2228                file.write(res.content)
2229            print("Download Complete")
2230        elif overwrite or res.status_code == 404:
2231            (
2232                is_portfolio_ready_for_processing,
2233                portfolio_ready_status,
2234            ) = self._getIsPortfolioReadyForProcessing(model_id, portfolio_id, formatted_date)
2235
2236            if not is_portfolio_ready_for_processing:
2237                logger.info(
2238                    f"""\nPortfolio {portfolio_id} for model {model_id}
2239                    on date {date} unavailable for Ranking2Date Explain file.
2240                    Status: {portfolio_ready_status}\n"""
2241                )
2242                return
2243
2244            generate_url = (
2245                self.base_uri
2246                + f"/api/explain-trades/{model_id}/{portfolio_id}"
2247                + f"/generate/date-data/{formatted_date}"
2248                + f"/{'true' if index_by_all_metadata else 'false'}"
2249            )
2250
2251            generate_res = requests.get(generate_url, headers=headers, **self._request_params)
2252            if generate_res.ok:
2253                download_res = requests.get(download_url, headers=headers, **self._request_params)
2254                while download_res.status_code == 404 or (
2255                    download_res.ok and len(download_res.content) == 0
2256                ):
2257                    print("waiting for file to be generated")
2258                    time.sleep(5)
2259                    download_res = requests.get(
2260                        download_url, headers=headers, **self._request_params
2261                    )
2262                if download_res.ok:
2263                    with open(download_location, "wb") as file:
2264                        file.write(download_res.content)
2265                    print("Download Complete")
2266            else:
2267                error_msg = self._try_extract_error_code(res)
2268                logger.error(error_msg)
2269                raise BoostedAPIException(
2270                    f"""Failed to generate ranking explain file for model:
2271                    {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2272                )
2273        else:
2274            error_msg = self._try_extract_error_code(res)
2275            logger.error(error_msg)
2276            raise BoostedAPIException(
2277                f"""Failed to download ranking explain file for model:
2278                 {model_id} - portfolio: {portfolio_id} on date: {formatted_date}"""
2279            )

Downloads the ranking explain file for the provided portfolio and model. If no file exists then it will send a request to generate the file and continuously poll the server every 5 seconds to try and download the file until the file is downloaded.

Parameters

model_id: str Model ID. Model IDs can be retrieved by clicking on the copy to clipboard button next to your model's name in the Model Summary Page in Boosted Insights. portfolio_id: str Portfolio ID. Portfolio IDs can be retrieved from portfolio's configuration page. date: datetime.date or YYYY-MM-DD string Date of the data to retrieve. file_name: str File name of the dense signals file to save as. If no file name is given the file name will be "-_explain_data_.xlsx" location: str The location to save the file to. If no location is given then it will be saved to the current directory. overwrite: bool Defaults to False, set to True to regenerate the file. index_by_all_metadata: bool If true, index by all metadata: ISIN, stock symbol, currency, and country.

Returns

None


def getRanking2DateExplain( self, model_id: str, portfolio_id: str, date: Union[datetime.date, NoneType], overwrite: bool = False) -> Dict[str, pandas.core.frame.DataFrame]:
2281    def getRanking2DateExplain(
2282        self,
2283        model_id: str,
2284        portfolio_id: str,
2285        date: Optional[datetime.date],
2286        overwrite: bool = False,
2287    ) -> Dict[str, pd.DataFrame]:
2288        """
2289        Wrapper around getRanking2DateExplainFile, but returns a pandas
2290        dataframe instead of downloading to a path. Dataframe is indexed by
2291        symbol and should always have 'rating' and 'rating_delta' columns. Other
2292        columns will be determined by model's features.
2293        """
2294        file_name = "explaindata.xlsx"
2295        with tempfile.TemporaryDirectory() as tmpdirname:
2296            self.getRanking2DateExplainFile(
2297                model_id=model_id,
2298                portfolio_id=portfolio_id,
2299                date=date,
2300                file_name=file_name,
2301                location=tmpdirname,
2302                overwrite=overwrite,
2303            )
2304            full_path = os.path.join(tmpdirname, file_name)
2305            excel_file = pd.ExcelFile(full_path)
2306            df_map = pd.read_excel(excel_file, sheet_name=None)
2307            df_map_final = {str(sheet): df.set_index("Symbol") for (sheet, df) in df_map.items()}
2308
2309        return df_map_final

Wrapper around getRanking2DateExplainFile, but returns a pandas dataframe instead of downloading to a path. Dataframe is indexed by symbol and should always have 'rating' and 'rating_delta' columns. Other columns will be determined by model's features.

def getTearSheet( self, model_id, portfolio_id, start_date=None, end_date=None, block=False):
2311    def getTearSheet(self, model_id, portfolio_id, start_date=None, end_date=None, block=False):
2312        if start_date is None or end_date is None:
2313            if start_date is not None or end_date is not None:
2314                raise ValueError("start_date and end_date must both be None or both be defined")
2315            return self._getCurrentTearSheet(model_id, portfolio_id)
2316
2317        start_date_obj = self.__to_date_obj(start_date)
2318        end_date_obj = self.__to_date_obj(end_date)
2319        if start_date_obj >= end_date_obj:
2320            raise ValueError("end_date must be later than the start_date")
2321
2322        # get for the given date
2323        url = self.base_uri + f"/api/analysis/keyfacts/{model_id}/{portfolio_id}"
2324        data = {
2325            "startDate": self.__iso_format(start_date),
2326            "endDate": self.__iso_format(end_date),
2327            "shouldRecalc": True,
2328        }
2329        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2330        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2331        if res.status_code == 404 and block:
2332            retries = 0
2333            data["shouldRecalc"] = False
2334            while retries < 10:
2335                time.sleep(10)
2336                retries += 1
2337                res = requests.post(
2338                    url, data=json.dumps(data), headers=headers, **self._request_params
2339                )
2340                if res.status_code != 404:
2341                    break
2342        if res.ok:
2343            return res.json()
2344        else:
2345            error_msg = self._try_extract_error_code(res)
2346            logger.error(error_msg)
2347            raise BoostedAPIException(
2348                "Failed to get tear sheet data: {0} {1}.".format(error_msg, str(res.status_code))
2349            )
def getPortfolioStatus(self, model_id, portfolio_id, job_date):
2363    def getPortfolioStatus(self, model_id, portfolio_id, job_date):
2364        url = (
2365            self.base_uri
2366            + f"/api/analysis/portfolioStatus/{model_id}/{portfolio_id}?jobDate={job_date}"
2367        )
2368        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2369        res = requests.get(url, headers=headers, **self._request_params)
2370        if res.ok:
2371            result = res.json()
2372            return {
2373                "is_complete": result["status"],
2374                "last_update": None if result["lastUpdate"] is None else result["lastUpdate"][:10],
2375                "next_update": None if result["nextUpdate"] is None else result["nextUpdate"][:10],
2376            }
2377        else:
2378            error_msg = self._try_extract_error_code(res)
2379            logger.error(error_msg)
2380            raise BoostedAPIException("Failed to get portfolio status: {0}".format(error_msg))
def get_portfolio_factor_attribution( self, portfolio_id: str, start_date: Union[datetime.date, str, NoneType] = None, end_date: Union[datetime.date, str, NoneType] = None):
2399    def get_portfolio_factor_attribution(
2400        self,
2401        portfolio_id: str,
2402        start_date: Optional[BoostedDate] = None,
2403        end_date: Optional[BoostedDate] = None,
2404    ):
2405        """Get portfolio factor attribution for a portfolio
2406
2407        Args:
2408            portfolio_id (str): a valid UUID string
2409            start_date (BoostedDate, optional): The start date. Defaults to None.
2410            end_date (BoostedDate, optional): The end date. Defaults to None.
2411        """
2412        response = self._query_portfolio_factor_attribution(portfolio_id, start_date, end_date)
2413        factor_attribution = response["data"]["portfolio"]["factorAttribution"]
2414        dates = pd.DatetimeIndex(data=factor_attribution["dates"])
2415        beta = factor_attribution["factorBetas"]
2416        beta_df = pd.DataFrame(index=dates, data={x["name"]: x["data"] for x in beta})
2417        beta_df = beta_df.add_suffix("_beta")
2418        returns = factor_attribution["portfolioFactorPerformance"]
2419        returns_df = pd.DataFrame(index=dates, data={x["name"]: x["data"] for x in returns})
2420        returns_df = returns_df.add_suffix("_return")
2421        returns_df = (returns_df - 1) * 100
2422
2423        final_df = pd.concat([returns_df, beta_df], axis=1)
2424        ordered_columns = list(itertools.chain(*zip(returns_df.columns, beta_df.columns)))
2425        ordered_final_df = final_df.reindex(columns=ordered_columns)
2426
2427        # Add the column `total_return` which is the sum of returns_data
2428        ordered_final_df["total_return"] = returns_df.sum(axis=1)
2429        return ordered_final_df

Get portfolio factor attribution for a portfolio

Args: portfolio_id (str): a valid UUID string start_date (BoostedDate, optional): The start date. Defaults to None. end_date (BoostedDate, optional): The end date. Defaults to None.

def getBlacklist(self, blacklist_id):
2431    def getBlacklist(self, blacklist_id):
2432        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2433        headers = {"Authorization": "ApiKey " + self.api_key}
2434        res = requests.get(url, headers=headers, **self._request_params)
2435        if res.ok:
2436            result = res.json()
2437            return result
2438        error_msg = self._try_extract_error_code(res)
2439        logger.error(error_msg)
2440        raise BoostedAPIException(f"Failed to get blacklist with id {blacklist_id}: {error_msg}")
def getBlacklists(self, model_id=None, company_id=None, last_N=None):
2442    def getBlacklists(self, model_id=None, company_id=None, last_N=None):
2443        params = {}
2444        if last_N:
2445            params["lastN"] = last_N
2446        if model_id:
2447            params["modelId"] = model_id
2448        if company_id:
2449            params["companyId"] = company_id
2450        url = self.base_uri + f"/api/blacklist"
2451        headers = {"Authorization": "ApiKey " + self.api_key}
2452        res = requests.get(url, headers=headers, params=params, **self._request_params)
2453        if res.ok:
2454            result = res.json()
2455            return result
2456        error_msg = self._try_extract_error_code(res)
2457        logger.error(error_msg)
2458        raise BoostedAPIException(
2459            f"""Failed to get blacklists with \
2460            model_id {model_id} company_id {company_id} last_N {last_N}: {error_msg}"""
2461        )
def createBlacklist( self, isin, long_short=2, start_date=datetime.date(2024, 4, 17), end_date='4000-01-01', model_id=None):
2463    def createBlacklist(
2464        self,
2465        isin,
2466        long_short=2,
2467        start_date=datetime.date.today(),
2468        end_date="4000-01-01",
2469        model_id=None,
2470    ):
2471        url = self.base_uri + f"/api/blacklist"
2472        data = {
2473            "modelId": model_id,
2474            "isin": isin,
2475            "longShort": long_short,
2476            "startDate": self.__iso_format(start_date),
2477            "endDate": self.__iso_format(end_date),
2478        }
2479        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2480        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2481        if res.ok:
2482            return res.json()
2483        else:
2484            error_msg = self._try_extract_error_code(res)
2485            logger.error(error_msg)
2486            raise BoostedAPIException(
2487                f"""Failed to create the blacklist with \
2488                  isin {isin} long_short {long_short} start_date {start_date} end_date {end_date} \
2489                  model_id {model_id}: {error_msg}."""
2490            )
def createBlacklistsFromCSV(self, csv_name):
2492    def createBlacklistsFromCSV(self, csv_name):
2493        url = self.base_uri + f"/api/blacklists"
2494        data = []
2495        with open(csv_name, mode="r") as f:
2496            csv_reader = csv.DictReader(f)
2497            for row in csv_reader:
2498                blacklist = {"modelId": row["ModelID"], "isin": row["ISIN"]}
2499                if not row.get("LongShort"):
2500                    blacklist["longShort"] = 2
2501                else:
2502                    blacklist["longShort"] = row["LongShort"]
2503
2504                if not row.get("StartDate"):
2505                    blacklist["startDate"] = self.__iso_format(datetime.date.today())
2506                else:
2507                    blacklist["startDate"] = self.__iso_format(row["StartDate"])
2508
2509                if not row.get("EndDate"):
2510                    blacklist["endDate"] = self.__iso_format("4000-01-01")
2511                else:
2512                    blacklist["endDate"] = self.__iso_format(row["EndDate"])
2513                data.append(blacklist)
2514        print(f"Processed {len(data)} blacklists.")
2515        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2516        res = requests.post(url, data=json.dumps(data), headers=headers, **self._request_params)
2517        if res.ok:
2518            return res.json()
2519        else:
2520            error_msg = self._try_extract_error_code(res)
2521            logger.error(error_msg)
2522            raise BoostedAPIException("failed to create blacklists")
def updateBlacklist(self, blacklist_id, long_short=None, start_date=None, end_date=None):
2524    def updateBlacklist(self, blacklist_id, long_short=None, start_date=None, end_date=None):
2525        params = {}
2526        if long_short:
2527            params["longShort"] = long_short
2528        if start_date:
2529            params["startDate"] = start_date
2530        if end_date:
2531            params["endDate"] = end_date
2532        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2533        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2534        res = requests.patch(url, json=params, headers=headers, **self._request_params)
2535        if res.ok:
2536            return res.json()
2537        else:
2538            error_msg = self._try_extract_error_code(res)
2539            logger.error(error_msg)
2540            raise BoostedAPIException(
2541                f"Failed to update blacklist with id {blacklist_id}: {error_msg}"
2542            )
def deleteBlacklist(self, blacklist_id):
2544    def deleteBlacklist(self, blacklist_id):
2545        url = self.base_uri + f"/api/blacklist/{blacklist_id}"
2546        headers = {"Authorization": "ApiKey " + self.api_key}
2547        res = requests.delete(url, headers=headers, **self._request_params)
2548        if res.ok:
2549            result = res.json()
2550            return result
2551        else:
2552            error_msg = self._try_extract_error_code(res)
2553            logger.error(error_msg)
2554            raise BoostedAPIException(
2555                f"Failed to delete blacklist with id {blacklist_id}: {error_msg}"
2556            )
def getFeatureImportance(self, model_id, date, N=None):
2558    def getFeatureImportance(self, model_id, date, N=None):
2559        url = self.base_uri + f"/api/analysis/explainability/{model_id}"
2560        headers = {"Authorization": "ApiKey " + self.api_key}
2561        logger.info("Retrieving rankings information for date {0}.".format(date))
2562        res = requests.get(url, headers=headers, **self._request_params)
2563        if not res.ok:
2564            error_msg = self._try_extract_error_code(res)
2565            logger.error(error_msg)
2566            raise BoostedAPIException(
2567                f"Failed to fetch feature importance for model/portfolio {model_id}: {error_msg}"
2568            )
2569
2570        json_data = res.json()
2571        if "all" not in json_data.keys() or not json_data["all"]:
2572            raise BoostedAPIException(f"Unexpected formatting of feature importance response")
2573
2574        feature_data = json_data["all"]
2575        # find the right period (assuming returned json has dates in descending order)
2576        date_obj = self.__to_date_obj(date)
2577        start_date_for_return_data = self.__to_date_obj(feature_data[0]["date"])
2578        features_for_requested_period = None
2579
2580        if date_obj > start_date_for_return_data:
2581            features_for_requested_period = feature_data[0]["variable"]
2582        else:
2583            i = 0
2584            while i < len(feature_data) - 1:
2585                current_date = self.__to_date_obj(feature_data[i]["date"])
2586                next_date = self.__to_date_obj(feature_data[i + 1]["date"])
2587                if next_date <= date_obj <= current_date:
2588                    features_for_requested_period = feature_data[i + 1]["variable"]
2589                    start_date_for_return_data = next_date
2590                    break
2591                i += 1
2592
2593        if features_for_requested_period is None:
2594            raise BoostedAPIException(f"No feature data was found for requested date: {date_obj}")
2595
2596        features_for_requested_period.sort(key=lambda x: x["value"], reverse=True)
2597
2598        if type(N) is int and N > 0:
2599            df = pd.DataFrame.from_dict(features_for_requested_period[0:N])
2600        else:
2601            df = pd.DataFrame.from_dict(features_for_requested_period)
2602        result = df[["feature", "value"]]
2603
2604        return result.rename(columns={"feature": f"feature ({start_date_for_return_data})"})
def getAllModelNames(self) -> Dict[str, str]:
2606    def getAllModelNames(self) -> Dict[str, str]:
2607        url = f"{self.base_uri}/api/graphql"
2608        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2609        req_json = {"query": "query listOfModels {\n models { id name }}", "variables": {}}
2610        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
2611        if not res.ok:
2612            error_msg = self._try_extract_error_code(res)
2613            logger.error(error_msg)
2614            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
2615        data = res.json()
2616        if data["data"]["models"] is None:
2617            return {}
2618        return {rec["id"]: rec["name"] for rec in data["data"]["models"]}
def getAllModelDetails(self) -> Dict[str, Dict[str, Any]]:
2620    def getAllModelDetails(self) -> Dict[str, Dict[str, Any]]:
2621        url = f"{self.base_uri}/api/graphql"
2622        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
2623        req_json = {
2624            "query": "query listOfModels {\n models { id name lastUpdated portfolios { id name }}}",
2625            "variables": {},
2626        }
2627        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
2628        if not res.ok:
2629            error_msg = self._try_extract_error_code(res)
2630            logger.error(error_msg)
2631            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
2632        data = res.json()
2633        if data["data"]["models"] is None:
2634            return {}
2635
2636        output_data = {}
2637        for rec in data["data"]["models"]:
2638            model_id = rec["id"]
2639            output_data[model_id] = {
2640                "name": rec["name"],
2641                "last_updated": parser.parse(rec["lastUpdated"]),
2642                "portfolios": rec["portfolios"],
2643            }
2644
2645        return output_data
def get_hedge_experiments(self):
2647    def get_hedge_experiments(self):
2648        url = self.base_uri + "/api/graphql"
2649        qry = """
2650            query getHedgeExperiments {
2651                hedgeExperiments {
2652                    hedgeExperimentId
2653                    experimentName
2654                    userId
2655                    config
2656                    description
2657                    experimentType
2658                    lastCalculated
2659                    lastModified
2660                    status
2661                    portfolioCalcStatus
2662                    targetSecurities {
2663                        gbiId
2664                        security {
2665                            gbiId
2666                            symbol
2667                            name
2668                        }
2669                        weight
2670                    }
2671                    targetPortfolios {
2672                        portfolioId
2673                    }
2674                    baselineModel {
2675                        id
2676                        name
2677
2678                    }
2679                    baselineScenario {
2680                        hedgeExperimentScenarioId
2681                        scenarioName
2682                        description
2683                        portfolioSettingsJson
2684                        hedgeExperimentPortfolios {
2685                            portfolio {
2686                                id
2687                                name
2688                                modelId
2689                                performanceGridHeader
2690                                performanceGrid
2691                                status
2692                                tearSheet {
2693                                    groupName
2694                                    members {
2695                                        name
2696                                        value
2697                                    }
2698                                }
2699                            }
2700                        }
2701                        status
2702                    }
2703                    baselineStockUniverseId
2704                }
2705            }
2706        """
2707
2708        headers = {"Authorization": "ApiKey " + self.api_key}
2709        resp = requests.post(url, json={"query": qry}, headers=headers, params=self._request_params)
2710
2711        json_resp = resp.json()
2712        # graphql endpoints typically return 200 or 400 status codes, so we must
2713        # check if we have any errors, even with a 200
2714        if (resp.ok and "errors" in json_resp) or not resp.ok:
2715            error_msg = self._try_extract_error_code(resp)
2716            logger.error(error_msg)
2717            raise BoostedAPIException(
2718                (f"Failed to get hedge experiments: {resp.status_code=}; {error_msg=}")
2719            )
2720
2721        json_experiments = resp.json()["data"]["hedgeExperiments"]
2722        experiments = [HedgeExperiment.from_json_dict(exp_json) for exp_json in json_experiments]
2723        return experiments
def get_hedge_experiment_details(self, experiment_id: str):
2725    def get_hedge_experiment_details(self, experiment_id: str):
2726        url = self.base_uri + "/api/graphql"
2727        qry = """
2728            query getHedgeExperimentDetails($hedgeExperimentId: ID!) {
2729                hedgeExperiment(hedgeExperimentId: $hedgeExperimentId) {
2730                ...HedgeExperimentDetailsSummaryListFragment
2731                }
2732            }
2733
2734            fragment HedgeExperimentDetailsSummaryListFragment on HedgeExperiment {
2735                hedgeExperimentId
2736                experimentName
2737                userId
2738                config
2739                description
2740                experimentType
2741                lastCalculated
2742                lastModified
2743                status
2744                portfolioCalcStatus
2745                targetSecurities {
2746                    gbiId
2747                    security {
2748                        gbiId
2749                        symbol
2750                        name
2751                    }
2752                    weight
2753                }
2754                selectedModels {
2755                    id
2756                    name
2757                    stockUniverse {
2758                        name
2759                    }
2760                }
2761                hedgeExperimentScenarios {
2762                    ...experimentScenarioFragment
2763                }
2764                selectedDummyHedgeExperimentModels {
2765                    id
2766                    name
2767                    stockUniverse {
2768                        name
2769                    }
2770                }
2771                targetPortfolios {
2772                    portfolioId
2773                }
2774                baselineModel {
2775                    id
2776                    name
2777
2778                }
2779                baselineScenario {
2780                    hedgeExperimentScenarioId
2781                    scenarioName
2782                    description
2783                    portfolioSettingsJson
2784                    hedgeExperimentPortfolios {
2785                        portfolio {
2786                            id
2787                            name
2788                            modelId
2789                            performanceGridHeader
2790                            performanceGrid
2791                            status
2792                            tearSheet {
2793                                groupName
2794                                members {
2795                                    name
2796                                    value
2797                                }
2798                            }
2799                        }
2800                    }
2801                    status
2802                }
2803                baselineStockUniverseId
2804            }
2805
2806            fragment experimentScenarioFragment on HedgeExperimentScenario {
2807                hedgeExperimentScenarioId
2808                scenarioName
2809                status
2810                description
2811                portfolioSettingsJson
2812                hedgeExperimentPortfolios {
2813                    portfolio {
2814                        id
2815                        name
2816                        modelId
2817                        performanceGridHeader
2818                        performanceGrid
2819                        status
2820                        tearSheet {
2821                            groupName
2822                            members {
2823                                name
2824                                value
2825                            }
2826                        }
2827                    }
2828                }
2829            }
2830        """
2831        headers = {"Authorization": "ApiKey " + self.api_key}
2832        resp = requests.post(
2833            url,
2834            json={"query": qry, "variables": {"hedgeExperimentId": experiment_id}},
2835            headers=headers,
2836            params=self._request_params,
2837        )
2838
2839        json_resp = resp.json()
2840        # graphql endpoints typically return 200 or 400 status codes, so we must
2841        # check if we have any errors, even with a 200
2842        if (resp.ok and "errors" in json_resp) or not resp.ok:
2843            error_msg = self._try_extract_error_code(resp)
2844            logger.error(error_msg)
2845            raise BoostedAPIException(
2846                (
2847                    f"Failed to get hedge experiment results for {experiment_id=}: "
2848                    f"{resp.status_code=}; {error_msg=}"
2849                )
2850            )
2851
2852        json_exp_results = json_resp["data"]["hedgeExperiment"]
2853        if json_exp_results is None:
2854            return None  # issued a request with a non-existent experiment_id
2855        exp_results = HedgeExperimentDetails.from_json_dict(json_exp_results)
2856        return exp_results
def get_portfolio_performance( self, portfolio_id: str, start_date: Union[datetime.date, NoneType], end_date: Union[datetime.date, NoneType], daily_returns: bool) -> pandas.core.frame.DataFrame:
2858    def get_portfolio_performance(
2859        self,
2860        portfolio_id: str,
2861        start_date: Optional[datetime.date],
2862        end_date: Optional[datetime.date],
2863        daily_returns: bool,
2864    ) -> pd.DataFrame:
2865        """
2866        Get performance data for a portfolio.
2867
2868        Parameters
2869        ----------
2870        portfolio_id: str
2871            UUID corresponding to the portfolio in question.
2872        start_date: datetime.date
2873            Starting cutoff date to filter performance data
2874        end_date: datetime.date
2875            Ending cutoff date to filter performance data
2876        daily_returns: bool
2877            Flag indicating whether to add a new column with the daily return pct calculated
2878
2879        Returns
2880        -------
2881        pd.DataFrame object
2882            Portfolio and benchmark performance.
2883            -index:
2884                "date": pd.DatetimeIndex
2885            -columns:
2886                "benchmark": benchmark performance, % return
2887                "turnover": portfolio turnover, % of equity
2888                "portfolio": return since beginning of portfolio, % return
2889                "daily_returns": daily percent change in value of the portfolio, % return
2890                                (this column is optional and depends on the daily_returns flag)
2891        """
2892        url = f"{self.base_uri}/api/graphql"
2893        qry = """
2894            query getPortfolioPerformance($portfolioId: ID!) {
2895                portfolio(id: $portfolioId) {
2896                    id
2897                    modelId
2898                    name
2899                    status
2900                    performance {
2901                        benchmark
2902                        date
2903                        turnover
2904                        value
2905                    }
2906                }
2907            }
2908        """
2909
2910        headers = {"Authorization": "ApiKey " + self.api_key}
2911        resp = requests.post(
2912            url,
2913            json={"query": qry, "variables": {"portfolioId": portfolio_id}},
2914            headers=headers,
2915            params=self._request_params,
2916        )
2917
2918        json_resp = resp.json()
2919        # the webserver returns an error for non-ready portfolios, so we have to check
2920        # for this prior to the error check below
2921        pf = json_resp["data"].get("portfolio")
2922        if pf is not None and pf["status"] != "READY":
2923            return pd.DataFrame()
2924
2925        # graphql endpoints typically return 200 or 400 status codes, so we must
2926        # check if we have any errors, even with a 200
2927        if (resp.ok and "errors" in json_resp) or not resp.ok:
2928            error_msg = self._try_extract_error_code(resp)
2929            logger.error(error_msg)
2930            raise BoostedAPIException(
2931                (
2932                    f"Failed to get portfolio performance for {portfolio_id=}: "
2933                    f"{resp.status_code=}; {error_msg=}"
2934                )
2935            )
2936
2937        perf = json_resp["data"]["portfolio"]["performance"]
2938        df = pd.DataFrame(perf).set_index("date").rename(columns={"value": "portfolio"})
2939        df.index = pd.to_datetime(df.index)
2940        if daily_returns:
2941            df["daily_returns"] = pd.to_numeric(df["portfolio"]).pct_change()
2942            df = df.dropna(subset=["daily_returns"])
2943        if start_date:
2944            df = df[df.index >= pd.to_datetime(start_date)]
2945        if end_date:
2946            df = df[df.index <= pd.to_datetime(end_date)]
2947        return df.astype(float)

Get performance data for a portfolio.

Parameters

portfolio_id: str UUID corresponding to the portfolio in question. start_date: datetime.date Starting cutoff date to filter performance data end_date: datetime.date Ending cutoff date to filter performance data daily_returns: bool Flag indicating whether to add a new column with the daily return pct calculated

Returns

pd.DataFrame object Portfolio and benchmark performance. -index: "date": pd.DatetimeIndex -columns: "benchmark": benchmark performance, % return "turnover": portfolio turnover, % of equity "portfolio": return since beginning of portfolio, % return "daily_returns": daily percent change in value of the portfolio, % return (this column is optional and depends on the daily_returns flag)

def get_portfolio_factors(self, model_id: str, portfolio_id: str) -> pandas.core.frame.DataFrame:
2956    def get_portfolio_factors(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
2957        url = f"{self.base_uri}/api/analysis/factors/{model_id}/{portfolio_id}"
2958        headers = {"Authorization": "ApiKey " + self.api_key}
2959        resp = requests.get(url, headers=headers, params=self._request_params)
2960
2961        json_resp = resp.json()
2962        if (resp.ok and "errors" in json_resp) or not resp.ok:
2963            error_msg = json_resp["errors"][0]
2964            if self._is_portfolio_still_running(error_msg):
2965                return pd.DataFrame()
2966            logger.error(error_msg)
2967            raise BoostedAPIException(
2968                (
2969                    f"Failed to get portfolio factors for {portfolio_id=}: "
2970                    f"{resp.status_code=}; {error_msg=}"
2971                )
2972            )
2973
2974        df = pd.DataFrame(json_resp["data"], columns=json_resp["header_row"])
2975
2976        def to_lower_snake_case(s):  # why are we linting lambdas? :(
2977            return "_".join(w.lower() for w in s.split(" "))
2978
2979        df = df.rename(columns={old: to_lower_snake_case(old) for old in df.columns}).set_index(
2980            "date"
2981        )
2982        df.index = pd.to_datetime(df.index)
2983        return df
def get_portfolio_volatility(self, model_id: str, portfolio_id: str) -> pandas.core.frame.DataFrame:
2985    def get_portfolio_volatility(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
2986        url = f"{self.base_uri}/api/analysis/volatility_rolling/{model_id}/{portfolio_id}"
2987        headers = {"Authorization": "ApiKey " + self.api_key}
2988        resp = requests.get(url, headers=headers, params=self._request_params)
2989
2990        json_resp = resp.json()
2991        if (resp.ok and "errors" in json_resp) or not resp.ok:
2992            error_msg = json_resp["errors"][0]
2993            if self._is_portfolio_still_running(error_msg):
2994                return pd.DataFrame()
2995            logger.error(error_msg)
2996            raise BoostedAPIException(
2997                (
2998                    f"Failed to get portfolio volatility for {portfolio_id=}: "
2999                    f"{resp.status_code=}; {error_msg=}"
3000                )
3001            )
3002
3003        df = pd.DataFrame(json_resp["data"], columns=json_resp["headerRow"])
3004        df = df.rename(
3005            columns={old: old.lower().replace("avg", "avg_") for old in df.columns}  # type: ignore
3006        ).set_index("date")
3007        df.index = pd.to_datetime(df.index)
3008        return df
def get_portfolio_holdings(self, model_id: str, portfolio_id: str) -> pandas.core.frame.DataFrame:
3010    def get_portfolio_holdings(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
3011        url = f"{self.base_uri}/api/models/{model_id}/{portfolio_id}/basket-data"
3012        headers = {"Authorization": "ApiKey " + self.api_key}
3013        resp = requests.get(url, headers=headers, params=self._request_params)
3014
3015        # this is a classic abuse of try/except as control flow: we try to get json body
3016        # from the response so that we can error-check. if this fails, we assume we have
3017        # a legit text response (corresponding to the csv data we care about)
3018        try:
3019            json_resp = resp.json()
3020        except json.decoder.JSONDecodeError:
3021            df = pd.read_csv(io.StringIO(resp.text), header=[0])
3022        else:
3023            error_msg = json_resp["errors"][0]
3024            if self._is_portfolio_still_running(error_msg):
3025                return pd.DataFrame()
3026            else:
3027                logger.error(error_msg)
3028                raise BoostedAPIException(
3029                    (
3030                        f"Failed to get portfolio holdings for {portfolio_id=}: "
3031                        f"{resp.status_code=}; {error_msg=}"
3032                    )
3033                )
3034
3035        df = df.rename(columns={old: old.lower() for old in df.columns}).set_index("date")
3036        df.index = pd.to_datetime(df.index)
3037        return df
def getStockDataTableForDate( self, model_id: str, portfolio_id: str, date: datetime.date) -> pandas.core.frame.DataFrame:
3039    def getStockDataTableForDate(
3040        self, model_id: str, portfolio_id: str, date: datetime.date
3041    ) -> pd.DataFrame:
3042        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3043
3044        url_base = f"{self.base_uri}/api/analysis"
3045        url_params = f"{model_id}/{portfolio_id}"
3046        formatted_date = date.strftime("%Y-%m-%d")
3047
3048        stock_prices_url = f"{url_base}/stock-prices/{url_params}/{formatted_date}"
3049        stock_factors_url = f"{url_base}/stock-factors/{url_params}/date/{formatted_date}"
3050
3051        prices_params = {"useTicker": "false", "useCurrentSignals": "true"}
3052        factors_param = {"useTicker": "false", "useCurrentSignals": "true"}
3053
3054        prices_resp = requests.get(
3055            stock_prices_url, headers=headers, params=prices_params, **self._request_params
3056        )
3057        factors_resp = requests.get(
3058            stock_factors_url, headers=headers, params=factors_param, **self._request_params
3059        )
3060
3061        frames = []
3062        gbi_ids = set()
3063        for res in (prices_resp, factors_resp):
3064            if not res.ok:
3065                error_msg = self._try_extract_error_code(res)
3066                logger.error(error_msg)
3067                raise BoostedAPIException(
3068                    (
3069                        f"Failed to fetch stock data table for model {model_id}"
3070                        f" (it's possible no data is present for the given date: {date})."
3071                        f" Error message: {error_msg}"
3072                    )
3073                )
3074            result = res.json()
3075            df = pd.DataFrame(result)
3076            gbi_ids.update(df.columns.to_list())
3077            frames.append(pd.DataFrame(result))
3078
3079        all_gbiid_df = pd.concat(frames)
3080
3081        # Get the metadata of all GBI IDs
3082        gbiid_metadata_res = self._get_graphql(
3083            query=graphql_queries.GET_SEC_INFO_QRY, variables={"ids": [int(x) for x in gbi_ids]}
3084        )
3085        # Build a DF of metadata x GBI IDs
3086        gbiid_metadata_df = pd.DataFrame(
3087            {str(x["gbiId"]): x for x in gbiid_metadata_res["data"]["securities"]}
3088        )
3089        # Slice metadata we care. We'll drop "symbol" at the end.
3090        isin_country_currency_df = gbiid_metadata_df.loc[["isin", "country", "currency", "symbol"]]
3091        # Concatenate metadata to the existing stock data DF
3092        all_gbiid_with_metadata_df = pd.concat([all_gbiid_df, isin_country_currency_df])
3093        gbiid_with_symbol_df = all_gbiid_with_metadata_df.loc[
3094            :, all_gbiid_with_metadata_df.loc["symbol"].notna()
3095        ]
3096        renamed_df = gbiid_with_symbol_df.rename(
3097            index={"isin": "ISIN"}, columns=gbiid_with_symbol_df.loc["symbol"].to_dict()
3098        )
3099        output_df = renamed_df.drop(index=["symbol"])
3100        return output_df
def add_hedge_experiment_scenario( self, experiment_id: str, scenario_name: str, scenario_settings: boosted.api.api_type.PortfolioSettings, run_scenario_immediately: bool) -> boosted.api.api_type.HedgeExperimentScenario:
3102    def add_hedge_experiment_scenario(
3103        self,
3104        experiment_id: str,
3105        scenario_name: str,
3106        scenario_settings: PortfolioSettings,
3107        run_scenario_immediately: bool,
3108    ) -> HedgeExperimentScenario:
3109        add_scenario_input = {
3110            "hedgeExperimentId": experiment_id,
3111            "scenarioName": scenario_name,
3112            "portfolioSettingsJson": str(scenario_settings),
3113            "runExperimentOnScenario": run_scenario_immediately,
3114            "createDefaultPortfolio": "false",
3115        }
3116        qry = """
3117            mutation addHedgeExperimentScenario(
3118                $input: AddHedgeExperimentScenarioInput!
3119            ) {
3120                addHedgeExperimentScenario(input: $input) {
3121                    hedgeExperimentScenario {
3122                        hedgeExperimentScenarioId
3123                        scenarioName
3124                        description
3125                        portfolioSettingsJson
3126                    }
3127                }
3128            }
3129
3130        """
3131
3132        url = f"{self.base_uri}/api/graphql"
3133
3134        resp = requests.post(
3135            url,
3136            headers={"Authorization": "ApiKey " + self.api_key},
3137            json={"query": qry, "variables": {"input": add_scenario_input}},
3138        )
3139
3140        json_resp = resp.json()
3141        if (resp.ok and "errors" in json_resp) or not resp.ok:
3142            error_msg = self._try_extract_error_code(resp)
3143            logger.error(error_msg)
3144            raise BoostedAPIException(
3145                (f"Failed to add scenario: {resp.status_code=}; {error_msg=}")
3146            )
3147
3148        scenario_dict = json_resp["data"]["addHedgeExperimentScenario"]["hedgeExperimentScenario"]
3149        if scenario_dict is None:
3150            raise BoostedAPIException(
3151                "Failed to add scenario, likely due to bad experiment id or api key"
3152            )
3153        s = HedgeExperimentScenario.from_json_dict(scenario_dict)
3154        return s
def create_hedge_experiment( self, name: str, description: str, experiment_type: Literal['HEDGE', 'MIMIC'], target_securities: Union[Dict[boosted.api.api_type.GbiIdSecurity, float], str, NoneType]) -> boosted.api.api_type.HedgeExperiment:
3163    def create_hedge_experiment(
3164        self,
3165        name: str,
3166        description: str,
3167        experiment_type: hedge_experiment_type,
3168        target_securities: Union[Dict[GbiIdSecurity, float], str, None],
3169    ) -> HedgeExperiment:
3170        # we don't pass target_securities here (as much as id like to) because the
3171        # graphql input doesn't support it at this point
3172
3173        # note that this query returns a lot of null fields at this point, but
3174        # they are necessary for building a HE.
3175        create_qry = """
3176            mutation createDraftMutation($input: CreateHedgeExperimentDraftInput!) {
3177                createHedgeExperimentDraft(input: $input) {
3178                    hedgeExperiment {
3179                        hedgeExperimentId
3180                        experimentName
3181                        userId
3182                        config
3183                        description
3184                        experimentType
3185                        lastCalculated
3186                        lastModified
3187                        status
3188                        portfolioCalcStatus
3189                        targetSecurities {
3190                            gbiId
3191                            security {
3192                                gbiId
3193                                name
3194                                symbol
3195                            }
3196                            weight
3197                        }
3198                        baselineModel {
3199                            id
3200                            name
3201                        }
3202                        baselineScenario {
3203                            hedgeExperimentScenarioId
3204                            scenarioName
3205                            description
3206                            portfolioSettingsJson
3207                            hedgeExperimentPortfolios {
3208                                portfolio {
3209                                    id
3210                                    name
3211                                    modelId
3212                                    performanceGridHeader
3213                                    performanceGrid
3214                                    status
3215                                    tearSheet {
3216                                        groupName
3217                                        members {
3218                                            name
3219                                            value
3220                                        }
3221                                    }
3222                                }
3223                            }
3224                            status
3225                        }
3226                        baselineStockUniverseId
3227                    }
3228                }
3229            }
3230        """
3231
3232        create_input: Dict[str, Any] = {
3233            "name": name,
3234            "experimentType": experiment_type,
3235            "description": description,
3236        }
3237        if isinstance(target_securities, dict):
3238            create_input["setTargetSecurities"] = [
3239                {"gbiId": sec.gbi_id, "weight": weight}
3240                for (sec, weight) in target_securities.items()
3241            ]
3242        elif isinstance(target_securities, str):
3243            create_input["setTargetPortfolios"] = [{"portfolioId": target_securities}]
3244        elif target_securities is None:
3245            pass
3246        else:
3247            raise TypeError(
3248                "Expected value of type Union[Dict[GbiIdSecurity, str], str] for "
3249                f"argument 'target_securities'; got {type(target_securities)}"
3250            )
3251        resp = requests.post(
3252            f"{self.base_uri}/api/graphql",
3253            json={"query": create_qry, "variables": {"input": create_input}},
3254            headers={"Authorization": "ApiKey " + self.api_key},
3255            params=self._request_params,
3256        )
3257
3258        json_resp = resp.json()
3259        if (resp.ok and "errors" in json_resp) or not resp.ok:
3260            error_msg = self._try_extract_error_code(resp)
3261            logger.error(error_msg)
3262            raise BoostedAPIException(
3263                (f"Failed to create hedge experiment: {resp.status_code=}; {error_msg=}")
3264            )
3265
3266        exp_dict = json_resp["data"]["createHedgeExperimentDraft"]["hedgeExperiment"]
3267        experiment = HedgeExperiment.from_json_dict(exp_dict)
3268        return experiment
def modify_hedge_experiment( self, experiment_id: str, name: Union[str, NoneType] = None, description: Union[str, NoneType] = None, experiment_type: Union[Literal['HEDGE', 'MIMIC'], NoneType] = None, target_securities: Union[Dict[boosted.api.api_type.GbiIdSecurity, float], str, NoneType] = None, model_ids: Union[List[str], NoneType] = None, stock_universe_ids: Union[List[str], NoneType] = None, create_default_scenario: bool = True, baseline_model_id: Union[str, NoneType] = None, baseline_stock_universe_id: Union[str, NoneType] = None, baseline_portfolio_settings: Union[str, NoneType] = None) -> boosted.api.api_type.HedgeExperiment:
3270    def modify_hedge_experiment(
3271        self,
3272        experiment_id: str,
3273        name: Optional[str] = None,
3274        description: Optional[str] = None,
3275        experiment_type: Optional[hedge_experiment_type] = None,
3276        target_securities: Union[Dict[GbiIdSecurity, float], str, None] = None,
3277        model_ids: Optional[List[str]] = None,
3278        stock_universe_ids: Optional[List[str]] = None,
3279        create_default_scenario: bool = True,
3280        baseline_model_id: Optional[str] = None,
3281        baseline_stock_universe_id: Optional[str] = None,
3282        baseline_portfolio_settings: Optional[str] = None,
3283    ) -> HedgeExperiment:
3284        mod_qry = """
3285            mutation modifyHedgeExperimentDraft(
3286                $input: ModifyHedgeExperimentDraftInput!
3287            ) {
3288                modifyHedgeExperimentDraft(input: $input) {
3289                    hedgeExperiment {
3290                    ...HedgeExperimentSelectedSecuritiesPageFragment
3291                    }
3292                }
3293            }
3294
3295            fragment HedgeExperimentSelectedSecuritiesPageFragment on HedgeExperiment {
3296                hedgeExperimentId
3297                experimentName
3298                userId
3299                config
3300                description
3301                experimentType
3302                lastCalculated
3303                lastModified
3304                status
3305                portfolioCalcStatus
3306                targetSecurities {
3307                    gbiId
3308                    security {
3309                        gbiId
3310                        name
3311                        symbol
3312                    }
3313                    weight
3314                }
3315                targetPortfolios {
3316                    portfolioId
3317                }
3318                baselineModel {
3319                    id
3320                    name
3321                }
3322                baselineScenario {
3323                    hedgeExperimentScenarioId
3324                    scenarioName
3325                    description
3326                    portfolioSettingsJson
3327                    hedgeExperimentPortfolios {
3328                        portfolio {
3329                            id
3330                            name
3331                            modelId
3332                            performanceGridHeader
3333                            performanceGrid
3334                            status
3335                            tearSheet {
3336                                groupName
3337                                members {
3338                                    name
3339                                    value
3340                                }
3341                            }
3342                        }
3343                    }
3344                    status
3345                }
3346                baselineStockUniverseId
3347            }
3348        """
3349        mod_input = {
3350            "hedgeExperimentId": experiment_id,
3351            "createDefaultScenario": create_default_scenario,
3352        }
3353        if name is not None:
3354            mod_input["newExperimentName"] = name
3355        if description is not None:
3356            mod_input["newExperimentDescription"] = description
3357        if experiment_type is not None:
3358            mod_input["newExperimentType"] = experiment_type
3359        if model_ids is not None:
3360            mod_input["setSelectdModels"] = model_ids
3361        if stock_universe_ids is not None:
3362            mod_input["selectedStockUniverseIds"] = stock_universe_ids
3363        if baseline_model_id is not None:
3364            mod_input["setBaselineModel"] = baseline_model_id
3365        if baseline_stock_universe_id is not None:
3366            mod_input["setBaselineStockUniverse"] = baseline_stock_universe_id
3367        if baseline_portfolio_settings is not None:
3368            mod_input["setBaselinePortfolioSettings"] = baseline_portfolio_settings
3369        # note that the behaviors bound to these data are mutually exclusive,
3370        # and its possible the opposite was set earlier in the DRAFT phase
3371        # of experiment creation, so when setting one, we must unset the other
3372        if isinstance(target_securities, dict):
3373            mod_input["setTargetSecurities"] = [
3374                {"gbiId": sec.gbi_id, "weight": weight}
3375                for (sec, weight) in target_securities.items()
3376            ]
3377            mod_input["setTargetPortfolios"] = None
3378        elif isinstance(target_securities, str):
3379            mod_input["setTargetPortfolios"] = [{"portfolioId": target_securities}]
3380            mod_input["setTargetSecurities"] = None
3381        elif target_securities is None:
3382            pass
3383        else:
3384            raise TypeError(
3385                "Expected value of type Union[Dict[GbiIdSecurity, str], str] "
3386                f"for argument 'target_securities'; got {type(target_securities)}"
3387            )
3388
3389        resp = requests.post(
3390            f"{self.base_uri}/api/graphql",
3391            json={"query": mod_qry, "variables": {"input": mod_input}},
3392            headers={"Authorization": "ApiKey " + self.api_key},
3393            params=self._request_params,
3394        )
3395
3396        json_resp = resp.json()
3397        if (resp.ok and "errors" in json_resp) or not resp.ok:
3398            error_msg = self._try_extract_error_code(resp)
3399            logger.error(error_msg)
3400            raise BoostedAPIException(
3401                (
3402                    f"Failed to modify hedge experiment in preparation for start {experiment_id=}: "
3403                    f"{resp.status_code=}; {error_msg=}"
3404                )
3405            )
3406
3407        exp_dict = json_resp["data"]["modifyHedgeExperimentDraft"]["hedgeExperiment"]
3408        experiment = HedgeExperiment.from_json_dict(exp_dict)
3409        return experiment
def start_hedge_experiment( self, experiment_id: str, *scenario_ids: str) -> boosted.api.api_type.HedgeExperiment:
3411    def start_hedge_experiment(self, experiment_id: str, *scenario_ids: str) -> HedgeExperiment:
3412        start_qry = """
3413            mutation startHedgeExperiment($input: StartHedgeExperimentInput!) {
3414                startHedgeExperiment(input: $input) {
3415                    hedgeExperiment {
3416                        hedgeExperimentId
3417                        experimentName
3418                        userId
3419                        config
3420                        description
3421                        experimentType
3422                        lastCalculated
3423                        lastModified
3424                        status
3425                        portfolioCalcStatus
3426                        targetSecurities {
3427                            gbiId
3428                            security {
3429                                gbiId
3430                                name
3431                                symbol
3432                            }
3433                            weight
3434                        }
3435                        targetPortfolios {
3436                            portfolioId
3437                        }
3438                        baselineModel {
3439                            id
3440                            name
3441                        }
3442                        baselineScenario {
3443                            hedgeExperimentScenarioId
3444                            scenarioName
3445                            description
3446                            portfolioSettingsJson
3447                            hedgeExperimentPortfolios {
3448                                portfolio {
3449                                    id
3450                                    name
3451                                    modelId
3452                                    performanceGridHeader
3453                                    performanceGrid
3454                                    status
3455                                    tearSheet {
3456                                        groupName
3457                                        members {
3458                                            name
3459                                            value
3460                                        }
3461                                    }
3462                                }
3463                            }
3464                            status
3465                        }
3466                        baselineStockUniverseId
3467                    }
3468                }
3469            }
3470        """
3471        start_input: Dict[str, Any] = {"hedgeExperimentId": experiment_id}
3472        if len(scenario_ids) > 0:
3473            start_input["hedgeExperimentScenarioIds"] = list(scenario_ids)
3474
3475        resp = requests.post(
3476            f"{self.base_uri}/api/graphql",
3477            json={"query": start_qry, "variables": {"input": start_input}},
3478            headers={"Authorization": "ApiKey " + self.api_key},
3479            params=self._request_params,
3480        )
3481
3482        json_resp = resp.json()
3483        if (resp.ok and "errors" in json_resp) or not resp.ok:
3484            error_msg = self._try_extract_error_code(resp)
3485            logger.error(error_msg)
3486            raise BoostedAPIException(
3487                (
3488                    f"Failed to start hedge experiment {experiment_id=}: "
3489                    f"{resp.status_code=}; {error_msg=}"
3490                )
3491            )
3492
3493        exp_dict = json_resp["data"]["startHedgeExperiment"]["hedgeExperiment"]
3494        experiment = HedgeExperiment.from_json_dict(exp_dict)
3495        return experiment
def delete_hedge_experiment(self, experiment_id: str) -> bool:
3497    def delete_hedge_experiment(self, experiment_id: str) -> bool:
3498        delete_qry = """
3499            mutation($input: DeleteHedgeExperimentsInput!) {
3500                deleteHedgeExperiments(input: $input) {
3501                    success
3502                }
3503            }
3504        """
3505        delete_input = {"hedgeExperimentIds": [experiment_id]}
3506        resp = requests.post(
3507            f"{self.base_uri}/api/graphql",
3508            json={"query": delete_qry, "variables": {"input": delete_input}},
3509            headers={"Authorization": "ApiKey " + self.api_key},
3510            params=self._request_params,
3511        )
3512
3513        json_resp = resp.json()
3514        if (resp.ok and "errors" in json_resp) or not resp.ok:
3515            error_msg = self._try_extract_error_code(resp)
3516            logger.error(error_msg)
3517            raise BoostedAPIException(
3518                (
3519                    f"Failed to delete hedge experiment {experiment_id=}: "
3520                    + f"status_code={resp.status_code}; error_msg={error_msg}"
3521                )
3522            )
3523
3524        return json_resp["data"]["deleteHedgeExperiments"]["success"]
def create_hedge_basket_position_bounds_from_csv( self, filepath: str, name: str, description: Union[str, NoneType], mapping_result_filepath: Union[str, NoneType]) -> str:
3526    def create_hedge_basket_position_bounds_from_csv(
3527        self,
3528        filepath: str,
3529        name: str,
3530        description: Optional[str],
3531        mapping_result_filepath: Optional[str],
3532    ) -> str:
3533        DATE = "Date"
3534        ISIN = "ISIN"
3535        COUNTRY = "Country"
3536        CURRENCY = "Currency"
3537        LOWER_BOUND = "Lower Bound"
3538        UPPER_BOUND = "Upper Bound"
3539        supported_columns = {
3540            DATE,
3541            ISIN,
3542            COUNTRY,
3543            CURRENCY,
3544            LOWER_BOUND,
3545            UPPER_BOUND,
3546        }
3547        required_columns = {ISIN, LOWER_BOUND, UPPER_BOUND}
3548
3549        try:
3550            df: pd.DataFrame = pd.read_csv(filepath, parse_dates=True)
3551        except Exception as e:
3552            raise BoostedAPIException(f"Error reading {filepath=}: {e}")
3553
3554        columns = set(df.columns)
3555
3556        # First perform basic data validation
3557        missing_required_columns = required_columns - columns
3558        if missing_required_columns:
3559            raise BoostedAPIException(
3560                f"The following required columns are missing: {missing_required_columns}"
3561            )
3562        extra_columns = columns - supported_columns
3563        if extra_columns:
3564            logger.warning(
3565                f"The following columns are unsupported and will be ignored: {extra_columns}"
3566            )
3567        try:
3568            df[LOWER_BOUND] = df[LOWER_BOUND].astype(float)
3569            df[UPPER_BOUND] = df[UPPER_BOUND].astype(float)
3570            df[ISIN] = df[ISIN].astype(str)
3571        except Exception as e:
3572            raise BoostedAPIException(f"Column datatypes are incorrect: {e}")
3573        lb_gt_ub = df[df[LOWER_BOUND] > df[UPPER_BOUND]]
3574        if not lb_gt_ub.empty:
3575            raise BoostedAPIException(
3576                f"Lower Bound must be <= Upper Bound, but these are not: {lb_gt_ub[ISIN].tolist()}"
3577            )
3578        out_of_range = df[
3579            (
3580                (df[LOWER_BOUND] < 0)
3581                | (df[LOWER_BOUND] > 1)
3582                | (df[UPPER_BOUND] < 0)
3583                | (df[UPPER_BOUND] > 1)
3584            )
3585        ]
3586        if not out_of_range.empty:
3587            raise BoostedAPIException("Lower Bound and Upper Bound values must be in range [0, 1]")
3588
3589        # Now map the security info into GBI IDs
3590        rows = list(df.to_dict(orient="index").values())
3591        sec_data_list = self.getGbiIdFromIdentCountryCurrencyDate(
3592            ident_country_currency_dates=[
3593                DateIdentCountryCurrency(
3594                    date=row.get(DATE, datetime.date.today().isoformat()),
3595                    identifier=row.get(ISIN),
3596                    id_type=ColumnSubRole.ISIN,
3597                    country=row.get(COUNTRY),
3598                    currency=row.get(CURRENCY),
3599                )
3600                for row in rows
3601            ]
3602        )
3603
3604        # Now take each row and its gbi id mapping, and create the bounds list
3605        bounds = []
3606        for row, sec_data in zip(rows, sec_data_list):
3607            if sec_data is None:
3608                logger.warning(f"Failed to map {row[ISIN]}, skipping this security.")
3609            else:
3610                bounds.append(
3611                    {"gbi_id": str(sec_data.gbi_id), "lb": row[LOWER_BOUND], "ub": row[UPPER_BOUND]}
3612                )
3613
3614                # Add security metadata to see the mapping
3615                row["Mapped GBI ID"] = sec_data.gbi_id
3616                row[f"Mapped {ISIN}"] = sec_data.isin_info.identifier
3617                row[f"Mapped {COUNTRY}"] = sec_data.isin_info.country
3618                row[f"Mapped {CURRENCY}"] = sec_data.isin_info.currency
3619                row["Mapped Ticker"] = sec_data.ticker
3620                row["Mapped Company Name"] = sec_data.company_name
3621
3622        # Call endpoint to create the bounds settings template
3623        qry = """
3624              mutation CreatePartialStrategyTemplate(
3625                $portfolioSettingsKey: String!
3626                $partialSettings: String!
3627                $name: String!
3628                $description: String
3629              ) {
3630                createPartialStrategyTemplate(
3631                  portfolioSettingsKey: $portfolioSettingsKey
3632                  partialSettings: $partialSettings
3633                  name: $name
3634                  description: $description
3635                )
3636              }
3637            """
3638        variables = {
3639            "portfolioSettingsKey": "basketTrading.positionSizeBounds",
3640            "partialSettings": json.dumps(bounds),
3641            "name": name,
3642            "description": description,
3643        }
3644        resp = self._get_graphql(qry, variables=variables)
3645
3646        # Write mapped csv for reference
3647        if mapping_result_filepath is not None:
3648            pd.DataFrame(rows).to_csv(mapping_result_filepath)
3649
3650        return resp["data"]["createPartialStrategyTemplate"]
def get_portfolio_accuracy( self, model_id: str, portfolio_id: str, start_date: Union[datetime.date, str, NoneType] = None, end_date: Union[datetime.date, str, NoneType] = None) -> dict:
3652    def get_portfolio_accuracy(
3653        self,
3654        model_id: str,
3655        portfolio_id: str,
3656        start_date: Optional[BoostedDate] = None,
3657        end_date: Optional[BoostedDate] = None,
3658    ) -> dict:
3659        if start_date and end_date:
3660            validate_start_and_end_dates(start_date=start_date, end_date=end_date)
3661            start_date = convert_date(start_date)
3662            end_date = convert_date(end_date)
3663
3664        # TODO: Later change this URI to not use the watchlist prefix. It is misnamed.
3665        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_PA_ROUTE}/get-hit-rate/"
3666        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3667        req_json = {"model_id": model_id, "portfolio_id": portfolio_id}
3668        if start_date and end_date:
3669            req_json["start_date"] = start_date.isoformat()
3670            req_json["end_date"] = end_date.isoformat()
3671        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3672
3673        if not res.ok:
3674            error_msg = self._try_extract_error_code(res)
3675            logger.error(error_msg)
3676            raise BoostedAPIException(f"Failed to get Hit Rate: {error_msg}")
3677
3678        data = res.json()
3679        return data
def create_watchlist(self, name: str) -> str:
3681    def create_watchlist(self, name: str) -> str:
3682        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/create/"
3683        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3684        req_json = {"name": name}
3685        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3686
3687        if not res.ok:
3688            error_msg = self._try_extract_error_code(res)
3689            logger.error(error_msg)
3690            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
3691
3692        data = res.json()
3693        return data["watchlist_id"]
def get_coverage_info( self, watchlist_id: str, portfolio_group_id: str) -> pandas.core.frame.DataFrame:
3822    def get_coverage_info(self, watchlist_id: str, portfolio_group_id: str) -> pd.DataFrame:
3823        # get securities list in watchlist
3824        watchlist_details = self.get_watchlist_details(watchlist_id)
3825        security_list = watchlist_details["targets"]
3826
3827        gbi_ids = [x["gbi_id"] for x in security_list]
3828
3829        gbi_data: Dict[Any, Dict] = {x: {} for x in gbi_ids}
3830
3831        # get security info ticker, name, industry etc
3832        sec_info = self._get_security_info(gbi_ids)
3833
3834        for sec in sec_info["data"]["securities"]:
3835            gbi_id = sec["gbiId"]
3836            for k in ["symbol", "name", "isin", "country", "currency"]:
3837                gbi_data[gbi_id][self._coverage_column_name_format(k)] = sec[k]
3838
3839            gbi_data[gbi_id][self._coverage_column_name_format("Sector")] = sec["sector"][
3840                "topParentName"
3841            ]
3842
3843        # get portfolios list in portfolio_Group
3844        portfolio_group = self.get_portfolio_group(portfolio_group_id)
3845        portfolio_ids = [x["portfolio_id"] for x in portfolio_group["portfolios"]]
3846        portfolio_info = {x["portfolio_id"]: x for x in portfolio_group["portfolios"]}
3847
3848        model_resp = self._get_models_for_portfolio(portfolio_ids=portfolio_ids)
3849        for portfolio in model_resp["data"]["portfolios"]:
3850            portfolio_info[portfolio["id"]].update(portfolio)
3851
3852        model_info = {
3853            x["modelId"]: portfolio_info[x["id"]] for x in model_resp["data"]["portfolios"]
3854        }
3855
3856        # model_ids and portfolio_ids are parallel arrays
3857        model_ids = [portfolio_info[x]["modelId"] for x in portfolio_ids]
3858
3859        # graphql: get watchlist analysis
3860        wl_analysis = self._get_watchlist_analysis(
3861            gbi_ids=gbi_ids,
3862            model_ids=model_ids,
3863            portfolio_ids=portfolio_ids,
3864            asof_date=datetime.date.today(),
3865        )
3866
3867        portfolio_gbi_data: Dict[Any, Dict] = {k: {} for k in portfolio_ids}
3868        for pi, v in portfolio_gbi_data.items():
3869            v.update({k: {} for k in gbi_data.keys()})
3870
3871        equity_explorer_date = wl_analysis["data"]["watchlistAnalysis"][0]["analysisDates"][0][
3872            "date"
3873        ]
3874        for wla in wl_analysis["data"]["watchlistAnalysis"]:
3875            gbi_id = wla["gbiId"]
3876            gbi_data[gbi_id]["Composite Rating"] = wla["analysisDates"][0]["aggregateSignal"][
3877                "rating"
3878            ]
3879            gbi_data[gbi_id]["Composite Rating Delta"] = wla["analysisDates"][0]["aggregateSignal"][
3880                "ratingDelta"
3881            ]
3882
3883            for p in wla["analysisDates"][0]["portfoliosSignals"]:
3884                model_name = portfolio_info[p["portfolioId"]]["modelName"]
3885
3886                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3887                    model_name + self._coverage_column_name_format(": rank")
3888                ] = (p["rank"] + 1)
3889                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3890                    model_name + self._coverage_column_name_format(": rank delta")
3891                ] = (-1 * p["signalDelta"])
3892                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3893                    model_name + self._coverage_column_name_format(": rating")
3894                ] = p["rating"]
3895                portfolio_gbi_data[p["portfolioId"]][gbi_id][
3896                    model_name + self._coverage_column_name_format(": rating delta")
3897                ] = p["ratingDelta"]
3898
3899        neg_rec: Dict[Any, Dict] = {k: {} for k in gbi_data.keys()}
3900        pos_rec: Dict[Any, Dict] = {k: {} for k in gbi_data.keys()}
3901        for wla in wl_analysis["data"]["watchlistAnalysis"]:
3902            gbi_id = wla["gbiId"]
3903
3904            for pid, signals in zip(portfolio_ids, wla["analysisDates"][0]["portfoliosSignals"]):
3905                model_name = portfolio_info[pid]["modelName"]
3906                neg_rec[gbi_id][
3907                    model_name + self._coverage_column_name_format(": negative recommendation")
3908                ] = signals["explainWeightNeg"]
3909                pos_rec[gbi_id][
3910                    model_name + self._coverage_column_name_format(": positive recommendation")
3911                ] = signals["explainWeightPos"]
3912
3913        # graphql: GetExcessReturn - slugging pct
3914        er_sp = self._get_excess_return(
3915            model_ids=model_ids, gbi_ids=gbi_ids, asof_date=equity_explorer_date
3916        )
3917
3918        for model in er_sp["data"]["models"]:
3919            model_name = model_info[model["id"]]["modelName"]
3920            for stat in model["equityExplorerData"]["equityExplorerSummaryStatistics"]:
3921                portfolioId = model_info[model["id"]]["id"]
3922                portfolio_gbi_data[portfolioId][int(stat["gbiId"])][
3923                    model_name + self._coverage_column_name_format(": slugging %")
3924                ] = (stat["ER"]["SP"]["sixMonthWindowOneMonthHorizon"] * 100)
3925
3926        # add rank, rating, slugging
3927        for pid, v in portfolio_gbi_data.items():
3928            for gbi_id, vv in v.items():
3929                gbi_data[gbi_id].update(vv)
3930
3931        # add neg/pos rec scores
3932        for rec in [neg_rec, pos_rec]:
3933            for k, v in rec.items():
3934                gbi_data[k].update(v)
3935
3936        df = pd.DataFrame.from_records([v for _, v in gbi_data.items()])
3937
3938        return df
def get_coverage_csv( self, watchlist_id: str, portfolio_group_id: str, filepath: Union[str, NoneType] = None) -> Union[str, NoneType]:
3940    def get_coverage_csv(
3941        self, watchlist_id: str, portfolio_group_id: str, filepath: Optional[str] = None
3942    ) -> Optional[str]:
3943        """
3944        Converts the coverage contents to CSV format
3945        Parameters
3946        ----------
3947        watchlist_id: str
3948            UUID str identifying the coverage watchlist
3949        portfolio_group_id: str
3950            UUID str identifying the group of portfolio to use for analysis
3951        filepath: Optional[str]
3952            UUID str identifying the group of portfolio to use for analysis
3953
3954        Returns:
3955        ----------
3956        None if filepath is provided, else a string with a csv's contents is returned
3957        """
3958
3959        df = self.get_coverage_info(watchlist_id, portfolio_group_id)
3960
3961        return df.to_csv(filepath, index=False, float_format="%.4f")

Converts the coverage contents to CSV format

Parameters

watchlist_id: str UUID str identifying the coverage watchlist portfolio_group_id: str UUID str identifying the group of portfolio to use for analysis filepath: Optional[str] UUID str identifying the group of portfolio to use for analysis

Returns:

None if filepath is provided, else a string with a csv's contents is returned

def get_watchlist_details(self, watchlist_id: str) -> Dict:
3963    def get_watchlist_details(self, watchlist_id: str) -> Dict:
3964        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/details/"
3965        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
3966        req_json = {"watchlist_id": watchlist_id}
3967        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
3968
3969        if not res.ok:
3970            error_msg = self._try_extract_error_code(res)
3971            logger.error(error_msg)
3972            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
3973
3974        data = res.json()
3975        return data
def create_watchlist_from_file(self, name: str, filepath: str) -> str:
3977    def create_watchlist_from_file(self, name: str, filepath: str) -> str:
3978        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/create_watchlist_from_file/"
3979        headers = {"Authorization": "ApiKey " + self.api_key}
3980
3981        with open(filepath, "rb") as fp:
3982            file_bytes = fp.read()
3983
3984        file_bytes_base64 = base64.b64encode(file_bytes).decode("ascii")
3985        json_req = {
3986            "content_type": mimetypes.guess_type(filepath)[0],
3987            "file_bytes_base64": file_bytes_base64,
3988            "name": name,
3989        }
3990
3991        res = requests.post(url, json=json_req, headers=headers)
3992
3993        if not res.ok:
3994            error_msg = self._try_extract_error_code(res)
3995            logger.error(error_msg)
3996            raise BoostedAPIException(f"Failed to create watchlist from file: {error_msg}")
3997
3998        data = res.json()
3999        return data["watchlist_id"]
def get_watchlists(self) -> List[Dict]:
4001    def get_watchlists(self) -> List[Dict]:
4002        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/get_user_watchlists/"
4003        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4004        req_json: Dict = {}
4005        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4006
4007        if not res.ok:
4008            error_msg = self._try_extract_error_code(res)
4009            logger.error(error_msg)
4010            raise BoostedAPIException(f"Failed to get user watchlists: {error_msg}")
4011
4012        data = res.json()
4013        return data["watchlists"]
def get_watchlist_contents(self, watchlist_id) -> Dict:
4015    def get_watchlist_contents(self, watchlist_id) -> Dict:
4016        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/contents/"
4017        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4018        req_json = {"watchlist_id": watchlist_id}
4019        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4020
4021        if not res.ok:
4022            error_msg = self._try_extract_error_code(res)
4023            logger.error(error_msg)
4024            raise BoostedAPIException(f"Failed to get watchlist contents: {error_msg}")
4025
4026        data = res.json()
4027        return data
def get_watchlist_contents_as_csv(self, watchlist_id, filepath) -> None:
4029    def get_watchlist_contents_as_csv(self, watchlist_id, filepath) -> None:
4030        data = self.get_watchlist_contents(watchlist_id)
4031        df = pd.DataFrame(data["contents"])
4032        df.to_csv(filepath, index=False)
def add_securities_to_watchlist( self, watchlist_id: str, identifiers: List[str], identifier_type: Literal['TICKER', 'ISIN']) -> Dict:
4035    def add_securities_to_watchlist(
4036        self, watchlist_id: str, identifiers: List[str], identifier_type: Literal["TICKER", "ISIN"]
4037    ) -> Dict:
4038        # should we just make the arg lower? all caps has a flag-like feel to it
4039        id_type = identifier_type.lower()
4040        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/add_{id_type}s/"
4041        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4042        req_json = {"watchlist_id": watchlist_id, id_type: identifiers}
4043        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4044
4045        if not res.ok:
4046            error_msg = self._try_extract_error_code(res)
4047            logger.error(error_msg)
4048            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
4049
4050        data = res.json()
4051        return data
def remove_securities_from_watchlist( self, watchlist_id: str, identifiers: List[str], identifier_type: Literal['TICKER', 'ISIN']) -> Dict:
4053    def remove_securities_from_watchlist(
4054        self, watchlist_id: str, identifiers: List[str], identifier_type: Literal["TICKER", "ISIN"]
4055    ) -> Dict:
4056        # should we just make the arg lower? all caps has a flag-like feel to it
4057        id_type = identifier_type.lower()
4058        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/remove_{id_type}s/"
4059        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4060        req_json = {"watchlist_id": watchlist_id, id_type: identifiers}
4061        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4062
4063        if not res.ok:
4064            error_msg = self._try_extract_error_code(res)
4065            logger.error(error_msg)
4066            raise BoostedAPIException(f"Failed to get user models: {error_msg}")
4067
4068        data = res.json()
4069        return data
def get_portfolio_groups(self) -> Dict:
4071    def get_portfolio_groups(
4072        self,
4073    ) -> Dict:
4074        """
4075        Parameters: None
4076
4077
4078        Returns:
4079        ----------
4080
4081        Dict:  {
4082        user_id: str
4083        portfolio_groups: List[PortfolioGroup]
4084        }
4085        where PortfolioGroup is defined as = Dict {
4086        group_id: str
4087        group_name: str
4088        portfolios: List[PortfolioInGroup]
4089        }
4090        where PortfolioInGroup is defined as = Dict {
4091        portfolio_id: str
4092        rank_in_group: Optional[int]
4093        }
4094        """
4095        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get"
4096        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4097        req_json: Dict = {}
4098        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4099
4100        if not res.ok:
4101            error_msg = self._try_extract_error_code(res)
4102            logger.error(error_msg)
4103            raise BoostedAPIException(f"Failed to get user portfolio groups: {error_msg}")
4104
4105        data = res.json()
4106        return data

Parameters: None

Returns:

Dict: { user_id: str portfolio_groups: List[PortfolioGroup] } where PortfolioGroup is defined as = Dict { group_id: str group_name: str portfolios: List[PortfolioInGroup] } where PortfolioInGroup is defined as = Dict { portfolio_id: str rank_in_group: Optional[int] }

def get_portfolio_group(self, portfolio_group_id: str) -> Dict:
4108    def get_portfolio_group(self, portfolio_group_id: str) -> Dict:
4109        """
4110        Parameters:
4111        portfolio_group_id: str
4112           UUID identifier for the portfolio group
4113
4114
4115        Returns:
4116        ----------
4117
4118        PortfolioGroup: Dict:  {
4119        group_id: str
4120        group_name: str
4121        portfolios: List[PortfolioInGroup]
4122        }
4123        where PortfolioInGroup is defined as = Dict {
4124        portfolio_id: str
4125        portfolio_name: str
4126        rank_in_group: Optional[int]
4127        }
4128        """
4129        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get-one"
4130        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4131        req_json = {"portfolio_group_id": portfolio_group_id}
4132        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4133
4134        if not res.ok:
4135            error_msg = self._try_extract_error_code(res)
4136            logger.error(error_msg)
4137            raise BoostedAPIException(f"Failed to get user portfolio groups: {error_msg}")
4138
4139        data = res.json()
4140        return data

Parameters: portfolio_group_id: str UUID identifier for the portfolio group

Returns:

PortfolioGroup: Dict: { group_id: str group_name: str portfolios: List[PortfolioInGroup] } where PortfolioInGroup is defined as = Dict { portfolio_id: str portfolio_name: str rank_in_group: Optional[int] }

def set_sticky_portfolio_group(self, portfolio_group_id: str) -> Dict:
4142    def set_sticky_portfolio_group(
4143        self,
4144        portfolio_group_id: str,
4145    ) -> Dict:
4146        """
4147        Set sticky portfolio group
4148
4149        Parameters
4150        ----------
4151
4152        group_id: str,
4153           UUID str identifying a portfolio group
4154
4155        Returns:
4156        -------
4157        Dict {
4158            changed: int - 1 == success
4159        }
4160        """
4161        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/set-sticky"
4162        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4163        req_json = {"portfolio_group_id": portfolio_group_id}
4164        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4165
4166        if not res.ok:
4167            error_msg = self._try_extract_error_code(res)
4168            logger.error(error_msg)
4169            raise BoostedAPIException(f"Failed to set sticky portfolio group: {error_msg}")
4170
4171        data = res.json()
4172        return data

Set sticky portfolio group

Parameters

group_id: str, UUID str identifying a portfolio group

Returns:

Dict { changed: int - 1 == success }

def get_sticky_portfolio_group(self) -> Dict:
4174    def get_sticky_portfolio_group(
4175        self,
4176    ) -> Dict:
4177        """
4178        Get sticky portfolio group for the user
4179
4180        Parameters
4181        ----------
4182
4183        Returns:
4184        -------
4185        Dict {
4186            group_id: str
4187            group_name: str
4188            portfolios: List[PortfolioInGroup(Dict)]
4189                  PortfolioInGroup(Dict):
4190                           portfolio_id: str
4191                           rank_in_group: Optional[int] = None
4192                           portfolio_name: Optional[str] = None
4193        }
4194        """
4195        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/get-sticky"
4196        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4197        req_json: Dict = {}
4198        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4199
4200        if not res.ok:
4201            error_msg = self._try_extract_error_code(res)
4202            logger.error(error_msg)
4203            raise BoostedAPIException(f"Failed to get sticky portfolio group: {error_msg}")
4204
4205        data = res.json()
4206        return data

Get sticky portfolio group for the user

Parameters

Returns:

Dict { group_id: str group_name: str portfolios: List[PortfolioInGroup(Dict)] PortfolioInGroup(Dict): portfolio_id: str rank_in_group: Optional[int] = None portfolio_name: Optional[str] = None }

def create_portfolio_group( self, group_name: str, portfolios: Union[List[Dict], NoneType] = None) -> Dict:
4208    def create_portfolio_group(
4209        self,
4210        group_name: str,
4211        portfolios: Optional[List[Dict]] = None,
4212    ) -> Dict:
4213        """
4214        Create a new portfolio group
4215
4216        Parameters
4217        ----------
4218
4219        group_name: str
4220           name of the new group
4221
4222        portfolios: List of Dict [:
4223
4224        portfolio_id: str
4225        rank_in_group: Optional[int] = None
4226        ]
4227
4228        Returns:
4229        ----------
4230
4231        Dict: {
4232        group_id: str
4233           UUID identifier for the portfolio group
4234
4235        created: int
4236           num groups created, 1 == success
4237
4238        added: int
4239           num portfolios added to the group, should match the length of 'portfolios' argument
4240        }
4241        """
4242        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/create"
4243        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4244        req_json = {"group_name": group_name, "portfolios": portfolios}
4245
4246        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4247
4248        if not res.ok:
4249            error_msg = self._try_extract_error_code(res)
4250            logger.error(error_msg)
4251            raise BoostedAPIException(f"Failed to create portfolio group: {error_msg}")
4252
4253        data = res.json()
4254        return data

Create a new portfolio group

Parameters

group_name: str name of the new group

portfolios: List of Dict [:

portfolio_id: str rank_in_group: Optional[int] = None ]

Returns:

Dict: { group_id: str UUID identifier for the portfolio group

created: int num groups created, 1 == success

added: int num portfolios added to the group, should match the length of 'portfolios' argument }

def rename_portfolio_group(self, group_id: str, group_name: str) -> Dict:
4256    def rename_portfolio_group(
4257        self,
4258        group_id: str,
4259        group_name: str,
4260    ) -> Dict:
4261        """
4262        Rename a portfolio group
4263
4264        Parameters
4265        ----------
4266
4267        group_id: str,
4268           UUID str identifying a portfolio group
4269
4270        group_name: str,
4271           The new name for the porfolio
4272
4273        Returns:
4274        -------
4275        Dict {
4276            changed: int - 1 == success
4277        }
4278        """
4279        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/rename"
4280        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4281        req_json = {"group_id": group_id, "group_name": group_name}
4282        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4283
4284        if not res.ok:
4285            error_msg = self._try_extract_error_code(res)
4286            logger.error(error_msg)
4287            raise BoostedAPIException(f"Failed to rename portfolio group: {error_msg}")
4288
4289        data = res.json()
4290        return data

Rename a portfolio group

Parameters

group_id: str, UUID str identifying a portfolio group

group_name: str, The new name for the porfolio

Returns:

Dict { changed: int - 1 == success }

def add_to_portfolio_group(self, group_id: str, portfolios: List[Dict]) -> Dict:
4292    def add_to_portfolio_group(
4293        self,
4294        group_id: str,
4295        portfolios: List[Dict],
4296    ) -> Dict:
4297        """
4298        Add portfolios to a group
4299
4300        Parameters
4301        ----------
4302
4303        group_id: str,
4304           UUID str identifying a portfolio group
4305
4306        portfolios: List of Dict [:
4307            portfolio_id: str
4308            rank_in_group: Optional[int] = None
4309        ]
4310
4311
4312        Returns:
4313        -------
4314        Dict {
4315            added: int
4316               number of successful changes
4317        }
4318        """
4319        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/add-to-group"
4320        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4321        req_json = {"group_id": group_id, "portfolios": portfolios}
4322
4323        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4324
4325        if not res.ok:
4326            error_msg = self._try_extract_error_code(res)
4327            logger.error(error_msg)
4328            raise BoostedAPIException(f"Failed to add portfolios to portfolio group: {error_msg}")
4329
4330        data = res.json()
4331        return data

Add portfolios to a group

Parameters

group_id: str, UUID str identifying a portfolio group

portfolios: List of Dict [: portfolio_id: str rank_in_group: Optional[int] = None ]

Returns:

Dict { added: int number of successful changes }

def remove_from_portfolio_group(self, group_id: str, portfolios: List[str]) -> Dict:
4333    def remove_from_portfolio_group(
4334        self,
4335        group_id: str,
4336        portfolios: List[str],
4337    ) -> Dict:
4338        """
4339        Remove portfolios from a group
4340
4341        Parameters
4342        ----------
4343
4344        group_id: str,
4345           UUID str identifying a portfolio group
4346
4347        portfolios: List of str
4348
4349
4350        Returns:
4351        -------
4352        Dict {
4353            removed: int
4354               number of successful changes
4355        }
4356        """
4357        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/remove-from-group"
4358        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4359        req_json = {"group_id": group_id, "portfolios": portfolios}
4360        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4361
4362        if not res.ok:
4363            error_msg = self._try_extract_error_code(res)
4364            logger.error(error_msg)
4365            raise BoostedAPIException(
4366                f"Failed to remove portfolios from portfolio group: {error_msg}"
4367            )
4368
4369        data = res.json()
4370        return data

Remove portfolios from a group

Parameters

group_id: str, UUID str identifying a portfolio group

portfolios: List of str

Returns:

Dict { removed: int number of successful changes }

def delete_portfolio_group(self, group_id: str) -> Dict:
4372    def delete_portfolio_group(
4373        self,
4374        group_id: str,
4375    ) -> Dict:
4376        """
4377        Delete a portfolio group
4378
4379        Parameters
4380        ----------
4381
4382        group_id: str,
4383           UUID str identifying a portfolio group
4384
4385
4386        Returns:
4387        -------
4388        Dict {
4389            removed_groups: int
4390               number of successful changes
4391
4392            removed_portfolios: int
4393               number of successful changes
4394        }
4395        """
4396        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{PORTFOLIO_GROUP_ROUTE}/remove"
4397        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4398        req_json = {"group_id": group_id}
4399        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4400
4401        if not res.ok:
4402            error_msg = self._try_extract_error_code(res)
4403            logger.error(error_msg)
4404            raise BoostedAPIException(f"Failed to delete portfolio group: {error_msg}")
4405
4406        data = res.json()
4407        return data

Delete a portfolio group

Parameters

group_id: str, UUID str identifying a portfolio group

Returns:

Dict { removed_groups: int number of successful changes

removed_portfolios: int
   number of successful changes

}

def set_portfolio_group_for_watchlist(self, portfolio_group_id: str, watchlist_id: str) -> Dict:
4409    def set_portfolio_group_for_watchlist(
4410        self,
4411        portfolio_group_id: str,
4412        watchlist_id: str,
4413    ) -> Dict:
4414        """
4415        Set portfolio group for watchlist.
4416
4417        Parameters
4418        ----------
4419
4420        portfolio_group_id: str,
4421           UUID str identifying a portfolio group
4422
4423        watchlist_id: str,
4424           UUID str identifying a watchlist
4425
4426
4427        Returns:
4428        -------
4429        Dict {
4430            success: bool
4431            errors:
4432            data: Dict
4433                changed: int
4434        }
4435        """
4436        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_WATCHLIST_ROUTE}/set-portfolio-groups/"
4437        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4438        req_json = {"portfolio_group_id": portfolio_group_id, "watchlist_id": watchlist_id}
4439        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
4440
4441        if not res.ok:
4442            error_msg = self._try_extract_error_code(res)
4443            logger.error(error_msg)
4444            raise BoostedAPIException(f"Failed to set portfolio group for watchlist: {error_msg}")
4445
4446        return res.json()

Set portfolio group for watchlist.

Parameters

portfolio_group_id: str, UUID str identifying a portfolio group

watchlist_id: str, UUID str identifying a watchlist

Returns:

Dict { success: bool errors: data: Dict changed: int }

def get_ranking_dates(self, model_id: str, portfolio_id: str) -> List[datetime.date]:
4448    def get_ranking_dates(self, model_id: str, portfolio_id: str) -> List[datetime.date]:
4449        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4450        url = self.base_uri + f"/api/analysis/ranking-dates/{model_id}/{portfolio_id}"
4451        res = requests.get(url, headers=headers, **self._request_params)
4452        self._check_ok_or_err_with_msg(res, "Failed to get ranking dates")
4453        data = res.json().get("ranking_dates", [])
4454
4455        return [parser.parse(d).date() for d in data]
def get_prior_ranking_date( self, ranking_dates: List[datetime.date], starting_date: datetime.date) -> datetime.date:
4457    def get_prior_ranking_date(
4458        self, ranking_dates: List[datetime.date], starting_date: datetime.date
4459    ) -> datetime.date:
4460        """
4461        Given a starting date and a list of ranking dates, return the most
4462        recent previous ranking date.
4463        """
4464        # order from most recent to least
4465        ranking_dates.sort(reverse=True)
4466
4467        for d in ranking_dates:
4468            if d <= starting_date:
4469                return d
4470
4471        # if we get here, the starting date is before the earliest ranking date
4472        raise BoostedAPIException(f"No rankins exist on or before {starting_date}")

Given a starting date and a list of ranking dates, return the most recent previous ranking date.

def get_risk_groups( self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False) -> List[Dict[str, Any]]:
4489    def get_risk_groups(
4490        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4491    ) -> List[Dict[str, Any]]:
4492        # first get the group descriptors
4493        descriptors = self._get_risk_factors_descriptors(model_id, portfolio_id, use_v2)
4494
4495        # calculate the most recent prior rankings date. This is the date
4496        # we need to use to query for risk group data.
4497        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4498        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4499        date_str = ranking_date.strftime("%Y-%m-%d")
4500
4501        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4502
4503        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4504        url = self.base_uri + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-groups/{date_str}"
4505        res = requests.get(url, headers=headers, **self._request_params)
4506
4507        self._check_ok_or_err_with_msg(
4508            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4509        )
4510
4511        # Response is a list of objects like:
4512        # [
4513        #   [
4514        #     0,
4515        #     14,
4516        #     1
4517        #   ],
4518        #   [
4519        #     25,
4520        #     12,
4521        #     13
4522        #   ],
4523        # 0.67013
4524        # ],
4525        #
4526        # Where each integer in the lists is a descriptor id.
4527
4528        groups = []
4529        for i, row in enumerate(res.json()):
4530            row_map: Dict[str, Any] = {}
4531            # map descriptor id to name
4532            row_map["machine"] = i + 1  # start at 1 not 0
4533            row_map["risk_group_a"] = [descriptors[i] for i in row[0]]
4534            row_map["risk_group_b"] = [descriptors[i] for i in row[1]]
4535            row_map["volatility_explained"] = row[2]
4536            groups.append(row_map)
4537
4538        return groups
def get_risk_factors_discovered_descriptors( self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False) -> pandas.core.frame.DataFrame:
4540    def get_risk_factors_discovered_descriptors(
4541        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4542    ) -> pd.DataFrame:
4543        # first get the group descriptors
4544        descriptors = self._get_risk_factors_descriptors(model_id, portfolio_id)
4545
4546        # calculate the most recent prior rankings date. This is the date
4547        # we need to use to query for risk group data.
4548        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4549        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4550        date_str = ranking_date.strftime("%Y-%m-%d")
4551
4552        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4553
4554        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4555        url = (
4556            self.base_uri
4557            + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-descriptors/json/{date_str}"
4558        )
4559        res = requests.get(url, headers=headers, **self._request_params)
4560
4561        self._check_ok_or_err_with_msg(
4562            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4563        )
4564
4565        # Endpoint returns a nested list of floats
4566        df = pd.DataFrame(res.json(), columns=RISK_FACTOR_COLUMNS)
4567
4568        # This flat dataframe represents a potentially doubly nested structure
4569        # of Sector -> (high/low volatility) -> security. We don't care about
4570        # the high/low volatility rows, (which will have negative identifiers)
4571        # so we can filter these out.
4572        df = df[df["identifier"] >= 0]
4573
4574        # now, any values that had a depth of 2 should be set to a depth of 1,
4575        # since we removed the double nesting.
4576        df.replace(to_replace=2, value=1, inplace=True)
4577
4578        # This dataframe represents data that is nested on the UI, so the
4579        # "depth" field indicates which level of nesting each row is at. At this
4580        # point, a depth of 0 indicates a sector, and following depth 1 rows are
4581        # securities within the sector.
4582
4583        # Identifiers in rows with depth 1 will be gbi ids, need to convert to
4584        # symbols.
4585        gbi_ids = df[df["depth"] == 1]["identifier"].tolist()
4586        sec_info = self._get_security_info(gbi_ids)["data"]["securities"]
4587        sec_map = {s["gbiId"]: s["symbol"] for s in sec_info}
4588
4589        def convert_ids(row: pd.Series) -> pd.Series:
4590            # convert each row's "identifier" to the appropriate id type. If the
4591            # depth is 0, the identifier should be a sector, otherwise it should
4592            # be a ticker.
4593            ident = int(row["identifier"])
4594            row["identifier"] = (
4595                descriptors.get(ident).title() if row["depth"] == 0 else sec_map.get(ident)
4596            )
4597            return row
4598
4599        df["depth"] = df["depth"].astype(int)
4600        df["stock_count"] = df["stock_count"].astype(int)
4601        df = df.apply(convert_ids, axis=1)
4602        df = df.reset_index(drop=True)
4603        return df
def get_risk_factors_sectors( self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False) -> pandas.core.frame.DataFrame:
4605    def get_risk_factors_sectors(
4606        self, model_id: str, portfolio_id: str, date: datetime.date, use_v2: bool = False
4607    ) -> pd.DataFrame:
4608        # first get the group descriptors
4609        sectors = {s["id"]: s["name"] for s in self._get_sector_info()}
4610
4611        # calculate the most recent prior rankings date. This is the date
4612        # we need to use to query for risk group data.
4613        ranking_dates = self.get_ranking_dates(model_id, portfolio_id)
4614        ranking_date = self.get_prior_ranking_date(ranking_dates, date)
4615        date_str = ranking_date.strftime("%Y-%m-%d")
4616
4617        risk_factor = RISK_FACTOR_V2 if use_v2 else RISK_FACTOR
4618
4619        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4620        url = (
4621            self.base_uri
4622            + f"/api/{risk_factor}/{model_id}/{portfolio_id}/risk-sectors/json/{date_str}"
4623        )
4624        res = requests.get(url, headers=headers, **self._request_params)
4625
4626        self._check_ok_or_err_with_msg(
4627            res, f"Failed to get risk factors for {model_id=}, {portfolio_id=}, {date=}"
4628        )
4629
4630        # Endpoint returns a nested list of floats
4631        df = pd.DataFrame(res.json(), columns=RISK_FACTOR_COLUMNS)
4632
4633        # identifier is a gics sector identifier
4634        df["identifier"] = df["identifier"].apply(lambda i: sectors.get(int(i), None))
4635
4636        # This dataframe represents data that is nested on the UI, so the
4637        # "depth" field indicates which level of nesting each row is at. For
4638        # risk factors sectors, each "depth" represents a level of specificity
4639        # for the sector. E.g. Energy -> Energy Equipment -> Oil & Gas Equipment
4640        df["depth"] = df["depth"].astype(int)
4641        df["stock_count"] = df["stock_count"].astype(int)
4642        df = df.reset_index(drop=True)
4643        return df
def download_complete_portfolio_data(self, model_id: str, portfolio_id: str, download_filepath: str):
4645    def download_complete_portfolio_data(
4646        self, model_id: str, portfolio_id: str, download_filepath: str
4647    ):
4648        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4649        url = self.base_uri + f"/api/models/{model_id}/{portfolio_id}/excel"
4650
4651        res = requests.get(url, headers=headers, **self._request_params)
4652        self._check_ok_or_err_with_msg(
4653            res, f"Failed to get full data for {model_id=}, {portfolio_id=}"
4654        )
4655
4656        with open(download_filepath, "wb") as f:
4657            f.write(res.content)
def diff_hedge_experiment_portfolio_data( self, hedge_experiment_id: str, comparison_portfolios: List[str], categories: List[str]) -> Dict:
4659    def diff_hedge_experiment_portfolio_data(
4660        self,
4661        hedge_experiment_id: str,
4662        comparison_portfolios: List[str],
4663        categories: List[str],
4664    ) -> Dict:
4665        qry = """
4666        query diffHedgeExperimentPortfolios(
4667            $input: DiffHedgeExperimentPortfoliosInput!
4668        ) {
4669            diffHedgeExperimentPortfolios(input: $input) {
4670            data {
4671                diffs {
4672                    volatility {
4673                        date
4674                        vol5D
4675                        vol10D
4676                        vol21D
4677                        vol21D
4678                        vol63D
4679                        vol126D
4680                        vol189D
4681                        vol252D
4682                        vol315D
4683                        vol378D
4684                        vol441D
4685                        vol504D
4686                    }
4687                    performance {
4688                        date
4689                        value
4690                    }
4691                    performanceGrid {
4692                        headerRow
4693                        values
4694                    }
4695                    factors {
4696                        date
4697                        momentum
4698                        growth
4699                        size
4700                        value
4701                        dividendYield
4702                        volatility
4703                    }
4704                }
4705            }
4706            errors
4707            }
4708        }
4709        """
4710        headers = {"Authorization": "ApiKey " + self.api_key}
4711        params = {
4712            "hedgeExperimentId": hedge_experiment_id,
4713            "portfolioIds": comparison_portfolios,
4714            "categories": categories,
4715        }
4716        resp = requests.post(
4717            f"{self.base_uri}/api/graphql",
4718            json={"query": qry, "variables": params},
4719            headers=headers,
4720            params=self._request_params,
4721        )
4722
4723        json_resp = resp.json()
4724
4725        # graphql endpoints typically return 200 or 400 status codes, so we must
4726        # check if we have any errors, even with a 200
4727        if (resp.ok and "errors" in json_resp) or not resp.ok:
4728            error_msg = self._try_extract_error_code(resp)
4729            logger.error(error_msg)
4730            raise BoostedAPIException(
4731                (
4732                    f"Failed to get portfolio diffs for {hedge_experiment_id=}: "
4733                    f"{resp.status_code=}; {error_msg=}"
4734                )
4735            )
4736
4737        diffs = json_resp["data"]["diffHedgeExperimentPortfolios"]["data"]["diffs"]
4738        comparisons = {}
4739        for pf, cmp in zip(comparison_portfolios, diffs):
4740            res: Dict[str, Any] = {
4741                "performance": None,
4742                "performanceGrid": None,
4743                "factors": None,
4744                "volatility": None,
4745            }
4746            if "performanceGrid" in cmp:
4747                grid = cmp["performanceGrid"]
4748                grid_df = pd.DataFrame(grid["values"], columns=grid["headerRow"])
4749                res["performanceGrid"] = grid_df
4750            if "performance" in cmp:
4751                perf_df = pd.DataFrame(cmp["performance"]).set_index("date")
4752                perf_df.index = pd.to_datetime(perf_df.index)
4753                res["performance"] = perf_df
4754            if "volatility" in cmp:
4755                vol_df = pd.DataFrame(cmp["volatility"]).set_index("date")
4756                vol_df.index = pd.to_datetime(vol_df.index)
4757                res["volatility"] = vol_df
4758            if "factors" in cmp:
4759                factors_df = pd.DataFrame(cmp["factors"]).set_index("date")
4760                factors_df.index = pd.to_datetime(factors_df.index)
4761                res["factors"] = factors_df
4762            comparisons[pf] = res
4763        return comparisons
def get_signal_strength(self, model_id: str, portfolio_id: str) -> pandas.core.frame.DataFrame:
4765    def get_signal_strength(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
4766        url = self.base_uri + f"/api/analysis/signal_strength/{model_id}/{portfolio_id}"
4767        headers = {"Authorization": "ApiKey " + self.api_key}
4768
4769        logger.info(f"Retrieving portfolio signals for {model_id=}, {portfolio_id=}")
4770
4771        # Response format is a json object with a "header_row" key for column
4772        # names, and then a nested list of data.
4773        resp = requests.get(url, headers=headers, **self._request_params)
4774        self._check_ok_or_err_with_msg(
4775            resp, f"Failed to get portfolio signals for {model_id=}, {portfolio_id=}"
4776        )
4777
4778        data = resp.json()
4779
4780        df = pd.DataFrame(data=data["data"], columns=data["header_row"])
4781        df["Date"] = pd.to_datetime(df["Date"])
4782        df = df.set_index("Date")
4783        return df.astype(float)
def get_rolling_signal_strength(self, model_id: str, portfolio_id: str) -> pandas.core.frame.DataFrame:
4785    def get_rolling_signal_strength(self, model_id: str, portfolio_id: str) -> pd.DataFrame:
4786        url = self.base_uri + f"/api/analysis/signal_strength_rolling/{model_id}/{portfolio_id}"
4787        headers = {"Authorization": "ApiKey " + self.api_key}
4788
4789        logger.info(f"Retrieving rolling portfolio signals for {model_id=}, {portfolio_id=}")
4790
4791        # Response format is a json object with a "header_row" key for column
4792        # names, and then a nested list of data.
4793        resp = requests.get(url, headers=headers, **self._request_params)
4794        self._check_ok_or_err_with_msg(
4795            resp, f"Failed to get rolling portfolio signals for {model_id=}, {portfolio_id=}"
4796        )
4797
4798        data = resp.json()
4799
4800        df = pd.DataFrame(data=data["data"], columns=data["header_row"])
4801        df["Date"] = pd.to_datetime(df["Date"])
4802        df = df.set_index("Date")
4803        return df.astype(float)
def get_portfolio_quantiles( self, model_id: str, portfolio_id: str, id_type: Literal['TICKER', 'ISIN'] = 'TICKER'):
4805    def get_portfolio_quantiles(
4806        self,
4807        model_id: str,
4808        portfolio_id: str,
4809        id_type: Literal["TICKER", "ISIN"] = "TICKER",
4810    ):
4811        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4812        date = datetime.date.today().strftime("%Y-%m-%d")
4813
4814        payload = {
4815            "model_id": model_id,
4816            "portfolio_id": portfolio_id,
4817            "fields": ["quantile"],
4818            "min_date": date,
4819            "max_date": date,
4820            "return_format": "json",
4821        }
4822        # TODO: Later change this URI to not use the watchlist prefix. It is misnamed.
4823        url = f"{self.base_uri}{WATCHLIST_ROUTE_PREFIX}{DAL_PA_ROUTE}/get-data/"
4824
4825        res: requests.Response = requests.post(
4826            url, json=payload, headers=headers, **self._request_params
4827        )
4828        self._check_ok_or_err_with_msg(res, "Unable to get quantile data")
4829
4830        resp: Dict = res.json()
4831        quantile_index = resp["field_map"]["Quantile"]
4832        quantile_data = [[c[quantile_index] for c in r] for r in resp["data"]]
4833        date_cols = pd.to_datetime(resp["columns"])
4834
4835        # Need to map gbi id's to isins or tickers
4836        gbi_ids = [int(i) for i in resp["rows"]]
4837        security_info = self._get_security_info(gbi_ids)
4838
4839        # We now have security data, go through and create a map from internal
4840        # gbi id to client facing identifier
4841        id_key = "isin" if id_type == "ISIN" else "symbol"
4842        gbi_identifier_map = {
4843            sec["gbiId"]: sec[id_key] for sec in security_info["data"]["securities"]
4844        }
4845
4846        df = pd.DataFrame(quantile_data, index=gbi_ids, columns=date_cols).transpose()
4847        df = df.rename(columns=gbi_identifier_map)
4848        return df
def get_similar_stocks( self, model_id: str, portfolio_id: str, symbol_list: List[str], date: Union[datetime.date, str], identifier_type: Literal['TICKER', 'ISIN'], preferred_country: Union[str, NoneType] = None, preferred_currency: Union[str, NoneType] = None) -> pandas.core.frame.DataFrame:
4850    def get_similar_stocks(
4851        self,
4852        model_id: str,
4853        portfolio_id: str,
4854        symbol_list: List[str],
4855        date: BoostedDate,
4856        identifier_type: Literal["TICKER", "ISIN"],
4857        preferred_country: Optional[str] = None,
4858        preferred_currency: Optional[str] = None,
4859    ) -> pd.DataFrame:
4860        date_str = convert_date(date).strftime("%Y-%m-%d")
4861
4862        sec_data = self.getGbiIdFromIdentCountryCurrencyDate(
4863            ident_country_currency_dates=[
4864                DateIdentCountryCurrency(
4865                    date=datetime.date.today().isoformat(),
4866                    identifier=s,
4867                    id_type=(
4868                        ColumnSubRole.SYMBOL if identifier_type == "TICKER" else ColumnSubRole.ISIN
4869                    ),
4870                    country=preferred_country,
4871                    currency=preferred_currency,
4872                )
4873                for s in symbol_list
4874            ]
4875        )
4876
4877        gbi_id_ident_map: Dict[int, str] = {}
4878        for sec in sec_data:
4879            ident = sec.ticker if identifier_type == "TICKER" else sec.isin_info.identifier
4880            gbi_id_ident_map[sec.gbi_id] = ident
4881        gbi_ids = list(gbi_id_ident_map.keys())
4882
4883        qry = """
4884          query GetSimilarStocks(
4885            $modelId: ID!
4886            $portfolioId: ID!
4887            $gbiIds: [Int]!
4888            $startDate: String!
4889            $endDate: String!
4890            $includeCorrelation: Boolean
4891          ) {
4892            similarStocks(
4893              modelId: $modelId,
4894              portfolioId: $portfolioId,
4895              gbiIds: $gbiIds,
4896              startDate: $startDate,
4897              endDate: $endDate,
4898              includeCorrelation: $includeCorrelation
4899            ) {
4900              gbiId
4901              overallSimilarityScore
4902              priceSimilarityScore
4903              factorSimilarityScore
4904              correlation
4905            }
4906          }
4907        """
4908        variables = {
4909            "startDate": date_str,
4910            "endDate": date_str,
4911            "modelId": model_id,
4912            "portfolioId": portfolio_id,
4913            "gbiIds": gbi_ids,
4914            "includeCorrelation": True,
4915        }
4916
4917        resp = self._get_graphql(
4918            qry, variables=variables, error_msg_prefix="Failed to get similar stocks result: "
4919        )
4920        df = pd.DataFrame(resp["data"]["similarStocks"])
4921
4922        # Now that we have the rest of the securities in the portfolio, we need
4923        # to map them back to the correct identifiers
4924        all_gbi_ids = df["gbiId"].tolist()
4925        sec_info = self._get_security_info(all_gbi_ids)
4926        for s in sec_info["data"]["securities"]:
4927            ident = s["symbol"] if identifier_type == "TICKER" else s["isin"]
4928            gbi_id_ident_map[s["gbiId"]] = ident
4929        df["identifier"] = df["gbiId"].map(gbi_id_ident_map)
4930        df = df.set_index("identifier")
4931        return df.drop("gbiId", axis=1)
def get_portfolio_trades( self, model_id: str, portfolio_id: str, start_date: Union[datetime.date, str, NoneType] = None, end_date: Union[datetime.date, str, NoneType] = None) -> pandas.core.frame.DataFrame:
4933    def get_portfolio_trades(
4934        self,
4935        model_id: str,
4936        portfolio_id: str,
4937        start_date: Optional[BoostedDate] = None,
4938        end_date: Optional[BoostedDate] = None,
4939    ) -> pd.DataFrame:
4940        if not end_date:
4941            end_date = datetime.date.today()
4942        end_date = convert_date(end_date)
4943
4944        if not start_date:
4945            # default to a year of data
4946            start_date = end_date - datetime.timedelta(days=365)
4947        start_date = convert_date(start_date)
4948
4949        start_date_str = start_date.strftime("%Y-%m-%d")
4950        end_date_str = end_date.strftime("%Y-%m-%d")
4951
4952        if end_date - start_date > datetime.timedelta(days=365 * 7):
4953            raise BoostedAPIException(
4954                f"Date range ({start_date_str}, {end_date_str}) too large, max 7 years"
4955            )
4956
4957        url = f"{self.base_uri}{ROUTE_PREFIX}{DAL_PA_ROUTE}/get-data/"
4958        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
4959        payload = {
4960            "model_id": model_id,
4961            "portfolio_id": portfolio_id,
4962            "fields": ["price", "shares_traded", "shares_owned"],
4963            "min_date": start_date_str,
4964            "max_date": end_date_str,
4965            "return_format": "json",
4966        }
4967
4968        res: requests.Response = requests.post(
4969            url, json=payload, headers=headers, **self._request_params
4970        )
4971        self._check_ok_or_err_with_msg(res, "Unable to get portfolio trades data")
4972
4973        data = res.json()
4974        gbi_ids = [int(ident) for ident in data["rows"]]
4975
4976        # need both isin and ticker to distinguish between possible duplicates
4977        isin_map = {
4978            str(s["gbiId"]): s["isin"]
4979            for s in self._get_security_info(gbi_ids)["data"]["securities"]
4980        }
4981        ticker_map = {
4982            str(s["gbiId"]): s["symbol"]
4983            for s in self._get_security_info(gbi_ids)["data"]["securities"]
4984        }
4985
4986        # construct individual dataframes for each security, then join them together
4987        dfs: List[pd.DataFrame] = []
4988        full_data = data["data"]
4989        for i, gbi_id in enumerate(data["rows"]):
4990            df = pd.DataFrame(
4991                index=pd.to_datetime(data["columns"]), columns=data["fields"], data=full_data[i]
4992            )
4993            # drop rows where no shares are owned or traded
4994            df.drop(
4995                df.loc[((df["shares_owned"] == 0.0) & (df["shares_traded"] == 0.0))].index,
4996                inplace=True,
4997            )
4998            df["isin"] = isin_map[gbi_id]
4999            df["ticker"] = ticker_map[gbi_id]
5000            dfs.append(df)
5001
5002        full_df = pd.concat(dfs)
5003        full_df["date"] = full_df.index
5004        full_df.sort_index(inplace=True)
5005        full_df.reset_index(drop=True, inplace=True)
5006
5007        # reorder the columns to match the spreadsheet
5008        columns = ["isin", "ticker", "date", *data["fields"]]
5009        return full_df[columns]
def get_ideas( self, model_id: str, portfolio_id: str, investment_horizon: Literal['1M', '3M', '1Y'] = '1M', delta_horizon: str = '1M'):
5011    def get_ideas(
5012        self,
5013        model_id: str,
5014        portfolio_id: str,
5015        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5016        delta_horizon: str = "1M",
5017    ):
5018        if investment_horizon not in ("1M", "3M", "1Y"):
5019            raise BoostedAPIException(f"Invalid investment horizon: {investment_horizon}")
5020
5021        if delta_horizon not in ("1W", "1M", "3M", "6M", "9M", "1Y"):
5022            raise BoostedAPIException(f"Invalid delta horizon: {delta_horizon}")
5023
5024        # First compute dates based on the delta horizon. "0D" is the latest rebalance.
5025        try:
5026            dates = self._get_portfolio_rebalance_from_periods(
5027                portfolio_id=portfolio_id, rel_periods=["0D", delta_horizon]
5028            )
5029        except Exception:
5030            raise BoostedAPIException(
5031                f"Portfolio {portfolio_id} does not exist or you do not have permission to view it."
5032            )
5033        end_date = dates[0].strftime("%Y-%m-%d")
5034        start_date = dates[1].strftime("%Y-%m-%d")
5035
5036        resp = self._get_graphql(
5037            graphql_queries.GET_IDEAS_QUERY,
5038            variables={
5039                "modelId": model_id,
5040                "portfolioId": portfolio_id,
5041                "horizon": investment_horizon,
5042                "deltaHorizon": delta_horizon,
5043                "startDate": start_date,
5044                "endDate": end_date,
5045                # Note: market data date is needed to fetch market cap.
5046                # we don't fetch that data from this endpoint so we stub
5047                # out the mandatory parameter with the end date requested
5048                "marketDataDate": end_date,
5049            },
5050            error_msg_prefix="Failed to get ideas: ",
5051        )
5052        # rows is a list of dicts like:
5053        # {
5054        #   "category": "Strong Sell",
5055        #   "dividendYield": 0.0,
5056        #   "reason": "Boosted Insights has given this stock...",
5057        #   "rating": 0.458167,
5058        #   "ratingDelta": 0.438087,
5059        #   "risk": {
5060        #     "text": "high"
5061        #   },
5062        #   "security": {
5063        #     "symbol": "BA"
5064        #   }
5065        # }
5066        try:
5067            rows = resp["data"]["recommendations"]["recommendations"]
5068            data = [
5069                {
5070                    "symbol": r["security"]["symbol"],
5071                    "recommendation": r["category"],
5072                    "rating": r["rating"],
5073                    "rating_delta": r["ratingDelta"],
5074                    "dividend_yield": r["dividendYield"],
5075                    "predicted_excess_return_1m": r["ER"]["oneMonth"],
5076                    "predicted_excess_return_3m": r["ER"]["threeMonth"],
5077                    "predicted_excess_return_1y": r["ER"]["oneYear"],
5078                    "risk": r["risk"]["text"],
5079                    "reward": r["reward"]["text"],
5080                    "reason": r["reason"],
5081                }
5082                for r in rows
5083            ]
5084            df = pd.DataFrame(data)
5085            df.set_index("symbol", inplace=True)
5086        except Exception:
5087            # Don't show old exception info to client
5088            raise BoostedAPIException(
5089                "No recommendations found, try selecting another horizon."
5090            ) from None
5091
5092        return df
def get_stock_recommendations( self, model_id: str, portfolio_id: str, symbols: Union[List[str], NoneType] = None, investment_horizon: Literal['1M', '3M', '1Y'] = '1M') -> pandas.core.frame.DataFrame:
5094    def get_stock_recommendations(
5095        self,
5096        model_id: str,
5097        portfolio_id: str,
5098        symbols: Optional[List[str]] = None,
5099        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5100    ) -> pd.DataFrame:
5101        model_stocks = self._get_model_stocks(model_id)
5102
5103        symbols_to_gbiids = {s.ticker: s.gbi_id for s in model_stocks}
5104        gbi_ids_to_symbols = {s.gbi_id: s.ticker for s in model_stocks}
5105
5106        variables: Dict[str, Any] = {
5107            "strategyId": portfolio_id,
5108        }
5109        if symbols:
5110            variables["gbiIds"] = [
5111                symbols_to_gbiids.get(symbol) for symbol in symbols if symbols_to_gbiids.get(symbol)
5112            ]
5113        try:
5114            recs = self._get_graphql(
5115                graphql_queries.MULTI_STOCK_RECOMMENDATION_QUERY,
5116                variables=variables,
5117                log_error=False,
5118            )["data"]["currentRecommendationsFull"]
5119        except BoostedAPIException:
5120            raise BoostedAPIException(f"Error getting recommendations for strategy {portfolio_id}")
5121
5122        data = []
5123        recommendation_key = f"recommendation{investment_horizon}"
5124        for rec in recs:
5125            # Keys to rec are:
5126            # ['ER', 'rewardCategories', 'riskCategories', 'reasons',
5127            #  'recommendation', 'rewardCategory', 'riskCategory']
5128            # need to flatten these out and add to a DF
5129            rec_data = rec[recommendation_key]
5130            reasons_dict = {r["type"]: r["text"] for r in rec_data["reasons"]}
5131            row = {
5132                "symbol": gbi_ids_to_symbols[rec["gbiId"]],
5133                "recommendation": rec_data["currentCategory"],
5134                "predicted_excess_return_1m": rec_data["ER"]["oneMonth"],
5135                "predicted_excess_return_3m": rec_data["ER"]["threeMonth"],
5136                "predicted_excess_return_1y": rec_data["ER"]["oneYear"],
5137                "risk": rec_data["risk"]["text"],
5138                "reward": rec_data["reward"]["text"],
5139                "reasons": reasons_dict,
5140            }
5141
5142            data.append(row)
5143        df = pd.DataFrame(data)
5144        df.set_index("symbol", inplace=True)
5145        return df
def get_stock_recommendation_reasons( self, model_id: str, portfolio_id: str, investment_horizon: Literal['1M', '3M', '1Y'] = '1M', symbols: Union[List[str], NoneType] = None) -> Dict[str, Union[List[str], NoneType]]:
5150    def get_stock_recommendation_reasons(
5151        self,
5152        model_id: str,
5153        portfolio_id: str,
5154        investment_horizon: Literal["1M", "3M", "1Y"] = "1M",
5155        symbols: Optional[List[str]] = None,
5156    ) -> Dict[str, Optional[List[str]]]:
5157        if investment_horizon not in ("1M", "3M", "1Y"):
5158            raise BoostedAPIException(f"Invalid investment horizon: {investment_horizon}")
5159
5160        # "0D" is the latest rebalance - its all we have in terms of recs
5161        dates = self._get_portfolio_rebalance_from_periods(
5162            portfolio_id=portfolio_id, rel_periods=["0D"]
5163        )
5164        date = dates[0].strftime("%Y-%m-%d")
5165
5166        model_stocks = self._get_model_stocks(model_id)
5167
5168        symbols_to_gbiids = {s.ticker: s.gbi_id for s in model_stocks}
5169        if symbols is None:  # potentially iterate through all holdings
5170            symbols = symbols_to_gbiids.keys()  # type: ignore
5171
5172        reasons: Dict[str, Optional[List[str]]] = {}
5173        for sym in symbols:
5174            # it's possible that a passed symbol was not actually a portfolio holding
5175            try:
5176                gbi_id = symbols_to_gbiids[sym]
5177            except KeyError:
5178                logger.warning(f"Symbol={sym} not found for in universe on {date=}")
5179                reasons[sym] = None
5180                continue
5181
5182            try:
5183                recs = self._get_graphql(
5184                    graphql_queries.STOCK_RECOMMENDATION_QUERY,
5185                    variables={
5186                        "modelId": model_id,
5187                        "portfolioId": portfolio_id,
5188                        "horizon": investment_horizon,
5189                        "gbiId": gbi_id,
5190                        "date": date,
5191                    },
5192                    log_error=False,
5193                )
5194                reasons[sym] = [
5195                    reason["text"] for reason in recs["data"]["stockRecommendation"]["reasons"]
5196                ]
5197            except BoostedAPIException:
5198                logger.warning(f"No recommendation for: {sym}, skipping...")
5199        return reasons
def get_stock_mapping_alternatives( self, isin: Union[str, NoneType] = None, symbol: Union[str, NoneType] = None, country: Union[str, NoneType] = None, currency: Union[str, NoneType] = None, asof_date: Union[datetime.date, str, NoneType] = None) -> Dict:
5201    def get_stock_mapping_alternatives(
5202        self,
5203        isin: Optional[str] = None,
5204        symbol: Optional[str] = None,
5205        country: Optional[str] = None,
5206        currency: Optional[str] = None,
5207        asof_date: Optional[BoostedDate] = None,
5208    ) -> Dict:
5209        """
5210        Return the stock mapping for the given criteria,
5211        also suggestions for alternate matches,
5212        if the mapping is not what is wanted
5213
5214
5215            Parameters [One of either ISIN or SYMBOL must be provided]
5216            ----------
5217            isin: Optional[str]
5218                search by ISIN
5219            symbol: Optional[str]
5220                search by Ticker Symbol
5221            country: Optional[str]
5222                Additionally filter by country code - ex: None, "ANY", "p_USA", "CAN"
5223            currency: Optional[str]
5224                Additionally filter by currency code - ex: None, "ANY", "p_USD", "CAD"
5225            asof_date: Optional[date]
5226                as of which date to perform the search, default is today()
5227
5228            Note: country/currency filter starting with "p_" indicates
5229                  only a soft preference but allows other matches
5230
5231        Returns
5232        -------
5233        Dictionary Representing this 'MapSecurityResponse' structure:
5234
5235        class MapSecurityResponse():
5236            stock_mapping: Optional[SecurityInfo]
5237               The mapping we would perform given your inputs
5238
5239            alternatives: Optional[List[SecurityInfo]]
5240               Alternative suggestions based on your input
5241
5242            error: Optional[str]
5243
5244        class SecurityInfo():
5245            gbi_id: int
5246            isin: str
5247            symbol: Optional[str]
5248            country: str
5249            currency: str
5250            name: str
5251            from_date: date
5252            to_date: date
5253            is_primary_trading_item: bool
5254
5255        """
5256
5257        url = f"{self.base_uri}/api/stock-mapping/alternatives"
5258        headers = {"Authorization": "ApiKey " + self.api_key, "Content-Type": "application/json"}
5259        req_json: Dict = {
5260            "isin": isin,
5261            "symbol": symbol,
5262            "countryPreference": country,
5263            "currencyPreference": currency,
5264        }
5265
5266        if asof_date:
5267            req_json["date"] = convert_date(asof_date).isoformat()
5268
5269        res = requests.post(url, json=req_json, headers=headers, **self._request_params)
5270
5271        if not res.ok:
5272            error_msg = self._try_extract_error_code(res)
5273            logger.error(error_msg)
5274            raise BoostedAPIException(f"Failed to get user watchlists: {error_msg}")
5275
5276        data = res.json()
5277        return data

Return the stock mapping for the given criteria, also suggestions for alternate matches, if the mapping is not what is wanted

Parameters [One of either ISIN or SYMBOL must be provided]
----------
isin: Optional[str]
    search by ISIN
symbol: Optional[str]
    search by Ticker Symbol
country: Optional[str]
    Additionally filter by country code - ex: None, "ANY", "p_USA", "CAN"
currency: Optional[str]
    Additionally filter by currency code - ex: None, "ANY", "p_USD", "CAD"
asof_date: Optional[date]
    as of which date to perform the search, default is today()

Note: country/currency filter starting with "p_" indicates
      only a soft preference but allows other matches

Returns

Dictionary Representing this 'MapSecurityResponse' structure:

class MapSecurityResponse(): stock_mapping: Optional[SecurityInfo] The mapping we would perform given your inputs

alternatives: Optional[List[SecurityInfo]]
   Alternative suggestions based on your input

error: Optional[str]

class SecurityInfo(): gbi_id: int isin: str symbol: Optional[str] country: str currency: str name: str from_date: date to_date: date is_primary_trading_item: bool

def get_pros_cons_for_stocks( self, model_id: Union[str, NoneType] = None, symbols: Union[List[str], NoneType] = None, preferred_country: Union[str, NoneType] = None, preferred_currency: Union[str, NoneType] = None) -> Dict[str, Dict[str, List]]:
5279    def get_pros_cons_for_stocks(
5280        self,
5281        model_id: Optional[str] = None,
5282        symbols: Optional[List[str]] = None,
5283        preferred_country: Optional[str] = None,
5284        preferred_currency: Optional[str] = None,
5285    ) -> Dict[str, Dict[str, List]]:
5286        if symbols:
5287            ident_objs = [
5288                DateIdentCountryCurrency(
5289                    date=datetime.date.today().strftime("%Y-%m-%d"),
5290                    identifier=symbol,
5291                    country=preferred_country,
5292                    currency=preferred_currency,
5293                    id_type=ColumnSubRole.SYMBOL,
5294                )
5295                for symbol in symbols
5296            ]
5297            sec_objs = self.getGbiIdFromIdentCountryCurrencyDate(
5298                ident_country_currency_dates=ident_objs
5299            )
5300            gbi_id_ticker_map = {sec.gbi_id: sec.ticker for sec in sec_objs if sec}
5301        elif model_id:
5302            gbi_id_ticker_map = {
5303                sec.gbi_id: sec.ticker for sec in self._get_model_stocks(model_id=model_id)
5304            }
5305        gbi_id_pros_cons_map = {}
5306        gbi_ids = list(gbi_id_ticker_map.keys())
5307        data = self._get_graphql(
5308            query=graphql_queries.GET_PROS_CONS_QUERY,
5309            variables={"gbiIds": gbi_ids},
5310            error_msg_prefix="Failed to get pros/cons:",
5311        )
5312        gbi_id_pros_cons_map = {
5313            row["gbiId"]: {"pros": row["pros"], "cons": row["cons"]}
5314            for row in data["data"]["bulkSecurityProsCons"]
5315        }
5316
5317        return {
5318            gbi_id_ticker_map[gbi_id]: pros_cons
5319            for gbi_id, pros_cons in gbi_id_pros_cons_map.items()
5320        }
def generate_theme( self, theme_name: str, stock_universes: List[boosted.api.api_type.ThemeUniverse]) -> str:
5322    def generate_theme(self, theme_name: str, stock_universes: List[ThemeUniverse]) -> str:
5323        # First get universe name and id mappings
5324        try:
5325            resp = self._get_graphql(
5326                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5327            )
5328            data = resp["data"]["getMarketTrendsUniverses"]
5329        except Exception:
5330            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5331
5332        universe_name_to_id = {u["name"]: u["id"] for u in data}
5333        universe_ids = [universe_name_to_id[u.value] for u in stock_universes]
5334        try:
5335            resp = self._get_graphql(
5336                query=graphql_queries.GENERATE_THEME_QUERY,
5337                variables={"input": {"themeName": theme_name, "universeIds": universe_ids}},
5338            )
5339            data = resp["data"]["generateTheme"]
5340        except Exception:
5341            raise BoostedAPIException(f"Failed to generate theme: {theme_name}")
5342
5343        if not data["success"]:
5344            raise BoostedAPIException(f"Failed to generate theme: {theme_name}")
5345
5346        logger.info(
5347            f"Successfully generated theme: {theme_name}. The theme ID is {data['themeId']}"
5348        )
5349        return data["themeId"]
def get_themes_for_stock_universe( self, stock_universe: boosted.api.api_type.ThemeUniverse, start_date: Union[datetime.date, str, NoneType] = None, end_date: Union[datetime.date, str, NoneType] = None, language: Union[boosted.api.api_type.Language, str, NoneType] = None) -> List[Dict]:
5367    def get_themes_for_stock_universe(
5368        self,
5369        stock_universe: ThemeUniverse,
5370        start_date: Optional[BoostedDate] = None,
5371        end_date: Optional[BoostedDate] = None,
5372        language: Optional[Union[str, Language]] = None,
5373    ) -> List[Dict]:
5374        """Get all themes data for a particular stock universe
5375        (start_date, end_date) are used to calculate the theme importance for ranking purpose. If
5376        None, default to past 30 days
5377        Returns: A list of below dictionaries
5378        {
5379            themeId: str
5380            themeName: str
5381            themeImportance: float
5382            volatility: float
5383            positiveStockPerformance: float
5384            negativeStockPerformance: float
5385        }
5386        """
5387        translate = functools.partial(self.translate_text, language)
5388        # First get universe name and id mappings
5389        universe_id = self._get_stock_universe_id(stock_universe)
5390
5391        start_date_iso, end_date_iso = get_valid_iso_dates(start_date, end_date)
5392
5393        try:
5394            resp = self._get_graphql(
5395                query=graphql_queries.GET_THEMES,
5396                variables={
5397                    "type": "UNIVERSE",
5398                    "id": universe_id,
5399                    "startDate": start_date_iso,
5400                    "endDate": end_date_iso,
5401                    "deltaHorizon": "",  # not needed here
5402                },
5403            )
5404            data = resp["data"]["themes"]
5405        except Exception:
5406            raise BoostedAPIException(
5407                f"Failed to get themes for stock universe: {stock_universe.name}"
5408            )
5409
5410        for theme_data in data:
5411            theme_data["themeName"] = translate(theme_data["themeName"])
5412        return data

Get all themes data for a particular stock universe (start_date, end_date) are used to calculate the theme importance for ranking purpose. If None, default to past 30 days Returns: A list of below dictionaries { themeId: str themeName: str themeImportance: float volatility: float positiveStockPerformance: float negativeStockPerformance: float }

def get_themes_for_stock( self, isin: str, currency: Union[str, NoneType] = None, country: Union[str, NoneType] = None, start_date: Union[datetime.date, str, NoneType] = None, end_date: Union[datetime.date, str, NoneType] = None, language: Union[boosted.api.api_type.Language, str, NoneType] = None) -> List[Dict]:
5414    def get_themes_for_stock(
5415        self,
5416        isin: str,
5417        currency: Optional[str] = None,
5418        country: Optional[str] = None,
5419        start_date: Optional[BoostedDate] = None,
5420        end_date: Optional[BoostedDate] = None,
5421        language: Optional[Union[str, Language]] = None,
5422    ) -> List[Dict]:
5423        """Get all themes data for a particular stock
5424        (ISIN, currency, country) compose a unique identifier for a stock for us to map to GBI ID
5425        (start_date, end_date) are used to calculate the theme importance for ranking purpose. If
5426        None, default to past 30 days
5427
5428        Returns
5429        A list of below dictionaries
5430        {
5431            themeId: str
5432            themeName: str
5433            importanceScore: float
5434            similarityScore: float
5435            positiveThemeRelation: bool
5436            reason: String
5437        }
5438        """
5439        translate = functools.partial(self.translate_text, language)
5440        security_info = self.get_stock_mapping_alternatives(
5441            isin, country=country, currency=currency
5442        )
5443        gbi_id = security_info["stock_mapping"]["gbi_id"]
5444
5445        if (start_date and not end_date) or (end_date and not start_date):
5446            raise BoostedAPIException("Must provide both start and end dates or neither")
5447        elif not end_date and not start_date:
5448            end_date = datetime.date.today()
5449            start_date = end_date - datetime.timedelta(days=30)
5450            end_date = end_date.isoformat()
5451            start_date = start_date.isoformat()
5452        else:
5453            if isinstance(start_date, datetime.date):
5454                start_date = start_date.isoformat()
5455            if isinstance(end_date, datetime.date):
5456                end_date = end_date.isoformat()
5457
5458        try:
5459            resp = self._get_graphql(
5460                query=graphql_queries.GET_THEMES_FOR_STOCK_WITH_REASONS,
5461                variables={"gbiId": gbi_id, "startDate": start_date, "endDate": end_date},
5462            )
5463            data = resp["data"]["themesForStockWithReasons"]
5464        except Exception:
5465            raise BoostedAPIException(f"Failed to get themes for stock: {isin}")
5466
5467        for item in data:
5468            item["themeName"] = translate(item["themeName"])
5469            item["reason"] = translate(item["reason"])
5470        return data

Get all themes data for a particular stock (ISIN, currency, country) compose a unique identifier for a stock for us to map to GBI ID (start_date, end_date) are used to calculate the theme importance for ranking purpose. If None, default to past 30 days

Returns A list of below dictionaries { themeId: str themeName: str importanceScore: float similarityScore: float positiveThemeRelation: bool reason: String }

def get_stock_news( self, time_horizon: boosted.api.api_type.NewsHorizon, isin: str, currency: Union[str, NoneType] = None, country: Union[str, NoneType] = None, language: Union[boosted.api.api_type.Language, str, NoneType] = None) -> Dict:
5472    def get_stock_news(
5473        self,
5474        time_horizon: NewsHorizon,
5475        isin: str,
5476        currency: Optional[str] = None,
5477        country: Optional[str] = None,
5478        language: Optional[Union[str, Language]] = None,
5479    ) -> Dict:
5480        """
5481        The API to get a stock's news summary for a given time horizon, the topics summarized by
5482        these news and the corresponding news to these topics
5483        Returns
5484        -------
5485        A nested dictionary in the following format:
5486        {
5487            summary: str
5488            topics: [
5489                {
5490                    topicId: str
5491                    topicLabel: str
5492                    topicDescription: str
5493                    topicPolarity: str
5494                    newsItems: [
5495                        {
5496                            newsId: str
5497                            headline: str
5498                            url: str
5499                            summary: str
5500                            source: str
5501                            publishedAt: str
5502                        }
5503                    ]
5504                }
5505            ]
5506            other_news_count: int
5507        }
5508        """
5509        translate = functools.partial(self.translate_text, language)
5510        security_info = self.get_stock_mapping_alternatives(
5511            isin, country=country, currency=currency
5512        )
5513        gbi_id = security_info["stock_mapping"]["gbi_id"]
5514
5515        try:
5516            resp = self._get_graphql(
5517                query=graphql_queries.GET_STOCK_NEWS_QUERY,
5518                variables={"gbiId": gbi_id, "deltaHorizon": time_horizon.value},
5519            )
5520            data = resp["data"]
5521        except Exception:
5522            raise BoostedAPIException(f"Failed to get themes for stock: {isin}")
5523
5524        outputs: Dict[str, Any] = {}
5525        outputs["summary"] = translate(data["getStockNewsSummary"]["summary"])
5526        # Return the top 10 topics
5527        outputs["topics"] = data["getStockNewsTopics"][:10]
5528
5529        for topic in outputs["topics"]:
5530            topic["topicLabel"] = translate(topic["topicLabel"])
5531            topic["topicDescription"] = translate(topic["topicDescription"])
5532
5533        other_news_count = 0
5534        for source_count in data["getStockNewsSummary"]["sourceCounts"]:
5535            other_news_count += source_count["count"]
5536
5537        for topic in outputs["topics"]:
5538            other_news_count -= len(topic["newsItems"])
5539
5540        outputs["other_news_count"] = other_news_count
5541
5542        return outputs

The API to get a stock's news summary for a given time horizon, the topics summarized by these news and the corresponding news to these topics

Returns

A nested dictionary in the following format: { summary: str topics: [ { topicId: str topicLabel: str topicDescription: str topicPolarity: str newsItems: [ { newsId: str headline: str url: str summary: str source: str publishedAt: str } ] } ] other_news_count: int }

def get_theme_details( self, theme_id: str, universe: boosted.api.api_type.ThemeUniverse, language: Union[boosted.api.api_type.Language, str, NoneType] = None) -> Dict[str, Any]:
5544    def get_theme_details(
5545        self,
5546        theme_id: str,
5547        universe: ThemeUniverse,
5548        language: Optional[Union[str, Language]] = None,
5549    ) -> Dict[str, Any]:
5550        translate = functools.partial(self.translate_text, language)
5551        universe_id = self._get_stock_universe_id(universe)
5552        date = datetime.date.today()
5553        prev_date = date - datetime.timedelta(days=30)
5554        result = self._get_graphql(
5555            query=graphql_queries.GET_THEME_DEEPDIVE_DETAILS,
5556            variables={
5557                "deltaHorizon": "1W",
5558                "startDate": prev_date.strftime("%Y-%m-%d"),
5559                "endDate": date.strftime("%Y-%m-%d"),
5560                "id": universe_id,
5561                "themeId": theme_id,
5562                "type": "UNIVERSE",
5563            },
5564            error_msg_prefix="Failed to get theme details",
5565        )["data"]["marketThemes"]
5566
5567        gbi_id_stock_data_map: Dict[int, Dict] = {}
5568
5569        stocks = []
5570        for stock_info in result["stockInfos"]:
5571            gbi_id_stock_data_map[stock_info["gbiId"]] = stock_info["security"]
5572            stocks.append(
5573                {
5574                    "isin": stock_info["security"]["isin"],
5575                    "name": stock_info["security"]["name"],
5576                    "reason": translate(stock_info["polarityReasonScores"]["reason"]),
5577                    "positive_theme_relation": stock_info["polarityReasonScores"][
5578                        "positiveThemeRelation"
5579                    ],
5580                    "theme_stock_impact_score": stock_info["polarityReasonScores"][
5581                        "similarityScore"
5582                    ],
5583                }
5584            )
5585
5586        impacts = []
5587        for impact in result["impactInfos"]:
5588            articles = [
5589                {
5590                    "title": newsitem["headline"],
5591                    "url": newsitem["url"],
5592                    "source": newsitem["source"],
5593                    "publish_date": newsitem["publishedAt"],
5594                }
5595                for newsitem in impact["newsItems"]
5596            ]
5597
5598            impact_stocks = []
5599            for impact_stock_data in impact["stocks"]:
5600                stock_metadata = gbi_id_stock_data_map[impact_stock_data["gbiId"]]
5601                impact_stocks.append(
5602                    {
5603                        "isin": stock_metadata["isin"],
5604                        "name": stock_metadata["name"],
5605                        "positive_impact_relation": impact_stock_data["positiveThemeRelation"],
5606                    }
5607                )
5608
5609            impact_dict = {
5610                "impact_name": translate(impact["impactName"]),
5611                "impact_description": translate(impact["impactDescription"]),
5612                "impact_score": impact["impactScore"],
5613                "articles": articles,
5614                "impact_stocks": impact_stocks,
5615            }
5616            impacts.append(impact_dict)
5617
5618        developments = []
5619        for dev in result["themeDevelopments"]:
5620            developments.append(
5621                {
5622                    "name": dev["label"],
5623                    "article_count": dev["articleCount"],
5624                    "date": parser.parse(dev["date"]).date(),
5625                    "description": dev["description"],
5626                    "is_major_development": dev["isMajorDevelopment"],
5627                    "sentiment": dev["sentiment"],
5628                    "news": [
5629                        {
5630                            "headline": entry["headline"],
5631                            "published_at": parser.parse(entry["publishedAt"]),
5632                            "source": entry["source"],
5633                            "url": entry["url"],
5634                        }
5635                        for entry in dev["news"]
5636                    ],
5637                }
5638            )
5639
5640        developments = sorted(developments, key=lambda d: d["date"], reverse=True)
5641
5642        output = {
5643            "theme_name": translate(result["themeName"]),
5644            "theme_summary": translate(result["themeDescription"]),
5645            "impacts": impacts,
5646            "stocks": stocks,
5647            "developments": developments,
5648        }
5649        return output
def get_all_theme_metadata( self, language: Union[boosted.api.api_type.Language, str, NoneType] = None) -> List[Dict[str, Any]]:
5651    def get_all_theme_metadata(
5652        self, language: Optional[Union[str, Language]] = None
5653    ) -> List[Dict[str, Any]]:
5654        translate = functools.partial(self.translate_text, language)
5655        result = self._get_graphql(
5656            graphql_queries.GET_ALL_THEMES,
5657            variables={"universeIds": None},
5658            error_msg_prefix="Failed to fetch all themes metadata",
5659        )
5660
5661        try:
5662            resp = self._get_graphql(
5663                query=graphql_queries.GET_MARKET_TRENDS_UNIVERSES_QUERY, variables={}
5664            )
5665            data = resp["data"]["getMarketTrendsUniverses"]
5666        except Exception:
5667            raise BoostedAPIException(f"Failed to load market trends universes mapping")
5668        universe_id_to_name = {u["id"]: u["name"] for u in data}
5669
5670        outputs = []
5671        for theme in result["data"]["getAllThemesForUser"]:
5672            # map universe ID to universe ticker
5673            universe_tickers = []
5674            for universe_id in theme["universeIds"]:
5675                if universe_id in universe_id_to_name:  # don't support unlisted universes - skip
5676                    universe_name = universe_id_to_name[universe_id]
5677                    ticker = ThemeUniverse.get_ticker_from_name(universe_name)
5678                    if ticker:
5679                        universe_tickers.append(ticker)
5680
5681            outputs.append(
5682                {
5683                    "theme_id": theme["themeId"],
5684                    "theme_name": translate(theme["themeName"]),
5685                    "universes": universe_tickers,
5686                }
5687            )
5688
5689        return outputs
def get_earnings_impacting_security( self, isin: str, currency: Union[str, NoneType] = None, country: Union[str, NoneType] = None, language: Union[boosted.api.api_type.Language, str, NoneType] = None) -> List[Dict[str, Any]]:
5691    def get_earnings_impacting_security(
5692        self,
5693        isin: str,
5694        currency: Optional[str] = None,
5695        country: Optional[str] = None,
5696        language: Optional[Union[str, Language]] = None,
5697    ) -> List[Dict[str, Any]]:
5698        translate = functools.partial(self.translate_text, language)
5699        date = datetime.date.today().strftime("%Y-%m-%d")
5700        company_data = self.getGbiIdFromIdentCountryCurrencyDate(
5701            ident_country_currency_dates=[
5702                DateIdentCountryCurrency(
5703                    date=date, identifier=isin, country=country, currency=currency
5704                )
5705            ]
5706        )
5707        try:
5708            gbi_id = company_data[0].gbi_id
5709        except Exception:
5710            raise BoostedAPIException(f"ISIN {isin} not found")
5711
5712        result = self._get_graphql(
5713            graphql_queries.EARNINGS_IMPACTS_CALENDAR_FOR_STOCK,
5714            variables={"date": date, "days": 180, "gbiId": gbi_id},
5715            error_msg_prefix="Failed to fetch earnings impacts data for stock",
5716        )
5717        earnings_events = result["data"]["earningsCalendarForStock"]
5718        output_events = []
5719        for event in earnings_events:
5720            if not event["impactedCompanies"]:
5721                continue
5722            fixed_event = {
5723                "event_date": event["eventDate"],
5724                "company_name": event["security"]["name"],
5725                "symbol": event["security"]["symbol"],
5726                "isin": event["security"]["isin"],
5727                "impact_reason": translate(event["impactedCompanies"][0]["reason"]),
5728            }
5729            output_events.append(fixed_event)
5730
5731        return output_events
def get_earnings_insights_for_stocks( self, isin: str, currency: Union[str, NoneType] = None, country: Union[str, NoneType] = None) -> Dict[str, Any]:
5733    def get_earnings_insights_for_stocks(
5734        self, isin: str, currency: Optional[str] = None, country: Optional[str] = None
5735    ) -> Dict[str, Any]:
5736        date = datetime.date.today().strftime("%Y-%m-%d")
5737        company_data = self.getGbiIdFromIdentCountryCurrencyDate(
5738            ident_country_currency_dates=[
5739                DateIdentCountryCurrency(
5740                    date=date, identifier=isin, country=country, currency=currency
5741                )
5742            ]
5743        )
5744        gbi_id_isin_map = {
5745            company.gbi_id: company.isin_info.identifier
5746            for company in company_data
5747            if company is not None
5748        }
5749        try:
5750            resp = self._get_graphql(
5751                query=graphql_queries.GET_EARNINGS_INSIGHTS_SUMMARIES,
5752                variables={"gbiIds": list(gbi_id_isin_map.keys())},
5753            )
5754            # list of objects with gbi id and data
5755            summaries = resp["data"]["getEarningsSummaries"]
5756            resp = self._get_graphql(
5757                query=graphql_queries.GET_EARNINGS_COMPARISONS,
5758                variables={"gbiIds": list(gbi_id_isin_map.keys())},
5759            )
5760            # list of objects with gbi id and data
5761            comparison = resp["data"]["getLatestEarningsChanges"]
5762        except Exception:
5763            raise BoostedAPIException(f"Failed to earnings insights data")
5764
5765        if not summaries:
5766            raise BoostedAPIException(
5767                (
5768                    f"Failed to find earnings insights data for {isin}"
5769                    ", please try with another security"
5770                )
5771            )
5772
5773        output: Dict[str, Any] = {}
5774        reports = sorted(summaries[0]["reports"], key=lambda r: r["date"], reverse=True)
5775        current_report = reports[0]
5776
5777        def is_aligned_formatter(acc: Tuple[List, List], cur: Dict[str, Any]):
5778            if cur["isAligned"]:
5779                acc[0].append({k: cur[k] for k in cur if k != "isAligned"})
5780            else:
5781                acc[1].append({k: cur[k] for k in cur if k != "isAligned"})
5782            return acc
5783
5784        current_report_common_remarks: Union[List[Dict[str, Any]], List]
5785        current_report_dropped_remarks: Union[List[Dict[str, Any]], List]
5786        current_report_common_remarks, current_report_dropped_remarks = functools.reduce(
5787            is_aligned_formatter, current_report["details"], ([], [])
5788        )
5789        prev_report_common_remarks: Union[List[Dict[str, Any]], List]
5790        prev_report_new_remarks: Union[List[Dict[str, Any]], List]
5791        prev_report_common_remarks, prev_report_new_remarks = functools.reduce(
5792            is_aligned_formatter, current_report["details"], ([], [])
5793        )
5794
5795        output["earnings_report"] = {
5796            "release_date": datetime.datetime.strptime(current_report["date"], "%Y-%m-%d").date(),
5797            "quarter": current_report["quarter"],
5798            "year": current_report["year"],
5799            "details": [
5800                {
5801                    "header": detail_obj["header"],
5802                    "detail": detail_obj["detail"],
5803                    "sentiment": detail_obj["sentiment"],
5804                }
5805                for detail_obj in current_report["details"]
5806            ],
5807            "call_summary": current_report["highlights"],
5808            "common_remarks": current_report_common_remarks,
5809            "dropped_remarks": current_report_dropped_remarks,
5810            "qa_summary": current_report["qaHighlights"],
5811            "qa_details": current_report["qaDetails"],
5812        }
5813        prev_report = summaries[0]["reports"][1]
5814        output["prior_earnings_report"] = {
5815            "release_date": datetime.datetime.strptime(prev_report["date"], "%Y-%m-%d").date(),
5816            "quarter": prev_report["quarter"],
5817            "year": prev_report["year"],
5818            "details": [
5819                {
5820                    "header": detail_obj["header"],
5821                    "detail": detail_obj["detail"],
5822                    "sentiment": detail_obj["sentiment"],
5823                }
5824                for detail_obj in prev_report["details"]
5825            ],
5826            "call_summary": prev_report["highlights"],
5827            "common_remarks": prev_report_common_remarks,
5828            "new_remarks": prev_report_new_remarks,
5829            "qa_summary": prev_report["qaHighlights"],
5830            "qa_details": prev_report["qaDetails"],
5831        }
5832
5833        if not comparison:
5834            output["report_comparison"] = []
5835        else:
5836            output["report_comparison"] = comparison[0]["changes"]
5837
5838        return output
def get_portfolio_inference_status(self, portfolio_id: str, inference_date: str) -> dict:
5840    def get_portfolio_inference_status(self, portfolio_id: str, inference_date: str) -> dict:
5841        url = f"{self.base_uri}/api/inference/status/{portfolio_id}/{inference_date}"
5842        headers = {"Authorization": "ApiKey " + self.api_key}
5843        res = requests.get(url, headers=headers)
5844
5845        if not res.ok:
5846            error_msg = self._try_extract_error_code(res)
5847            logger.error(error_msg)
5848            raise BoostedAPIException(
5849                f"Failed to get portfolio inference status, portfolio_id={portfolio_id}, "
5850                f"inference_date={inference_date}: {error_msg}"
5851            )
5852
5853        data = res.json()
5854        return data