diff --git a/.env b/.env deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index 6d17870..ed8ebf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -__pycache__ -.env \ No newline at end of file +__pycache__ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index acdfb65..b6455bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,3 +3,32 @@ black: image: registry.gitlab.com/pipeline-components/black:latest script: - black --check --verbose -- . + + +build:master: + stage: build + image: + name: gcr.io/kaniko-project/executor:v1.9.0-debug + entrypoint: [""] + script: + - /kaniko/executor + --context "${CI_PROJECT_DIR}" + --dockerfile "${CI_PROJECT_DIR}/Dockerfile" + --destination "${CI_REGISTRY_IMAGE}:latest" + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + + +build:branch: + stage: build + image: + name: gcr.io/kaniko-project/executor:v1.9.0-debug + entrypoint: [""] + script: + - /kaniko/executor + --context "${CI_PROJECT_DIR}" + --dockerfile "${CI_PROJECT_DIR}/Dockerfile" + --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}" + --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_BRANCH}" + rules: + - if: '$CI_COMMIT_BRANCH != "master"' \ No newline at end of file diff --git a/telegram/Dockerfile b/Dockerfile similarity index 73% rename from telegram/Dockerfile rename to Dockerfile index d7de68e..c175b32 100644 --- a/telegram/Dockerfile +++ b/Dockerfile @@ -1,8 +1,7 @@ FROM python:3.11-buster AS builder -COPY telegram/requirements.txt . - +COPY requirements.txt /requirements.txt RUN pip install --user -r requirements.txt @@ -13,9 +12,6 @@ ENV MPLBACKEND=Agg COPY --from=builder /root/.local /root/.local -COPY common common -COPY telegram . - - +COPY . . CMD [ "python", "./bot.py" ] \ No newline at end of file diff --git a/common/MarketData.py b/MarketData.py similarity index 99% rename from common/MarketData.py rename to MarketData.py index 3bf1a9c..1ebd4d1 100644 --- a/common/MarketData.py +++ b/MarketData.py @@ -8,7 +8,7 @@ import pytz import requests as r import schedule -from common.Symbol import Stock +from Symbol import Stock log = logging.getLogger(__name__) diff --git a/common/Symbol.py b/Symbol.py similarity index 100% rename from common/Symbol.py rename to Symbol.py diff --git a/telegram/T_info.py b/T_info.py similarity index 100% rename from telegram/T_info.py rename to T_info.py diff --git a/telegram/bot.py b/bot.py similarity index 99% rename from telegram/bot.py rename to bot.py index f54966d..d7b2469 100644 --- a/telegram/bot.py +++ b/bot.py @@ -28,7 +28,7 @@ from telegram.ext import ( Updater, ) -from common.symbol_router import Router +from symbol_router import Router from T_info import T_info # Enable logging diff --git a/common/cg_Crypto.py b/cg_Crypto.py similarity index 96% rename from common/cg_Crypto.py rename to cg_Crypto.py index aa2f3af..94544f4 100644 --- a/common/cg_Crypto.py +++ b/cg_Crypto.py @@ -1,367 +1,367 @@ -import logging -from typing import List - -import pandas as pd -import requests as r -import schedule -from markdownify import markdownify - -from common.Symbol import Coin - -log = logging.getLogger(__name__) - - -class cg_Crypto: - """ - Functions for finding crypto info - """ - - vs_currency = "usd" # simple/supported_vs_currencies for list of options - - trending_cache: List[str] = [] - - def __init__(self) -> None: - self.get_symbol_list() - schedule.every().day.do(self.get_symbol_list) - - def get(self, endpoint, params: dict = {}, timeout=10) -> dict: - url = "https://api.coingecko.com/api/v3" + endpoint - resp = r.get(url, params=params, timeout=timeout) - # Make sure API returned a proper status code - try: - resp.raise_for_status() - except r.exceptions.HTTPError as e: - log.error(e) - return {} - - # Make sure API returned valid JSON - try: - resp_json = resp.json() - return resp_json - except r.exceptions.JSONDecodeError as e: - log.error(e) - return {} - - def symbol_id(self, symbol) -> str: - try: - return self.symbol_list[self.symbol_list["symbol"] == symbol]["id"].values[0] - except KeyError: - return "" - - def get_symbol_list(self): - raw_symbols = self.get("/coins/list") - symbols = pd.DataFrame(data=raw_symbols) - - # Removes all binance-peg symbols - symbols = symbols[~symbols["id"].str.contains("binance-peg")] - - symbols["description"] = "$$" + symbols["symbol"].str.upper() + ": " + symbols["name"] - symbols = symbols[["id", "symbol", "name", "description"]] - symbols["type_id"] = "$$" + symbols["symbol"] - - self.symbol_list = symbols - - def status(self) -> str: - """Checks CoinGecko /ping endpoint for API issues. - - Returns - ------- - str - Human readable text on status of CoinGecko API - """ - status = r.get( - "https://api.coingecko.com/api/v3/ping", - timeout=5, - ) - - try: - status.raise_for_status() - return ( - f"CoinGecko API responded that it was OK with a {status.status_code} in {status.elapsed.total_seconds()} Seconds." - ) - except r.HTTPError: - return f"CoinGecko API returned an error code {status.status_code} in {status.elapsed.total_seconds()} Seconds." - - def price_reply(self, coin: Coin) -> str: - """Returns current market price or after hours if its available for a given coin symbol. - - Parameters - ---------- - symbols : list - List of coin symbols. - - Returns - ------- - Dict[str, str] - Each symbol passed in is a key with its value being a human readable - markdown formatted string of the symbols price and movement. - """ - - if resp := self.get( - "/simple/price", - params={ - "ids": coin.id, - "vs_currencies": self.vs_currency, - "include_24hr_change": "true", - }, - ): - try: - data = resp[coin.id] - - price = data[self.vs_currency] - change = data[self.vs_currency + "_24h_change"] - if change is None: - change = 0 - except KeyError: - return f"{coin.id} returned an error." - - message = f"The current price of {coin.name} is $**{price:,}**" - - # Determine wording of change text - if change > 0: - message += f", the coin is currently **up {change:.3f}%** for today" - elif change < 0: - message += f", the coin is currently **down {change:.3f}%** for today" - else: - message += ", the coin hasn't shown any movement today." - - else: - message = f"The price for {coin.name} is not available. If you suspect this is an error run `/status`" - - return message - - def intra_reply(self, symbol: Coin) -> pd.DataFrame: - """Returns price data for a symbol since the last market open. - - Parameters - ---------- - symbol : str - Stock symbol. - - Returns - ------- - pd.DataFrame - Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. - """ - - if resp := self.get( - f"/coins/{symbol.id}/ohlc", - params={"vs_currency": self.vs_currency, "days": 1}, - ): - df = pd.DataFrame(resp, columns=["Date", "Open", "High", "Low", "Close"]).dropna() - df["Date"] = pd.to_datetime(df["Date"], unit="ms") - df = df.set_index("Date") - return df - - return pd.DataFrame() - - def chart_reply(self, symbol: Coin) -> pd.DataFrame: - """Returns price data for a symbol of the past month up until the previous trading days close. - Also caches multiple requests made in the same day. - - Parameters - ---------- - symbol : str - Stock symbol. - - Returns - ------- - pd.DataFrame - Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. - """ - - if resp := self.get( - f"/coins/{symbol.id}/ohlc", - params={"vs_currency": self.vs_currency, "days": 30}, - ): - df = pd.DataFrame(resp, columns=["Date", "Open", "High", "Low", "Close"]).dropna() - df["Date"] = pd.to_datetime(df["Date"], unit="ms") - df = df.set_index("Date") - return df - - return pd.DataFrame() - - def stat_reply(self, symbol: Coin) -> str: - """Gathers key statistics on coin. Mostly just CoinGecko scores. - - Parameters - ---------- - symbol : Coin - - Returns - ------- - str - Preformatted markdown. - """ - - if data := self.get( - f"/coins/{symbol.id}", - params={ - "localization": "false", - }, - ): - return f""" - [{data['name']}]({data['links']['homepage'][0]}) Statistics: - Market Cap: ${data['market_data']['market_cap'][self.vs_currency]:,} - Market Cap Ranking: {data.get('market_cap_rank',"Not Available")} - CoinGecko Scores: - Overall: {data.get('coingecko_score','Not Available')} - Development: {data.get('developer_score','Not Available')} - Community: {data.get('community_score','Not Available')} - Public Interest: {data.get('public_interest_score','Not Available')} - """ - else: - return f"{symbol.symbol} returned an error." - - def cap_reply(self, coin: Coin) -> str: - """Gets market cap for Coin - - Parameters - ---------- - coin : Coin - - Returns - ------- - str - Preformatted markdown. - """ - - if resp := self.get( - "/simple/price", - params={ - "ids": coin.id, - "vs_currencies": self.vs_currency, - "include_market_cap": "true", - }, - ): - log.debug(resp) - try: - data = resp[coin.id] - - price = data[self.vs_currency] - cap = data[self.vs_currency + "_market_cap"] - except KeyError: - return f"{coin.id} returned an error." - - if cap == 0: - return f"The market cap for {coin.name} is not available for unknown reasons." - - message = ( - f"The current price of {coin.name} is $**{price:,}** and" - + " its market cap is $**{cap:,.2f}** {self.vs_currency.upper()}" - ) - - else: - message = f"The Coin: {coin.name} was not found or returned and error." - - return message - - def info_reply(self, symbol: Coin) -> str: - """Gets coin description - - Parameters - ---------- - symbol : Coin - - Returns - ------- - str - Preformatted markdown. - """ - - if data := self.get( - f"/coins/{symbol.id}", - params={"localization": "false"}, - ): - try: - return markdownify(data["description"]["en"]) - except KeyError: - return f"{symbol} does not have a description available." - - return f"No information found for: {symbol}\nEither today is boring or the symbol does not exist." - - def spark_reply(self, symbol: Coin) -> str: - change = self.get( - "/simple/price", - params={ - "ids": symbol.id, - "vs_currencies": self.vs_currency, - "include_24hr_change": "true", - }, - )[symbol.id]["usd_24h_change"] - - return f"`{symbol.tag}`: {symbol.name}, {change:.2f}%" - - def trending(self) -> list[str]: - """Gets current coins trending on coingecko - - Returns - ------- - list[str] - list of $$ID: NAME, CHANGE% - """ - - coins = self.get("/search/trending") - try: - trending = [] - for coin in coins["coins"]: - c = coin["item"] - - sym = c["symbol"].upper() - name = c["name"] - change = self.get( - "/simple/price", - params={ - "ids": c["id"], - "vs_currencies": self.vs_currency, - "include_24hr_change": "true", - }, - )[c["id"]]["usd_24h_change"] - - msg = f"`$${sym}`: {name}, {change:.2f}%" - - trending.append(msg) - - except Exception as e: - log.warning(e) - return self.trending_cache - - self.trending_cache = trending - return trending - - def batch_price(self, coins: list[Coin]) -> list[str]: - """Gets price of a list of coins all in one API call - - Parameters - ---------- - coins : list[Coin] - - Returns - ------- - list[str] - returns preformatted list of strings detailing price movement of each coin passed in. - """ - query = ",".join([c.id for c in coins]) - - prices = self.get( - "/simple/price", - params={ - "ids": query, - "vs_currencies": self.vs_currency, - "include_24hr_change": "true", - }, - ) - - replies = [] - for coin in coins: - if coin.id in prices: - p = prices[coin.id] - - if p.get("usd_24h_change") is None: - p["usd_24h_change"] = 0 - - replies.append( - f"{coin.name}: ${p.get('usd',0):,} and has moved {p.get('usd_24h_change',0.0):.2f}% in the past 24 hours." - ) - - return replies +import logging +from typing import List + +import pandas as pd +import requests as r +import schedule +from markdownify import markdownify + +from Symbol import Coin + +log = logging.getLogger(__name__) + + +class cg_Crypto: + """ + Functions for finding crypto info + """ + + vs_currency = "usd" # simple/supported_vs_currencies for list of options + + trending_cache: List[str] = [] + + def __init__(self) -> None: + self.get_symbol_list() + schedule.every().day.do(self.get_symbol_list) + + def get(self, endpoint, params: dict = {}, timeout=10) -> dict: + url = "https://api.coingecko.com/api/v3" + endpoint + resp = r.get(url, params=params, timeout=timeout) + # Make sure API returned a proper status code + try: + resp.raise_for_status() + except r.exceptions.HTTPError as e: + log.error(e) + return {} + + # Make sure API returned valid JSON + try: + resp_json = resp.json() + return resp_json + except r.exceptions.JSONDecodeError as e: + log.error(e) + return {} + + def symbol_id(self, symbol) -> str: + try: + return self.symbol_list[self.symbol_list["symbol"] == symbol]["id"].values[0] + except KeyError: + return "" + + def get_symbol_list(self): + raw_symbols = self.get("/coins/list") + symbols = pd.DataFrame(data=raw_symbols) + + # Removes all binance-peg symbols + symbols = symbols[~symbols["id"].str.contains("binance-peg")] + + symbols["description"] = "$$" + symbols["symbol"].str.upper() + ": " + symbols["name"] + symbols = symbols[["id", "symbol", "name", "description"]] + symbols["type_id"] = "$$" + symbols["symbol"] + + self.symbol_list = symbols + + def status(self) -> str: + """Checks CoinGecko /ping endpoint for API issues. + + Returns + ------- + str + Human readable text on status of CoinGecko API + """ + status = r.get( + "https://api.coingecko.com/api/v3/ping", + timeout=5, + ) + + try: + status.raise_for_status() + return ( + f"CoinGecko API responded that it was OK with a {status.status_code} in {status.elapsed.total_seconds()} Seconds." + ) + except r.HTTPError: + return f"CoinGecko API returned an error code {status.status_code} in {status.elapsed.total_seconds()} Seconds." + + def price_reply(self, coin: Coin) -> str: + """Returns current market price or after hours if its available for a given coin symbol. + + Parameters + ---------- + symbols : list + List of coin symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + markdown formatted string of the symbols price and movement. + """ + + if resp := self.get( + "/simple/price", + params={ + "ids": coin.id, + "vs_currencies": self.vs_currency, + "include_24hr_change": "true", + }, + ): + try: + data = resp[coin.id] + + price = data[self.vs_currency] + change = data[self.vs_currency + "_24h_change"] + if change is None: + change = 0 + except KeyError: + return f"{coin.id} returned an error." + + message = f"The current price of {coin.name} is $**{price:,}**" + + # Determine wording of change text + if change > 0: + message += f", the coin is currently **up {change:.3f}%** for today" + elif change < 0: + message += f", the coin is currently **down {change:.3f}%** for today" + else: + message += ", the coin hasn't shown any movement today." + + else: + message = f"The price for {coin.name} is not available. If you suspect this is an error run `/status`" + + return message + + def intra_reply(self, symbol: Coin) -> pd.DataFrame: + """Returns price data for a symbol since the last market open. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. + """ + + if resp := self.get( + f"/coins/{symbol.id}/ohlc", + params={"vs_currency": self.vs_currency, "days": 1}, + ): + df = pd.DataFrame(resp, columns=["Date", "Open", "High", "Low", "Close"]).dropna() + df["Date"] = pd.to_datetime(df["Date"], unit="ms") + df = df.set_index("Date") + return df + + return pd.DataFrame() + + def chart_reply(self, symbol: Coin) -> pd.DataFrame: + """Returns price data for a symbol of the past month up until the previous trading days close. + Also caches multiple requests made in the same day. + + Parameters + ---------- + symbol : str + Stock symbol. + + Returns + ------- + pd.DataFrame + Returns a timeseries dataframe with high, low, and volume data if its available. Otherwise returns empty pd.DataFrame. + """ + + if resp := self.get( + f"/coins/{symbol.id}/ohlc", + params={"vs_currency": self.vs_currency, "days": 30}, + ): + df = pd.DataFrame(resp, columns=["Date", "Open", "High", "Low", "Close"]).dropna() + df["Date"] = pd.to_datetime(df["Date"], unit="ms") + df = df.set_index("Date") + return df + + return pd.DataFrame() + + def stat_reply(self, symbol: Coin) -> str: + """Gathers key statistics on coin. Mostly just CoinGecko scores. + + Parameters + ---------- + symbol : Coin + + Returns + ------- + str + Preformatted markdown. + """ + + if data := self.get( + f"/coins/{symbol.id}", + params={ + "localization": "false", + }, + ): + return f""" + [{data['name']}]({data['links']['homepage'][0]}) Statistics: + Market Cap: ${data['market_data']['market_cap'][self.vs_currency]:,} + Market Cap Ranking: {data.get('market_cap_rank',"Not Available")} + CoinGecko Scores: + Overall: {data.get('coingecko_score','Not Available')} + Development: {data.get('developer_score','Not Available')} + Community: {data.get('community_score','Not Available')} + Public Interest: {data.get('public_interest_score','Not Available')} + """ + else: + return f"{symbol.symbol} returned an error." + + def cap_reply(self, coin: Coin) -> str: + """Gets market cap for Coin + + Parameters + ---------- + coin : Coin + + Returns + ------- + str + Preformatted markdown. + """ + + if resp := self.get( + "/simple/price", + params={ + "ids": coin.id, + "vs_currencies": self.vs_currency, + "include_market_cap": "true", + }, + ): + log.debug(resp) + try: + data = resp[coin.id] + + price = data[self.vs_currency] + cap = data[self.vs_currency + "_market_cap"] + except KeyError: + return f"{coin.id} returned an error." + + if cap == 0: + return f"The market cap for {coin.name} is not available for unknown reasons." + + message = ( + f"The current price of {coin.name} is $**{price:,}** and" + + " its market cap is $**{cap:,.2f}** {self.vs_currency.upper()}" + ) + + else: + message = f"The Coin: {coin.name} was not found or returned and error." + + return message + + def info_reply(self, symbol: Coin) -> str: + """Gets coin description + + Parameters + ---------- + symbol : Coin + + Returns + ------- + str + Preformatted markdown. + """ + + if data := self.get( + f"/coins/{symbol.id}", + params={"localization": "false"}, + ): + try: + return markdownify(data["description"]["en"]) + except KeyError: + return f"{symbol} does not have a description available." + + return f"No information found for: {symbol}\nEither today is boring or the symbol does not exist." + + def spark_reply(self, symbol: Coin) -> str: + change = self.get( + "/simple/price", + params={ + "ids": symbol.id, + "vs_currencies": self.vs_currency, + "include_24hr_change": "true", + }, + )[symbol.id]["usd_24h_change"] + + return f"`{symbol.tag}`: {symbol.name}, {change:.2f}%" + + def trending(self) -> list[str]: + """Gets current coins trending on coingecko + + Returns + ------- + list[str] + list of $$ID: NAME, CHANGE% + """ + + coins = self.get("/search/trending") + try: + trending = [] + for coin in coins["coins"]: + c = coin["item"] + + sym = c["symbol"].upper() + name = c["name"] + change = self.get( + "/simple/price", + params={ + "ids": c["id"], + "vs_currencies": self.vs_currency, + "include_24hr_change": "true", + }, + )[c["id"]]["usd_24h_change"] + + msg = f"`$${sym}`: {name}, {change:.2f}%" + + trending.append(msg) + + except Exception as e: + log.warning(e) + return self.trending_cache + + self.trending_cache = trending + return trending + + def batch_price(self, coins: list[Coin]) -> list[str]: + """Gets price of a list of coins all in one API call + + Parameters + ---------- + coins : list[Coin] + + Returns + ------- + list[str] + returns preformatted list of strings detailing price movement of each coin passed in. + """ + query = ",".join([c.id for c in coins]) + + prices = self.get( + "/simple/price", + params={ + "ids": query, + "vs_currencies": self.vs_currency, + "include_24hr_change": "true", + }, + ) + + replies = [] + for coin in coins: + if coin.id in prices: + p = prices[coin.id] + + if p.get("usd_24h_change") is None: + p["usd_24h_change"] = 0 + + replies.append( + f"{coin.name}: ${p.get('usd',0):,} and has moved {p.get('usd_24h_change',0.0):.2f}% in the past 24 hours." + ) + + return replies diff --git a/dev-reqs.txt b/dev-reqs.txt index 39c1246..c122358 100644 --- a/dev-reqs.txt +++ b/dev-reqs.txt @@ -1,4 +1,4 @@ --r telegram/requirements.txt +-r requirements.txt black==23.3.0 flake8==5.0.4 Flake8-pyproject==1.2.3 diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 5c0e2f0..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: '3' -services: - telegram: - build: - context: . - dockerfile: telegram/Dockerfile - image: registry.gitlab.com/simple-stock-bots/simple-telegram-stock-bot - env_file: .env \ No newline at end of file diff --git a/telegram/requirements.txt b/requirements.txt similarity index 100% rename from telegram/requirements.txt rename to requirements.txt diff --git a/common/symbol_router.py b/symbol_router.py similarity index 98% rename from common/symbol_router.py rename to symbol_router.py index f85b142..7c5544c 100644 --- a/common/symbol_router.py +++ b/symbol_router.py @@ -10,9 +10,9 @@ import pandas as pd import schedule from cachetools import TTLCache, cached -from common.cg_Crypto import cg_Crypto -from common.MarketData import MarketData -from common.Symbol import Coin, Stock, Symbol +from cg_Crypto import cg_Crypto +from MarketData import MarketData +from Symbol import Coin, Stock, Symbol from typing import Dict