├── py.typed ├── i8_terminal ├── __init__.py ├── app │ ├── __init__.py │ ├── plot_server.py │ └── layout.py ├── common │ ├── __init__.py │ ├── cli.py │ ├── layout.py │ ├── screen.py │ ├── price.py │ ├── stock_info.py │ ├── utils.py │ └── formatting.py ├── services │ ├── __init__.py │ └── earnings.py ├── types │ ├── __init__.py │ ├── ticker_param_type.py │ ├── output_param_type.py │ ├── chart_param_type.py │ ├── sort_order_param_type.py │ ├── market_indice_param_type.py │ ├── period_type_param_type.py │ ├── metric_param_type.py │ ├── fin_statement_param_type.py │ ├── price_period_param_type.py │ ├── metric_view_param_type.py │ ├── metric_identifier_param_type.py │ ├── screening_profile_param_type.py │ ├── fin_period_param_type.py │ ├── user_watchlists_param_type.py │ ├── user_watchlist_tickers_param_type.py │ ├── fin_identifier_param_type.py │ ├── screening_value_field_param_type.py │ ├── screening_operator_param_type.py │ ├── period_param_type.py │ ├── screening_condition_param_type.py │ ├── indicator_param_type.py │ ├── command_parser.py │ ├── auto_complete_choice.py │ ├── i8_auto_suggest.py │ └── condition_param_type.py ├── service_result │ ├── __init__.py │ ├── column_info.py │ ├── columns_context.py │ └── earning_list_result.py ├── version.txt ├── i8_exception.py ├── api │ ├── __init__.py │ └── earnings │ │ └── __init__.py ├── assets │ ├── favicon.ico │ ├── i8t_logo.png │ ├── loading.gif │ └── i8t_chart_logo.png ├── commands │ ├── user │ │ ├── __init__.py │ │ ├── user_logout.py │ │ ├── webserver.py │ │ └── user_login.py │ ├── notebook │ │ ├── __init__.py │ │ └── notebook_launch.py │ ├── market │ │ └── __init__.py │ ├── news │ │ ├── __init__.py │ │ └── news_list.py │ ├── metrics │ │ ├── __init__.py │ │ ├── metrics_search.py │ │ ├── metrics_describe.py │ │ └── metrics_current.py │ ├── earnings │ │ ├── __init__.py │ │ ├── earnings_list.py │ │ ├── earnings_recent.py │ │ ├── earnings_plot.py │ │ └── earnings_upcoming.py │ ├── watchlist │ │ ├── __init__.py │ │ ├── watchlist_create.py │ │ ├── watchlist_list.py │ │ ├── watchlist_add.py │ │ ├── watchlist_rm.py │ │ ├── watchlist_summary.py │ │ ├── watchlist_financials.py │ │ ├── watchlist_export.py │ │ └── watchlist_metrics.py │ ├── financials │ │ ├── __init__.py │ │ ├── financials_coverage.py │ │ └── financials_list.py │ ├── price │ │ ├── __init__.py │ │ ├── price_list.py │ │ └── price_compare.py │ ├── screen │ │ ├── __init__.py │ │ ├── screen_list.py │ │ ├── screen_losers.py │ │ └── screen_gainers.py │ ├── company │ │ ├── __init__.py │ │ ├── company_search.py │ │ └── compnay_details.py │ └── __init__.py ├── utils_setup.py ├── config.yml ├── config.py └── main.py ├── docs ├── CNAME ├── assets │ └── images │ │ └── favicon.png ├── index.md ├── metrics │ ├── index.md.j2 │ └── metric.md.j2 ├── generate_metrics.py └── get_started │ └── index.md ├── requirements-pub.txt ├── requirements-notebook.txt ├── tox.ini ├── requirements-dev.txt ├── MANIFEST.in ├── requirements-docs.txt ├── pyproject.toml ├── mkdocs.yml ├── requirements.txt ├── .pre-commit-config.yaml ├── mypy.ini ├── .gitignore ├── setup_cx.py ├── LICENSE ├── setup.py ├── CONTRIBUTING.md ├── update_msi.py ├── README.md └── .github └── workflows └── publish.yml /py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /i8_terminal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /i8_terminal/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | docs.i8terminal.io 2 | -------------------------------------------------------------------------------- /i8_terminal/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /i8_terminal/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /i8_terminal/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /i8_terminal/service_result/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-pub.txt: -------------------------------------------------------------------------------- 1 | cx-Freeze==6.10 2 | -------------------------------------------------------------------------------- /requirements-notebook.txt: -------------------------------------------------------------------------------- 1 | matplotlib==8.1.2 2 | -------------------------------------------------------------------------------- /i8_terminal/version.txt: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.106" 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E402 3 | max-line-length = 120 -------------------------------------------------------------------------------- /i8_terminal/i8_exception.py: -------------------------------------------------------------------------------- 1 | class I8Exception(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /i8_terminal/api/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.api import earnings # noqa: F401 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==22.3.0 2 | click==8.1.2 3 | flake8==6.0.0 4 | isort==5.10.1 5 | mypy==0.910 6 | -------------------------------------------------------------------------------- /docs/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/investoreight/i8-terminal/HEAD/docs/assets/images/favicon.png -------------------------------------------------------------------------------- /i8_terminal/api/earnings/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.services.earnings import get_earnings_list as list # noqa: F401 2 | -------------------------------------------------------------------------------- /i8_terminal/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/investoreight/i8-terminal/HEAD/i8_terminal/assets/favicon.ico -------------------------------------------------------------------------------- /i8_terminal/assets/i8t_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/investoreight/i8-terminal/HEAD/i8_terminal/assets/i8t_logo.png -------------------------------------------------------------------------------- /i8_terminal/assets/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/investoreight/i8-terminal/HEAD/i8_terminal/assets/loading.gif -------------------------------------------------------------------------------- /i8_terminal/assets/i8t_chart_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/investoreight/i8-terminal/HEAD/i8_terminal/assets/i8t_chart_logo.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include i8_terminal/config.yml 2 | include i8_terminal/version.txt 3 | graft i8_terminal/assets 4 | include requirements.txt 5 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | jinja2<3.1.0 2 | mkdocs==1.2.2 3 | mkdocs-click==0.4.0 4 | mkdocs-material==7.3.2 5 | mkdocs-material-extensions==1.0.3 6 | investor8-sdk 7 | -------------------------------------------------------------------------------- /i8_terminal/commands/user/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def user() -> None: 6 | """Users commands.""" 7 | pass 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Commands Reference 2 | 3 | ::: mkdocs-click 4 | :module: i8_terminal.main 5 | :command: cli 6 | :prog_name: i8 7 | :depth: 1 8 | :style: table 9 | -------------------------------------------------------------------------------- /i8_terminal/commands/notebook/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def notebook() -> None: 6 | """Run notebook commands.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/market/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def market() -> None: 6 | """Get information about market.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/news/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def news() -> None: 6 | """Get the latest financial markets news.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def metrics() -> None: 6 | """Get information about company metrics.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/earnings/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def earnings() -> None: 6 | """Get information about company earnings.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def watchlist() -> None: 6 | """Get information about user watchlists.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/financials/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def financials() -> None: 6 | """Get information about company financials.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/price/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group(chain=True) 5 | def price() -> None: 6 | """Get the latest and historical security prices.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/screen/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def screen() -> None: 6 | """Screen the market to find stocks that match your criteria.""" 7 | pass 8 | -------------------------------------------------------------------------------- /i8_terminal/commands/company/__init__.py: -------------------------------------------------------------------------------- 1 | from i8_terminal.commands import cli 2 | 3 | 4 | @cli.group() 5 | def company() -> None: 6 | """Get information about all U.S companies and securities listed in the main U.S exchanges.""" 7 | pass 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | buck-out 13 | | build 14 | | dist 15 | )/ 16 | ''' 17 | 18 | [tool.isort] 19 | profile = 'black' -------------------------------------------------------------------------------- /i8_terminal/commands/screen/screen_list.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | from i8_terminal.commands.screen import screen 4 | from i8_terminal.common.cli import pass_command 5 | 6 | 7 | @screen.command() 8 | @pass_command 9 | def list() -> None: 10 | console = Console() 11 | console.print("The screen list command is not implemented yet!", style="yellow") 12 | -------------------------------------------------------------------------------- /docs/metrics/index.md.j2: -------------------------------------------------------------------------------- 1 | # Metrics and Indicators 2 | 3 | i8 Terminal uses more than 500 built-in financial metrics and indicators that are used in several commands and can be used to implement custom analysis. 4 | 5 | | Name | Display Name | Type | 6 | | --- | --- | --- | 7 | {% for m in metrics %}| [{{ m.metric_name }}](./{{ m.metric_name }}.md) | {{ m.display_name }} | {{ m.type }} | 8 | {% endfor %} 9 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: i8 Terminal Documentation 2 | theme: 3 | logo: https://www.investoreight.com/media/logo-i8t.png 4 | name: material 5 | palette: 6 | primary: black 7 | 8 | nav: 9 | - Commands Reference: index.md 10 | - Getting started: get_started/index.md 11 | - Metrics: metrics/index.md 12 | 13 | markdown_extensions: 14 | - attr_list 15 | - mkdocs-click 16 | 17 | dev_addr: '0.0.0.0:8000' 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.1.2 2 | pandas==1.4.3 3 | requests==2.25.1 4 | arrow==1.0.3 5 | investor8-sdk==1.1.107 6 | pyyaml==5.4.1 7 | rich==12.0.0 8 | plotly==5.3.1 9 | dash==2.3.1 10 | dash-bootstrap-components==1.1.0 11 | kaleido==0.1.0.post1; platform_system == "Windows" 12 | kaleido==0.2.1; platform_system != "Windows" 13 | openpyxl==3.0.9 14 | xlsxwriter==3.0.3 15 | click-repl==0.2.0 16 | wget==3.2 17 | mergedeep==1.3.4 18 | werkzeug==2.0.3 19 | jupyter==1.0.0 20 | matplotlib==3.7.1 -------------------------------------------------------------------------------- /docs/metrics/metric.md.j2: -------------------------------------------------------------------------------- 1 | # {{ m.display_name }} 2 | 3 | {{ description }} 4 | 5 | {{ m.remarks }} 6 | 7 | ## Additional Details 8 | 9 | | Metric Name | Type | Default Period Type | 10 | | --- | --- | --- | 11 | | {{ m.metric_name }} | {{ m.type }} | {{ m.period_type_default }} | 12 | 13 | ## Formatting Details 14 | 15 | | Data Format | Display Format | Unit | 16 | | --- | --- | --- | 17 | | {{ m.data_format }} | {{ m.display_format }} | {{ m.unit }} | 18 | -------------------------------------------------------------------------------- /i8_terminal/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import click 4 | 5 | from i8_terminal.common.cli import log_terminal_usage 6 | 7 | 8 | @click.group() 9 | def cli() -> None: 10 | """i8 Terminal - Modern Market Research powered by the Command-Line 11 | 12 | Copyright © 2020-2022 Investoreight | https://investoreight.com/""" 13 | pass 14 | 15 | 16 | @cli.result_callback() 17 | @click.pass_context 18 | def process_commands(ctx: click.Context, processors: Any) -> None: 19 | log_terminal_usage(ctx) 20 | -------------------------------------------------------------------------------- /i8_terminal/commands/user/user_logout.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from i8_terminal.commands.user import user 6 | from i8_terminal.common.cli import pass_command 7 | from i8_terminal.config import USER_SETTINGS, restore_user_settings 8 | 9 | 10 | @user.command() 11 | @pass_command 12 | def logout() -> None: 13 | if USER_SETTINGS: 14 | restore_user_settings() 15 | click.echo("✅ User logged out successfully!") 16 | os._exit(0) 17 | else: 18 | click.echo("You are already logged out!") 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: mixed-line-ending 7 | - repo: https://github.com/psf/black 8 | rev: 22.3.0 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/pycqa/isort 12 | rev: 5.10.1 13 | hooks: 14 | - id: isort 15 | - repo: https://github.com/pycqa/flake8 16 | rev: 6.0.0 17 | hooks: 18 | - id: flake8 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v0.910 21 | hooks: 22 | - id: mypy -------------------------------------------------------------------------------- /i8_terminal/types/ticker_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.common.stock_info import get_stocks 4 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 5 | 6 | 7 | class TickerParamType(AutoCompleteChoice): 8 | name = "ticker" 9 | 10 | def get_suggestions( 11 | self, keyword: str, pre_populate: bool = False, include_peers: bool = False 12 | ) -> List[Tuple[str, str]]: 13 | if not self.is_loaded: 14 | self.set_choices(get_stocks(include_peers)) 15 | 16 | if pre_populate and keyword.strip() == "": 17 | return self._choices[: self.size] 18 | 19 | return self.search_keyword(keyword) 20 | 21 | def __repr__(self) -> str: 22 | return "TICKER" 23 | -------------------------------------------------------------------------------- /i8_terminal/service_result/column_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Optional 5 | 6 | 7 | @dataclass 8 | class ColumnInfo: 9 | name: str 10 | col_type: str 11 | display_name: Optional[str] = None 12 | data_type: Optional[str] = None 13 | unit: Optional[str] = None 14 | colorable: Optional[bool] = None 15 | 16 | def enrich(self, other: ColumnInfo) -> None: 17 | if not self.display_name: 18 | self.display_name = other.display_name 19 | if not self.data_type: 20 | self.data_type = other.data_type 21 | if not self.unit: 22 | self.unit = other.unit 23 | if not self.colorable: 24 | self.colorable = other.colorable 25 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # mypy.ini 2 | 3 | [mypy] 4 | disallow_any_generics = true 5 | disallow_incomplete_defs = true 6 | disallow_untyped_calls = false 7 | disallow_untyped_defs = true 8 | follow_imports = normal 9 | ignore_missing_imports = true 10 | no_implicit_reexport = true 11 | show_error_codes = true 12 | show_error_context = true 13 | strict_equality = true 14 | strict_optional = true 15 | warn_redundant_casts = true 16 | warn_return_any = true 17 | warn_unused_ignores = false 18 | implicit_reexport = true 19 | 20 | [mypy-numpy,click.*,pandas.*,requests.*,investor8_sdk.*,pyyaml.*,yaml.*,plotly.*,rich.*,kaleido.*,dash.*,mkdocs.*,mkdocs-click.*,mkdocs-material.*,mkdocs-material-extensions.*,dash_bootstrap_components.*, nbformat.*] 21 | ignore_missing_imports = True 22 | disallow_untyped_decorators = false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't track content of these folders 2 | .idea/ 3 | __pycache__/ 4 | .vscode/ 5 | .venv*/ 6 | *.swp 7 | .DS_Store 8 | 9 | # Compiled source # 10 | ################### 11 | *.env 12 | *.com 13 | *.class 14 | *.dll 15 | *.exe 16 | *.o 17 | *.so 18 | *.pyc 19 | *.pkl 20 | *.ipynb 21 | 22 | # Setuptools distribution folder. 23 | dist/ 24 | build/ 25 | 26 | # Python egg metadata, regenerated from source files by setuptools. 27 | **/*.egg-info 28 | 29 | # Packages # 30 | ############ 31 | # it's better to unpack these files and commit the raw source 32 | # git has its own built in compression methods 33 | *.7z 34 | *.dmg 35 | *.gz 36 | *.iso 37 | *.jar 38 | *.rar 39 | *.tar 40 | *.zip 41 | *.html 42 | 43 | notebooks/.ipynb_checkpoints 44 | 45 | .user 46 | .mypy_cashe 47 | 48 | docs/metrics/*.md 49 | -------------------------------------------------------------------------------- /i8_terminal/types/output_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_output_param_types() -> List[Tuple[str, str]]: 7 | return [("terminal", "Generate table"), ("plot", "Generate chart")] 8 | 9 | 10 | class OutputParamType(AutoCompleteChoice): 11 | name = "outputtype" 12 | 13 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 14 | if not self.is_loaded: 15 | self.set_choices(get_output_param_types()) 16 | 17 | if pre_populate and keyword.strip() == "": 18 | return self._choices[: self.size] 19 | return self.search_keyword(keyword) 20 | 21 | def __repr__(self) -> str: 22 | return "OUTPUTTYPE" 23 | -------------------------------------------------------------------------------- /i8_terminal/utils_setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | from typing import Any 4 | 5 | """These methods are used in `setup.py`, so keep in mind to use only built-in packages in here.""" 6 | 7 | 8 | def read(rel_path: str) -> str: 9 | """ 10 | Read a file. 11 | """ 12 | here = os.path.abspath(os.path.dirname(__file__)) 13 | with codecs.open(os.path.join(here, rel_path), "r") as fp: 14 | return fp.read() 15 | 16 | 17 | def get_version() -> Any: 18 | """ 19 | Read version from a file. 20 | """ 21 | for line in read("version.txt").splitlines(): 22 | if line.startswith("__version__"): 23 | delim = '"' if '"' in line else "'" 24 | return line.split(delim)[1] 25 | else: 26 | raise RuntimeError("Unable to find version string.") 27 | -------------------------------------------------------------------------------- /i8_terminal/types/chart_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_chart_param_types() -> List[Tuple[str, str]]: 7 | return [("bar", "Bar chart"), ("line", "Line chart"), ("candlestick", "Candlestick chart")] 8 | 9 | 10 | class ChartParamType(AutoCompleteChoice): 11 | name = "charttype" 12 | 13 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 14 | if not self.is_loaded: 15 | self.set_choices(get_chart_param_types()) 16 | 17 | if pre_populate and keyword.strip() == "": 18 | return self._choices[: self.size] 19 | return self.search_keyword(keyword) 20 | 21 | def __repr__(self) -> str: 22 | return "CHARTTYPE" 23 | -------------------------------------------------------------------------------- /i8_terminal/types/sort_order_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_sort_order_param_types() -> List[Tuple[str, str]]: 7 | return [("asc", "Ascending order"), ("desc", "Descending order")] 8 | 9 | 10 | class SortOrderParamType(AutoCompleteChoice): 11 | name = "sortordertype" 12 | 13 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 14 | if not self.is_loaded: 15 | self.set_choices(get_sort_order_param_types()) 16 | 17 | if pre_populate and keyword.strip() == "": 18 | return self._choices[: self.size] 19 | return self.search_keyword(keyword) 20 | 21 | def __repr__(self) -> str: 22 | return "SORTORDERTYPE" 23 | -------------------------------------------------------------------------------- /i8_terminal/types/market_indice_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_market_indice_types() -> List[Tuple[str, str]]: 7 | return [("$DJI", "Dow 30"), ("$SPX", "S&P 500"), ("$NDX", "NASDAQ 100")] 8 | 9 | 10 | class MarketIndiceParamType(AutoCompleteChoice): 11 | name = "marketindice" 12 | 13 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 14 | if not self.is_loaded: 15 | self.set_choices(get_market_indice_types()) 16 | 17 | if pre_populate and keyword.strip() == "": 18 | return self._choices[: self.size] 19 | return self.search_keyword(keyword) 20 | 21 | def __repr__(self) -> str: 22 | return "MARKETINDICE" 23 | -------------------------------------------------------------------------------- /i8_terminal/types/period_type_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_period_types() -> List[Tuple[str, str]]: 7 | return [("D", "Daily"), ("Q", "Quarterly"), ("FY", "Anual"), ("TTM", "Trailing 12-month"), ("YTD", "Year to date")] 8 | 9 | 10 | class PeriodTypeParamType(AutoCompleteChoice): 11 | name = "periodtype" 12 | 13 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 14 | if not self.is_loaded: 15 | self.set_choices(get_period_types()) 16 | 17 | if pre_populate and keyword.strip() == "": 18 | return self._choices[: self.size] 19 | return self.search_keyword(keyword) 20 | 21 | def __repr__(self) -> str: 22 | return "PERIODTYPE" 23 | -------------------------------------------------------------------------------- /i8_terminal/types/metric_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.common.metrics import get_all_metrics_df 4 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 5 | 6 | 7 | def get_metrics() -> List[Tuple[str, str]]: 8 | df = get_all_metrics_df()[["metric_name", "display_name"]] 9 | return list(df.to_records(index=False)) 10 | 11 | 12 | class MetricParamType(AutoCompleteChoice): 13 | name = "metric" 14 | 15 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 16 | if not self.is_loaded: 17 | self.set_choices(get_metrics()) 18 | 19 | if pre_populate and keyword.strip() == "": 20 | return self._choices[: self.size] 21 | 22 | return self.search_keyword(keyword) 23 | 24 | def __repr__(self) -> str: 25 | return "METRIC" 26 | -------------------------------------------------------------------------------- /i8_terminal/types/fin_statement_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_statements() -> List[Tuple[str, str]]: 7 | return [ 8 | ("income", "Income Statment"), 9 | ("cash_flow", "Cash Flow Statment"), 10 | ("balance_sheet", "Balance Sheet Statment"), 11 | ] 12 | 13 | 14 | class FinancialStatementParamType(AutoCompleteChoice): 15 | name = "financialstatement" 16 | 17 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 18 | if not self.is_loaded: 19 | self.set_choices(get_statements()) 20 | 21 | if pre_populate and keyword.strip() == "": 22 | return self._choices[: self.size] 23 | return self.search_keyword(keyword) 24 | 25 | def __repr__(self) -> str: 26 | return "FINANCIALSTATEMENT" 27 | -------------------------------------------------------------------------------- /i8_terminal/types/price_period_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_price_periods() -> List[Tuple[str, str]]: 7 | return [ 8 | ("1M", "One Month"), 9 | ("3M", "Three Months"), 10 | ("6M", "Six Months"), 11 | ("1Y", "One Year"), 12 | ("3Y", "Three Years"), 13 | ("5Y", "Five Years"), 14 | ] 15 | 16 | 17 | class PricePeriodParamType(AutoCompleteChoice): 18 | name = "priceperiod" 19 | 20 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 21 | if not self.is_loaded: 22 | self.set_choices(get_price_periods()) 23 | 24 | if pre_populate and keyword.strip() == "": 25 | return self._choices[: self.size] 26 | return self.search_keyword(keyword) 27 | 28 | def __repr__(self) -> str: 29 | return "PRICEPERIOD" 30 | -------------------------------------------------------------------------------- /i8_terminal/types/metric_view_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import investor8_sdk 4 | import pandas as pd 5 | 6 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 7 | 8 | 9 | def get_metric_view_names() -> List[Tuple[str, str]]: 10 | results = investor8_sdk.MetricsApi().get_list_metric_views() 11 | df = pd.DataFrame([d.to_dict() for d in results])[["view_name", "display_name"]] 12 | return list(df.to_records(index=False)) 13 | 14 | 15 | class MetricViewParamType(AutoCompleteChoice): 16 | name = "metricview" 17 | 18 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 19 | if not self.is_loaded: 20 | self.set_choices(get_metric_view_names()) 21 | 22 | if pre_populate and keyword.strip() == "": 23 | return self._choices[: self.size] 24 | return self.search_keyword(keyword) 25 | 26 | def __repr__(self) -> str: 27 | return "METRICVIEW" 28 | -------------------------------------------------------------------------------- /i8_terminal/types/metric_identifier_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | from i8_terminal.types.metric_param_type import MetricParamType 5 | from i8_terminal.types.period_param_type import PeriodParamType 6 | 7 | 8 | class MetricIdentifierParamType(AutoCompleteChoice): 9 | name = "metricidentifier" 10 | 11 | def __init__(self) -> None: 12 | self._metric_auto_comp = MetricParamType() 13 | self._period_auto_comp = PeriodParamType() 14 | 15 | def get_suggestions( 16 | self, keyword: str, pre_populate: bool = True, param_type: str = None, metric: str = None 17 | ) -> List[Tuple[str, str]]: 18 | if param_type == "metric": 19 | return self._metric_auto_comp.get_suggestions(keyword) 20 | else: 21 | return self._period_auto_comp.get_suggestions(keyword, metric=metric) # type: ignore 22 | 23 | def __repr__(self) -> str: 24 | return "METRICIDENTIFIER" 25 | -------------------------------------------------------------------------------- /i8_terminal/types/screening_profile_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import investor8_sdk 4 | import pandas as pd 5 | 6 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 7 | 8 | 9 | def get_screening_profile_names() -> List[Tuple[str, str]]: 10 | results = investor8_sdk.ScreenerApi().get_list_screening_profiles() 11 | df = pd.DataFrame([d.to_dict() for d in results])[["profile_name", "display_name"]] 12 | return list(df.to_records(index=False)) 13 | 14 | 15 | class ScreeningProfileParamType(AutoCompleteChoice): 16 | name = "screeningprofile" 17 | 18 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 19 | if not self.is_loaded: 20 | self.set_choices(get_screening_profile_names()) 21 | 22 | if pre_populate and keyword.strip() == "": 23 | return self._choices[: self.size] 24 | return self.search_keyword(keyword) 25 | 26 | def __repr__(self) -> str: 27 | return "SCREENINGPROFILE" 28 | -------------------------------------------------------------------------------- /i8_terminal/types/fin_period_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_fin_periods() -> List[Tuple[str, str]]: 7 | all_periods = [("FY", "Anual")] 8 | for i in range(1, 5): 9 | all_periods.append((f"Q{i}", f"Fiscal Quarter {i}")) 10 | all_periods.append((f"Q{i}TTM", f"Trailing 12 Months Ending Q{i}")) 11 | all_periods.append((f"Q{i}YTD", f"Year to Date Ending Q{i}")) 12 | return all_periods 13 | 14 | 15 | class FinancialsPeriodParamType(AutoCompleteChoice): 16 | name = "financialsperiod" 17 | 18 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 19 | if not self.is_loaded: 20 | self.set_choices(get_fin_periods()) 21 | 22 | if pre_populate and keyword.strip() == "": 23 | return self._choices[: self.size] 24 | return self.search_keyword(keyword) 25 | 26 | def __repr__(self) -> str: 27 | return "FINANCIALSPERIOD" 28 | -------------------------------------------------------------------------------- /setup_cx.py: -------------------------------------------------------------------------------- 1 | from cx_Freeze import Executable, setup 2 | from setuptools import find_packages 3 | 4 | from setup import COMMON_ARGS, PACKAGE_NAME 5 | 6 | upgrade_code = "{5CA39ABE-9649-34E5-8DA3-138D74AE7E40}" 7 | bdist_msi_options = { 8 | "add_to_path": True, 9 | "upgrade_code": upgrade_code, 10 | } 11 | build_exe_options = { 12 | "packages": find_packages(), 13 | "include_files": [ 14 | f"{PACKAGE_NAME}/assets/favicon.ico", 15 | f"{PACKAGE_NAME}/config.yml", 16 | f"{PACKAGE_NAME}/version.txt", 17 | ], 18 | "excludes": ["mypy", "isort", "black", "pyflakes"], 19 | } 20 | 21 | setup( 22 | **COMMON_ARGS, 23 | executables=[ 24 | Executable( 25 | f"{PACKAGE_NAME}/main.py", base=None, target_name="i8.exe", icon=f"{PACKAGE_NAME}/assets/favicon.ico" 26 | ), 27 | Executable( 28 | "update_msi.py", 29 | base=None, 30 | target_name="i8update.exe", 31 | ), 32 | ], 33 | options={"build_exe": build_exe_options, "bdist_msi": bdist_msi_options}, 34 | ) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 investoreight 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /i8_terminal/commands/notebook/notebook_launch.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | import nbformat as nbf 5 | from rich.console import Console 6 | 7 | from i8_terminal.commands.notebook import notebook 8 | from i8_terminal.common.cli import pass_command 9 | 10 | 11 | @notebook.command() 12 | @click.option("--name", "-n", help="Notebook file name.") 13 | @pass_command 14 | def launch(name: str) -> None: 15 | """ 16 | Launches jupyter notebook. 17 | 18 | Examples: 19 | 20 | `i8 notebook launch --name mynotebook` 21 | 22 | """ 23 | console = Console() 24 | with console.status("Launching notebook...", spinner="material"): 25 | nb = nbf.v4.new_notebook() 26 | text = "Welcome to notebook of i8 Terminal." 27 | 28 | code = ( 29 | "from i8_terminal.config import init_notebook\n" "from i8_terminal import api as i8\n\n" "init_notebook()" 30 | ) 31 | 32 | nb["cells"] = [nbf.v4.new_markdown_cell(text), nbf.v4.new_code_cell(code)] 33 | fname = f"{name}.ipynb" 34 | 35 | with open(fname, "w") as f: 36 | nbf.write(nb, f) 37 | 38 | os.system(f"jupyter notebook {name}.ipynb") 39 | -------------------------------------------------------------------------------- /i8_terminal/types/user_watchlists_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import investor8_sdk 4 | import pandas as pd 5 | 6 | from i8_terminal.config import USER_SETTINGS 7 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 8 | 9 | 10 | def get_user_watchlists() -> List[Tuple[str, str]]: 11 | results = investor8_sdk.UserApi().get_watchlists_by_user(user_id=USER_SETTINGS.get("user_id")) 12 | df = pd.DataFrame([d.to_dict() for d in results.watchlists])[["name"]] 13 | df["name"] = df["name"].apply(lambda x: f'"{x}"' if " " in x else x) 14 | df["desc"] = "" 15 | return list(df.to_records(index=False)) 16 | 17 | 18 | class UserWatchlistsParamType(AutoCompleteChoice): 19 | name = "userwatchlists" 20 | 21 | def get_suggestions(self, keyword: str, pre_populate: bool = False) -> List[Tuple[str, str]]: 22 | if not self.is_loaded: 23 | self.set_choices(get_user_watchlists()) 24 | 25 | if pre_populate and keyword.strip() == "": 26 | return self._choices[: self.size] 27 | 28 | return self.search_keyword(keyword) 29 | 30 | def __repr__(self) -> str: 31 | return "USERWATCHLISTS" 32 | -------------------------------------------------------------------------------- /i8_terminal/types/user_watchlist_tickers_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | import investor8_sdk 4 | 5 | from i8_terminal.common.stock_info import get_stocks 6 | from i8_terminal.config import USER_SETTINGS 7 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 8 | 9 | 10 | def get_user_watchlist_tickers() -> List[Tuple[str, str]]: 11 | results = investor8_sdk.UserApi().get_watchlists_by_user(user_id=USER_SETTINGS.get("user_id")) 12 | tickers = set(ticker for wl in results.watchlists for ticker in wl.tickers) 13 | stocks = get_stocks(True) 14 | return [(tk, name) for (tk, name) in stocks if tk in tickers] 15 | 16 | 17 | class UserWatchlistTickersParamType(AutoCompleteChoice): 18 | name = "userwatchlisttickers" 19 | 20 | def get_suggestions( 21 | self, keyword: str, pre_populate: bool = False, include_peers: bool = False 22 | ) -> List[Tuple[str, str]]: 23 | if not self.is_loaded: 24 | self.set_choices(get_user_watchlist_tickers()) 25 | 26 | if pre_populate and keyword.strip() == "": 27 | return self._choices[: self.size] 28 | 29 | return self.search_keyword(keyword) 30 | 31 | def __repr__(self) -> str: 32 | return "USERWATCHLISTTICKERS" 33 | -------------------------------------------------------------------------------- /i8_terminal/types/fin_identifier_param_type.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Tuple 3 | 4 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 5 | from i8_terminal.types.fin_period_param_type import FinancialsPeriodParamType 6 | from i8_terminal.types.ticker_param_type import TickerParamType 7 | 8 | 9 | class FinancialsIdentifierParamType(AutoCompleteChoice): 10 | name = "financials" 11 | 12 | def __init__(self) -> None: 13 | self._ticker_auto_comp = TickerParamType() 14 | self._period_auto_comp = FinancialsPeriodParamType() 15 | 16 | def get_suggestions( 17 | self, keyword: str, pre_populate: bool = False, param_type: str = None 18 | ) -> List[Tuple[str, str]]: 19 | if param_type == "ticker": 20 | return self._ticker_auto_comp.get_suggestions(keyword) 21 | elif param_type == "year": 22 | years = [(str(i), "") for i in range(datetime.now().year, 2008, -1)] 23 | if keyword.strip() == "": 24 | return years 25 | return [y for y in years if y[0].startswith(keyword)] 26 | else: 27 | return self._period_auto_comp.get_suggestions(keyword, True) 28 | 29 | def __repr__(self) -> str: 30 | return "FINANCIALS" 31 | -------------------------------------------------------------------------------- /i8_terminal/types/screening_value_field_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_value_fields() -> List[Tuple[str, str]]: 7 | return [ 8 | ("value", "Absolute Value"), 9 | ("rank", "Rank (SPX Rank)"), 10 | ("dow_rank", "Dow Rank"), 11 | ("sector_rank", "Sector Rank"), 12 | ("industry_rank", "Industry Rank"), 13 | ("percentile", "Percentile (SPX Percentile)"), 14 | ("dow_percentile", "Dow Percentile"), 15 | ("sector_percentile", "Sector Percentile"), 16 | ("industry_percentile", "Industry Percentile"), 17 | ] 18 | 19 | 20 | class ScreeningValueFieldParamType(AutoCompleteChoice): 21 | name = "screeningvaluefield" 22 | metrics_default_pt: Dict[str, str] = {} 23 | 24 | def get_suggestions(self, keyword: str, pre_populate: bool = True, metric: str = None) -> List[Tuple[str, str]]: 25 | if not self.is_loaded: 26 | self.set_choices(get_value_fields()) 27 | 28 | if pre_populate and keyword.strip() == "": 29 | return self._choices[: self.size] 30 | return self.search_keyword(keyword) 31 | 32 | def __repr__(self) -> str: 33 | return "SCREENINGVALUEFIELD" 34 | -------------------------------------------------------------------------------- /i8_terminal/commands/financials/financials_coverage.py: -------------------------------------------------------------------------------- 1 | import click 2 | import investor8_sdk 3 | from pandas import DataFrame 4 | from rich.console import Console 5 | 6 | from i8_terminal.commands.financials import financials 7 | from i8_terminal.common.cli import pass_command 8 | from i8_terminal.common.financials import available_fin_df2tree 9 | from i8_terminal.common.stock_info import validate_ticker 10 | from i8_terminal.types.ticker_param_type import TickerParamType 11 | 12 | 13 | def get_available_financials_df(ticker: str) -> DataFrame: 14 | available_fins = investor8_sdk.FinancialsApi().get_list_available_standardized_financials(ticker=ticker) 15 | return DataFrame([d.to_dict() for d in available_fins]) 16 | 17 | 18 | @financials.command() 19 | @click.option("--ticker", "-k", type=TickerParamType(), required=True, callback=validate_ticker, help="Company ticker.") 20 | @pass_command 21 | def coverage(ticker: str) -> None: 22 | """ 23 | Shows available financial statements of the given company. 24 | 25 | Examples: 26 | 27 | `i8 financials coverage --ticker AAPL` 28 | 29 | """ 30 | console = Console() 31 | with console.status("Fetching data...", spinner="material"): 32 | available_fin_df = get_available_financials_df(ticker) 33 | available_fins_tree = available_fin_df2tree(available_fin_df, ticker) 34 | console.print(available_fins_tree) 35 | -------------------------------------------------------------------------------- /i8_terminal/commands/company/company_search.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import click 4 | import investor8_sdk 5 | from pandas import DataFrame 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.company import company 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.layout import df2Table, format_df 11 | 12 | 13 | def search_stocks_df(keyword: str) -> DataFrame: 14 | results = investor8_sdk.SearchApi().search_stocks(keyword, 8) 15 | df = DataFrame([d.to_dict() for d in results]) 16 | return df[["ticker", "name"]] 17 | 18 | 19 | def format_stocks_df(df: DataFrame, target: str) -> DataFrame: 20 | formatters: Dict[str, Any] = {} 21 | col_names = { 22 | "ticker": "Ticker", 23 | "name": "Name", 24 | } 25 | return format_df(df, col_names, formatters) 26 | 27 | 28 | @company.command() 29 | @click.option("--keyword", "-k", required=True, help="Keyword can be ticker or company name.") 30 | @pass_command 31 | def search(keyword: str) -> None: 32 | """ 33 | Searches and shows all securities that match with the given KEYWORD. 34 | 35 | Examples: 36 | 37 | `i8 company search --keyword apl` 38 | 39 | """ 40 | console = Console() 41 | with console.status("Fetching data...", spinner="material"): 42 | df = search_stocks_df(keyword) 43 | df_formatted = format_stocks_df(df, "console") 44 | table = df2Table(df_formatted) 45 | console.print(table) 46 | -------------------------------------------------------------------------------- /i8_terminal/commands/screen/screen_losers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | from rich.console import Console 5 | 6 | from i8_terminal.commands.screen import screen 7 | from i8_terminal.common.cli import pass_command 8 | from i8_terminal.common.screen import get_top_stocks_df, render_top_stocks 9 | from i8_terminal.types.market_indice_param_type import MarketIndiceParamType 10 | from i8_terminal.types.metric_view_param_type import MetricViewParamType 11 | 12 | 13 | @screen.command() 14 | @click.option( 15 | "--index", 16 | "-i", 17 | type=MarketIndiceParamType(), 18 | default="$SPX", 19 | help="Index of market.", 20 | ) 21 | @click.option( 22 | "--view_name", "view_name", "-v", type=MetricViewParamType(), help="Metric view name in configuration file." 23 | ) 24 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 25 | @pass_command 26 | def losers(index: str, view_name: Optional[str], export_path: Optional[str]) -> None: 27 | """ 28 | Lists today loser companies. 29 | 30 | Examples: 31 | 32 | `i8 screen losers --index $SPX` 33 | 34 | """ 35 | console = Console() 36 | with console.status("Fetching data...", spinner="material"): 37 | df = get_top_stocks_df("losers", index, view_name) 38 | if df is None: 39 | console.print("No data found for today losers", style="yellow") 40 | return 41 | table = render_top_stocks(df, export_path=export_path) 42 | if table: 43 | console.print(table) 44 | -------------------------------------------------------------------------------- /i8_terminal/commands/screen/screen_gainers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | from rich.console import Console 5 | 6 | from i8_terminal.commands.screen import screen 7 | from i8_terminal.common.cli import pass_command 8 | from i8_terminal.common.screen import get_top_stocks_df, render_top_stocks 9 | from i8_terminal.types.market_indice_param_type import MarketIndiceParamType 10 | from i8_terminal.types.metric_view_param_type import MetricViewParamType 11 | 12 | 13 | @screen.command() 14 | @click.option( 15 | "--index", 16 | "-i", 17 | type=MarketIndiceParamType(), 18 | default="$SPX", 19 | help="Index of market.", 20 | ) 21 | @click.option( 22 | "--view_name", "view_name", "-v", type=MetricViewParamType(), help="Metric view name in configuration file." 23 | ) 24 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 25 | @pass_command 26 | def gainers(index: str, view_name: Optional[str], export_path: Optional[str]) -> None: 27 | """ 28 | Lists today winner companies. 29 | 30 | Examples: 31 | 32 | `i8 screen gainers --index $SPX` 33 | 34 | """ 35 | console = Console() 36 | with console.status("Fetching data...", spinner="material"): 37 | df = get_top_stocks_df("winners", index, view_name) 38 | if df is None: 39 | console.print("No data found for today winners", style="yellow") 40 | return 41 | table = render_top_stocks(df, export_path=export_path, ascending=False) 42 | if table: 43 | console.print(table) 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from setuptools import find_packages, setup 4 | 5 | from i8_terminal.utils_setup import get_version 6 | 7 | PACKAGE_NAME = "i8_terminal" 8 | 9 | 10 | def get_long_description() -> str: 11 | with open("README.md", "r", encoding="utf-8") as fh: 12 | long_description = fh.read() 13 | return long_description 14 | 15 | 16 | def get_requirements() -> List[str]: 17 | with open("requirements.txt", "r", encoding="utf-8") as fh: 18 | requirements = fh.read() 19 | return requirements.strip().split("\n") 20 | 21 | 22 | project_urls = { 23 | "Homepage": "https://i8terminal.io/", 24 | "Documentation": "https://docs.i8terminal.io/", 25 | "Download": "https://i8terminal.io/download", 26 | "Source Code": "https://github.com/investoreight/i8-terminal", 27 | "Bug Tracker": "https://github.com/investoreight/i8-terminal/issues", 28 | } 29 | 30 | 31 | COMMON_ARGS = dict( 32 | name="i8-terminal", 33 | version=get_version(), 34 | author="investoreight", 35 | author_email="info@investoreight.com", 36 | license="MIT", 37 | description="Investor8 CLI", 38 | long_description=get_long_description(), 39 | long_description_content_type="text/markdown", 40 | ) 41 | 42 | 43 | setup( 44 | **COMMON_ARGS, 45 | packages=find_packages(), 46 | package_dir={PACKAGE_NAME: PACKAGE_NAME, "": PACKAGE_NAME}, 47 | project_urls=project_urls, 48 | install_requires=get_requirements(), 49 | entry_points={"console_scripts": [f"i8={PACKAGE_NAME}.main:main"]}, 50 | include_package_data=True, 51 | ) 52 | -------------------------------------------------------------------------------- /docs/generate_metrics.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import investor8_sdk 5 | import pandas as pd 6 | from jinja2 import Template 7 | 8 | API_KEY = os.getenv("I8_CORE_API_KEY") 9 | 10 | def main(): 11 | investor8_sdk.ApiClient().configuration.api_key["apiKey"] = API_KEY 12 | all_metrics = investor8_sdk.MetricsApi().get_list_metrics_metadata(page_size=1000) 13 | all_metrics_df = pd.DataFrame([d.to_dict() for d in all_metrics]) 14 | all_metrics_desc = investor8_sdk.MetricsApi().get_list_metrics_description(page_size=1000) 15 | all_metrics_desc_df = pd.DataFrame([d.to_dict() for d in all_metrics_desc])[["metric_name", "description"]] 16 | all_metrics_df = all_metrics_df.merge(all_metrics_desc_df, on="metric_name") 17 | all_metrics_df = all_metrics_df.sort_values("type", ascending=False) 18 | metrics_list = json.loads(all_metrics_df.to_json(orient="records")) 19 | with open("docs/metrics/index.md.j2", "r") as reader: 20 | index_template = Template(reader.read()) 21 | 22 | with open("docs/metrics/index.md", "w") as writer: 23 | writer.write(index_template.render({"metrics": metrics_list})) 24 | 25 | with open("docs/metrics/metric.md.j2", "r") as reader: 26 | metric_template = Template(reader.read()) 27 | 28 | for m in metrics_list: 29 | with open(f"docs/metrics/{m['metric_name']}.md", "w") as writer: 30 | writer.write(metric_template.render({"m": m, "description": m["description"].replace("’", "'")})) 31 | 32 | print("Metric docs are generated successfully!") 33 | 34 | if __name__ == '__main__': 35 | main() 36 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_create.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import click 4 | import investor8_sdk 5 | from rich.console import Console 6 | 7 | from i8_terminal.commands.watchlist import watchlist 8 | from i8_terminal.common.cli import pass_command 9 | from i8_terminal.common.stock_info import get_tickers_list, validate_tickers 10 | from i8_terminal.config import USER_SETTINGS 11 | from i8_terminal.types.ticker_param_type import TickerParamType 12 | 13 | 14 | def create_watchlist(name: str, tickers: List[str]) -> None: 15 | investor8_sdk.UserApi().create_watchlist( 16 | body={"UserId": USER_SETTINGS.get("user_id"), "Name": name, "Tickers": tickers} 17 | ) 18 | 19 | 20 | @watchlist.command() 21 | @click.option( 22 | "--name", 23 | "-n", 24 | help="Name of the watchlist you want to create.", 25 | ) 26 | @click.option( 27 | "--tickers", 28 | "-k", 29 | type=TickerParamType(), 30 | required=True, 31 | callback=validate_tickers, 32 | help="Comma-separated list of tickers.", 33 | ) 34 | @pass_command 35 | def create(name: str, tickers: str) -> None: 36 | """ 37 | Creates new watchlist with given name and tickers. 38 | 39 | Examples: 40 | 41 | `i8 watchlist create --name MyWatchlist --tickers AAPL,MSFT` 42 | 43 | """ 44 | console = Console() 45 | tickers_list = get_tickers_list(tickers.replace(" ", "").upper()) 46 | with console.status("Creating Watchlist...", spinner="material"): 47 | create_watchlist(name, tickers_list) 48 | console.print(f"✅ Watchlist [cyan]{name}[/cyan] is created successfully!") 49 | -------------------------------------------------------------------------------- /i8_terminal/types/screening_operator_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple 2 | 3 | from i8_terminal.common.metrics import get_all_metrics_df 4 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 5 | 6 | SCREENING_OPERATORS: Dict[str, List[Tuple[str, str]]] = { 7 | "Numeric": [ 8 | ("gt", "greater than"), 9 | ("lt", "less than"), 10 | ("bw", "between"), 11 | ], 12 | "Non-Numeric": [ 13 | ("eq", "equal"), 14 | ], 15 | } 16 | 17 | 18 | def get_metrics_data_format_dict() -> Dict[str, str]: 19 | df = get_all_metrics_df()[["metric_name", "data_format"]] 20 | return dict([(i, j) for i, j in zip(df.metric_name, df.data_format)]) 21 | 22 | 23 | class ScreeningOperatorParamType(AutoCompleteChoice): 24 | name = "screeningoperator" 25 | metrics_default_pt: Dict[str, str] = {} 26 | 27 | def get_suggestions(self, keyword: str, pre_populate: bool = True, metric: str = None) -> List[Tuple[str, str]]: 28 | if not self.is_loaded: 29 | self.metrics_data_formats = get_metrics_data_format_dict() 30 | self.set_choices( 31 | SCREENING_OPERATORS.get( 32 | "Numeric" 33 | if self.metrics_data_formats.get(metric, "str") in ["float", "int", "unsigned_int"] # type: ignore 34 | else "Non-Numeric" 35 | ) 36 | ) # type: ignore 37 | 38 | if pre_populate and keyword.strip() == "": 39 | return self._choices[: self.size] 40 | return self.search_keyword(keyword) 41 | 42 | def __repr__(self) -> str: 43 | return "SCREENINGOPERATOR" 44 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_list.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | import investor8_sdk 4 | from investor8_sdk.models import WatchlistDto 5 | from pandas import DataFrame 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.watchlist import watchlist 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.layout import df2Table, format_df 11 | from i8_terminal.config import USER_SETTINGS 12 | 13 | 14 | def get_user_watchlists() -> DataFrame: 15 | results = investor8_sdk.UserApi().get_watchlists_by_user(user_id=USER_SETTINGS.get("user_id")) 16 | return prepare_watchlists_df(results.watchlists) 17 | 18 | 19 | def prepare_watchlists_df(watchlists: List[WatchlistDto]) -> DataFrame: 20 | wls: List[Dict[str, str]] = [] 21 | for wl in watchlists: 22 | wls.append( 23 | { 24 | "name": wl.name, 25 | "tickers": f"{', '.join(str(ticker) for ticker in wl.tickers[:5])} {f'and {len(wl.tickers)-5} more' if len(wl.tickers) > 5 else ''}", # noqa: E501 26 | } 27 | ) 28 | watchlists_df = DataFrame(wls) 29 | col_names = { 30 | "name": "Name", 31 | "tickers": "Tickers", 32 | } 33 | return format_df(watchlists_df, col_names, {}) 34 | 35 | 36 | @watchlist.command() 37 | @pass_command 38 | def list() -> None: 39 | """ 40 | Lists user watchlists. 41 | 42 | Examples: 43 | 44 | `i8 watchlist list` 45 | 46 | """ 47 | console = Console() 48 | with console.status("Fetching data...", spinner="material"): 49 | df = get_user_watchlists() 50 | table = df2Table(df) 51 | console.print(table) 52 | -------------------------------------------------------------------------------- /i8_terminal/services/earnings.py: -------------------------------------------------------------------------------- 1 | import investor8_sdk 2 | from pandas import DataFrame 3 | 4 | from i8_terminal.common.utils import status 5 | from i8_terminal.service_result.column_info import ColumnInfo 6 | from i8_terminal.service_result.columns_context import ColumnsContext 7 | from i8_terminal.service_result.earning_list_result import EarningsListResult 8 | 9 | 10 | @status() 11 | def get_earnings_list(ticker: str, size: int) -> EarningsListResult: 12 | historical_earnings = investor8_sdk.EarningsApi().get_historical_earnings(ticker, size=size) 13 | historical_earnings = [d.to_dict() for d in historical_earnings] 14 | df = DataFrame(historical_earnings) 15 | df["period"] = df.fyq.str[2:-2] + " " + df.fyq.str[-2:] 16 | df.rename( 17 | columns={ 18 | "actual_report_time": "earning_date", 19 | "eps_ws": "eps_consensus", 20 | "revenue_ws": "revenue_consensus", 21 | "call_time": "earning_call_time", 22 | }, 23 | inplace=True, 24 | ) 25 | cc = ColumnsContext( 26 | [ 27 | ColumnInfo(name="period", col_type="context", display_name="Period", data_type="str", unit="string"), 28 | ColumnInfo(name="earning_date", col_type="metric"), 29 | ColumnInfo(name="earning_call_time", col_type="metric"), 30 | ColumnInfo(name="eps_consensus", col_type="metric"), 31 | ColumnInfo(name="eps_actual", col_type="metric"), 32 | ColumnInfo(name="eps_surprise", col_type="metric"), 33 | ColumnInfo(name="revenue_consensus", col_type="metric"), 34 | ColumnInfo(name="revenue_actual", col_type="metric"), 35 | ColumnInfo(name="revenue_surprise", col_type="metric"), 36 | ] 37 | ) 38 | return EarningsListResult(df, cc) 39 | -------------------------------------------------------------------------------- /i8_terminal/service_result/columns_context.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from i8_terminal.common.metrics import get_all_metrics_df 4 | from i8_terminal.i8_exception import I8Exception 5 | from i8_terminal.service_result.column_info import ColumnInfo 6 | 7 | 8 | class ColumnsContext: 9 | def __init__(self, col_infos: List[ColumnInfo]): 10 | self._col_infos = col_infos 11 | self._enrich_col_infos() 12 | self._col_info_dict = {ci.name: ci for ci in self._col_infos} 13 | 14 | def _enrich_col_infos(self) -> None: 15 | all_metrics_dict = self.get_metrics_dict() 16 | 17 | for ci in self._col_infos: 18 | if ci.col_type == "metric": 19 | if ci.name not in all_metrics_dict: 20 | raise I8Exception( 21 | f"Metric `{ci.name}` is not a known metric! You need to explicitly define the metadata!" 22 | ) 23 | ci.enrich(all_metrics_dict[ci.name]) 24 | 25 | def get_metrics_dict(self) -> Dict[str, ColumnInfo]: 26 | metrics_df = get_all_metrics_df() 27 | metrics_dict: Dict[str, ColumnInfo] = {} 28 | for _, r in metrics_df.iterrows(): 29 | metrics_dict[r["metric_name"]] = ColumnInfo( 30 | r["metric_name"], "metric", r["display_name"], r["data_format"], r["unit"] # , r["colorable"] 31 | ) 32 | return metrics_dict 33 | 34 | def get_col_infos(self) -> List[ColumnInfo]: 35 | return self._col_infos 36 | 37 | def get_col_info_dict(self) -> Dict[str, ColumnInfo]: 38 | return self._col_info_dict 39 | 40 | def get_col_info(self, name: str) -> ColumnInfo: 41 | if name not in self._col_info_dict: 42 | raise I8Exception(f"Column `{name}` is not found!") 43 | return self._col_info_dict[name] 44 | -------------------------------------------------------------------------------- /i8_terminal/commands/earnings/earnings_list.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | from rich.console import Console 5 | 6 | import i8_terminal.api.earnings as earnings_api 7 | from i8_terminal.commands.earnings import earnings 8 | from i8_terminal.common.cli import pass_command 9 | from i8_terminal.common.layout import df2Table 10 | from i8_terminal.common.stock_info import validate_ticker 11 | from i8_terminal.common.utils import export_data, export_to_html 12 | from i8_terminal.config import APP_SETTINGS 13 | from i8_terminal.service_result.earning_list_result import EarningsListResult 14 | from i8_terminal.types.ticker_param_type import TickerParamType 15 | 16 | 17 | @earnings.command() 18 | @click.option("--ticker", "-k", type=TickerParamType(), required=True, callback=validate_ticker, help="Company ticker.") 19 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 20 | @pass_command 21 | def list(ticker: str, export_path: Optional[str]) -> None: 22 | """ 23 | Lists upcoming company earnings. 24 | 25 | Examples: 26 | 27 | `i8 earnings list --ticker AAPL` 28 | 29 | """ 30 | earnings_list: EarningsListResult = earnings_api.list(ticker, 10) 31 | if export_path: 32 | if export_path.split(".")[-1] == "html": 33 | df = earnings_list.to_df() 34 | table = df2Table(df) 35 | export_to_html(table, export_path) 36 | else: 37 | export_data( 38 | earnings_list.to_df("raw"), 39 | export_path, 40 | column_width=18, 41 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 42 | ) 43 | else: 44 | df = earnings_list.to_df() 45 | console = Console() 46 | console.print(earnings_list._to_rich_table("humanize", "default")) 47 | -------------------------------------------------------------------------------- /i8_terminal/common/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from functools import update_wrapper 3 | from typing import Any, Dict, Optional 4 | 5 | import click 6 | import investor8_sdk 7 | 8 | from i8_terminal.config import USER_SETTINGS 9 | from i8_terminal.utils_setup import get_version 10 | 11 | 12 | def get_click_command_path(ctx: Any, parsed_options_dict: Optional[Dict[str, str]] = None) -> str: 13 | command_path = ctx.command_path.replace(" ", " ") 14 | cm_path = f"i8 {' '.join(command_path.split(' ')[1:])}" 15 | params = ctx.params 16 | args = [] 17 | options = {} 18 | for p in ctx.command.params: 19 | if p.param_type_name == "argument": 20 | args.append(params[p.name]) 21 | elif p.param_type_name == "option" and params[p.name] is not None: 22 | options[f"--{p.name}"] = params[p.name] if type(params[p.name]) != bool else None 23 | 24 | if parsed_options_dict: 25 | options = {**options, **parsed_options_dict} 26 | return ( 27 | f"{cm_path} {' '.join([f'{k} {val}' if val else k for (k, val) in options.items()])} {''.join(args)}".rstrip() 28 | ) 29 | 30 | 31 | def log_terminal_usage(ctx: click.Context, exception: Optional[str] = "") -> None: 32 | if USER_SETTINGS.get("allow_terminal_logging"): 33 | investor8_sdk.UserApi().log_terminal_usage( 34 | body={ 35 | "Command": ctx.obj["command"], 36 | "Version": get_version(), 37 | "OS": sys.platform, 38 | "AppInstanceId": USER_SETTINGS.get("app_instance_id"), 39 | "Exception": exception, 40 | } 41 | ) 42 | 43 | 44 | def pass_command(f: Any) -> Any: 45 | @click.pass_context 46 | def new_func(ctx: click.Context, *args: Any, **kwargs: Any) -> Any: 47 | ctx.obj["command"] = get_click_command_path(ctx) 48 | return ctx.invoke(f, *args, **kwargs) 49 | 50 | return update_wrapper(new_func, f) 51 | -------------------------------------------------------------------------------- /i8_terminal/types/period_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple 2 | 3 | from i8_terminal.common.metrics import get_all_metrics_default_period_types_dict 4 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 5 | 6 | PERIOD_TYPES: Dict[str, List[Tuple[str, str]]] = { 7 | "D": [("p", "Default period type (daily)")], 8 | "R": [("p", "Default period type (realtime)")], 9 | "FY": [ 10 | ("fy", "most recent fiscal year (default)"), 11 | ("q", "most recent quarter"), 12 | ("ttm", "trailing 12 months"), 13 | ("ytd", "year to date"), 14 | ], 15 | "Q": [ 16 | ("q", "most recent quarter (default)"), 17 | ("fy", "most recent fiscal year"), 18 | ("ttm", "trailing 12 months"), 19 | ("ytd", "year to date"), 20 | ], 21 | "TTM": [ 22 | ("ttm", "trailing 12 months (default)"), 23 | ("fy", "most recent fiscal year"), 24 | ("q", "most recent quarter"), 25 | ("ytd", "year to date"), 26 | ], 27 | "YTD": [ 28 | ("ytd", "year to date (default)"), 29 | ("fy", "most recent fiscal year"), 30 | ("q", "most recent quarter"), 31 | ("ttm", "trailing 12 months"), 32 | ], 33 | } 34 | 35 | 36 | class PeriodParamType(AutoCompleteChoice): 37 | name = "period" 38 | metrics_default_pt: Dict[str, str] = {} 39 | 40 | def get_suggestions(self, keyword: str, pre_populate: bool = True, metric: str = None) -> List[Tuple[str, str]]: 41 | if not self.is_loaded: 42 | self.metrics_default_pt = get_all_metrics_default_period_types_dict() 43 | self.set_choices( 44 | PERIOD_TYPES.get(self.metrics_default_pt[metric if metric else ""], [("p", "Default period type (NA)")]) 45 | ) 46 | 47 | if pre_populate and keyword.strip() == "": 48 | return self._choices[: self.size] 49 | return self.search_keyword(keyword) 50 | 51 | def __repr__(self) -> str: 52 | return "PERIOD" 53 | -------------------------------------------------------------------------------- /i8_terminal/commands/metrics/metrics_search.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import click 4 | from pandas import DataFrame 5 | from rich.console import Console 6 | 7 | from i8_terminal.commands.metrics import metrics 8 | from i8_terminal.common.cli import pass_command 9 | from i8_terminal.common.layout import df2Table, format_df 10 | from i8_terminal.common.metrics import get_all_metrics_df 11 | 12 | 13 | def search_metrics_df(keyword: str) -> Optional[DataFrame]: 14 | keyword = keyword.lower() 15 | metrics_df = get_all_metrics_df() 16 | metrics_df["lower_display_name"] = metrics_df["display_name"].str.lower() 17 | result = metrics_df[ 18 | metrics_df["metric_name"].str.contains(keyword) | metrics_df["lower_display_name"].str.contains(keyword) 19 | ] 20 | if result.empty: 21 | return None 22 | return result 23 | 24 | 25 | def format_metrics_df(df: DataFrame) -> DataFrame: 26 | formatters: Dict[str, Any] = {} 27 | col_names = { 28 | "metric_name": "Metric Name", 29 | "display_name": "Display Name", 30 | "unit": "Unit", 31 | "type": "Type", 32 | "display_format": "Display Format", 33 | "data_format": "Data Format", 34 | "period_type_default": "Default Period Type", 35 | } 36 | return format_df(df, col_names, formatters) 37 | 38 | 39 | @metrics.command() 40 | @click.option("--keyword", "-k", required=True, help="Keyword can be metric name.") 41 | @pass_command 42 | def search(keyword: str) -> None: 43 | """ 44 | Searches and shows all metrics that match with the given KEYWORD. 45 | 46 | Examples: 47 | 48 | `i8 metrics search --keyword return` 49 | 50 | """ 51 | console = Console() 52 | with console.status("Fetching data...", spinner="material"): 53 | df = search_metrics_df(keyword) 54 | if df is None: 55 | console.print(f"No metrics found for keyword '{keyword}'", style="yellow") 56 | return 57 | df_formatted = format_metrics_df(df) 58 | table = df2Table(df_formatted) 59 | console.print(table) 60 | -------------------------------------------------------------------------------- /i8_terminal/service_result/earning_list_result.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | 3 | import plotly.express as px 4 | from pandas import DataFrame 5 | 6 | from i8_terminal.common.formatting import color 7 | from i8_terminal.service_result.columns_context import ColumnsContext 8 | from i8_terminal.service_result.service_result import ServiceResult 9 | 10 | 11 | class EarningsListResult(ServiceResult): 12 | def __init__(self, data: DataFrame, columns_context: ColumnsContext): 13 | super().__init__(data, columns_context) 14 | 15 | def __repr__(self) -> str: 16 | return repr(self._df.head(2)) 17 | 18 | def __create_plot_traces(self, df: DataFrame, column: str, beat_color: str) -> List[Any]: 19 | fig_traces = [] 20 | 21 | fig = px.bar( 22 | df, 23 | x="period", 24 | y=column, 25 | category_orders={"period": df["period"]}, 26 | color=beat_color, 27 | color_discrete_map={"Yes": color.i8_green.value, "No": color.i8_red.value}, 28 | labels={"eps_beat_?": "Beat?", "eps_actual": "EPS $", "period": ""}, 29 | ) 30 | 31 | fig.update_layout(legend_title_text="EPS Beat?") 32 | fig.update_layout(title="EPS per Quarter", font=dict(color=color.i8_dark.value)) 33 | for trace in range(len(fig["data"])): 34 | fig_traces.append(fig["data"][trace]) 35 | return fig_traces 36 | 37 | def __add_traces_to_fig(self, traces: List[Any], fig: Any, row: int, col: int) -> Any: 38 | for trace in traces: 39 | fig.append_trace(trace, row=row, col=col) 40 | return fig 41 | 42 | def to_plot(self, x: str = None, y: List[str] = None, kind: str = "bar") -> Any: 43 | if x is None: 44 | x = "period" 45 | if y is None: 46 | y = ["eps_consensus", "eps_actual"] 47 | return self._to_plot(x, y, kind) 48 | 49 | def to_xlsx(self, path: str, formatter: Optional[str] = None, styler: Optional[str] = None) -> None: 50 | df = self.to_df() 51 | df.to_excel(path) 52 | -------------------------------------------------------------------------------- /i8_terminal/commands/user/webserver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import signal 4 | import sys 5 | from threading import Timer 6 | from typing import Any 7 | 8 | import click 9 | import investor8_sdk 10 | from flask import Flask, redirect, request 11 | 12 | from i8_terminal.config import APP_SETTINGS, save_user_settings 13 | 14 | app = Flask(__name__) 15 | 16 | 17 | def _configure_flask() -> None: 18 | # TODO: the following method does not disable dash logging (it should) 19 | log = logging.getLogger("werkzeug") 20 | log.setLevel(logging.ERROR) 21 | log.disabled = True 22 | 23 | cli = sys.modules["flask.cli"] 24 | cli.show_server_banner = lambda *x: None # type: ignore 25 | 26 | 27 | @app.route("/") 28 | def login() -> Any: 29 | verification_code = request.args.get("verificationCode") 30 | if verification_code: 31 | body = {"ReqId": app.config["REQUEST_ID"], "VerificationCode": verification_code} 32 | try: 33 | resp = investor8_sdk.UserApi().login_with_code(body=body) 34 | user_setting = { 35 | "i8_core_token": resp.token, 36 | "i8_core_api_key": resp.api_key, 37 | "user_id": resp.user_id, 38 | "allow_terminal_logging": resp.allow_terminal_logging, 39 | } 40 | save_user_settings(user_setting) 41 | click.echo("User logged in successfully!") 42 | Timer(4.0, shutdown_server).start() 43 | return redirect("https://www.investoreight.com/account/loginsuccessful") 44 | except Exception: 45 | click.echo("Login failed. Invalid request-id or verification code.") 46 | else: 47 | click.echo("Missing the required parameter 'verificationCode'!") 48 | shutdown_server() 49 | 50 | 51 | def shutdown_server() -> None: 52 | os.kill(os.getpid(), signal.SIGINT) # Shutdown server 53 | 54 | 55 | def run_server(req_id: str) -> None: 56 | _configure_flask() 57 | app.config["REQUEST_ID"] = req_id 58 | app.debug = False 59 | app.run(host="localhost", port=APP_SETTINGS["app"]["port"]) 60 | -------------------------------------------------------------------------------- /i8_terminal/types/screening_condition_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | from i8_terminal.types.condition_param_type import ConditionParamType 5 | from i8_terminal.types.metric_param_type import MetricParamType 6 | from i8_terminal.types.period_param_type import PeriodParamType 7 | from i8_terminal.types.screening_operator_param_type import ScreeningOperatorParamType 8 | from i8_terminal.types.screening_value_field_param_type import ( 9 | ScreeningValueFieldParamType, 10 | ) 11 | 12 | 13 | class ScreeningConditionParamType(AutoCompleteChoice): 14 | name = "screeningcondition" 15 | 16 | def __init__(self) -> None: 17 | self._metric_auto_comp = MetricParamType() 18 | self._period_auto_comp = PeriodParamType() 19 | self._value_field_auto_comp = ScreeningValueFieldParamType() 20 | self._operator_auto_comp = ScreeningOperatorParamType() 21 | self._condition_auto_comp = ConditionParamType() 22 | 23 | def get_suggestions( 24 | self, 25 | keyword: str, 26 | pre_populate: bool = True, 27 | param_type: str = None, 28 | metric: str = None, 29 | period: str = None, 30 | value_field: str = None, 31 | ) -> List[Tuple[str, str]]: 32 | if param_type == "metric": 33 | return self._metric_auto_comp.get_suggestions(keyword) 34 | elif param_type == "period": 35 | return self._period_auto_comp.get_suggestions(keyword, metric=metric) # type: ignore 36 | elif param_type == "value_field": 37 | return self._value_field_auto_comp.get_suggestions(keyword, metric=metric) # type: ignore 38 | elif param_type == "operator": 39 | return self._operator_auto_comp.get_suggestions(keyword, metric=metric) # type: ignore 40 | else: 41 | return self._condition_auto_comp.get_suggestions( 42 | keyword, metric=metric, period=period, value_field=value_field 43 | ) # type: ignore 44 | 45 | def __repr__(self) -> str: 46 | return "SCREENINGCONDITION" 47 | -------------------------------------------------------------------------------- /i8_terminal/types/indicator_param_type.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 4 | 5 | 6 | def get_indicators() -> List[Tuple[str, str]]: 7 | return [ 8 | ("ma5", "5 Days Moving Average"), 9 | ("ma12", "12 Days Moving Average"), 10 | ("ma26", "26 Days Moving Average"), 11 | ("ma52", "26 Days Moving Average"), 12 | ("ema5", "5 Days Exponential Moving Average"), 13 | ("ema12", "12 Days Exponential Moving Average"), 14 | ("ema26", "26 Days Exponential Moving Average"), 15 | ("ema52", "52 Days Exponential Moving Average"), 16 | ("rsi", "Relative strength index (14 Days)"), 17 | ("rsi_7d", "Relative strength index (7 Days)"), 18 | ("rsi_1m", "Relative strength index (1 Month)"), 19 | ("rsi_3m", "Relative strength index (3 Months)"), 20 | ("alpha", "Alpha (1 Year)"), 21 | ("alpha_1w", "Alpha (1 Week)"), 22 | ("alpha_2w", "Alpha (2 Weeks)"), 23 | ("alpha_1m", "Alpha (1 Months)"), 24 | ("alpha_3m", "Alpha (3 Months)"), 25 | ("alpha_6m", "Alpha (6 Months)"), 26 | ("alpha_2y", "Alpha (2 Years)"), 27 | ("alpha_5y", "Alpha (5 Years)"), 28 | ("beta", "Beta (1 Year)"), 29 | ("beta_1w", "Beta (1 Week)"), 30 | ("beta_2w", "Beta (2 Weeks)"), 31 | ("beta_1m", "Beta (1 Months)"), 32 | ("beta_3m", "Beta (3 Months)"), 33 | ("beta_6m", "Beta (6 Months)"), 34 | ("beta_2y", "Beta (2 Years)"), 35 | ("beta_5y", "Beta (5 Years)"), 36 | ("volume", "Volume"), 37 | ] 38 | 39 | 40 | class IndicatorParamType(AutoCompleteChoice): 41 | name = "indicator" 42 | 43 | def get_suggestions(self, keyword: str, pre_populate: bool = True) -> List[Tuple[str, str]]: 44 | if not self.is_loaded: 45 | self.set_choices(get_indicators()) 46 | 47 | if pre_populate and keyword.strip() == "": 48 | return self._choices[: self.size] 49 | return self.search_keyword(keyword) 50 | 51 | def __repr__(self) -> str: 52 | return "INDICATOR" 53 | -------------------------------------------------------------------------------- /i8_terminal/types/command_parser.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | from typing import Callable, List, Union, cast 3 | 4 | import click 5 | from click import Group 6 | from click.decorators import F 7 | from click.shell_completion import _resolve_context 8 | from prompt_toolkit.document import Document 9 | 10 | 11 | class CompleterContext: 12 | def __init__( 13 | self, 14 | cli: Callable[[F], Group], 15 | click_ctx: click.Context, 16 | tokens: List[str], 17 | used_options: List[str], 18 | last_option: Union[str, None], 19 | incomplete: str, 20 | ) -> None: 21 | self.cli = cli 22 | self.click_ctx = click_ctx 23 | self.tockens = tokens 24 | self.used_options = used_options 25 | self.last_option = last_option 26 | self.incomplete = incomplete 27 | 28 | 29 | class CommandParser: 30 | def __init__(self, cli: Callable[[F], Group]) -> None: 31 | self.cli = cast(Group, cli) 32 | 33 | def parse(self, document: Document) -> Union[CompleterContext, None]: 34 | tokens = document.text.split(" ") 35 | used_options = [p for p in tokens if p.startswith("-")] 36 | last_option = tokens[-2] if len(tokens) > 2 and tokens[-2].startswith("-") else None 37 | 38 | try: 39 | args = shlex.split(document.text_before_cursor) 40 | except ValueError: 41 | # Invalid command, perhaps caused by missing closing quotation. 42 | return None 43 | 44 | cursor_within_command = document.text_before_cursor.rstrip() == document.text_before_cursor 45 | 46 | if args and cursor_within_command: 47 | # We've entered some text and no space, give completions for the 48 | # current word. 49 | incomplete = args.pop() 50 | else: 51 | # We've not entered anything, either at all or for the current 52 | # command, so give all relevant completions for this context. 53 | incomplete = "" 54 | ctx = _resolve_context(self.cli, {}, "", args) 55 | 56 | return CompleterContext(self.cli, ctx, tokens, used_options, last_option, incomplete) 57 | -------------------------------------------------------------------------------- /i8_terminal/commands/metrics/metrics_describe.py: -------------------------------------------------------------------------------- 1 | import click 2 | from rich.console import Console 3 | from rich.table import Table 4 | 5 | from i8_terminal.commands.metrics import metrics 6 | from i8_terminal.common.cli import pass_command 7 | from i8_terminal.common.formatting import styling_markdown_text 8 | from i8_terminal.common.metrics import get_metric_info 9 | from i8_terminal.types.metric_param_type import MetricParamType 10 | 11 | 12 | def get_metric_info_table(metric: str) -> Table: 13 | h_color = "cyan" 14 | metric_info = get_metric_info(metric) 15 | metric_info_table = Table(padding=(0, 0, 1, 1), box=None) 16 | metric_info_table.add_column(justify="right", width=25) 17 | metric_info_table.add_column(justify="left") 18 | metric_info_table.add_row(f"[{h_color}]Metric Name[/{h_color}]", metric) 19 | metric_info_table.add_row(f"[{h_color}]Display Name[/{h_color}]", metric_info["display_name"]) 20 | metric_info_table.add_row(f"[{h_color}]Unit[/{h_color}]", metric_info["unit"]) 21 | metric_info_table.add_row(f"[{h_color}]Type[/{h_color}]", metric_info["type"]) 22 | metric_info_table.add_row(f"[{h_color}]Display Format[/{h_color}]", metric_info["display_format"]) 23 | metric_info_table.add_row(f"[{h_color}]Default Period type[/{h_color}]", metric_info["default_period_type"]) 24 | metric_info_table.add_row( 25 | f"[{h_color}]Url[/{h_color}]", f"[blue]https://docs.i8terminal.io/metrics/{metric}/[/blue]" 26 | ) 27 | metric_info_table.add_row(f"[{h_color}]Description[/{h_color}]", styling_markdown_text(metric_info["description"])) 28 | if metric_info["remarks"]: 29 | metric_info_table.add_row(f"[{h_color}]Remarks[/{h_color}]", styling_markdown_text(metric_info["remarks"])) 30 | return metric_info_table 31 | 32 | 33 | @metrics.command() 34 | @click.option("--name", "-n", type=MetricParamType(), required=True, help="Metric name.") 35 | @pass_command 36 | def describe(name: str) -> None: 37 | """ 38 | Describe the metric information. 39 | 40 | Examples: 41 | 42 | `i8 metrics describe --name basic_eps` 43 | """ 44 | console = Console() 45 | metric_info = get_metric_info_table(name) 46 | console.print(metric_info) 47 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_add.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import click 4 | import investor8_sdk 5 | from rich.console import Console 6 | from rich.style import Style 7 | 8 | from i8_terminal.app.layout import get_terminal_command_layout 9 | from i8_terminal.commands.watchlist import watchlist 10 | from i8_terminal.common.cli import pass_command 11 | from i8_terminal.common.stock_info import get_tickers_list, validate_tickers 12 | from i8_terminal.config import USER_SETTINGS 13 | from i8_terminal.types.ticker_param_type import TickerParamType 14 | from i8_terminal.types.user_watchlists_param_type import UserWatchlistsParamType 15 | 16 | 17 | def add_tickers_to_watchlist(name: str, tickers: List[str]) -> None: 18 | wl = investor8_sdk.UserApi().get_watchlist_by_name_user_id(name=name, user_id=USER_SETTINGS.get("user_id")) 19 | investor8_sdk.UserApi().add_to_watchlist(body={"Id": wl.id, "Tickers": tickers}) 20 | 21 | 22 | @watchlist.command() 23 | @click.option( 24 | "--name", 25 | "-n", 26 | type=UserWatchlistsParamType(), 27 | required=True, 28 | help="Name of the watchlist.", 29 | ) 30 | @click.option( 31 | "--tickers", 32 | "-k", 33 | type=TickerParamType(), 34 | required=True, 35 | callback=validate_tickers, 36 | help="Comma-separated list of tickers.", 37 | ) 38 | @pass_command 39 | def add(name: str, tickers: str) -> None: 40 | """ 41 | Adds given tickers to a given watchlist. 42 | 43 | Examples: 44 | 45 | `i8 watchlist add --name MyWatchlist --tickers AAPL,MSFT` 46 | 47 | """ 48 | console = Console() 49 | tickers_list = get_tickers_list(tickers.replace(" ", "").upper()) 50 | with console.status("Updating Watchlist...", spinner="material"): 51 | add_tickers_to_watchlist(name, tickers_list) 52 | console.print( 53 | f"✅ Ticker{'s' if len(tickers_list) > 1 else ''} [cyan]{', '.join(tickers_list)}[/cyan] added to watchlist [cyan]{name}[/cyan] successfully!" # noqa: E501 54 | ) 55 | terminal_command_style = Style(**get_terminal_command_layout()) 56 | console.print( 57 | f'Try `[{terminal_command_style}]watchlist summary --name "{name}"[/{terminal_command_style}]`to see the watchlist.' # noqa: E501 58 | ) 59 | -------------------------------------------------------------------------------- /i8_terminal/commands/user/user_login.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | 3 | import click 4 | import investor8_sdk 5 | 6 | from i8_terminal.commands.user import user, webserver 7 | from i8_terminal.common.cli import pass_command 8 | from i8_terminal.config import APP_SETTINGS, save_user_settings 9 | 10 | 11 | def get_login_authentication_request_id() -> str: 12 | resp = investor8_sdk.UserApi().create_login_authentication_request(body={"ReqType": 3}) 13 | return str(resp.request_id) 14 | 15 | 16 | def open_browser(request_id: str) -> None: 17 | url = f"https://www.investoreight.com/account/authorize?reqId={request_id}&redirectUrl=http://localhost:{APP_SETTINGS['app']['port']}" # noqa: E501 18 | webbrowser.open(url) 19 | 20 | 21 | def login_within_terminal() -> None: 22 | email = click.prompt("Email", show_default=False, type=str) 23 | password = click.prompt("Password", hide_input=True, show_default=False, type=str) 24 | body = {"Email": email, "Password": password} 25 | api_response = None 26 | try: 27 | api_response = investor8_sdk.UserApi().login_user(body=body) 28 | user_setting = { 29 | "user_id": api_response.user_id, 30 | "i8_core_token": api_response.token, 31 | "i8_core_api_key": api_response.api_key, 32 | } 33 | save_user_settings(user_setting) 34 | investor8_sdk.ApiClient().configuration.api_key["apiKey"] = api_response.api_key 35 | investor8_sdk.ApiClient().configuration.api_key["Authorization"] = api_response.token 36 | investor8_sdk.ApiClient().configuration.api_key_prefix["Authorization"] = "Bearer" 37 | click.echo("User logged in successfully!") 38 | except Exception as e: 39 | click.echo(e) 40 | 41 | 42 | @user.command() 43 | @click.option( 44 | "--terminal", 45 | is_flag=True, 46 | default=False, 47 | help="Login with your credentials in the console (Only applicable if you have a local Investoreight account).", 48 | ) 49 | @pass_command 50 | def login(terminal: bool) -> None: 51 | if terminal: 52 | login_within_terminal() 53 | else: 54 | request_id = get_login_authentication_request_id() 55 | open_browser(request_id) 56 | webserver.run_server(request_id) 57 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_rm.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import click 4 | import investor8_sdk 5 | from rich.console import Console 6 | from rich.style import Style 7 | 8 | from i8_terminal.app.layout import get_terminal_command_layout 9 | from i8_terminal.commands.watchlist import watchlist 10 | from i8_terminal.common.cli import pass_command 11 | from i8_terminal.common.stock_info import validate_tickers 12 | from i8_terminal.config import USER_SETTINGS 13 | from i8_terminal.types.user_watchlist_tickers_param_type import ( 14 | UserWatchlistTickersParamType, 15 | ) 16 | from i8_terminal.types.user_watchlists_param_type import UserWatchlistsParamType 17 | 18 | 19 | def remove_tickers_from_watchlist(name: str, tickers: List[str]) -> None: 20 | wl = investor8_sdk.UserApi().get_watchlist_by_name_user_id(name=name, user_id=USER_SETTINGS.get("user_id")) 21 | for ticker in tickers: 22 | investor8_sdk.UserApi().remove_from_watchlist(body={"Id": wl.id, "Ticker": ticker}) 23 | 24 | 25 | @watchlist.command() 26 | @click.option( 27 | "--name", 28 | "-n", 29 | type=UserWatchlistsParamType(), 30 | required=True, 31 | help="Name of the watchlist.", 32 | ) 33 | @click.option( 34 | "--tickers", 35 | "-k", 36 | type=UserWatchlistTickersParamType(), 37 | required=True, 38 | callback=validate_tickers, 39 | help="Comma-separated list of tickers.", 40 | ) 41 | @pass_command 42 | def rm(name: str, tickers: str) -> None: 43 | """ 44 | Removes given tickers from a given watchlist. 45 | 46 | Examples: 47 | 48 | `i8 watchlist rm --name MyWatchlist --tickers AAPL,MSFT` 49 | 50 | """ 51 | console = Console() 52 | tickers_list = tickers.replace(" ", "").upper().split(",") 53 | with console.status("Updating Watchlist...", spinner="material"): 54 | remove_tickers_from_watchlist(name, tickers_list) 55 | console.print( 56 | f"✅ Ticker{'s' if len(tickers_list) > 1 else ''} [cyan]{', '.join(tickers_list)}[/cyan] removed from watchlist [cyan]{name}[/cyan] successfully!" # noqa: E501 57 | ) 58 | terminal_command_style = Style(**get_terminal_command_layout()) 59 | console.print( 60 | f'Try `[{terminal_command_style}]watchlist summary --name "{name}"[/{terminal_command_style}]`to see the watchlist.' # noqa: E501 61 | ) 62 | -------------------------------------------------------------------------------- /i8_terminal/types/auto_complete_choice.py: -------------------------------------------------------------------------------- 1 | import heapq 2 | from typing import List, Optional, Tuple 3 | 4 | from click.types import StringParamType 5 | 6 | 7 | class AutoCompleteChoice(StringParamType): 8 | name = "autocompletechoice" 9 | 10 | def __init__(self, choices: Optional[List[Tuple[str, str]]] = None) -> None: 11 | self.is_loaded = False 12 | self.size = 10 13 | if choices: 14 | self.set_choices(choices) 15 | 16 | def set_choices(self, choices: List[Tuple[str, str]]) -> None: 17 | if choices and len(choices) > 0: 18 | self._choices = choices 19 | self._choices_l = [(c[0].lower(), c[1].lower()) for c in choices] 20 | self.is_loaded = True 21 | else: 22 | self._choices = [] 23 | self._choices_l = [] 24 | 25 | def search_keyword(self, keyword: str) -> List[Tuple[str, str]]: 26 | keyword = keyword.lower() 27 | 28 | scores: List[Tuple[float, int]] = [] 29 | for i, val in enumerate(self._choices_l): 30 | # A heurestic method to rank the list of choices 31 | score = 0.0 32 | for token in val[1].replace('"', "").split(" "): 33 | if token.startswith(keyword): 34 | score += 1 + 1 / len(token) 35 | for token in val[0].replace('"', "").split("_"): 36 | if token.startswith(keyword): 37 | score += 3 + 1 / len(val[0]) 38 | if val[0].startswith(keyword): 39 | score += 5 + 1 / len(val[0]) 40 | if val[0] == keyword: 41 | score += 10 42 | 43 | if score > 0: 44 | if len(scores) < self.size: 45 | heapq.heappush(scores, (score, i)) 46 | else: 47 | heapq.heappushpop(scores, (score, i)) 48 | 49 | scores_sorted = sorted(scores, key=lambda x: -x[0]) 50 | return [self._choices[i[1]] for i in scores_sorted] 51 | 52 | def get_suggestions(self, keyword: str, pre_populate: bool = False) -> List[Tuple[str, str]]: 53 | if self._choices: 54 | if pre_populate and keyword.strip() == "": 55 | return self._choices[: self.size] 56 | return self.search_keyword(keyword) 57 | return [] 58 | 59 | def __repr__(self) -> str: 60 | return "AUTOCOMPLETECHOICE" 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | i8 Terminal is open source, everyone is free to contribute. 3 | But please follow the guidelines below. 4 | 5 | ## Guidelines 6 | ### Branching Conventions 7 | We use the git flow convention: `feature/`. 8 | 9 | ### Reviewing 10 | All changes (not matter how minor) to main are made through Pull Requests (PRs). 11 | All PRs are reviewed by at least one reviewer. 12 | If you know that someone may have an opinion on your change, make sure this person reviewed your code before merging. 13 | 14 | ## How to contribute 15 | 16 | The preferred workflow for contributing to i8 Terminal is to clone the 17 | [GitHub repository](https://github.com/investoreight/i8-terminal), develop on a branch and make a Pull Request. 18 | 19 | Steps: 20 | 21 | 1. Create an issue of what you are going to do at [https://github.com/investoreight/i8-terminal/issues](https://github.com/investoreight/i8-terminal/issues). 22 | 23 | 2. Fork the Project 24 | 25 | 3. Create a ``feature`` branch to hold your development changes: 26 | 27 | ```bash 28 | $ git checkout -b {feature|fix}/ 29 | ``` 30 | 31 | Always use a ``feature`` branch. It's good practice to never work on the ``main`` branch! 32 | 33 | 4. Develop the feature on your feature branch. 34 | 35 | 5. Add changed files using ``git add`` and then ``git commit`` files: 36 | 37 | ```bash 38 | $ git add modified_files 39 | $ git commit 40 | ``` 41 | to record your changes in Git, then push the changes to your GitHub account with: 42 | 43 | ```bash 44 | $ git push -u origin {feature|fix}/ 45 | ``` 46 | 47 | * **Be descriptive in your commit messages. Start with a verb in the present tense.** 48 | * **Group commit changes that belong together.** 49 | 50 | 6. Browse to [https://github.com/investoreight/i8-terminal](https://github.com/investoreight/i8-terminal) and follow instructions to create Pull Request. 51 | Make sure you add reviewers to your PR. Your code should be reviewed by at least 1 person and by everyone in the wider contributing team you know could have an opinion on your change. You should also make sure all the required checked are passed. If any of them fails, please check the error and fix it. 52 | 53 | (If any of the above seems like magic to you, please look up the 54 | [Git documentation](https://git-scm.com/documentation) on the web, or ask a friend or another contributor for help.) -------------------------------------------------------------------------------- /i8_terminal/common/layout.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import numpy as np 4 | from pandas import DataFrame 5 | from rich.table import Table 6 | 7 | from i8_terminal.common.formatting import data_format_mapper, get_formatter 8 | from i8_terminal.config import get_table_style 9 | 10 | 11 | def format_df(df: DataFrame, cols_map: Dict[str, str], cols_formatters: Dict[str, Any]) -> DataFrame: 12 | for c, f in cols_formatters.items(): 13 | df[c] = df[c].map(f) 14 | return df[cols_map.keys()].rename(columns=cols_map) 15 | 16 | 17 | def format_metrics_df(df: DataFrame, target: str) -> DataFrame: 18 | df["value"] = df.apply( 19 | lambda metric: get_formatter( 20 | "number_int" 21 | if metric.data_format == "int" and metric.display_format == "number" 22 | else metric.display_format, 23 | target, 24 | )(data_format_mapper(metric)), 25 | axis=1, 26 | ) 27 | return df 28 | 29 | 30 | def df2Table(df: DataFrame, style_profile: str = "default", columns_justify: Dict[str, Any] = {}) -> Table: 31 | MIN_COL_LENGTH = 13 32 | style = get_table_style(style_profile) 33 | table = Table(**style) 34 | default_justify = { 35 | "Price": "right", 36 | "Open": "right", 37 | "Close": "right", 38 | "Low": "right", 39 | "High": "right", 40 | "Volume": "right", 41 | "Change": "right", 42 | "Change (%)": "right", 43 | "Market Cap": "right", 44 | "EPS Cons.": "right", 45 | "EPS Actual": "right", 46 | "Revenue Cons.": "right", 47 | "Revenue Actual": "right", 48 | "Level": "right", 49 | "EPS Estimate": "right", 50 | "Revenue Estimate": "right", 51 | "EPS Beat Rate": "right", 52 | "Revenue Beat Rate": "right", 53 | "EPS Surprise": "right", 54 | "Revenue Surprise": "right", 55 | "EPS Consensus": "right", 56 | "Revenue Consensus": "right", 57 | "Eps Surprise": "right", 58 | } 59 | for c in df.columns: 60 | table.add_column( 61 | c, 62 | justify=columns_justify.get(c, default_justify.get(c, "left")), 63 | min_width=min(max(df[c].str.len().max(), len(df[c].name)), MIN_COL_LENGTH), 64 | ) 65 | for _, r in df.iterrows(): 66 | row = [r[c] if r[c] is not np.nan and r[c] is not None else "-" for c in df.columns] 67 | table.add_row(*row) 68 | return table 69 | -------------------------------------------------------------------------------- /update_msi.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | from typing import Any, Dict, Optional 4 | 5 | import requests 6 | import wget 7 | 8 | 9 | def read(rel_path: str) -> str: 10 | """ 11 | Read a file. 12 | """ 13 | here = os.path.abspath(os.path.dirname(__file__)) 14 | with codecs.open(os.path.join(here, rel_path), "r") as fp: 15 | return fp.read() 16 | 17 | 18 | def get_version() -> Any: 19 | """ 20 | Read app version from a file. 21 | """ 22 | file_path = "version.txt" 23 | if not os.path.exists(file_path): 24 | file_path = "i8_terminal/version.txt" 25 | for line in read(file_path).splitlines(): 26 | if line.startswith("__version__"): 27 | delim = '"' if '"' in line else "'" 28 | return line.split(delim)[1] 29 | else: 30 | raise RuntimeError("Unable to find version string.") 31 | 32 | 33 | def is_latest_version(latest_version: str) -> Any: 34 | if latest_version: 35 | app_version = get_version() 36 | return app_version == latest_version 37 | return None 38 | 39 | 40 | def get_latest_release_metadata() -> Optional[Dict[str, Any]]: 41 | resp = requests.get("https://api.github.com/repos/investoreight/i8-terminal/releases/latest") 42 | if resp.status_code != 200: 43 | print("No new release found.") 44 | return None 45 | msi_url = None 46 | release_metadata = resp.json() 47 | if release_metadata.get("assets"): 48 | msi_url = release_metadata.get("assets")[0].get("browser_download_url") 49 | 50 | return {"version": release_metadata["tag_name"][1:], "msi_url": msi_url} 51 | 52 | 53 | def run_update() -> Any: 54 | release_metadata = get_latest_release_metadata() 55 | if not release_metadata: 56 | return None 57 | if not is_latest_version(latest_version=release_metadata["version"]): 58 | if not release_metadata["msi_url"]: 59 | print("Error while finding latest release!") 60 | return 61 | local_file = f"i8-terminal-{release_metadata['version']}-win64.msi" 62 | try: 63 | downloaded_msi = wget.download(release_metadata["msi_url"], local_file) 64 | if downloaded_msi: 65 | os.system(local_file) # Open downloaded MSI 66 | except: 67 | print("No new release found.") 68 | else: 69 | print("Latest version of i8-terminal is already installed.") 70 | 71 | 72 | if __name__ == "__main__": 73 | run_update() 74 | -------------------------------------------------------------------------------- /i8_terminal/config.yml: -------------------------------------------------------------------------------- 1 | i8_core_endpoint: https://api.investoreight.com 2 | app: 3 | port: 8050 4 | debug: false 5 | metrics: 6 | similarity_threshold: 0.75 7 | cache: 8 | age: 48 # Hours 9 | styles: 10 | plot: 11 | default: 12 | paper_bgcolor: "#F8F9F9" 13 | plot_bgcolor: "#E5E8E8" 14 | table: 15 | default: 16 | header: 17 | color: "magenta" 18 | bold: true 19 | row: 20 | color: "white" 21 | alternate_row: 22 | color: "cyan" 23 | show_lines: false 24 | company_compare: 25 | alternate_row: 26 | color: cyan 27 | header: 28 | bold: true 29 | color: magenta 30 | row: 31 | color: white 32 | show_lines: false 33 | metrics_historical: 34 | alternate_row: 35 | color: cyan 36 | header: 37 | bold: true 38 | color: magenta 39 | row: 40 | color: white 41 | show_lines: false 42 | terminal: 43 | command: 44 | color: "magenta" 45 | xlsx: 46 | default: 47 | header: 48 | bold: True 49 | fg_color: "#00B191" 50 | border: 1 51 | align: "center" 52 | valign: "vcenter" 53 | metric: 54 | bold: True 55 | text_wrap: True 56 | valign: "top" 57 | company: 58 | column: 59 | num_format: "#,##0.00" 60 | valign: "vcenter" 61 | text_wrap: True 62 | financials: 63 | column: 64 | num_format: "#,##0.00" 65 | valign: "vcenter" 66 | text_wrap: True 67 | price: 68 | column: 69 | num_format: "#,##0.00" 70 | metric_view: 71 | watchlist_summary: 72 | metrics: "company_name,stock_exchange,price,change,52_week_low,52_week_high,marketcap" 73 | watchlist_financials: 74 | metrics: "total_revenue,net_income,basic_eps,net_cash_from_operating_activities,total_assets,total_liabilities" 75 | commands: 76 | company_compare: 77 | metric_groups: 78 | - name: "Summary" 79 | metrics: "company_name,sector,industry_group,price,change,marketcap,pe_ratio_ttm" 80 | - name: "Financials" 81 | metrics: "operating_revenue,total_revenue,total_gross_profit,other_income,basic_eps,diluted_eps,adj_basic_eps" 82 | - name: "Performance" 83 | metrics: "return_1w,return_1m,return_3m,return_6m,return_ytd,return_1y,return_2y,return_5y" 84 | - name: "Key Ratios" 85 | metrics: "pe_ratio_ttm,current_ratio,quick_ratio,price_to_book,revenue_growth,dividend_yield,roe" 86 | screen_gainers: 87 | metrics: "company_name,price,change" 88 | screen_losers: 89 | metrics: "company_name,price,change" 90 | -------------------------------------------------------------------------------- /i8_terminal/commands/news/news_list.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | import click 4 | import investor8_sdk 5 | from pandas import DataFrame 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.news import news 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.formatting import format_number, get_formatter 11 | from i8_terminal.common.layout import df2Table, format_df 12 | from i8_terminal.common.stock_info import validate_ticker 13 | from i8_terminal.types.ticker_param_type import TickerParamType 14 | 15 | 16 | def get_news_df(identifier: str, page_size: int) -> DataFrame: 17 | if identifier: 18 | news = investor8_sdk.NewsApi().get_ticker_news(identifier) 19 | else: 20 | news = investor8_sdk.NewsApi().get_latest_news(page_size=page_size) 21 | df = DataFrame([d.to_dict() for d in news]) 22 | df["news_source"] = df["news_source"].apply(map_news_source) 23 | df["stock_prices"] = df["stock_prices"].apply(lambda x: format_stock_prices(x)) 24 | return df 25 | 26 | 27 | def format_stock_prices(prices: List[Dict[str, Any]]) -> str: 28 | formatted_stock_prices = [] 29 | for p in prices: 30 | formatted_stock_prices.append( 31 | f'{p["ticker"]} {format_number(p["latest_price"])} ({format_number(p["change_perc"], unit="percentage")})' 32 | ) 33 | return "\n".join(formatted_stock_prices) 34 | 35 | 36 | def format_news_df(df: DataFrame, target: str) -> DataFrame: 37 | formatters = { 38 | "publication_timestamp": get_formatter("date", target), 39 | } 40 | col_names = { 41 | "news_source": "Source", 42 | "publication_timestamp": "Date", 43 | "title": "Title", 44 | "stock_prices": "Tickers", 45 | } 46 | return format_df(df, col_names, formatters) 47 | 48 | 49 | def map_news_source(news_source: int) -> str: 50 | if news_source == 0: 51 | return "InvestorEight" 52 | elif news_source == 1: 53 | return "Yahoo!Finance" 54 | elif news_source == 2: 55 | return "Highlights" 56 | else: 57 | return "-" 58 | 59 | 60 | @news.command() 61 | @click.option( 62 | "--ticker", "-k", type=TickerParamType(), required=True, callback=validate_ticker, help="Ticker or company name." 63 | ) 64 | @pass_command 65 | def list(ticker: str) -> None: 66 | """ 67 | Lists the latest market news for a given ticker. 68 | 69 | Examples: 70 | 71 | `i8 news list --ticker AAPL` 72 | 73 | """ 74 | console = Console() 75 | with console.status("Fetching data...", spinner="material"): 76 | df = get_news_df(ticker, page_size=10) 77 | df_formatted = format_news_df(df, "console") 78 | table = df2Table(df_formatted) 79 | console.print(table) 80 | -------------------------------------------------------------------------------- /i8_terminal/commands/earnings/earnings_recent.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | import investor8_sdk 5 | import pandas as pd 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.earnings import earnings 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.formatting import get_formatter 11 | from i8_terminal.common.layout import df2Table, format_df 12 | from i8_terminal.common.stock_info import get_stocks_df 13 | from i8_terminal.common.utils import export_data, export_to_html 14 | from i8_terminal.config import APP_SETTINGS 15 | 16 | 17 | def get_recent_earnings_df(size: int) -> pd.DataFrame: 18 | earnings = investor8_sdk.EarningsApi().get_recent_earnings(size=size) 19 | earnings = [d.to_dict() for d in earnings] 20 | df = pd.DataFrame(earnings) 21 | stocks_df = get_stocks_df() 22 | return pd.merge(df, stocks_df, on="ticker") 23 | 24 | 25 | def format_recent_earnings_df(df: pd.DataFrame, target: str) -> pd.DataFrame: 26 | formatters = { 27 | "latest_price": get_formatter("price", target), 28 | "change": get_formatter("perc", target), 29 | "fyq": get_formatter("fyq", target), 30 | "eps_ws": get_formatter("number", target), 31 | "eps_actual": get_formatter("number", target), 32 | "revenue_ws": get_formatter("financial", target), 33 | "revenue_actual": get_formatter("financial", target), 34 | } 35 | col_names = { 36 | "ticker": "Ticker", 37 | "name": "Name", 38 | "latest_price": "Price", 39 | "change": "Change", 40 | "actual_report_date": "Report Date", 41 | "fyq": "Period", 42 | "call_time": "Call Time", 43 | "eps_ws": "EPS Estimate", 44 | "eps_actual": "EPS Actual", 45 | "revenue_ws": "Revenue Estimate", 46 | "revenue_actual": "Revenue Actual", 47 | } 48 | return format_df(df, col_names, formatters) 49 | 50 | 51 | @earnings.command() 52 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 53 | @pass_command 54 | def recent(export_path: Optional[str]) -> None: 55 | """ 56 | Lists recent company earnings. 57 | 58 | Examples: 59 | 60 | `i8 earnings recent` 61 | 62 | """ 63 | console = Console() 64 | with console.status("Fetching data...", spinner="material"): 65 | df = get_recent_earnings_df(size=20) 66 | if export_path: 67 | if export_path.split(".")[-1] == "html": 68 | df_formatted = format_recent_earnings_df(df, "console") 69 | table = df2Table(df_formatted) 70 | export_to_html(table, export_path) 71 | return 72 | export_data( 73 | format_recent_earnings_df(df, "store"), 74 | export_path, 75 | column_width=18, 76 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 77 | ) 78 | return 79 | df_formatted = format_recent_earnings_df(df, "console") 80 | table = df2Table(df_formatted) 81 | console.print(table) 82 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_summary.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import click 4 | import investor8_sdk 5 | import pandas as pd 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.watchlist import watchlist 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.layout import df2Table 11 | from i8_terminal.common.metrics import ( 12 | get_current_metrics_df, 13 | prepare_current_metrics_formatted_df, 14 | ) 15 | from i8_terminal.common.utils import export_data, export_to_html 16 | from i8_terminal.config import APP_SETTINGS, USER_SETTINGS 17 | from i8_terminal.types.user_watchlists_param_type import UserWatchlistsParamType 18 | 19 | 20 | def prepare_watchlist_stocks_df(name: str) -> Optional[pd.DataFrame]: 21 | watchlist = investor8_sdk.UserApi().get_watchlist_by_name_user_id(name=name, user_id=USER_SETTINGS.get("user_id")) 22 | watchlist_stocks_df = get_current_metrics_df( 23 | ",".join(watchlist.tickers), APP_SETTINGS["metric_view"]["watchlist_summary"]["metrics"] 24 | ) 25 | return watchlist_stocks_df 26 | 27 | 28 | @watchlist.command() 29 | @click.option( 30 | "--name", 31 | "-n", 32 | type=UserWatchlistsParamType(), 33 | required=True, 34 | help="Name of the watchlist.", 35 | ) 36 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 37 | @pass_command 38 | def summary(name: str, export_path: Optional[str]) -> None: 39 | """ 40 | Lists a summary of the companies added to a watchlist. 41 | 42 | Examples: 43 | 44 | `i8 watchlist summary --name MyWatchlist` 45 | 46 | """ 47 | console = Console() 48 | with console.status("Fetching data...", spinner="material"): 49 | df = prepare_watchlist_stocks_df(name) 50 | if df is None: 51 | console.print("No data found for metrics with selected tickers", style="yellow") 52 | return 53 | columns_justify: Dict[str, Any] = {} 54 | if export_path: 55 | if export_path.split(".")[-1] == "html": 56 | for metric_display_name, metric_df in df.groupby("display_name"): 57 | columns_justify[metric_display_name] = ( 58 | "left" if metric_df["display_format"].values[0] == "str" else "right" 59 | ) 60 | table = df2Table(prepare_current_metrics_formatted_df(df, "console"), columns_justify=columns_justify) 61 | export_to_html(table, export_path) 62 | return 63 | export_data( 64 | prepare_current_metrics_formatted_df(df, "store"), 65 | export_path, 66 | column_width=18, 67 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 68 | ) 69 | else: 70 | for metric_display_name, metric_df in df.groupby("display_name"): 71 | columns_justify[metric_display_name] = "left" if metric_df["display_format"].values[0] == "str" else "right" 72 | table = df2Table(prepare_current_metrics_formatted_df(df, "console"), columns_justify=columns_justify) 73 | console.print(table) 74 | -------------------------------------------------------------------------------- /i8_terminal/common/screen.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import investor8_sdk 4 | import pandas as pd 5 | from rich.table import Table 6 | 7 | from i8_terminal.common.layout import df2Table 8 | from i8_terminal.common.metrics import ( 9 | get_current_metrics_df, 10 | get_view_metrics, 11 | prepare_current_metrics_formatted_df, 12 | ) 13 | from i8_terminal.common.utils import export_data, export_to_html 14 | from i8_terminal.config import APP_SETTINGS 15 | 16 | 17 | def get_top_stocks_df(category: str, index: str, view_name: Optional[str]) -> Optional[pd.DataFrame]: 18 | metrics = APP_SETTINGS["commands"]["screen_gainers"]["metrics"] 19 | companies_data = investor8_sdk.ScreenerApi().get_top_stocks(category, index=index) 20 | if companies_data is None: 21 | return None 22 | companies = [company.ticker for company in companies_data] 23 | if view_name: 24 | metrics = metrics + "," + ",".join(get_view_metrics(view_name)) 25 | return get_current_metrics_df(",".join(companies), metrics) 26 | 27 | 28 | def render_top_stocks(df: pd.DataFrame, export_path: Optional[str], ascending: bool = True) -> Optional[Table]: 29 | columns_justify: Dict[str, Any] = {} 30 | for metric_display_name, metric_df in df.groupby("display_name"): 31 | columns_justify[metric_display_name] = "left" if metric_df["display_format"].values[0] == "str" else "right" 32 | change_rows = df.loc[df["metric_name"] == "change"] 33 | df = pd.concat( 34 | [ 35 | pd.DataFrame(change_rows.replace({"change": "change_numeric", "Change": "Change Numeric", "perc": "str"})), 36 | df, 37 | ], 38 | ignore_index=True, 39 | axis=0, 40 | ) 41 | if export_path: 42 | if export_path.split(".")[-1] == "html": 43 | formatted_df = prepare_current_metrics_formatted_df(df, "console").sort_values( 44 | "Change Numeric", ascending=ascending 45 | ) 46 | formatted_df.drop("Change Numeric", axis=1, inplace=True) 47 | table = df2Table( 48 | formatted_df, 49 | columns_justify=columns_justify, 50 | ) 51 | export_to_html(table, export_path) 52 | return None 53 | formatted_df = prepare_current_metrics_formatted_df(df, "store").sort_values( 54 | "Change Numeric", ascending=ascending 55 | ) 56 | formatted_df.drop("Change Numeric", axis=1, inplace=True) 57 | export_data( 58 | formatted_df, 59 | export_path, 60 | column_width=18, 61 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 62 | ) 63 | return None 64 | else: 65 | formatted_df = prepare_current_metrics_formatted_df(df, "console").sort_values( 66 | "Change Numeric", ascending=ascending 67 | ) 68 | formatted_df.drop("Change Numeric", axis=1, inplace=True) 69 | table = df2Table( 70 | formatted_df, 71 | columns_justify=columns_justify, 72 | ) 73 | return table 74 | -------------------------------------------------------------------------------- /i8_terminal/commands/company/compnay_details.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import click 4 | import investor8_sdk 5 | from rich.align import Align 6 | from rich.console import Console 7 | from rich.layout import Layout 8 | from rich.markdown import Markdown 9 | from rich.panel import Panel 10 | from rich.table import Table 11 | from rich.text import Text 12 | 13 | from i8_terminal.commands.company import company 14 | from i8_terminal.common.cli import pass_command 15 | from i8_terminal.common.formatting import format_number 16 | from i8_terminal.common.stock_info import validate_ticker 17 | from i8_terminal.types.ticker_param_type import TickerParamType 18 | 19 | 20 | def make_layout() -> Layout: 21 | """Define the layout.""" 22 | layout = Layout(name="root") 23 | 24 | layout.split( 25 | Layout(name="main", ratio=2), 26 | ) 27 | layout["main"].split_row( 28 | Layout(name="company_info", ratio=2), 29 | Layout(name="about_company", ratio=2, minimum_size=50), 30 | ) 31 | layout["company_info"].split(Layout(name="summary_box"), Layout(name="price_box")) 32 | return layout 33 | 34 | 35 | def get_stock_info(ticker: str) -> Tuple[investor8_sdk.StockInfoApi, investor8_sdk.StockInfoApi]: 36 | si = investor8_sdk.StockInfoApi().get_stock_summary(ticker) 37 | ci = investor8_sdk.StockInfoApi().get_company_info(ticker) 38 | 39 | return si, ci 40 | 41 | 42 | def get_price_details_content(si: investor8_sdk.StockInfoApi) -> Table: 43 | price_details = Table.grid() 44 | price_details.add_column(justify="left", width=18) 45 | price_details.add_column(justify="center") 46 | price_details.add_row("Price", str(si.current_price)) 47 | price_details.add_row("Change Percent", str(format_number(si.change_perc, unit="percentage"))) 48 | price_details.add_row("Low", str(si.low52_w)) 49 | price_details.add_row("High", str(si.high52_w)) 50 | 51 | return price_details 52 | 53 | 54 | @company.command() 55 | @click.option( 56 | "--ticker", "-k", type=TickerParamType(), required=True, callback=validate_ticker, help="Ticker or company name." 57 | ) 58 | @pass_command 59 | def details(ticker: str) -> None: 60 | """ 61 | Get details for a given company. 62 | 63 | Examples: 64 | 65 | `i8 company details --ticker MSFT` 66 | 67 | """ 68 | template = """ 69 | # {0} 70 | - Ticker: {1} 71 | - Exchange: {2} 72 | - Sector: {3} 73 | - [https://www.investoreight.com/stock/{1}](https://www.investoreight.com/stock/{1}) 74 | """ 75 | console = Console() 76 | si, ci = get_stock_info(ticker) 77 | 78 | md = Markdown(template.format(si.name, si.ticker, si.exchange, si.sector)) 79 | price_details = get_price_details_content(si) 80 | about_company = Text.from_markup(ci.description) 81 | 82 | layout = make_layout() 83 | layout["summary_box"].update(Panel(md, border_style="green", title="Summary")) 84 | layout["price_box"].update( 85 | Panel(Align.center(price_details, vertical="middle"), border_style="green", title="Price") 86 | ) 87 | layout["about_company"].update(Panel(about_company, border_style="green", title="About Company")) 88 | console.print(layout) 89 | -------------------------------------------------------------------------------- /i8_terminal/types/i8_auto_suggest.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | import click 4 | from click import Group 5 | from click.decorators import F 6 | from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion 7 | from prompt_toolkit.buffer import Buffer 8 | from prompt_toolkit.document import Document 9 | 10 | from i8_terminal.common.utils import get_matched_params 11 | from i8_terminal.types.command_parser import CommandParser 12 | from i8_terminal.types.fin_identifier_param_type import FinancialsIdentifierParamType 13 | from i8_terminal.types.metric_identifier_param_type import MetricIdentifierParamType 14 | 15 | 16 | class I8AutoSuggest(AutoSuggest): 17 | """ 18 | Give suggestions based on click option types. 19 | """ 20 | 21 | def __init__(self, cli: Callable[[F], Group]) -> None: 22 | self.cli = cli 23 | 24 | def get_suggestion(self, buffer: Buffer, document: Document) -> Optional[Suggestion]: 25 | ctx = CommandParser(self.cli).parse(document) 26 | if not ctx: 27 | return None 28 | 29 | command = ctx.click_ctx.command 30 | 31 | if ctx.last_option: 32 | matched_params = get_matched_params(ctx, command, document) 33 | if matched_params and len(matched_params) > 0: 34 | matched_param = matched_params[0] 35 | if type(matched_param.type) is click.types.DateTime: 36 | return Suggestion("YYYY-MM-DD"[len(ctx.incomplete) :]) # noqa: E203 37 | elif type(matched_param.type) is FinancialsIdentifierParamType: 38 | parts_num = 3 39 | parts = ctx.incomplete.split(",") 40 | incomplete = parts[-1] if len(parts) > 0 else " " 41 | sub_parts = incomplete.split("-") 42 | if len(sub_parts) > 2: 43 | parts_num = 1 44 | elif len(sub_parts) > 1: 45 | parts_num = 2 46 | 47 | if len(incomplete) < 1: 48 | return Suggestion("Ticker-[Fiscal Year]-[Fiscal Period]") 49 | elif parts_num == 3: 50 | return Suggestion("-[Fiscal Year]-[Fiscal Period]") 51 | elif parts_num == 2: 52 | return Suggestion("-[Fiscal Period]") 53 | elif matched_param.name == "export_path": 54 | if len(ctx.incomplete) < 1: 55 | return Suggestion("[path]/[filename].[csv|xlsx|pdf|html]") 56 | elif matched_param.name == "path": 57 | if len(ctx.incomplete) < 1: 58 | return Suggestion("[path]/[filename].[xlsx]") 59 | elif type(matched_param.type) is MetricIdentifierParamType: 60 | parts_num = 2 61 | parts = ctx.incomplete.split(",") 62 | incomplete = parts[-1] if len(parts) > 0 else " " 63 | sub_parts = incomplete.split(".") 64 | if len(sub_parts) > 1: 65 | parts_num = 1 66 | 67 | if len(incomplete) < 1: 68 | return Suggestion("Metric.[Optional Period]") 69 | elif parts_num == 2: 70 | return Suggestion(".[Optional Period]") 71 | 72 | return None 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # i8 Terminal: Modern Market Research powered by the Command-Line 2 | 3 | [i8 terminal](https://www.i8terminal.io) is a modern python-based terminal application that gives you superior power and flexibility to understand and analyze the market. The interface is simple, efficient, and powerful: it's command-line! 4 | 5 | i8 Terminal is backed by the [Investoreight Platform](https://www.investoreight.com) and currently covers major U.S. exchanges. 6 | 7 | ## Features and Highlights 8 | - Prompt Market Insights and Analysis 9 | - Custom Charting, Reporting, and Visualizations 10 | - Powerful and Customizable Screening 11 | - Easy-to-Use and Extendable 12 | - Backed by the [Investoreight Platform](https://www.investoreight.com) 13 | 14 | ![i8 Terminal Features](https://www.i8terminal.io/img/gif/i8-terminal-demo.gif) 15 | 16 | ## i8 Terminal Commands 17 | i8 Terminal offers some built-in commands to analyze and research the market. You can also create your own custom commands or extend the existing command. Find an overview of commands [here](https://i8terminal.io/#commands). 18 | 19 | Check out the following video to see some more commands from i8 Terminal: 20 | 21 | [![i8 Terminal Sample Commands](https://img.youtube.com/vi/NpOCqcb-RxY/0.jpg)](https://www.youtube.com/watch?v=NpOCqcb-RxY) 22 | 23 | 24 | ## Installing i8 Terminal 25 | **Note**: i8 Terminal currently only supports Python 3.9+ 26 | 27 | If you have Python 3 installed, you can simply install the tool with Python pip: 28 | 29 | ``` 30 | pip install i8-terminal 31 | ``` 32 | 33 | We recommend installing i8 terminal in an isolated virtual environment. This can be done as follows: 34 | 35 | #### On Mac OS or Linux: 36 | 37 | ``` 38 | python3 -m venv .venv 39 | source .venv/bin/activate 40 | pip install i8-terminal 41 | ``` 42 | 43 | #### On Windows: 44 | 45 | ``` 46 | python3 -m venv .venv 47 | source .venv/Script/activate 48 | pip install i8-terminal 49 | ``` 50 | 51 | ### Install i8 Terminal using the Windows Installer 52 | On Windows, you can also install i8 Terminal using the Windows executable. Check [here](https://i8terminal.io/download) if you want to download the windows executable. 53 | 54 | 55 | ## How to Contribute i8 Terminal 56 | The preferred workflow for contributing to i8 Terminal is to clone the 57 | [GitHub repository](https://github.com/investoreight/i8-terminal), develop on a branch and make a Pull Request. 58 | 59 | See [here](https://github.com/investoreight/i8-terminal/blob/main/CONTRIBUTING.md) for guidelines for contributors. 60 | 61 | i8 Terminal is built on top of the [Investoreight Core API](https://github.com/investoreight/investor8-sdk). 62 | 63 | ## How to Run i8 Terminal 64 | You can verify whether i8 Terminal is installed successfully by running i8 script: 65 | 66 | ``` 67 | i8 68 | ``` 69 | 70 | If you are using the application for the first time, you should first sign in. Run the following command, which will open a browser and redirect you to the investoreight platform to sign in (or sign up): 71 | 72 | ``` 73 | i8 user login 74 | ``` 75 | 76 | After a successful login, the most convenient way to use i8 terminal is to use its own shell: 77 | 78 | ``` 79 | i8 shell 80 | ``` 81 | 82 | You should now be able to run i8 commands. Check our [documentation](https://docs.i8terminal.io/) for more details. 83 | 84 | ## Documentation 85 | Click [here](https://docs.i8terminal.io/) to find more details about the commands. 86 | -------------------------------------------------------------------------------- /i8_terminal/commands/price/price_list.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, cast 2 | 3 | import click 4 | from click.types import DateTime 5 | from pandas import DataFrame 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.price import price 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.formatting import get_formatter 11 | from i8_terminal.common.layout import df2Table, format_df 12 | from i8_terminal.common.price import get_historical_price_list_df 13 | from i8_terminal.common.stock_info import validate_ticker 14 | from i8_terminal.common.utils import export_data, export_to_html, get_period_code 15 | from i8_terminal.config import APP_SETTINGS 16 | from i8_terminal.types.price_period_param_type import PricePeriodParamType 17 | from i8_terminal.types.ticker_param_type import TickerParamType 18 | 19 | 20 | def format_hist_price_df(df: DataFrame, target: str) -> DataFrame: 21 | formatters = { 22 | "Date": get_formatter("date", target), 23 | "open": get_formatter("price", target), 24 | "close": get_formatter("price", target), 25 | "low": get_formatter("price", target), 26 | "high": get_formatter("price", target), 27 | "volume": get_formatter("number_int", target), 28 | "change_perc": get_formatter("perc", target), 29 | } 30 | col_names = { 31 | "Date": "Date", 32 | "open": "Open", 33 | "close": "Close", 34 | "low": "Low", 35 | "high": "High", 36 | "volume": "Volume", 37 | "change_perc": "Change (%)", 38 | } 39 | return format_df(df, col_names, formatters) 40 | 41 | 42 | @price.command() 43 | @click.option("--ticker", "-k", type=TickerParamType(), required=True, callback=validate_ticker, help="Company ticker.") 44 | @click.option( 45 | "--period", 46 | "-p", 47 | type=PricePeriodParamType(), 48 | default="1M", 49 | help="Historical price period.", 50 | ) 51 | @click.option("--from_date", "-f", type=DateTime(), help="Histotical price from date.") 52 | @click.option("--to_date", "-t", type=DateTime(), help="Histotical price to date.") 53 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 54 | @pass_command 55 | def list( 56 | ticker: str, period: str, from_date: Optional[DateTime], to_date: Optional[DateTime], export_path: Optional[str] 57 | ) -> None: 58 | """ 59 | Lists historical prices for a given TICKER. 60 | 61 | Examples: 62 | 63 | `i8 price list --period 3M --ticker AAPL` 64 | 65 | """ 66 | period_code = get_period_code(period.replace(" ", "").upper()) 67 | console = Console() 68 | with console.status("Fetching data...", spinner="material"): 69 | df = get_historical_price_list_df([ticker], period_code, cast(str, from_date), cast(str, to_date)) 70 | 71 | if export_path: 72 | if export_path.split(".")[-1] == "html": 73 | df_formatted = format_hist_price_df(df, "console") 74 | table = df2Table(df_formatted) 75 | export_to_html(table, export_path) 76 | return 77 | df_formatted = format_hist_price_df(df, "store") 78 | export_data( 79 | df_formatted, 80 | export_path, 81 | column_width=14, 82 | column_format=APP_SETTINGS["styles"]["xlsx"]["price"]["column"], 83 | ) 84 | else: 85 | df_formatted = format_hist_price_df(df, "console") 86 | table = df2Table(df_formatted) 87 | console.print(table) 88 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_financials.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import click 4 | import investor8_sdk 5 | import pandas as pd 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.watchlist import watchlist 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.layout import df2Table 11 | from i8_terminal.common.metrics import ( 12 | get_current_metrics_df, 13 | prepare_current_metrics_formatted_df, 14 | ) 15 | from i8_terminal.common.utils import export_data, export_to_html 16 | from i8_terminal.config import APP_SETTINGS, USER_SETTINGS 17 | from i8_terminal.types.user_watchlists_param_type import UserWatchlistsParamType 18 | 19 | 20 | def prepare_watchlist_stocks_df(name: str) -> Optional[pd.DataFrame]: 21 | watchlist = investor8_sdk.UserApi().get_watchlist_by_name_user_id(name=name, user_id=USER_SETTINGS.get("user_id")) 22 | watchlist_stocks_df = get_current_metrics_df( 23 | ",".join(watchlist.tickers), 24 | APP_SETTINGS["metric_view"]["watchlist_financials"]["metrics"], 25 | ) 26 | return watchlist_stocks_df 27 | 28 | 29 | @watchlist.command() 30 | @click.option( 31 | "--name", 32 | "-n", 33 | type=UserWatchlistsParamType(), 34 | required=True, 35 | help="Name of the watchlist.", 36 | ) 37 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 38 | @pass_command 39 | def financials(name: str, export_path: Optional[str]) -> None: 40 | """ 41 | Lists financial metrics for a given watchlist. 42 | 43 | Examples: 44 | 45 | `i8 watchlist financials --name MyWatchlist` 46 | 47 | """ 48 | console = Console() 49 | with console.status("Fetching data...", spinner="material"): 50 | df = prepare_watchlist_stocks_df(name) 51 | if df is None: 52 | console.print("No data found for metrics with selected tickers", style="yellow") 53 | return 54 | period_rows = [] 55 | for ticker, ticker_df in df.groupby("Ticker"): 56 | period_rows.append( 57 | { 58 | "Ticker": ticker, 59 | "metric_name": "period", 60 | "value": ticker_df["period"].values[0], 61 | "display_name": "Period", 62 | "data_format": "str", 63 | "display_format": "str", 64 | } 65 | ) 66 | df = pd.concat([pd.DataFrame(period_rows), df], ignore_index=True, axis=0) 67 | columns_justify: Dict[str, Any] = {} 68 | if export_path: 69 | if export_path.split(".")[-1] == "html": 70 | for metric_display_name, metric_df in df.groupby("display_name"): 71 | columns_justify[metric_display_name] = ( 72 | "left" if metric_df["display_format"].values[0] == "str" else "right" 73 | ) 74 | table = df2Table(prepare_current_metrics_formatted_df(df, "console"), columns_justify=columns_justify) 75 | export_to_html(table, export_path) 76 | return 77 | export_data( 78 | prepare_current_metrics_formatted_df(df, "store"), 79 | export_path, 80 | column_width=18, 81 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 82 | ) 83 | else: 84 | for metric_display_name, metric_df in df.groupby("display_name"): 85 | columns_justify[metric_display_name] = "left" if metric_df["display_format"].values[0] == "str" else "right" 86 | table = df2Table(prepare_current_metrics_formatted_df(df, "console"), columns_justify=columns_justify) 87 | console.print(table) 88 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_export.py: -------------------------------------------------------------------------------- 1 | import click 2 | import investor8_sdk 3 | import pandas as pd 4 | from rich.console import Console 5 | 6 | from i8_terminal.commands.watchlist import watchlist 7 | from i8_terminal.common.cli import pass_command 8 | from i8_terminal.common.metrics import ( 9 | get_current_metrics_df, 10 | prepare_current_metrics_formatted_df, 11 | ) 12 | from i8_terminal.config import APP_SETTINGS, USER_SETTINGS 13 | from i8_terminal.types.user_watchlists_param_type import UserWatchlistsParamType 14 | 15 | 16 | def export_watchlist_data( 17 | name: str, 18 | path: str, 19 | ) -> None: 20 | console = Console() 21 | extension = path.split(".")[-1] 22 | if extension != "xlsx": 23 | console.print("\n⚠ Error: path is not valid", style="yellow") 24 | return 25 | writer = pd.ExcelWriter(path, engine="xlsxwriter") 26 | workbook = writer.book 27 | column_width = 18 28 | header_format = workbook.add_format(APP_SETTINGS["styles"]["xlsx"]["default"]["header"]) 29 | metric_format = workbook.add_format(APP_SETTINGS["styles"]["xlsx"]["default"]["metric"]) 30 | column_format = workbook.add_format(APP_SETTINGS["styles"]["xlsx"]["company"]["column"]) 31 | tickers = ( 32 | investor8_sdk.UserApi().get_watchlist_by_name_user_id(name=name, user_id=USER_SETTINGS.get("user_id")).tickers 33 | ) 34 | summary_df = prepare_current_metrics_formatted_df( 35 | get_current_metrics_df( 36 | ",".join(tickers), "company_name,stock_exchange,price,change,52_week_low,52_week_high,marketcap" 37 | ), 38 | "store", 39 | ) 40 | summary_df.to_excel(writer, sheet_name="Summary", startrow=1, header=False, index=False) 41 | worksheet = writer.sheets["Summary"] 42 | headers = summary_df.columns.tolist() 43 | for col_num, value in enumerate(headers): 44 | if type(value) is tuple: 45 | worksheet.merge_range(0, col_num, len(value) - 1, col_num, " ".join(reversed(value)), header_format) 46 | else: 47 | worksheet.write(0, col_num, value, header_format) 48 | worksheet.set_column(0, 0, 20, metric_format) 49 | worksheet.set_column(1, len(summary_df.columns) - 1, column_width, column_format) 50 | financials_df = prepare_current_metrics_formatted_df( 51 | get_current_metrics_df( 52 | ",".join(tickers), 53 | "total_revenue,net_income,basic_eps,net_cash_from_operating_activities,total_assets,total_liabilities", 54 | ), 55 | "store", 56 | ) 57 | financials_df.to_excel(writer, sheet_name="Financials", startrow=1, header=False, index=False) 58 | worksheet = writer.sheets["Financials"] 59 | headers = financials_df.columns.tolist() 60 | for col_num, value in enumerate(headers): 61 | if type(value) is tuple: 62 | worksheet.merge_range(0, col_num, len(value) - 1, col_num, " ".join(reversed(value)), header_format) 63 | else: 64 | worksheet.write(0, col_num, value, header_format) 65 | worksheet.set_column(0, 0, 20, metric_format) 66 | worksheet.set_column(1, len(financials_df.columns) - 1, column_width, column_format) 67 | writer.save() 68 | console.print(f"\nData is saved on: {path}") 69 | 70 | 71 | @watchlist.command() 72 | @click.option( 73 | "--name", 74 | "-n", 75 | type=UserWatchlistsParamType(), 76 | required=True, 77 | help="Name of the watchlist.", 78 | ) 79 | @click.option("--path", "path", "-p", required=True, help="Filename to export the output to.") 80 | @pass_command 81 | def export(name: str, path: str) -> None: 82 | """ 83 | Exports a given watchlist to an excel file. 84 | 85 | Examples: 86 | 87 | `i8 watchlist export --name MyWatchlist --path MyWatchlist.xlsx` 88 | 89 | """ 90 | console = Console() 91 | with console.status("Fetching data...", spinner="material"): 92 | export_watchlist_data(name, path) 93 | -------------------------------------------------------------------------------- /i8_terminal/common/price.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict, List, Optional 3 | 4 | import investor8_sdk 5 | import numpy as np 6 | import pandas as pd 7 | from pandas.core.frame import DataFrame 8 | 9 | from i8_terminal.common.formatting import format_number 10 | 11 | 12 | def get_historical_price_df( 13 | tickers: List[str], 14 | period_code: int, 15 | from_date: Optional[str], 16 | to_date: Optional[str], 17 | pivot_value: Optional[str] = None, 18 | ) -> Optional[DataFrame]: 19 | historical_prices = [] 20 | if from_date: 21 | if not to_date: 22 | to_date = datetime.now().strftime("%Y-%m-%d") 23 | for tk in tickers: 24 | historical_prices.extend( 25 | investor8_sdk.PriceApi().get_historical_prices(ticker=tk, from_date=from_date, to_date=to_date) 26 | ) 27 | else: 28 | for tk in tickers: 29 | historical_prices.extend(investor8_sdk.PriceApi().get_historical_prices(ticker=tk, period=period_code)) 30 | if not historical_prices: 31 | return None 32 | df = DataFrame([h.to_dict() for h in historical_prices]) 33 | df = df.sort_values(by=["ticker", "timestamp"], ascending=False).reset_index(drop=True) 34 | df["Date"] = pd.to_datetime(df["timestamp"], unit="s").dt.tz_localize("UTC") 35 | if len(tickers) > 1: 36 | df["change_perc"] = ( 37 | df.groupby(["ticker"]) 38 | .apply(lambda x: (x["close"] / x["close"].iloc[-1] - 1)) 39 | .reset_index(level=0) 40 | .sort_index()["close"] 41 | ) 42 | else: 43 | df["change_perc"] = df["close"] / df["close"].iloc[-1] - 1 44 | df.rename(columns={"ticker": "Ticker"}, inplace=True) 45 | if pivot_value: 46 | df = pd.pivot_table(df, index="Date", columns=["Ticker"], values=pivot_value).reset_index(level=0) 47 | 48 | return df 49 | 50 | 51 | def get_historical_price_export_df( 52 | tickers: List[str], 53 | period_code: int, 54 | from_date: Optional[str], 55 | to_date: Optional[str], 56 | compare_columns: Optional[Dict[str, str]] = None, 57 | ) -> Optional[DataFrame]: 58 | df = get_historical_price_df(tickers, period_code, from_date, to_date) 59 | if df is None: 60 | return None 61 | df["Date"] = pd.to_datetime(df["timestamp"], unit="s", utc=True).dt.date 62 | if compare_columns: 63 | if "change_perc" in compare_columns: 64 | if len(tickers) > 1: 65 | df["change_perc"] = ( 66 | df.groupby(["Ticker"]) 67 | .apply(lambda x: (x["close"] / x["close"].shift(-1) - 1)) 68 | .reset_index(level=0) 69 | .sort_index()["close"] 70 | ) 71 | else: 72 | df["change_perc"] = df["close"] / df["close"].shift(-1) - 1 73 | df = df.loc[~np.isnan(df["change_perc"])] 74 | df["change_perc"] = df["change_perc"].apply(lambda x: format_number(x * 100, exportize=True)) 75 | df.rename(columns=compare_columns, inplace=True) 76 | df = pd.pivot_table(df, index="Date", columns=["Ticker"], values=compare_columns.values()) 77 | return df 78 | 79 | 80 | def get_historical_price_list_df( 81 | tickers: List[str], 82 | period_code: int, 83 | from_date: Optional[str], 84 | to_date: Optional[str], 85 | ) -> Optional[DataFrame]: 86 | df = get_historical_price_df(tickers, period_code, from_date, to_date) 87 | if df is None: 88 | return None 89 | if len(tickers) > 1: 90 | df["change_perc"] = ( 91 | df.groupby(["Ticker"]) 92 | .apply(lambda x: (x["close"] / x["close"].shift(-1) - 1)) 93 | .reset_index(level=0) 94 | .sort_index()["close"] 95 | ) 96 | else: 97 | df["change_perc"] = df["close"] / df["close"].shift(-1) - 1 98 | df = df.loc[~np.isnan(df["change_perc"])] 99 | df["change_perc"] = df["change_perc"].apply(lambda x: format_number(x * 100, exportize=True)) 100 | return df 101 | -------------------------------------------------------------------------------- /i8_terminal/commands/watchlist/watchlist_metrics.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import click 4 | import investor8_sdk 5 | import pandas as pd 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.watchlist import watchlist 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.layout import df2Table 11 | from i8_terminal.common.metrics import ( 12 | get_current_metrics_df, 13 | get_view_metrics, 14 | prepare_current_metrics_formatted_df, 15 | ) 16 | from i8_terminal.common.utils import export_data, export_to_html 17 | from i8_terminal.config import APP_SETTINGS, USER_SETTINGS 18 | from i8_terminal.types.metric_param_type import MetricParamType 19 | from i8_terminal.types.metric_view_param_type import MetricViewParamType 20 | from i8_terminal.types.user_watchlists_param_type import UserWatchlistsParamType 21 | 22 | 23 | def prepare_watchlist_stocks_df(name: str, metrics: str) -> Optional[pd.DataFrame]: 24 | watchlist = investor8_sdk.UserApi().get_watchlist_by_name_user_id(name=name, user_id=USER_SETTINGS.get("user_id")) 25 | watchlist_stocks_df = get_current_metrics_df(",".join(watchlist.tickers), metrics) 26 | return watchlist_stocks_df 27 | 28 | 29 | @watchlist.command() 30 | @click.option( 31 | "--name", 32 | "-n", 33 | type=UserWatchlistsParamType(), 34 | required=True, 35 | help="Name of the watchlist.", 36 | ) 37 | @click.option( 38 | "--metrics", 39 | "-m", 40 | type=MetricParamType(), 41 | help="Comma-separated list of daily metrics.", 42 | ) 43 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 44 | @click.option( 45 | "--view_name", "view_name", "-v", type=MetricViewParamType(), help="Metric view name in configuration file." 46 | ) 47 | @pass_command 48 | def metrics(name: str, metrics: str, export_path: Optional[str], view_name: Optional[str]) -> None: 49 | """ 50 | Lists and compares watchlist companies based on a given list of metrics. 51 | 52 | Examples: 53 | 54 | `i8 watchlist metrics --name MyWatchlist --metrics total_revenue,net_income,price_to_earnings` 55 | 56 | """ 57 | console = Console() 58 | if not metrics and not view_name: 59 | console.print("The 'metrics' or 'view_name' parameter must be provided", style="yellow") 60 | return 61 | if view_name and metrics: 62 | console.print("The 'metrics' or 'view_name' options are mutually exclusive", style="yellow") 63 | return 64 | if view_name: 65 | metrics = ",".join(get_view_metrics(view_name)) 66 | with console.status("Fetching data...", spinner="material"): 67 | df = prepare_watchlist_stocks_df(name, metrics) 68 | if df is None: 69 | console.print("No data found for metrics with selected tickers", style="yellow") 70 | return 71 | for m in [*set(metric.split(".")[0] for metric in set(metrics.split(","))) - set(df["metric_name"])]: 72 | console.print(f"\nNo data found for metric {m} with selected tickers", style="yellow") 73 | columns_justify: Dict[str, Any] = {} 74 | if export_path: 75 | if export_path.split(".")[-1] == "html": 76 | for metric_display_name, metric_df in df.groupby("display_name"): 77 | columns_justify[metric_display_name] = ( 78 | "left" if metric_df["display_format"].values[0] == "str" else "right" 79 | ) 80 | table = df2Table(prepare_current_metrics_formatted_df(df, "console"), columns_justify=columns_justify) 81 | export_to_html(table, export_path) 82 | return 83 | export_data( 84 | prepare_current_metrics_formatted_df(df, "store"), 85 | export_path, 86 | column_width=18, 87 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 88 | ) 89 | else: 90 | for metric_display_name, metric_df in df.groupby("display_name"): 91 | columns_justify[metric_display_name] = "left" if metric_df["display_format"].values[0] == "str" else "right" 92 | table = df2Table(prepare_current_metrics_formatted_df(df, "console"), columns_justify=columns_justify) 93 | console.print(table) 94 | -------------------------------------------------------------------------------- /i8_terminal/commands/earnings/earnings_plot.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | import click 4 | import investor8_sdk 5 | import pandas as pd 6 | import plotly.express as px 7 | import plotly.graph_objects as go 8 | from pandas.core.frame import DataFrame 9 | from rich.console import Console 10 | 11 | from i8_terminal.app.layout import get_plot_default_layout 12 | from i8_terminal.app.plot_server import serve_plot 13 | from i8_terminal.commands.earnings import earnings 14 | from i8_terminal.common.cli import get_click_command_path, pass_command 15 | from i8_terminal.common.formatting import format_number 16 | from i8_terminal.common.stock_info import validate_tickers 17 | from i8_terminal.common.utils import PlotType 18 | from i8_terminal.types.metric_param_type import MetricParamType 19 | from i8_terminal.types.ticker_param_type import TickerParamType 20 | 21 | 22 | def get_historical_earnings_df(tickers: List[str], size: int) -> DataFrame: 23 | hist_earnings = [] 24 | for tk in tickers: 25 | hist_earnings.extend(investor8_sdk.EarningsApi().get_historical_earnings(tk, size=size)) 26 | df = pd.DataFrame([h.to_dict() for h in hist_earnings])[ 27 | [ 28 | "ticker", 29 | "fyq", 30 | "eps_actual", 31 | "eps_beat_rate", 32 | "eps_surprise", 33 | "eps_ws", 34 | "revenue_actual", 35 | "revenue_beat_rate", 36 | "revenue_surprise", 37 | "revenue_ws", 38 | ] 39 | ] 40 | df = df.sort_values(by=["ticker", "fyq"], ascending=True).reset_index(drop=True) 41 | df.fyq = df.fyq.str[2:-2] + " " + df.fyq.str[-2:] 42 | df = df.rename(columns={"eps_actual": "Actual", "eps_ws": "Consensus"}) 43 | return df 44 | 45 | 46 | def create_fig(df: DataFrame, cmd_context: Dict[str, Any]) -> go.Figure: 47 | fig = px.bar( 48 | df, 49 | hover_data=[], 50 | x="fyq", 51 | y=["Actual", "Consensus"], 52 | barmode="group", 53 | title=cmd_context["plot_title"], 54 | labels={"value": "Metric Value", "fyq": "Period"}, 55 | ) 56 | fig.update_xaxes( 57 | tickvals=df.fyq.to_list(), 58 | ticktext=df.apply(lambda x: x.fyq + f"
Beat by ${format_number(x.eps_surprise, decimal=2)}", axis=1), 59 | ) 60 | fig.update_traces(width=0.3, hovertemplate="%{y}%{_xother}") 61 | fig.update_layout( 62 | **get_plot_default_layout(), 63 | bargap=0.4, 64 | legend_title_text=None, 65 | xaxis_title=None, 66 | margin=dict(b=15, l=70, r=20), 67 | ) 68 | 69 | return fig 70 | 71 | 72 | @earnings.command() 73 | @click.pass_context 74 | @click.option("--metric", "-m", type=MetricParamType(), default="basiceps", help="Metric name.") 75 | @click.option( 76 | "--tickers", 77 | "-k", 78 | type=TickerParamType(), 79 | required=True, 80 | callback=validate_tickers, 81 | help="Comma-separated list of tickers.", 82 | ) 83 | @pass_command 84 | def plot(ctx: click.Context, metric: str, tickers: str) -> None: 85 | """ 86 | Compare and plot earning metrics of given companies. TICKERS is a comma-separated list of tickers. 87 | 88 | Examples: 89 | 90 | `i8 earnings plot --metric net_ppe --tickers AMD,INTC,QCOM` 91 | """ 92 | metric = metric.replace(" ", "").upper() 93 | tickers_list = tickers.replace(" ", "").upper().split(",") 94 | command_path = get_click_command_path(ctx) 95 | tickers_list = tickers.replace(" ", "").upper().split(",") 96 | plot_title = f"{', '.join(tickers_list)} - {metric} Surprise & Estimates by Quarter" 97 | cmd_context = { 98 | "command_path": command_path, 99 | "tickers": tickers_list, 100 | "plot_title": plot_title, 101 | "plot_type": PlotType.CHART.value, 102 | } 103 | 104 | console = Console() 105 | with console.status("Fetching data...", spinner="material") as status: 106 | df = get_historical_earnings_df(tickers_list, size=4) 107 | 108 | status.update("Generating plot...") 109 | fig = create_fig(df, cmd_context) 110 | 111 | serve_plot(fig, cmd_context) 112 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Test, Publish i8 Terminal to PyPi and Release MSI 2 | on: [pull_request, push] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout Code 9 | uses: actions/checkout@v2 10 | 11 | - name: Setup Python 3.9 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.9" 15 | architecture: x64 16 | 17 | - uses: actions/cache@v2 18 | with: 19 | path: ~/.cache/pip 20 | key: ${{ runner.os }}-v1-test 21 | restore-keys: ${{ runner.os }}-v1-test 22 | 23 | - run: pip3 install -r requirements-dev.txt 24 | - run: black --check i8_terminal/ 25 | - run: isort --check i8_terminal/ 26 | - run: flake8 i8_terminal/ 27 | - run: mypy i8_terminal/ 28 | 29 | publish_pypi: 30 | runs-on: ubuntu-latest 31 | needs: [test] 32 | if: github.ref == 'refs/heads/main' 33 | steps: 34 | - name: Checkout Code 35 | uses: actions/checkout@v2 36 | 37 | - name: Setup Python 3.9 38 | uses: actions/setup-python@v2 39 | with: 40 | python-version: "3.9" 41 | architecture: x64 42 | 43 | - uses: actions/cache@v2 44 | with: 45 | path: ~/.cache/pip 46 | key: ${{ runner.os }}-v1-test 47 | restore-keys: ${{ runner.os }}-v1-test 48 | 49 | - name: Build package & publish to PyPi 50 | env: 51 | TWINE_USERNAME: __token__ 52 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 53 | run: | 54 | pip3 install -r requirements.txt 55 | pip3 install twine==4.0.0 56 | python setup.py sdist 57 | twine upload dist/i8-terminal-$(python setup.py -V).tar.gz 58 | 59 | publish_ghdoc: 60 | runs-on: ubuntu-latest 61 | needs: [test, publish_pypi] 62 | if: github.ref == 'refs/heads/main' 63 | steps: 64 | - name: Checkout Code 65 | uses: actions/checkout@v2 66 | 67 | - name: Setup Python 3.9 68 | uses: actions/setup-python@v2 69 | with: 70 | python-version: "3.9" 71 | architecture: x64 72 | 73 | - name: Build documentation & publish to ghpages 74 | env: 75 | I8_CORE_API_KEY: ${{ secrets.I8_CORE_API_KEY }} 76 | run: | 77 | pip3 install -r requirements-docs.txt 78 | pip3 install i8-terminal 79 | python docs/generate_metrics.py 80 | mkdocs gh-deploy --force --clean --verbose 81 | 82 | publish_msi_win: 83 | runs-on: windows-latest 84 | needs: [test] 85 | if: github.ref == 'refs/heads/main' 86 | steps: 87 | - name: Checkout Code 88 | uses: actions/checkout@v2 89 | 90 | - name: Setup Python 3.9 91 | uses: actions/setup-python@v2 92 | with: 93 | python-version: "3.9" 94 | architecture: x64 95 | - name: Set Package Version 96 | run: echo "::set-output name=version::$(python setup.py -V)" 97 | id: version 98 | - name: Build package & create msi 99 | run: | 100 | pip3 install -r requirements.txt 101 | pip3 install -r requirements-pub.txt 102 | python setup_cx.py bdist_msi 103 | 104 | - name: Release MSI 105 | uses: actions/create-release@v1 106 | id: create_release 107 | with: 108 | draft: false 109 | prerelease: false 110 | release_name: i8 Terminal v${{ steps.version.outputs.version }} 111 | tag_name: v${{ steps.version.outputs.version }} 112 | env: 113 | GITHUB_TOKEN: ${{ github.token }} 114 | 115 | - name: Upload Release Asset 116 | id: upload-release-asset 117 | uses: actions/upload-release-asset@v1 118 | env: 119 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 120 | with: 121 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 122 | asset_path: ./dist/i8-terminal-${{ steps.version.outputs.version }}-win64.msi 123 | asset_name: i8-terminal-${{ steps.version.outputs.version }}.msi 124 | asset_content_type: application/msi -------------------------------------------------------------------------------- /i8_terminal/common/stock_info.py: -------------------------------------------------------------------------------- 1 | import os 2 | from ast import literal_eval 3 | from typing import List, Optional, Tuple 4 | 5 | import click 6 | import investor8_sdk 7 | import numpy as np 8 | import pandas as pd 9 | 10 | from i8_terminal.common.utils import is_cached_file_expired 11 | from i8_terminal.config import SETTINGS_FOLDER 12 | 13 | 14 | def sort_stocks(df: pd.DataFrame, include_peers: bool = False) -> pd.DataFrame: 15 | df["default_rank"] = 11 16 | if not include_peers: 17 | default_rank = { 18 | "A": 1, 19 | "AAL": 2, 20 | "AAP": 3, 21 | "AAPL": 4, 22 | "AABV": 5, 23 | "ABC": 6, 24 | "ABMD": 7, 25 | "ABT": 8, 26 | "ACN": 9, 27 | "ADBE": 10, 28 | } 29 | else: 30 | default_rank = { 31 | "A": 1, 32 | "A.peers": 2, 33 | "AAL": 3, 34 | "AAL.peers": 4, 35 | "AAP": 5, 36 | "AAP.peers": 6, 37 | "AAPL": 7, 38 | "AAPL.peers": 8, 39 | "AABV": 9, 40 | "AABV.peers": 10, 41 | } 42 | df["default_rank"] = df["ticker"].apply(lambda x: default_rank.get(x, 11)) 43 | df = df.sort_values("default_rank").reset_index(drop=True) 44 | return df[["ticker", "name", "peers"]] 45 | 46 | 47 | def get_stocks_df() -> pd.DataFrame: 48 | companies_path = f"{SETTINGS_FOLDER}/companies.csv" 49 | if os.path.exists(companies_path) and not is_cached_file_expired(companies_path): 50 | stocks_df = pd.read_csv(companies_path, keep_default_na=False) 51 | else: 52 | results = investor8_sdk.StockInfoApi().get_all_active_companies() 53 | stocks_df = pd.DataFrame([d.to_dict() for d in results])[["ticker", "name", "peers"]] 54 | stocks_df = sort_stocks(stocks_df) 55 | stocks_df.to_csv(companies_path, index=False) 56 | return stocks_df 57 | 58 | 59 | def get_stocks(include_peers: bool) -> List[Tuple[str, str]]: 60 | columns_list = ["ticker", "name"] 61 | df = get_stocks_df().replace("", np.nan) 62 | if include_peers: 63 | df_peers = df[~df["peers"].isna()].copy() 64 | df_peers.loc[:, "ticker"] = df_peers["ticker"].apply(lambda x: x + ".peers") 65 | df_peers.loc[:, "name"] = df_peers["name"].apply(lambda x: "Peers of " + x) 66 | df = sort_stocks(pd.concat([df, df_peers]), include_peers) 67 | return list(df[columns_list].to_records(index=False)) 68 | 69 | 70 | def validate_ticker(ctx: click.Context, param: str, value: str) -> Optional[str]: 71 | if not ctx.resilient_parsing: 72 | if value and len(value.replace(" ", "").split(",")) > 1: 73 | click.echo(click.style(f"`{value}` is not a valid ticker name.", fg="yellow")) 74 | ctx.exit() 75 | if value and value.replace(" ", "").upper() not in set(get_stocks_df()["ticker"]): 76 | click.echo(click.style(f"`{value}` is not a valid ticker name.", fg="yellow")) 77 | ctx.exit() 78 | return value 79 | 80 | 81 | def validate_tickers(ctx: click.Context, param: str, value: str) -> Optional[str]: 82 | tickers = {d[0] for d in get_stocks(True)} 83 | if not ctx.resilient_parsing: 84 | inputted_tickers = [] 85 | for ticker in value.replace(" ", "").split(","): 86 | splitted_ticker = ticker.split(".") 87 | splitted_ticker[0] = splitted_ticker[0].upper() 88 | inputted_tickers.append(".".join(splitted_ticker)) 89 | invalid_tickers = [*set(inputted_tickers) - tickers] if value else [] 90 | if value and invalid_tickers: 91 | msg = "are not valid ticker names." if len(invalid_tickers) > 1 else "is not a valid ticker name." 92 | click.echo( 93 | click.style( 94 | f"`{', '.join(invalid_tickers)}` {msg}", 95 | fg="yellow", 96 | ) 97 | ) 98 | ctx.exit() 99 | return value 100 | 101 | 102 | def get_tickers_list(tickers: str) -> List[str]: 103 | stocks_peers = get_stocks_df()[["ticker", "peers"]].set_index("ticker").to_dict()["peers"] 104 | tickers_list = [] 105 | for tk in tickers.split(","): 106 | if "peers" in tk.lower() and stocks_peers.get(tk.split(".")[0]): 107 | ticker_name = tk.split(".")[0] 108 | tickers_list.append(ticker_name) 109 | tickers_list.extend(literal_eval(stocks_peers.get(ticker_name))) 110 | else: 111 | tickers_list.append(tk) 112 | return tickers_list 113 | -------------------------------------------------------------------------------- /i8_terminal/types/condition_param_type.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, List, Tuple 3 | 4 | import numpy as np 5 | 6 | from i8_terminal.common.formatting import format_number_v2 7 | from i8_terminal.common.metrics import get_all_metrics_df 8 | from i8_terminal.types.auto_complete_choice import AutoCompleteChoice 9 | 10 | PERIOD_TYPES: Dict[str, str] = { 11 | "fy": "mry", 12 | "q": "mrq", 13 | "ttm": "ttm", 14 | "ytd": "ytd", 15 | "d": "d", 16 | "r": "r", 17 | "mry": "mry", 18 | "mrq": "mrq", 19 | } 20 | 21 | 22 | def get_metrics_conditions_dict() -> Dict[str, str]: 23 | df = get_all_metrics_df()[["metric_name", "screening_bounds"]] 24 | return dict([(i, j) for i, j in zip(df.metric_name, df.screening_bounds)]) 25 | 26 | 27 | def get_metrics_default_period_types_dict() -> Dict[str, str]: 28 | df = get_all_metrics_df()[["metric_name", "period_type_default"]] 29 | return dict([(i, j) for i, j in zip(df.metric_name, df.period_type_default)]) 30 | 31 | 32 | def get_metrics_data_format_dict() -> Dict[str, str]: 33 | df = get_all_metrics_df()[["metric_name", "data_format"]] 34 | return dict([(i, j) for i, j in zip(df.metric_name, df.data_format)]) 35 | 36 | 37 | def get_metrics_screening_categories_dict() -> Dict[str, str]: 38 | df = get_all_metrics_df()[["metric_name", "screening_categories"]] 39 | return dict([(i, j) for i, j in zip(df.metric_name, df.screening_categories)]) 40 | 41 | 42 | class ConditionParamType(AutoCompleteChoice): 43 | name = "condition" 44 | 45 | def get_suggestions( 46 | self, keyword: str, pre_populate: bool = True, metric: str = None, period: str = None, value_field: str = None 47 | ) -> List[Tuple[str, str]]: 48 | if not self.is_loaded: 49 | self.metrics_conditions = get_metrics_conditions_dict() 50 | self.metrics_default_period_types = get_metrics_default_period_types_dict() 51 | self.metrics_data_format = get_metrics_data_format_dict() 52 | self.metrics_screening_categories = get_metrics_screening_categories_dict() 53 | metric_screening_bounds_dict = json.loads( 54 | self.metrics_conditions.get(metric, "").replace("'", '"') # type: ignore 55 | ) 56 | if value_field in ["rank", "dow_rank", "sector_rank", "industry_rank"]: 57 | self.set_choices([("5", ""), ("10", ""), ("20", ""), ("50", ""), ("100", ""), ("500", ""), ("1000", "")]) 58 | elif value_field in [ 59 | "percentile", 60 | "sector_percentile", 61 | "industry_percentile", 62 | "dow_percentile", 63 | ]: 64 | self.set_choices([("1", ""), ("3", ""), ("5", ""), ("10", ""), ("20", ""), ("50", "")]) 65 | else: 66 | if self.metrics_data_format.get(metric, "") == "categorical": # type: ignore 67 | metric_screening_categories = json.loads( 68 | self.metrics_screening_categories.get(metric, "").replace("'", '"') # type: ignore 69 | ) 70 | self.set_choices( 71 | [ 72 | ( 73 | c.get("category_name", ""), 74 | c.get("category_display_name", ""), 75 | ) 76 | for c in metric_screening_categories 77 | ] 78 | ) 79 | else: 80 | default_p_type = self.metrics_default_period_types.get(metric, "Q") # type: ignore 81 | self.set_choices( 82 | [ 83 | ( 84 | str(c), 85 | format_number_v2(c, percision=1 if c < 1000 else 0, humanize=False if c < 1000 else True), 86 | ) # type: ignore 87 | for c in metric_screening_bounds_dict.get( 88 | PERIOD_TYPES.get(period, "mrq") 89 | if period and period != "p" 90 | else PERIOD_TYPES.get( 91 | default_p_type.lower() if default_p_type is not np.nan else "Q" # type: ignore 92 | ), 93 | "", 94 | ) 95 | ] 96 | ) 97 | 98 | if pre_populate and keyword.strip() == "": 99 | return self._choices[: self.size] 100 | return self.search_keyword(keyword) 101 | 102 | def __repr__(self) -> str: 103 | return "CONDITION" 104 | -------------------------------------------------------------------------------- /i8_terminal/commands/metrics/metrics_current.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import click 4 | from rich.console import Console 5 | 6 | from i8_terminal.commands.metrics import metrics 7 | from i8_terminal.common.cli import pass_command 8 | from i8_terminal.common.layout import df2Table 9 | from i8_terminal.common.metrics import ( 10 | get_current_metrics_df, 11 | get_view_metrics, 12 | prepare_current_metrics_formatted_df, 13 | ) 14 | from i8_terminal.common.stock_info import get_tickers_list, validate_tickers 15 | from i8_terminal.common.utils import export_data, export_to_html 16 | from i8_terminal.config import APP_SETTINGS 17 | from i8_terminal.types.metric_identifier_param_type import MetricIdentifierParamType 18 | from i8_terminal.types.metric_view_param_type import MetricViewParamType 19 | from i8_terminal.types.ticker_param_type import TickerParamType 20 | 21 | 22 | @metrics.command() 23 | @click.option( 24 | "--tickers", 25 | "-k", 26 | type=TickerParamType(), 27 | required=True, 28 | callback=validate_tickers, 29 | help="Comma-separated list of tickers.", 30 | ) 31 | @click.option( 32 | "--metrics", 33 | "-m", 34 | type=MetricIdentifierParamType(), 35 | help="Comma-separated list of daily metrics.", 36 | ) 37 | @click.option( 38 | "--view_name", "view_name", "-v", type=MetricViewParamType(), help="Metric view name in configuration file." 39 | ) 40 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 41 | @pass_command 42 | def current(tickers: str, metrics: str, view_name: Optional[str], export_path: Optional[str]) -> None: 43 | """ 44 | Lists the given metrics for a given list of companies. TICKERS is a comma-separated list of tickers. 45 | METRICS can be in the below format: 46 | {metric}.{optional period} 47 | 48 | Available periods: 49 | q = most recent quarter, 50 | fy = most recent fiscal year, 51 | ttm = trailing 12 months, 52 | ytd = year to date, 53 | p = default period type 54 | 55 | Examples: 56 | 57 | `i8 metrics current --metrics total_revenue.q,net_income.fy,close.d,total_revenue --tickers AMD,INTC,QCOM` 58 | """ # noqa: E501 59 | console = Console() 60 | if not metrics and not view_name: 61 | console.print("The 'metrics' or 'view_name' parameter must be provided", style="yellow") 62 | return 63 | if view_name and metrics: 64 | console.print( 65 | "The 'metrics' or 'view_name' options are mutually exclusive. Provide a value only for one of them.", 66 | style="yellow", 67 | ) 68 | return 69 | if view_name: 70 | metrics = ",".join(get_view_metrics(view_name)) 71 | with console.status("Fetching data...", spinner="material"): 72 | df = get_current_metrics_df(tickers, metrics.replace(".p", "")) 73 | if df is None or df.empty: 74 | console.print("No data found for metrics with selected tickers", style="yellow") 75 | return 76 | for m in [*set(metric.split(".")[0] for metric in set(metrics.split(","))) - set(df["metric_name"])]: 77 | console.print(f"\nNo data found for metric {m} with selected tickers", style="yellow") 78 | tickers_order = get_tickers_list(tickers) 79 | metrics_order = [df.loc[df["input_metric"] == metric]["display_name"].values[0] for metric in metrics.split(",")] 80 | columns_justify: Dict[str, Any] = {} 81 | if export_path: 82 | if export_path.split(".")[-1] == "html": 83 | for metric_display_name, metric_df in df.groupby("display_name"): 84 | columns_justify[metric_display_name] = ( 85 | "left" if metric_df["display_format"].values[0] == "str" else "right" 86 | ) 87 | table = df2Table( 88 | prepare_current_metrics_formatted_df( 89 | df, "console", include_period=True, tickers_order=tickers_order, metrics_order=metrics_order 90 | ), 91 | columns_justify=columns_justify, 92 | ) 93 | export_to_html(table, export_path) 94 | return 95 | export_data( 96 | prepare_current_metrics_formatted_df( 97 | df, "store", include_period=True, tickers_order=tickers_order, metrics_order=metrics_order 98 | ), 99 | export_path, 100 | column_width=18, 101 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 102 | ) 103 | else: 104 | for metric_display_name, metric_df in df.groupby("display_name"): 105 | columns_justify[metric_display_name] = "left" if metric_df["display_format"].values[0] == "str" else "right" 106 | table = df2Table( 107 | prepare_current_metrics_formatted_df( 108 | df, "console", include_period=True, tickers_order=tickers_order, metrics_order=metrics_order 109 | ), 110 | columns_justify=columns_justify, 111 | ) 112 | console.print(table) 113 | -------------------------------------------------------------------------------- /i8_terminal/commands/earnings/earnings_upcoming.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | import investor8_sdk 5 | from pandas import DataFrame 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.earnings import earnings 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.formatting import get_formatter 11 | from i8_terminal.common.layout import df2Table, format_df 12 | from i8_terminal.common.stock_info import validate_tickers 13 | from i8_terminal.common.utils import export_data, export_to_html 14 | from i8_terminal.config import APP_SETTINGS 15 | from i8_terminal.types.ticker_param_type import TickerParamType 16 | 17 | 18 | def get_upcoming_earnings_df(size: int) -> DataFrame: 19 | earnings = investor8_sdk.EarningsApi().get_upcoming_earnings(size=size) 20 | earnings = [d.to_dict() for d in earnings] 21 | df = DataFrame(earnings) 22 | df["eps_beat_rate"] = df["eps_beat_rate"] * 100 23 | df["revenue_beat_rate"] = df["revenue_beat_rate"] * 100 24 | return df 25 | 26 | 27 | def get_upcoming_earnings_df_by_ticker(tickers: str) -> DataFrame: 28 | upcoming_earnings = [] 29 | for tk in tickers.replace(" ", "").upper().split(","): 30 | upcoming_earnings.extend([investor8_sdk.EarningsApi().get_upcoming_earning(tk)]) 31 | df = DataFrame([h.to_dict() for h in upcoming_earnings]) 32 | df["eps_beat_rate"] = df["eps_beat_rate"] * 100 33 | df["revenue_beat_rate"] = df["revenue_beat_rate"] * 100 34 | return df 35 | 36 | 37 | def format_upcoming_earnings_df(df: DataFrame, target: str) -> DataFrame: 38 | formatters = { 39 | "latest_price": get_formatter("price", target), 40 | "change": get_formatter("perc", target), 41 | "fyq": get_formatter("fyq", target), 42 | "eps_ws": get_formatter("number", target), 43 | "eps_beat_rate": get_formatter("number_perc" if target == "console" else "perc", target), 44 | "revenue_ws": get_formatter("financial", target), 45 | "revenue_beat_rate": get_formatter("number_perc" if target == "console" else "perc", target), 46 | } 47 | col_names = { 48 | "ticker": "Ticker", 49 | "name": "Name", 50 | "latest_price": "Price", 51 | "change": "Change", 52 | "actual_report_date": "Report Date", 53 | "fyq": "Period", 54 | "call_time": "Call Time", 55 | "eps_ws": "EPS Estimate", 56 | "eps_beat_rate": "EPS Beat Rate", 57 | "revenue_ws": "Revenue Estimate", 58 | "revenue_beat_rate": "Revenue Beat Rate", 59 | } 60 | return format_df(df, col_names, formatters) 61 | 62 | 63 | def format_upcoming_earnings_df_by_ticker(df: DataFrame, target: str) -> DataFrame: 64 | formatters = { 65 | "fyq": get_formatter("fyq", target), 66 | "eps_ws": get_formatter("number", target), 67 | "eps_beat_rate": get_formatter("number_perc" if target == "console" else "perc", target), 68 | "revenue_ws": get_formatter("financial", target), 69 | "revenue_beat_rate": get_formatter("number_perc" if target == "console" else "perc", target), 70 | } 71 | col_names = { 72 | "ticker": "Ticker", 73 | "actual_report_date": "Report Date", 74 | "fyq": "Period", 75 | "call_time": "Call Time", 76 | "eps_ws": "EPS Estimate", 77 | "eps_beat_rate": "EPS Beat Rate", 78 | "revenue_ws": "Revenue Estimate", 79 | "revenue_beat_rate": "Revenue Beat Rate", 80 | } 81 | return format_df(df, col_names, formatters) 82 | 83 | 84 | @earnings.command() 85 | @click.option( 86 | "--tickers", "-k", type=TickerParamType(), callback=validate_tickers, help="Comma-separated list of tickers." 87 | ) 88 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 89 | @pass_command 90 | def upcoming(tickers: Optional[str], export_path: Optional[str]) -> None: 91 | """ 92 | Lists upcoming company earnings. 93 | 94 | Examples: 95 | 96 | `i8 earnings upcoming` 97 | 98 | `i8 earnings upcoming --tickers AAPL,MSFT` 99 | 100 | """ 101 | console = Console() 102 | with console.status("Fetching data...", spinner="material"): 103 | df = get_upcoming_earnings_df_by_ticker(tickers) if tickers else get_upcoming_earnings_df(size=20) 104 | if export_path: 105 | if export_path.split(".")[-1] == "html": 106 | df_formatted = ( 107 | format_upcoming_earnings_df_by_ticker(df, "console") 108 | if tickers 109 | else format_upcoming_earnings_df(df, "console") 110 | ) 111 | table = df2Table(df_formatted) 112 | export_to_html(table, export_path) 113 | return 114 | export_data( 115 | format_upcoming_earnings_df_by_ticker(df, "store") if tickers else format_upcoming_earnings_df(df, "store"), 116 | export_path, 117 | column_width=18, 118 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 119 | ) 120 | return 121 | df_formatted = ( 122 | format_upcoming_earnings_df_by_ticker(df, "console") if tickers else format_upcoming_earnings_df(df, "console") 123 | ) 124 | table = df2Table(df_formatted) 125 | console.print(table) 126 | -------------------------------------------------------------------------------- /i8_terminal/commands/financials/financials_list.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import click 4 | import investor8_sdk 5 | import numpy as np 6 | from rich.console import Console 7 | 8 | from i8_terminal.commands.financials import financials 9 | from i8_terminal.common.cli import pass_command 10 | from i8_terminal.common.financials import ( 11 | fin_df2export_df, 12 | fin_df2Tree, 13 | find_similar_statement, 14 | get_statements_codes, 15 | get_statements_disp_name, 16 | parse_identifier, 17 | prepare_financials_df, 18 | ) 19 | from i8_terminal.common.metrics import get_all_financial_metrics_df 20 | from i8_terminal.common.utils import export_data, export_to_html 21 | from i8_terminal.config import APP_SETTINGS 22 | from i8_terminal.types.fin_identifier_param_type import FinancialsIdentifierParamType 23 | from i8_terminal.types.fin_statement_param_type import FinancialStatementParamType 24 | from i8_terminal.types.period_type_param_type import PeriodTypeParamType 25 | 26 | 27 | def get_standardized_financials( 28 | identifiers_dict: Dict[str, str], 29 | statement: str, 30 | period_type: Optional[str], 31 | period_size: int = 4, 32 | exportize: Optional[bool] = False, 33 | ) -> Optional[Dict[str, Any]]: 34 | fins = [] 35 | if identifiers_dict.get("fiscal_period"): 36 | fins = [ 37 | investor8_sdk.FinancialsApi().get_financials_single( 38 | ticker=identifiers_dict["ticker"], 39 | stat_code=statement, 40 | fiscal_year=identifiers_dict.get("fiscal_year"), 41 | fiscal_period=identifiers_dict.get("fiscal_period"), 42 | ) 43 | ] 44 | else: 45 | period_type = "FY" if not period_type else period_type 46 | fins = investor8_sdk.FinancialsApi().get_list_standardized_financials( 47 | ticker=identifiers_dict["ticker"], 48 | stat_code=statement, 49 | period_type=period_type, 50 | end_year=identifiers_dict.get("fiscal_year", ""), 51 | ) 52 | if not fins: 53 | return None 54 | return prepare_financials_df(fins, period_size, include_ticker=False, exportize=exportize) 55 | 56 | 57 | @financials.command() 58 | @click.option( 59 | "--identifier", 60 | "-i", 61 | type=FinancialsIdentifierParamType(), 62 | required=True, 63 | help="Financial identifier (e.g. MSFT-2023-Q2)", 64 | ) 65 | @click.option( 66 | "--statement", 67 | "-s", 68 | type=FinancialStatementParamType(), 69 | default="income", 70 | help="Type of financial statement.", 71 | ) 72 | @click.option( 73 | "--period_type", 74 | "-m", 75 | type=PeriodTypeParamType(), 76 | help="Period by which you want to view the report. Possible values are `FY` for yearly, `Q` for quarterly, and `TTM` for TTM reports.", # noqa: E501 77 | ) 78 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 79 | @pass_command 80 | def list(identifier: str, statement: str, period_type: Optional[str], export_path: Optional[str]) -> None: 81 | """ 82 | Lists financial metrics of a given company. 83 | 84 | Examples: 85 | 86 | `i8 financials list --period_type FY --statement income --identifier AAPL-2020-FY` 87 | 88 | """ 89 | matched_statement = find_similar_statement(statement) 90 | if not matched_statement: 91 | click.echo( 92 | f"`{statement}` is not a valid statement code. \nValid statement codes: {', '.join(get_statements_codes())}" 93 | ) 94 | click.echo("Unknown Statement Code!") 95 | return 96 | identifiers_dict = parse_identifier(identifier, period_type) 97 | console = Console() 98 | with console.status("Fetching data...", spinner="material") as status: 99 | fins = get_standardized_financials( 100 | identifiers_dict, matched_statement, period_type, period_size=4, exportize=True if export_path else False 101 | ) 102 | if fins is None: 103 | status.stop() 104 | click.echo("No data found!") 105 | return 106 | periods_list = fins["data"].columns[1:].to_list() 107 | df_metrics = get_all_financial_metrics_df() 108 | df = fins["data"].merge(df_metrics, on="tag", how="left") 109 | df["section_name"] = df["section_name"].apply(lambda x: "Others" if not x or x == "-" else x) 110 | df = df.astype(object).replace(np.nan, None) # Replace nan with None 111 | 112 | if export_path: 113 | if export_path.split(".")[-1] == "html": 114 | tree = fin_df2Tree( 115 | df, 116 | fins["header"], 117 | periods_list, 118 | title=f"{identifiers_dict['ticker'].upper()} {get_statements_disp_name(matched_statement)}", 119 | ) 120 | export_to_html(tree, export_path) 121 | return 122 | export_df = fin_df2export_df(df, periods_list) 123 | export_data( 124 | export_df, 125 | export_path, 126 | column_width=18, 127 | column_format=APP_SETTINGS["styles"]["xlsx"]["financials"]["column"], 128 | ) 129 | else: 130 | tree = fin_df2Tree( 131 | df, 132 | fins["header"], 133 | periods_list, 134 | title=f"{identifiers_dict['ticker'].upper()} {get_statements_disp_name(matched_statement)}", 135 | ) 136 | console.print(tree) 137 | -------------------------------------------------------------------------------- /i8_terminal/common/utils.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import os 3 | from difflib import SequenceMatcher 4 | from io import StringIO 5 | from typing import Any, Callable, Dict, List, Optional, TypeVar 6 | 7 | import arrow 8 | import click 9 | import pandas as pd 10 | from prompt_toolkit.document import Document 11 | from rich.console import Console 12 | 13 | from i8_terminal.config import APP_SETTINGS 14 | from i8_terminal.types.command_parser import CompleterContext 15 | 16 | T = TypeVar("T") 17 | 18 | 19 | class PlotType(enum.Enum): 20 | CHART = "chart" 21 | TABLE = "table" 22 | 23 | 24 | def to_snake_case(value: str) -> str: 25 | return "_".join(value.lower().split()) 26 | 27 | 28 | def get_period_code(period: str) -> int: 29 | return {"1D": 1, "5D": 2, "1M": 3, "3M": 4, "6M": 5, "1Y": 6, "3Y": 7, "5Y": 8}.get(period, 3) 30 | 31 | 32 | def get_period_days(period: str) -> int: 33 | return {"1D": 1, "5D": 5, "1M": 30, "3M": 90, "6M": 180, "1Y": 365, "3Y": 1095, "5Y": 1825}.get(period, 365) 34 | 35 | 36 | def similarity(a: str, b: str) -> float: 37 | return SequenceMatcher(None, a, b).ratio() 38 | 39 | 40 | def export_data( 41 | export_df: pd.DataFrame, 42 | export_path: str, 43 | column_width: Optional[int], 44 | column_format: Dict[str, Any], 45 | index: bool = False, 46 | ) -> None: 47 | console = Console() 48 | extension = export_path.split(".")[-1] 49 | if extension == "csv": 50 | export_df.to_csv(export_path, index=index) 51 | console.print(f"Data is saved on: {export_path}") 52 | elif extension == "xlsx": 53 | writer = pd.ExcelWriter(export_path, engine="xlsxwriter") 54 | export_df.to_excel(writer, sheet_name="Sheet1", startrow=1, header=False, index=index) 55 | workbook = writer.book 56 | worksheet = writer.sheets["Sheet1"] 57 | header_format = workbook.add_format(APP_SETTINGS["styles"]["xlsx"]["default"]["header"]) 58 | metric_format = workbook.add_format(APP_SETTINGS["styles"]["xlsx"]["default"]["metric"]) 59 | column_format = workbook.add_format(column_format) 60 | headers = export_df.columns.tolist() 61 | if index: 62 | headers.insert(0, (export_df.index.name, "")) 63 | for col_num, value in enumerate(headers): 64 | if type(value) is tuple: 65 | worksheet.merge_range(0, col_num, len(value) - 1, col_num, " ".join(reversed(value)), header_format) 66 | else: 67 | worksheet.write(0, col_num, value, header_format) 68 | worksheet.set_column(0, 0, 20, metric_format) 69 | worksheet.set_column( 70 | 1, len(export_df.columns) - 1 if not index else len(export_df.columns), column_width, column_format 71 | ) 72 | writer.save() 73 | console.print(f"Data is saved on: {export_path}") 74 | else: 75 | console.print("export_path is not valid") 76 | 77 | 78 | def is_cached_file_expired(file_path: str) -> bool: 79 | mtime = arrow.get(os.path.getmtime(file_path)) 80 | return bool(mtime < arrow.utcnow().shift(hours=-APP_SETTINGS.get("cache", {}).get("age", 48))) 81 | 82 | 83 | def reverse_period(period: str) -> str: 84 | """ 85 | If period is fyq type (eg. 'Q 2021'), the function will change it to '2021 Q'. 86 | """ 87 | splitted_period = period.split(" ") 88 | return f"{splitted_period[1]} {splitted_period[0]}" if len(splitted_period) > 1 else period 89 | 90 | 91 | def export_to_html(data: Any, export_path: str) -> None: 92 | console = Console(record=True, file=StringIO()) 93 | console.print(data) 94 | exported_html = console.export_html( 95 | inline_styles=True, 96 | code_format=""" 97 | 98 | 99 | i8 Terminal by Investor8 100 | 101 | 102 |
103 | 104 |
105 |
106 |
{code}
107 |
109 | 110 | """, 111 | ) 112 | with open(export_path, "w", encoding="utf-8") as file: 113 | file.write(exported_html) 114 | console = Console() 115 | console.print(f"Data is saved on: {export_path}") 116 | 117 | 118 | def status(text: str = "Fetching data...", spinner: str = "material") -> Callable[..., Callable[..., T]]: 119 | def decorate(func: Any) -> Any: 120 | def wrapper(*args: Any, **kwargs: Any) -> Any: 121 | console = Console() 122 | with console.status(text, spinner=spinner): 123 | return func(*args, **kwargs) 124 | 125 | return wrapper 126 | 127 | return decorate 128 | 129 | 130 | def concat_and(items: List[str]) -> str: 131 | return " and ".join(", ".join(items).rsplit(", ", 1)) 132 | 133 | 134 | def get_matched_params( 135 | ctx: CompleterContext, command: click.Command, document: Document 136 | ) -> Optional[List[click.Option]]: 137 | if not document.is_cursor_at_the_end: 138 | command_string = document.current_line 139 | cursor_position = document.cursor_position 140 | if document.char_before_cursor == " " and not document.current_line_before_cursor.split(" ")[-2].startswith( 141 | "-" 142 | ): 143 | return None 144 | options_positions = [ 145 | (o, cursor_position - command_string.find(o)) 146 | for c in command.params 147 | for o in ctx.used_options 148 | if cursor_position - command_string.find(o) > 0 149 | ] 150 | matched = min(options_positions, key=lambda option_position: option_position[1]) # type: ignore 151 | matched_params = [p for p in command.params if isinstance(p, click.Option) and matched[0] in p.opts] 152 | else: 153 | matched_params = [p for p in command.params if isinstance(p, click.Option) and ctx.last_option in p.opts] 154 | return matched_params 155 | -------------------------------------------------------------------------------- /i8_terminal/commands/price/price_compare.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, cast 2 | 3 | import click 4 | import plotly.express as px 5 | from click.types import DateTime 6 | from pandas.core.frame import DataFrame 7 | from plotly.graph_objects import Figure 8 | from rich.console import Console 9 | 10 | from i8_terminal.app.layout import get_date_range, get_plot_default_layout 11 | from i8_terminal.app.plot_server import serve_plot 12 | from i8_terminal.commands.price import price 13 | from i8_terminal.common.cli import get_click_command_path, pass_command 14 | from i8_terminal.common.layout import df2Table 15 | from i8_terminal.common.price import ( 16 | get_historical_price_df, 17 | get_historical_price_export_df, 18 | ) 19 | from i8_terminal.common.stock_info import get_tickers_list, validate_tickers 20 | from i8_terminal.common.utils import ( 21 | PlotType, 22 | export_data, 23 | export_to_html, 24 | get_period_code, 25 | ) 26 | from i8_terminal.config import APP_SETTINGS 27 | from i8_terminal.types.price_period_param_type import PricePeriodParamType 28 | from i8_terminal.types.ticker_param_type import TickerParamType 29 | 30 | 31 | def get_price_data( 32 | tickers: List[str], period_code: int, from_date: Optional[str], to_date: Optional[str] 33 | ) -> Optional[DataFrame]: 34 | hist_price_df = get_historical_price_df(tickers, period_code, from_date, to_date, pivot_value="change_perc") 35 | # FIXME: use realtime price in future 36 | # realtime_price_df = get_historical_price_df(tickers, 1) 37 | # df = hist_price_df.append(realtime_price_df) 38 | if hist_price_df is None: 39 | return None 40 | hist_price_df = hist_price_df.dropna() 41 | 42 | return hist_price_df.sort_values("Date") 43 | 44 | 45 | def get_export_data( 46 | tickers: List[str], period_code: int, from_date: Optional[str], to_date: Optional[str] 47 | ) -> Optional[DataFrame]: 48 | compare_columns = {"change_perc": "Change (%)", "close": "Close"} 49 | export_price_df = get_historical_price_export_df( 50 | tickers, period_code, from_date, to_date, compare_columns=compare_columns 51 | ) 52 | if export_price_df is None: 53 | return None 54 | return export_price_df.sort_values("Date") 55 | 56 | 57 | def create_fig(df: DataFrame, period_code: int, cmd_context: Dict[str, Any], range_selector: bool = False) -> Figure: 58 | layout = dict( 59 | autosize=True, 60 | hovermode="closest", 61 | legend=dict(font=dict(size=11), orientation="h", yanchor="top", y=0.98, xanchor="left", x=0.01), 62 | ) 63 | fig = px.line( 64 | df, 65 | x="Date", 66 | y=df.columns, 67 | title=cmd_context["plot_title"], 68 | labels={"value": "Change (%)"}, 69 | ) 70 | fig.update_xaxes( 71 | rangeslider_visible=False, 72 | spikemode="across", 73 | spikesnap="cursor", 74 | ) 75 | if range_selector: 76 | fig.update_xaxes(rangeselector=get_date_range(period_code)) 77 | fig.update_yaxes(tickformat=".0%") 78 | fig.update_layout( 79 | **layout, 80 | xaxis_title=None, 81 | **get_plot_default_layout(), 82 | margin=dict(b=15, l=90, r=20), 83 | ) 84 | 85 | return fig 86 | 87 | 88 | @price.command() 89 | @click.pass_context 90 | @click.option( 91 | "--period", 92 | "-p", 93 | type=PricePeriodParamType(), 94 | default="1M", 95 | help="Historical price period.", 96 | ) 97 | @click.option("--from_date", "-f", type=DateTime(), help="Histotical price from date.") 98 | @click.option("--to_date", "-t", type=DateTime(), help="Histotical price to date.") 99 | @click.option( 100 | "--tickers", 101 | "-k", 102 | type=TickerParamType(), 103 | required=True, 104 | callback=validate_tickers, 105 | help="Comma-separated list of tickers.", 106 | ) 107 | @click.option("--export", "export_path", "-e", help="Filename to export the output to.") 108 | @pass_command 109 | def compare( 110 | ctx: click.Context, 111 | period: str, 112 | from_date: Optional[DateTime], 113 | to_date: Optional[DateTime], 114 | tickers: str, 115 | export_path: Optional[str], 116 | ) -> None: 117 | """ 118 | Compares historical prices of given companies. TICKERS is a comma-separated list of tickers. 119 | 120 | Examples: 121 | 122 | `i8 price compare --period 3Y --tickers AMD,INTC,QCOM` 123 | """ 124 | command_path = get_click_command_path(ctx) 125 | period = period.replace(" ", "").upper() 126 | period_code = get_period_code(period) 127 | tickers_list = get_tickers_list(tickers.replace(" ", "").upper()) 128 | plot_title = f"Comparison of {', '.join(tickers_list)} prices" 129 | plot_title = " and ".join(plot_title.rsplit(", ", 1)) 130 | 131 | cmd_context = { 132 | "command_path": command_path, 133 | "tickers": tickers_list, 134 | "plot_title": plot_title, 135 | "plot_type": PlotType.CHART.value, 136 | } 137 | 138 | console = Console() 139 | with console.status("Fetching data...", spinner="material") as status: 140 | if export_path: 141 | df = get_export_data(tickers_list, period_code, cast(str, from_date), cast(str, to_date)) 142 | if df is None: 143 | status.stop() 144 | console.print("No data found!") 145 | return 146 | else: 147 | df = get_price_data(tickers_list, period_code, cast(str, from_date), cast(str, to_date)) 148 | if df is None: 149 | status.stop() 150 | console.print("No data found!") 151 | return 152 | status.update("Generating plot...") 153 | fig = create_fig(df, period_code, cmd_context) 154 | 155 | if export_path: 156 | if export_path.split(".")[-1] == "html": 157 | table = df2Table(df) 158 | export_to_html(table, export_path) 159 | return 160 | export_data( 161 | df, 162 | export_path, 163 | index=True, 164 | column_width=20, 165 | column_format=APP_SETTINGS["styles"]["xlsx"]["price"]["column"], 166 | ) 167 | else: 168 | serve_plot(fig, cmd_context) 169 | -------------------------------------------------------------------------------- /i8_terminal/app/plot_server.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | import os 4 | import socket 5 | import sys 6 | import webbrowser 7 | from threading import Timer 8 | from typing import Any, Dict, List, Tuple 9 | 10 | import dash 11 | import dash_bootstrap_components as dbc 12 | import investor8_sdk 13 | import plotly.graph_objects as go 14 | from dash import html 15 | from dash.dependencies import Input, Output, State 16 | from flask import request 17 | from rich.console import Console 18 | 19 | from i8_terminal import config 20 | from i8_terminal.app.layout import create_plot_layout 21 | from i8_terminal.common.formatting import make_svg_responsive 22 | from i8_terminal.config import APP_SETTINGS, ASSETS_PATH, USER_SETTINGS 23 | 24 | APP = dash.Dash(external_stylesheets=[dbc.themes.BOOTSTRAP], assets_folder=ASSETS_PATH) 25 | 26 | 27 | @APP.callback( 28 | Output("savePlotModal", "is_open"), 29 | Input("openModalBtn", "n_clicks"), 30 | Input("saveBtn", "n_clicks"), 31 | State("savePlotModal", "is_open"), 32 | ) 33 | def toggle_modal(open_btn_clicks: int, save_btn_clicks: int, is_open: bool) -> bool: 34 | if not open_btn_clicks: 35 | return False 36 | return not is_open 37 | 38 | 39 | @APP.callback( 40 | Output("fakeOutput", "children"), 41 | Input("closeServerBtn", "n_clicks"), 42 | ) 43 | def shutdown_server(close_btn_clicks: int) -> str: 44 | if close_btn_clicks: 45 | shutdown_func = request.environ.get("werkzeug.server.shutdown") 46 | if shutdown_func is None: 47 | raise RuntimeError("Not running werkzeug") 48 | shutdown_func() 49 | return "" 50 | 51 | 52 | @APP.callback( 53 | Output("saveAlert", "is_open"), 54 | Output("saveAlert", "children"), 55 | Input("saveBtn", "n_clicks"), 56 | State("cmdContextStore", "data"), 57 | State("titleInput", "value"), 58 | State("userNotesInput", "value"), 59 | State("isPublicBtn", "value"), 60 | State("figDict", "data"), 61 | ) 62 | def save_button( 63 | save_btn_clicks: int, 64 | cmd_context: Dict[str, Any], 65 | title: str, 66 | user_notes: str, 67 | is_public: List[int], 68 | fig_dict: Dict[str, Any], 69 | ) -> Tuple[bool, Any]: 70 | if save_btn_clicks: 71 | fig_obj = go.Figure(fig_dict) 72 | fig_obj_small = go.Figure(fig_obj) 73 | fig_obj_small.update_layout( 74 | title=dict(text="", font=dict(size=14)), 75 | font=dict(size=9), 76 | legend_title=dict(font=dict(size=11)), 77 | legend=dict(font=dict(size=9)), 78 | ) 79 | thumbnail = make_svg_responsive(fig_obj_small.to_image(format="svg", width=846, height=500).decode("utf-8")) 80 | tickers = cmd_context["tickers"] 81 | fig_obj.layout.images[0].source = config.I8_TERMINAL_LOGO_URL # Update plot logo to url 82 | fig_obj.layout.title.text = title # Update plot title 83 | body = { 84 | "Title": title, 85 | "Tickers": tickers if type(tickers) is list else [tickers], 86 | "UserId": USER_SETTINGS["user_id"], 87 | "PlotData": fig_obj.to_json(), 88 | "IsPublic": 1 in is_public, 89 | "I8Command": cmd_context["command_path"], 90 | "UserNotes": user_notes, 91 | "PlotType": cmd_context["plot_type"], 92 | "Thumbnail": thumbnail, 93 | # TODO: implement tags 94 | "Tags": [], 95 | } 96 | resp = investor8_sdk.UserApi().create_plot(body=body) 97 | plot_url = f"https://www.investoreight.com/plot/{resp.id}" 98 | alert_content = html.Div(["Your plot is saved and published at: ", html.A(plot_url, href=plot_url)]) 99 | 100 | return True, alert_content 101 | 102 | return False, None 103 | 104 | 105 | @APP.callback( 106 | Output("loadingGif", "hidden"), 107 | Input("saveBtn", "n_clicks"), 108 | State("loadingGif", "hidden"), 109 | ) 110 | def show_loading(save_btn_clicks: int, hidden: bool) -> bool: 111 | if not save_btn_clicks: 112 | return True 113 | return not hidden 114 | 115 | 116 | @APP.callback( 117 | Output("loadingGif", "style"), 118 | Input("saveAlert", "is_open"), 119 | ) 120 | def hide_loading(is_open: bool) -> Dict[str, Any]: 121 | if is_open: 122 | return {"display": "none"} 123 | return { 124 | "position": "fixed", 125 | "width": "100%", 126 | "height": "100%", 127 | "left": 0, 128 | "right": 0, 129 | "bottom": 0, 130 | "background-color": "rgba(0,0,0,0.5)", 131 | "z-index": 2, 132 | } 133 | 134 | 135 | def _configure_dash() -> None: 136 | # TODO: the following method does not disable dash logging (it should) 137 | dash_logger = logging.getLogger("dash") 138 | dash_logger.setLevel(logging.WARNING) 139 | dash_logger.disabled = True 140 | 141 | cli = sys.modules["flask.cli"] 142 | cli.show_server_banner = lambda *x: None # type: ignore 143 | 144 | 145 | def serve_plot(fig: go.Figure, cmd_context: Dict[str, Any]) -> None: 146 | _configure_dash() 147 | 148 | APP.layout = create_plot_layout(fig, cmd_context) 149 | APP.title = f"i8 Terminal: {cmd_context['plot_title']}" 150 | app_url = f"http://localhost:{APP_SETTINGS['app']['port']}/" 151 | console = Console() 152 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 153 | is_port_in_use = s.connect_ex(("localhost", APP_SETTINGS["app"]["port"])) == 0 154 | if is_port_in_use: 155 | console.print( 156 | f"Port number {APP_SETTINGS['app']['port']} is already in use. Please make sure that only one instance of i8-terminal runs at a time!", # noqa: E501 157 | style="yellow", 158 | ) 159 | return 160 | with open(os.devnull, "w") as f, contextlib.redirect_stderr(f): 161 | Timer(2, lambda: webbrowser.open_new(app_url)).start() 162 | console.print( 163 | f'[bold green]Your plot is serving on http://127.0.0.1:{APP_SETTINGS["app"]["port"]}/[/bold green]' 164 | ) 165 | console.print("Press `Ctrl + C` to stop the webserver.") 166 | if APP.logger.hasHandlers(): 167 | APP.logger.handlers.clear() 168 | APP.run_server(debug=APP_SETTINGS["app"]["debug"]) 169 | -------------------------------------------------------------------------------- /i8_terminal/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import sys 5 | import uuid 6 | from typing import Any, Dict 7 | 8 | import investor8_sdk 9 | import yaml 10 | from mergedeep import merge 11 | from rich.style import Style 12 | 13 | from i8_terminal.i8_exception import I8Exception 14 | 15 | PACKAGE_PATH = os.path.dirname(__file__) 16 | EXECUTABLE_APP_DIR = os.path.join(os.path.dirname(sys.executable)) 17 | OS_HOME_PATH = os.path.expanduser("~") 18 | SETTINGS_FOLDER = os.path.join(OS_HOME_PATH, ".i8_terminal") 19 | METRICS_METADATA_PATH = os.path.join(SETTINGS_FOLDER, "metrics_metadata.csv") 20 | USER_SETTINGS_PATH = os.path.join(SETTINGS_FOLDER, "user.yml") 21 | APP_SETTINGS_PATH = os.path.join(SETTINGS_FOLDER, "config.yml") 22 | ASSETS_PATH = os.path.join(PACKAGE_PATH, "assets") 23 | I8_TERMINAL_LOGO_URL = "https://www.investoreight.com/media/i8t-chart-logo.png" 24 | 25 | 26 | def init_settings() -> None: 27 | if not os.path.exists(SETTINGS_FOLDER): 28 | try: 29 | os.mkdir(SETTINGS_FOLDER) 30 | except Exception: 31 | logging.error( 32 | f"Cannot initialize app. Application needs write access to create app directory in the following path: '{OS_HOME_PATH}'" # noqa: E501 33 | ) 34 | 35 | if not os.path.exists(USER_SETTINGS_PATH): 36 | try: 37 | user_setting = {"app_instance_id": uuid.uuid4().hex} 38 | with open(USER_SETTINGS_PATH, "w") as f: 39 | yaml.dump(user_setting, f) 40 | except Exception: 41 | logging.error( 42 | f"Cannot initalize user settings. Make sure you have write access to the path: '{USER_SETTINGS_PATH}'" 43 | ) 44 | 45 | if not os.path.exists(APP_SETTINGS_PATH): 46 | try: 47 | app_settings_src_path = os.path.join(PACKAGE_PATH, "config.yml") 48 | if os.path.exists(app_settings_src_path): 49 | shutil.copyfile(app_settings_src_path, APP_SETTINGS_PATH) 50 | else: 51 | shutil.copyfile(f"{EXECUTABLE_APP_DIR}/config.yml", APP_SETTINGS_PATH) 52 | except Exception as e: 53 | logging.error( 54 | f"Cannot initalize app settings. Make sure you have write access to the path: '{APP_SETTINGS_PATH}'\n {e}" # noqa: E501 55 | ) 56 | 57 | 58 | def load_user_settings() -> Any: 59 | if not os.path.exists(USER_SETTINGS_PATH): 60 | return {} 61 | with open(USER_SETTINGS_PATH, "r") as f: 62 | return yaml.safe_load(f) or {} 63 | 64 | 65 | def load_app_settings() -> Any: 66 | if not os.path.exists(APP_SETTINGS_PATH): 67 | return {} 68 | with open(APP_SETTINGS_PATH, "r") as f: 69 | return yaml.safe_load(f) or {} 70 | 71 | 72 | def load_latest_app_settings() -> Any: 73 | app_settings_src_path = os.path.join(PACKAGE_PATH, "config.yml") 74 | with open(app_settings_src_path, "r") as f: 75 | return yaml.safe_load(f) or {} 76 | 77 | 78 | def save_user_settings(data: Dict[str, Any]) -> None: 79 | current_user_settings = load_user_settings() 80 | new_user_settings = {**current_user_settings, **data} 81 | with open(USER_SETTINGS_PATH, "w") as f: 82 | yaml.dump(new_user_settings, f) 83 | 84 | 85 | def delete_user_settings() -> None: 86 | file = open(USER_SETTINGS_PATH, "w") 87 | file.close() 88 | 89 | 90 | def restore_user_settings() -> None: 91 | current_user_settings = load_user_settings() 92 | restored_user_setting = {"app_instance_id": current_user_settings.get("app_instance_id")} 93 | with open(USER_SETTINGS_PATH, "w") as f: 94 | yaml.dump(restored_user_setting, f) 95 | 96 | 97 | def find_dicts_diff(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, Any]: 98 | result = {} 99 | for k in dict1: 100 | if k in dict2: 101 | if type(dict1[k]) is dict: 102 | res = find_dicts_diff(dict1[k], dict2[k]) 103 | if res: 104 | result[k] = res 105 | if dict1[k] != dict2[k]: 106 | result[k] = dict1[k] 107 | else: 108 | result[k] = dict1[k] 109 | for k in dict2: 110 | if k not in dict1: 111 | result[k] = dict2[k] 112 | return result 113 | 114 | 115 | def update_settings() -> None: 116 | current_app_settings = load_app_settings() 117 | latest_app_settings = load_latest_app_settings() 118 | app_new_settings = find_dicts_diff(latest_app_settings, current_app_settings) 119 | user_new_settings = find_dicts_diff(current_app_settings, latest_app_settings) 120 | if app_new_settings or user_new_settings: 121 | new_settings = merge({}, latest_app_settings, current_app_settings) 122 | with open(APP_SETTINGS_PATH, "w") as f: 123 | yaml.dump(new_settings, f) 124 | 125 | 126 | def get_table_style(profile_name: str = "default") -> Dict[str, Any]: 127 | styles = APP_SETTINGS["styles"]["table"][profile_name] 128 | try: 129 | return { 130 | "header_style": Style(**styles["header"]), 131 | "row_styles": [Style(**styles["row"]), Style(**styles["alternate_row"])], 132 | "show_lines": styles.get("show_lines", False), 133 | } 134 | except Exception: 135 | raise I8Exception( 136 | "Cannot parse table style settings from the configuration file! Check to see if the configuration file is formatted correctly!" # noqa: E501 137 | ) 138 | 139 | 140 | def is_user_logged_in() -> bool: 141 | if not USER_SETTINGS.get("i8_core_api_key") or not USER_SETTINGS.get("i8_core_token"): 142 | return False 143 | return True 144 | 145 | 146 | def init_api_configs() -> None: 147 | investor8_sdk.ApiClient().configuration.api_key["apiKey"] = USER_SETTINGS.get("i8_core_api_key") 148 | investor8_sdk.ApiClient().configuration.api_key["Authorization"] = USER_SETTINGS.get("i8_core_token") 149 | investor8_sdk.ApiClient().configuration.api_key_prefix["Authorization"] = "Bearer" 150 | 151 | 152 | def init_notebook() -> None: 153 | if is_user_logged_in(): 154 | init_api_configs() 155 | else: 156 | print("You are not logged in. Please login to i8 Terminal using 'user login' command.") 157 | 158 | 159 | if "USER_SETTINGS" not in globals(): 160 | init_settings() 161 | update_settings() 162 | USER_SETTINGS = load_user_settings() 163 | APP_SETTINGS = load_app_settings() 164 | 165 | if not USER_SETTINGS.get("app_instance_id"): 166 | user_setting = {"app_instance_id": uuid.uuid4().hex} 167 | save_user_settings(user_setting) 168 | -------------------------------------------------------------------------------- /docs/get_started/index.md: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Introduction[](#introduction) 5 | ----------------------------- 6 | 7 | i8 terminal is a modern python-based terminal application that gives you superior power and flexibility to understand and analyze the market. The interface is simple, efficient, and powerful: it's command-line! 8 | 9 | **i8 Terminal is open-source and backed by the Investoreight Platform and currently covers major U.S. exchanges.** 10 | 11 | Table of Contents 12 | ----------------- 13 | 14 | * [Introduction](#introduction) 15 | * [Installation](#installation) 16 | * [Binary Installer](#binary-installer) 17 | * [Installing with Python pip](#installing-with-python-pip) 18 | * [Running from Code](#running-from-code) 19 | * [How to Run i8 Terminal](#how-to-run-i8-terminal) 20 | * [Sign Up / Sign In to the i8 Terminal Server](#signup-signin-to-the-i8-terminal-server) 21 | * [Subscription Plans](#subscription-plans) 22 | * [Your First Command](#your-first-command) 23 | 24 | * * * 25 | 26 | Installation[](#installation) 27 | --------------------------------------------------------------- 28 | 29 | i8 Terminal can be directly installed on your computer via our installation program. Within this section, you are guided through the installation process. If you are a developer, please have a look [here](https://https://github.com/investoreight/i8-terminal). If you struggle with the installation process, please visit our [contact page](https://www.i8terminal.io/contact). 30 | 31 | ### Binary Installer[](#binary-installer) 32 | 33 | The process starts by downloading the installer, see below for how to download the most recent release: 34 | 35 | 1. Go to [the i8terminal.io website download page](https://www.i8terminal.io/download) 36 | 2. Click on the `Download For Windows` button in the Download i8 Terminal section 37 | 38 | When the file is downloaded, use the following steps to run i8 Terminal: 39 | 40 | **Step 1: Double-click the `.msi` file that got downloaded to your `Downloads` folder** 41 | 42 | You will most likely receive the error below stating “Windows protected your PC”. This is because the installer is still in the beta phase, and the team has not yet requested verification from Windows. 43 | 44 | [![windows_protected_your_pc](https://www.investoreight.com/media/i8terminal-binaryinstaller-step1.png)](https://www.investoreight.com/media/i8terminal-binaryinstaller-step1.png) 45 | 46 | **Step 2: Click on `More info` and select `Run anyway` to start the installation process** 47 | 48 | Proceed by following the steps. 49 | 50 | [![run_anyway](https://www.investoreight.com/media/i8terminal-binaryinstaller-step2.png)](https://www.investoreight.com/media/i8terminal-binaryinstaller-step2.png) 51 | 52 | **Step 3: Select the destination directory you want to install i8 Terminal** 53 | 54 | i8 Terminal is installed now! 55 | 56 | [![select_destination](https://www.investoreight.com/media/i8terminal-binaryinstaller-step3.png)](https://www.investoreight.com/media/i8terminal-binaryinstaller-step3.png) 57 | 58 | ### Installing with Python pip[](#installing-with-python-pip) 59 | 60 | If you have Python 3 installed, you can simply install the tool with Python pip: 61 | 62 | pip install i8-terminal 63 | 64 | We recommend installing i8 Terminal in an isolated virtual environment. This can be done as follows: 65 | 66 | #### On Mac OS or Linux: 67 | 68 | python3 -m venv .venv 69 | source .venv/bin/activate 70 | pip install i8-terminal 71 | 72 | #### On Windows (Using Git Bash): 73 | 74 | python3 -m venv .venv 75 | source .venv/Scripts/activate 76 | pip install i8-terminal 77 | 78 | #### On Windows (Using Command Prompt or PowerShell): 79 | 80 | python3 -m venv .venv 81 | .venv\\Scripts\\activate 82 | pip install i8-terminal 83 | 84 | ### Running from Code[](#running-from-code) 85 | 86 | The process starts by cloning the code, see below for how to run the terminal from the code: 87 | 88 | **Step 1: Clone the repo** 89 | 90 | git clone git@github.com:investoreight/i8-terminal.git 91 | 92 | **Step 2: Go to the directory** 93 | 94 | cd i8-terminal 95 | 96 | **Step 3: Activate the isolated virtual environment** 97 | 98 | #### On Mac OS or Linux: 99 | 100 | python3 -m venv .venv 101 | source .venv/bin/activate 102 | 103 | #### On Windows (Using Git Bash): 104 | 105 | python3 -m venv .venv 106 | source .venv/Scripts/activate 107 | 108 | #### On Windows (Using Command Prompt or PowerShell): 109 | 110 | python3 -m venv .venv 111 | .venv\\Scripts\\activate 112 | 113 | **Step 4: Install required libraries** 114 | 115 | pip install -r requirements.txt 116 | 117 | **Step 5: Run commands and enjoy!** 118 | 119 | python -m i8-terminal.main shell 120 | 121 | How to Run i8 Terminal[](#how-to-run-i8-terminal) 122 | --------------------------------------------------------------- 123 | 124 | You can verify whether i8 Terminal is installed successfully by running the i8 script: 125 | 126 | i8 127 | 128 | If you are using the application for the first time, you should first sign in. Run the following command, which will open a browser and redirect you to the Investoreight platform to sign in (or sign up): 129 | 130 | i8 user login 131 | 132 | After a successful login, the most convenient way to use i8 terminal is to use its own shell: 133 | 134 | i8 shell 135 | 136 | You should now be able to run i8 commands. Check our documentation for more details. 137 | 138 | [Read the Docs](https://docs.i8terminal.io) 139 | 140 | Sign Up / Sign In to i8 Terminal Server[](#signup-signin-to-the-i8-terminal-server) 141 | --------------------------------------------------------------- 142 | 143 | If you want to use i8 Terminal you should first sign in. Within this section, you are guided through the sign-in process. 144 | 145 | [![user-sign-in](https://www.investoreight.com/media/i8terminal-signin-gif.gif)](https://www.investoreight.com/media/i8terminal-signin-gif.gif) 146 | 147 | Also, you can sign in using the terminal with your email and password: 148 | 149 | [![user-sign-in-with-terminal](https://www.investoreight.com/media/i8terminal-signin-with-terminal-gif.gif)](https://www.investoreight.com/media/i8terminal-signin-with-terminal-gif.gif) 150 | 151 | Subscription Plans[](#subscription-plans) 152 | --------------------------------------------------------------- 153 | 154 | In Free Edition you will have full access to DOW 30 Stocks. 155 | 156 | For more features please have a look [here](https://www.i8terminal.io/#pricing). 157 | 158 | Your First Command[](#your-first-command) 159 | --------------------------------------------------------------- 160 | 161 | If you want to list financial metrics of Microsoft you can use i8 Terminal financials list command: 162 | 163 | financials list --identifier MSFT 164 | 165 | [![your_first_command](https://www.investoreight.com/media/i8terminal-your-first-command.png)](https://www.investoreight.com/media/i8terminal-your-first-command.png) 166 | 167 | For i8 Terminal more sample commands, please watch this video: 168 | 169 | [i8 Terminal Sample Commands](https://youtu.be/NpOCqcb-RxY) -------------------------------------------------------------------------------- /i8_terminal/common/formatting.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import date 3 | from enum import Enum 4 | from typing import Any, Optional, Union 5 | 6 | import arrow 7 | import numpy as np 8 | import pandas as pd 9 | 10 | 11 | class color(Enum): 12 | i8_dark = "#015560" 13 | i8_light = "#00b08f" 14 | i8_red = "#ef553b" 15 | i8_green = "#00cc96" 16 | 17 | 18 | def make_svg_responsive(svg_str: str) -> str: 19 | svg_str = re.sub(r'width=".+?"', "", svg_str, 1) 20 | svg_str = re.sub(r'height=".+?"', "", svg_str, 1) 21 | svg_str = re.sub(r'style=""', r'style="fill: rgba(0, 0, 0, 0);"', svg_str, 1) # Make svg transparent 22 | 23 | return svg_str 24 | 25 | 26 | def format_number( 27 | m: int, 28 | unit: Optional[str] = None, 29 | decimal: int = 2, 30 | humanize: bool = False, 31 | colorize: bool = False, 32 | in_millions: bool = False, 33 | exportize: Optional[bool] = False, 34 | ) -> Optional[Union[str, int]]: 35 | res: Optional[Union[str, int]] = None 36 | if m is None or np.isnan(m): 37 | return "-" 38 | 39 | if exportize: 40 | return round(m, 2) 41 | 42 | if in_millions and unit in ["usd", "shares"]: 43 | number = abs(m) 44 | if number >= 1e6: 45 | if m < 0: 46 | res = f"({number // 1e6:,.1f})" 47 | else: 48 | res = f"{number // 1e6:,.1f}" 49 | 50 | elif humanize or unit in ["shares", "usdpershare"]: 51 | number = abs(m) 52 | if number < 1e3: 53 | res = f"{m:,.{decimal}f}" 54 | if number >= 1e3 and number < 1e6: 55 | res = f"{m / 1e3:,.2f} K" 56 | if number >= 1e6 and number < 1e9: 57 | res = f"{m / 1e6:,.2f} M" 58 | if number >= 1e9 and number < 1e12: 59 | res = f"{m / 1e9:,.2f} B" 60 | if number >= 1e12: 61 | res = f"{m / 1e12:,.2f} T" 62 | else: 63 | res = f"{m:,.{decimal}f}" 64 | 65 | if unit == "percentage": 66 | res = f"{res}%" if m <= 0 else f"+{res}%" 67 | 68 | if unit == "usd": 69 | res = f"${res}" 70 | 71 | if colorize: 72 | color = "red" if m <= 0 else "green" 73 | res = f"[{color}]{res}[/{color}]" 74 | 75 | return res 76 | 77 | 78 | def format_number_v2( 79 | m: int, 80 | percision: int = 2, 81 | unit: Optional[str] = None, 82 | humanize: bool = False, 83 | in_millions: bool = False, 84 | ) -> Optional[Union[str, int]]: 85 | res: Optional[Union[str, int]] = None 86 | if m is None or np.isnan(m): 87 | return "-" 88 | 89 | if in_millions: 90 | number = abs(m) 91 | if m < 0: 92 | res = f"({number // 1e6:,.1f})" 93 | else: 94 | res = f"{number // 1e6:,.1f}" 95 | 96 | elif humanize: 97 | number = abs(m) 98 | if number < 1e3: 99 | res = f"{m:,.{percision}f}" 100 | if number >= 1e3 and number < 1e6: 101 | res = f"{m / 1e3:,.{percision}f} K" 102 | if number >= 1e6 and number < 1e9: 103 | res = f"{m / 1e6:,.{percision}f} M" 104 | if number >= 1e9 and number < 1e12: 105 | res = f"{m / 1e9:,.{percision}f} B" 106 | if number >= 1e12: 107 | res = f"{m / 1e12:,.{percision}f} T" 108 | else: 109 | res = f"{m:,.{percision}f}" 110 | 111 | if unit == "percentage": 112 | res = f"{res}%" if m <= 0 else f"+{res}%" 113 | 114 | if unit in ["usd", "usdpershare"]: 115 | res = f"${res}" 116 | 117 | return res 118 | 119 | 120 | def format_date(date: date, use_elapsed_format: bool = False, use_precise_format: bool = False) -> Any: 121 | if use_elapsed_format: 122 | time_span = arrow.utcnow() - arrow.get(date) 123 | days = time_span.days 124 | if days >= 2: 125 | return arrow.get(date, tzinfo="US/Eastern").strftime("%b %d, %Y %I:%M %p ET") 126 | hours, remainder = divmod(time_span.seconds, 3600) 127 | minutes, seconds = divmod(remainder, 60) 128 | if not use_precise_format: 129 | if days > 0: 130 | time = f'{days} day{"s" if days > 1 else ""}' 131 | elif hours > 0: 132 | time = f'{hours} hour{"s" if hours > 1 else ""}' 133 | elif minutes > 0: 134 | time = f'{minutes} minute{"s" if minutes > 1 else ""}' 135 | else: 136 | time = f"{seconds} seconds" 137 | 138 | return f"{time} ago" 139 | else: 140 | if days > 0: 141 | time = f"{days} day, {hours} hour" 142 | elif hours > 0: 143 | time = f"{hours} hour, {minutes} minute" 144 | elif minutes > 0: 145 | time = f"{minutes} minute, {seconds} second" 146 | else: 147 | time = f"{seconds} second" 148 | 149 | return time 150 | else: 151 | return arrow.get(date).strftime("%b %d, %Y") 152 | 153 | 154 | def format_fyq(fyq: str) -> str: 155 | return f"{fyq[-2:]} {fyq[2:-2]}" 156 | 157 | 158 | _formatters_map = { 159 | ("fyq", "console"): lambda x: format_fyq(x), 160 | ("fyq", "store"): lambda x: format_fyq(x), 161 | ("number", "console"): lambda x: format_number(x), 162 | ("number", "store"): lambda x: format_number(x, exportize=True), 163 | ("colorize_number", "console"): lambda x: format_number(x, colorize=True), 164 | ("price", "console"): lambda x: format_number(x, unit="usd"), 165 | ("price", "store"): lambda x: round(x, 2), 166 | ("financial", "console"): lambda x: format_number(x, unit="usd", humanize=True), 167 | ("financial", "store"): lambda x: format_number(x, exportize=True), 168 | ("colorize_financial", "console"): lambda x: format_number(x, unit="usd", humanize=True, colorize=True), 169 | ("number_int", "console"): lambda x: format_number(x, decimal=0), 170 | ("number_int", "store"): lambda x: int(x), 171 | ("perc", "console"): lambda x: format_number(x, decimal=2, unit="percentage", colorize=True), 172 | ("perc", "store"): lambda x: format_number(x, exportize=True), 173 | ("number_perc", "console"): lambda x: format_number(x, decimal=2, unit="percentage"), 174 | ("date", "console"): lambda x: format_date(x), 175 | ("date", "store"): lambda x: format_date(x), 176 | ("str", "store"): lambda x: x, 177 | ("str", "console"): lambda x: x, 178 | } 179 | 180 | 181 | def get_formatter(name: str, target: str) -> Any: 182 | return _formatters_map[(name, target)] 183 | 184 | 185 | def styling_markdown_text(text: str) -> str: 186 | text = text.replace("#", "") # Ignore headings 187 | text = re.sub("```\n([^`]*)\n```", "[magenta]\\1[/magenta]", text) 188 | return re.sub("`([^`]*)`", "[magenta]\\1[/magenta]", text) 189 | 190 | 191 | def data_format_mapper(metric: pd.Series) -> Any: 192 | if metric["data_format"] in ["int", "unsigned_int"]: 193 | return int(float(metric["value"])) 194 | elif metric["data_format"] == "float": 195 | return float(metric["value"]) 196 | else: 197 | # Includes "datetime", "categorical", "boolean", "string" and "str" 198 | return str(metric["value"]) 199 | -------------------------------------------------------------------------------- /i8_terminal/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from rich.console import Console 5 | 6 | from i8_terminal.common.cli import log_terminal_usage, pass_command 7 | from i8_terminal.config import USER_SETTINGS, init_api_configs, is_user_logged_in 8 | from i8_terminal.utils_setup import get_version 9 | 10 | console = Console(force_terminal=True, color_system="truecolor") 11 | if set(["i8"]) == set(sys.argv[1:]): 12 | console.print(f"\n👋 Welcome to i8 Terminal! Version: {get_version()}", style="yellow") 13 | console.print("Copyright © 2020-2022 Investoreight | https://www.i8terminal.io/\n") 14 | console.print("- Enter [magenta]i8 shell[/magenta] to run i8 Terminal shell.") 15 | os._exit(0) 16 | if set(["i8", "version"]) == set(sys.argv[1:]): 17 | console.print(f"Version: {get_version()}", style="yellow") 18 | os._exit(0) 19 | status = console.status("Starting Up...", spinner="material") 20 | status.start() 21 | 22 | 23 | import webbrowser 24 | 25 | import click 26 | import investor8_sdk 27 | from click_repl import repl 28 | from investor8_sdk.rest import ApiException 29 | 30 | from i8_terminal.commands import cli 31 | from i8_terminal.common.stock_info import validate_ticker 32 | from i8_terminal.types.i8_auto_suggest import I8AutoSuggest 33 | from i8_terminal.types.i8_completer import I8Completer 34 | from i8_terminal.types.ticker_param_type import TickerParamType 35 | 36 | 37 | def init_commands() -> None: 38 | status.start() 39 | app_dir = os.path.join(os.path.join(os.path.dirname(sys.executable), "lib"), "i8_terminal") 40 | sys.path.append(app_dir) 41 | commands_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "commands") 42 | commands_dir = commands_dir if os.path.exists(commands_dir) else os.path.join(app_dir, "commands") 43 | ignore_dir = ["__pycache__"] 44 | for cmd in [p for p in os.listdir(commands_dir) if os.path.isdir(os.path.join(commands_dir, p))]: 45 | if cmd not in ignore_dir: 46 | for sub_cmd in os.listdir(os.path.join(commands_dir, cmd)): 47 | sub_cmd_splitted = sub_cmd.split(".") 48 | if (sub_cmd_splitted[-1] in ["py", "pyc"]) and sub_cmd_splitted[0] not in ["__init__"]: 49 | __import__(f"i8_terminal.commands.{cmd}.{''.join(sub_cmd_splitted[:-1])}") 50 | status.stop() 51 | 52 | @cli.command() 53 | def shell() -> None: 54 | """Open i8-shell.""" 55 | print_welcome_msg() 56 | prompt_kwargs = {"completer": I8Completer(cli), "auto_suggest": I8AutoSuggest(cli)} 57 | 58 | while True: 59 | try: 60 | repl(click.get_current_context(), prompt_kwargs=prompt_kwargs) 61 | except ApiException as e: 62 | if "apiKey" in e.body.decode("utf-8"): 63 | console.print( 64 | "You need to login before using i8 Terminal. Please login to i8 Terminal using [magenta]user login[/magenta] command." # noqa: E501 65 | ) 66 | else: 67 | console.print(f"⚠ Error: {e.body.decode('utf-8')}", style="yellow") 68 | log_terminal_usage(click.get_current_context(), e.body.decode("utf-8")) 69 | except Exception as e: 70 | display_error = f"- Type: {type(e).__name__}\n- Message: {e}" 71 | log_terminal_usage(click.get_current_context(), display_error) 72 | console.print(f"⚠ Error:\n{display_error}", style="yellow") 73 | 74 | @cli.command() 75 | def exit() -> None: 76 | """Exit the terminal.""" 77 | os._exit(0) 78 | 79 | @cli.command() 80 | @pass_command 81 | def version() -> None: 82 | """Get i8-terminal version.""" 83 | click.echo(get_version()) 84 | 85 | @cli.command() 86 | @click.option("--all", "-a", is_flag=True, default=False, help="Clear entire screen.") 87 | @pass_command 88 | def clear(all: bool) -> None: 89 | """Clear the console screen.""" 90 | cls_screen() 91 | if not all: 92 | print_welcome_msg() 93 | 94 | @cli.command() 95 | @click.option( 96 | "--ticker", 97 | "-k", 98 | type=TickerParamType(), 99 | required=True, 100 | callback=validate_ticker, 101 | help="Ticker or company name.", 102 | ) 103 | @pass_command 104 | def browse(ticker: str) -> None: 105 | """ 106 | Open company detail page in investoreight.com 107 | 108 | Examples: 109 | 110 | `i8 browse --ticker MSFT` 111 | """ 112 | url = f"https://www.investoreight.com/stock/{ticker}" 113 | webbrowser.open(url) 114 | console.print(f"[blue]{url}[/blue]") 115 | 116 | 117 | def print_welcome_msg() -> None: 118 | console.print(f"\n👋 Welcome to i8 Terminal! Version: {get_version()}", style="yellow") 119 | console.print("Copyright © 2020-2022 Investoreight | https://www.i8terminal.io/") 120 | console.print("- Enter [magenta]?[/magenta] to get the list of commands.") 121 | console.print("- Enter [magenta]exit[/magenta] to exit the shell.\n") 122 | if is_user_logged_in(): 123 | console.print(f'Logged in as "{USER_SETTINGS["user_id"]}".\n') 124 | else: 125 | console.print( 126 | "You are not logged in. Please login to i8 Terminal using [magenta]user login[/magenta] command.\n" 127 | ) 128 | 129 | 130 | def cls_screen() -> None: 131 | os.system("cls" if os.name == "nt" else "clear") 132 | 133 | 134 | def check_version() -> None: 135 | resp = None 136 | try: 137 | resp = investor8_sdk.SettingsApi().check_i8t_version(get_version()) 138 | except Exception: 139 | pass 140 | if not resp or not resp.to_dict().get("version_supported"): 141 | status.stop() 142 | console.print( 143 | "[yellow]You are using an old version of i8 Terminal that is not supported anymore.[/yellow]", 144 | "[yellow]Please update i8 Terminal with the following command to be able to use the application.[/yellow]", 145 | "\n[magenta]i8update[/magenta]\n", 146 | "If you are using Python pip, you can run the following command to update i8 Terminal:\n", 147 | "[magenta]pip install --upgrade i8-terminal[/magenta]", 148 | ) 149 | os._exit(0) 150 | 151 | 152 | def main() -> None: 153 | check_version() 154 | init_commands() 155 | if is_user_logged_in(): 156 | init_api_configs() 157 | 158 | try: 159 | cli(obj={}) 160 | except ApiException as e: 161 | if "apiKey" in e.body.decode("utf-8"): 162 | console.print( 163 | "You need to login before using i8 Terminal. Please login to i8 Terminal using [magenta]user login[/magenta] command." # noqa: E501 164 | ) 165 | else: 166 | console.print(f"⚠ Error: {e.body.decode('utf-8')}", style="yellow") 167 | except Exception as e: 168 | display_error = f"- Type: {type(e).__name__}\n- Message: {e}" 169 | console.print(f"⚠ Error:\n{display_error}", style="yellow") 170 | 171 | 172 | if __name__ == "__main__": 173 | main() 174 | else: 175 | init_commands() 176 | -------------------------------------------------------------------------------- /i8_terminal/app/layout.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | import dash_bootstrap_components as dbc 4 | from dash import dcc, html 5 | from plotly.graph_objects import Figure 6 | 7 | from i8_terminal.common.utils import to_snake_case 8 | from i8_terminal.config import APP_SETTINGS 9 | 10 | 11 | def get_fig_config(cmd_context: Dict[str, Any]) -> Dict[str, Any]: 12 | return {"toImageButtonOptions": {"filename": f'{to_snake_case(cmd_context["plot_title"])}'}} 13 | 14 | 15 | def create_plot_layout(fig: Figure, cmd_context: Dict[str, Any]) -> html.Div: 16 | fig_config = get_fig_config(cmd_context) 17 | return html.Div( 18 | [ 19 | html.Div(dcc.Store(id="cmdContextStore", data=cmd_context)), 20 | html.Div(dcc.Store(id="figDict", data=fig.to_dict())), 21 | html.Div( 22 | [ 23 | html.Img(src="assets/i8t_logo.png", className="two columns", style={"margin": "auto"}), 24 | html.A( 25 | html.Button("Save / Publish Plot", id="openModalBtn", n_clicks=0, className="i8-button"), 26 | className="two columns", 27 | style={"margin": "auto"}, 28 | ), 29 | ], 30 | className="row", 31 | ), 32 | html.Div( 33 | dbc.Alert("", id="saveAlert", color="success", is_open=False), 34 | className="row", 35 | ), 36 | html.Div( 37 | [ 38 | html.Div( 39 | [dcc.Graph(id="mainPlot", figure=fig, responsive=fig.layout.autosize, config=fig_config)], 40 | className="pretty_container", 41 | ) 42 | ], 43 | className="row", 44 | ), 45 | html.Div( 46 | [ 47 | html.A("", id="fakeOutput", className="two columns", style={"margin": "auto"}), 48 | html.Button( 49 | "Close", 50 | id="closeServerBtn", 51 | n_clicks=0, 52 | className="btn-secondary two columns", 53 | style={"margin": "auto"}, 54 | ), 55 | ], 56 | className="row", 57 | style={"margin-top": "30px", "margin-right": "15px"}, 58 | ), 59 | dbc.Modal(_get_modal_layout(cmd_context), id="savePlotModal", is_open=False, size="lg"), 60 | html.Div(html.Img(src="assets/loading.gif", className="loadingImage"), id="loadingGif", hidden=True), 61 | ], 62 | id="mainContainer", 63 | style={"display": "flex", "flex-direction": "column"}, 64 | ) 65 | 66 | 67 | def _get_modal_layout(cmd_context: Dict[str, Any]) -> List[Any]: 68 | return [ 69 | dbc.ModalHeader( 70 | [ 71 | dbc.ModalTitle("Save / Publish plot on your Investoreight profile"), 72 | ], 73 | id="modalheader", 74 | ), 75 | dbc.ModalBody( 76 | [ 77 | html.Div( 78 | [ 79 | dbc.Label("Plot Title", className="h4"), 80 | dbc.Input( 81 | id="titleInput", 82 | value=cmd_context["plot_title"], 83 | type="text", 84 | ), 85 | ], 86 | className="mb-3", 87 | ), 88 | html.Div( 89 | [ 90 | dbc.Label("Notes (Optional)", className="h4"), 91 | dcc.Textarea( 92 | id="userNotesInput", 93 | value="", 94 | style={"width": "100%", "height": 100}, 95 | ), 96 | ], 97 | className="mb-3", 98 | ), 99 | html.Div( 100 | [ 101 | dbc.Checklist( 102 | options=[ 103 | {"label": "Make this plot public (URL can be shared)", "value": 1}, 104 | ], 105 | value=[1], 106 | id="isPublicBtn", 107 | inline=True, 108 | switch=True, 109 | ), 110 | ], 111 | ), 112 | ], 113 | style={"padding": "2%"}, 114 | ), 115 | dbc.ModalFooter( 116 | [ 117 | dcc.Loading( 118 | id="loading-2", 119 | children=dbc.Alert( 120 | "Chart Saved Successfully!", id="save_alert", className="ml-auto", is_open=False 121 | ), 122 | type="circle", 123 | style={"marginRight": 60}, 124 | ), 125 | dbc.Button("Save Plot", id="saveBtn", n_clicks=0, className="i8_button"), 126 | ] 127 | ), 128 | ] 129 | 130 | 131 | def get_chart_layout() -> List[Dict[str, Any]]: 132 | return [ 133 | dict( 134 | source="assets/i8t_chart_logo.png", 135 | xref="paper", 136 | yref="paper", 137 | x=1.01, 138 | y=1, 139 | sizex=0.2, 140 | sizey=0.2, 141 | xanchor="right", 142 | yanchor="bottom", 143 | ) 144 | ] 145 | 146 | 147 | def get_date_range(period_code: int) -> Dict[str, List[Dict[str, Any]]]: 148 | date_range: Dict[str, List[Dict[str, Any]]] = {"buttons": []} 149 | if period_code >= 2: 150 | date_range["buttons"].append(dict(count=5, label="5D", step="day", stepmode="backward")) 151 | elif period_code >= 3: 152 | date_range["buttons"].append(dict(count=1, label="1M", step="month", stepmode="backward")) 153 | elif period_code >= 4: 154 | date_range["buttons"].append(dict(count=3, label="3M", step="month", stepmode="backward")) 155 | elif period_code >= 5: 156 | date_range["buttons"].append(dict(count=6, label="6M", step="month", stepmode="backward")) 157 | elif period_code >= 6: 158 | date_range["buttons"].append(dict(count=1, label="YTD", step="year", stepmode="todate")) 159 | date_range["buttons"].append(dict(count=1, label="1Y", step="year", stepmode="backward")) 160 | elif period_code >= 7: 161 | date_range["buttons"].append(dict(count=3, label="3Y", step="year", stepmode="backward")) 162 | elif period_code >= 8: 163 | date_range["buttons"].append(dict(count=5, label="5Y", step="year", stepmode="backward")) 164 | date_range["buttons"].append(dict(step="all")) 165 | 166 | return date_range 167 | 168 | 169 | def get_plot_default_layout() -> Dict[str, Any]: 170 | return dict( 171 | images=get_chart_layout(), 172 | paper_bgcolor=APP_SETTINGS["styles"]["plot"]["default"]["paper_bgcolor"], 173 | plot_bgcolor=APP_SETTINGS["styles"]["plot"]["default"]["plot_bgcolor"], 174 | ) 175 | 176 | 177 | def get_terminal_command_layout() -> Dict[str, Any]: 178 | return dict(color=APP_SETTINGS["styles"]["terminal"]["command"]["color"]) 179 | --------------------------------------------------------------------------------