diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8a0bd07..7cd2357 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,41 +1,41 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/python -{ - "name": "Python 3", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - // "image": "mcr.microsoft.com/devcontainers/python:1-3-bookworm", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - }, - "features": { - "ghcr.io/devcontainers-contrib/features/black:2": {}, - "ghcr.io/devcontainers-contrib/features/mypy:2": {}, - "ghcr.io/devcontainers-contrib/features/pylint:2": {}, - "ghcr.io/devcontainers/features/docker-in-docker": {} - }, - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.black-formatter", - "ms-python.vscode-pylance", - "ms-python.isort", - "charliermarsh.ruff", - "ms-toolsai.jupyter", - "esbenp.prettier-vscode" - ] - } - }, - "postCreateCommand": "pip3 install --user -r dev-reqs.txt && apt-get update && apt-get install -y nodejs npm && npm install" - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "pip3 install --user -r requirements.txt", - // Configure tool-specific properties. - // "customizations": {}, - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + // "image": "mcr.microsoft.com/devcontainers/python:1-3-bookworm", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "features": { + "ghcr.io/devcontainers-contrib/features/black:2": {}, + "ghcr.io/devcontainers-contrib/features/mypy:2": {}, + "ghcr.io/devcontainers-contrib/features/pylint:2": {}, + "ghcr.io/devcontainers/features/docker-in-docker": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.black-formatter", + "ms-python.vscode-pylance", + "ms-python.isort", + "charliermarsh.ruff", + "ms-toolsai.jupyter", + "esbenp.prettier-vscode" + ] + } + }, + "postCreateCommand": "pip3 install --user -r dev-reqs.txt && apt-get update && apt-get install -y nodejs npm --fix-missing && npm install" + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip3 install --user -r requirements.txt", + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f0d3483..b7f5ab1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,27 +1,21 @@ -stages: - - lint - - build_site - - deploy - -black: - stage: lint - image: registry.gitlab.com/pipeline-components/black:latest - script: - - black --check --verbose -- . - -ruff: - stage: lint - image: python:3.11-slim - script: - - pip3 install ruff - - ruff --output-format gitlab . - -prettier: - stage: lint - image: node:16-slim # Use Node.js image since prettier is a Node.js tool - script: - - npm install prettier - - npx prettier --check . # Adjust the path as needed - -include: - - local: /site/.gitlab-ci.yml +stages: + - lint + - build_site + - deploy + +# ruff: +# stage: lint +# image: python:3.11-slim +# script: +# - pip3 install ruff +# - ruff . --output-format gitlab; ruff format . --diff + +# prettier: +# stage: lint +# image: node:16-slim # Use Node.js image since prettier is a Node.js tool +# script: +# - npm install prettier +# - npx prettier --check . # Adjust the path as needed + +include: + - local: /site/.gitlab-ci.yml diff --git a/.vscode/settings.json b/.vscode/settings.json index 91f903e..aa9559e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,10 @@ -{ - "editor.formatOnSave": true, - "editor.formatOnPaste": true, - "editor.formatOnSaveMode": "modificationsIfAvailable", - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", - "editor.formatOnSave": true, - "editor.formatOnSaveMode": "file" - } -} +{ + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.formatOnSaveMode": "modificationsIfAvailable", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file" + } +} diff --git a/README.md b/README.md index 453bf85..4f33084 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,48 @@ -# Simple Stock Bots - -[](https://t.me/SimpleStockBot) -[](/LICENSE) -[](https://ansonbiggs.com) - -Enhance your group chats on Telegram and Discord with real-time stock and cryptocurrency insights. Simple Stock Bots deliver timely stock quotes, charts, and market trends directly to your chats, making financial discussions more engaging and informed. - -## Documentation - -Comprehensive documentation is available to help you understand the features and capabilities of Simple Stock Bots: - -- [Official Documentation](https://simplestockbot.com/) -- [Command Reference](https://simplestockbot.com/commands/) - -## Support the Project - -Simple Stock Bots is sustained entirely through donations, with an annual expense of about $420 for server and premium market data subscriptions from [marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=repo). - -You can contribute by: - -- Donating through [Buy Me A Coffee](https://www.buymeacoffee.com/Anson) (PayPal or Credit Card accepted). -- Using the `/donate` [command](commands.md#donate-amount-in-usd) on Telegram. -- [Getting in touch](contact.md) for any inquiries or suggestions. - -### Additional Support: - -- Follow on [Twitter](https://twitter.com/AnsonBiggs) -- Contribute or star the project on [GitLab](https://gitlab.com/simple-stock-bots/simple-stock-bot) -- Utilize referral links for self-hosting: - - [DigitalOcean](https://m.do.co/c/6b5df7ef55b6) - - [marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=web) - -## Hosting - -Self-hosting instructions are provided for those interested in running the bot on their own servers: - -- [Hosting Guide](https://simplestockbot.com/hosting/) - -## Contact - -Reach out for bug reports, feature requests, or other inquiries: - -- [Contact Page](https://simplestockbot.com/contact/) - ---- - -Your support in any form is immensely appreciated and ensures the continuous improvement and availability of Simple Stock Bots for everyone. +# Simple Stock Bots + +[](https://t.me/SimpleStockBot) +[](/LICENSE) +[](https://ansonbiggs.com) + +Enhance your group chats on Telegram and Discord with real-time stock and cryptocurrency insights. Simple Stock Bots deliver timely stock quotes, charts, and market trends directly to your chats, making financial discussions more engaging and informed. + +## Documentation + +Comprehensive documentation is available to help you understand the features and capabilities of Simple Stock Bots: + +- [Official Documentation](https://simplestockbot.com/) +- [Command Reference](https://simplestockbot.com/commands/) + +## Support the Project + +Simple Stock Bots is sustained entirely through donations, with an annual expense of about $420 for server and premium market data subscriptions from [marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=repo). + +You can contribute by: + +- Donating through [Buy Me A Coffee](https://www.buymeacoffee.com/Anson) (PayPal or Credit Card accepted). +- Using the `/donate` [command](commands.md#donate-amount-in-usd) on Telegram. +- [Getting in touch](contact.md) for any inquiries or suggestions. + +### Additional Support: + +- Follow on [Twitter](https://twitter.com/AnsonBiggs) +- Contribute or star the project on [GitLab](https://gitlab.com/simple-stock-bots/simple-stock-bot) +- Utilize referral links for self-hosting: + - [DigitalOcean](https://m.do.co/c/6b5df7ef55b6) + - [marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=web) + +## Hosting + +Self-hosting instructions are provided for those interested in running the bot on their own servers: + +- [Hosting Guide](https://simplestockbot.com/hosting/) + +## Contact + +Reach out for bug reports, feature requests, or other inquiries: + +- [Contact Page](https://simplestockbot.com/contact/) + +--- + +Your support in any form is immensely appreciated and ensures the continuous improvement and availability of Simple Stock Bots for everyone. diff --git a/common/MarketData.py b/common/MarketData.py index dde8951..b204609 100644 --- a/common/MarketData.py +++ b/common/MarketData.py @@ -1,362 +1,367 @@ -import datetime as dt -import logging -import os -from collections import OrderedDict -from typing import Dict - -import humanize -import pandas as pd -import pytz -import requests as r -import schedule - -from common.Symbol import Stock - -log = logging.getLogger(__name__) - - -class MarketData: - """ - Functions for finding stock market information about symbols from MarkData.app - """ - - SYMBOL_REGEX = "[$]([a-zA-Z]{1,4})" - - symbol_list: Dict[str, Dict] = {} - charts: Dict[Stock, pd.DataFrame] = {} - - openTime = dt.time(hour=9, minute=30, second=0) - marketTimeZone = pytz.timezone("US/Eastern") - - def __init__(self) -> None: - """Creates a Symbol Object - - Parameters - ---------- - MARKETDATA_TOKEN : str - MarketData.app API Token - """ - - try: - self.MARKETDATA_TOKEN = os.environ["MARKETDATA"] - - if self.MARKETDATA_TOKEN == "TOKEN": - self.MARKETDATA_TOKEN = "" - except KeyError: - self.MARKETDATA_TOKEN = "" - log.warning("Starting without an MarketData.app Token will not allow you to get market data!") - log.warning("Use this affiliate link so that the bot can stay free:") - log.warning("https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=repo") - - if self.MARKETDATA_TOKEN != "": - schedule.every().day.do(self.clear_charts) - - self.get_symbol_list() - schedule.every().day.do(self.get_symbol_list) - - def get(self, endpoint, params=None, timeout=10, headers=None) -> dict: - url = "https://api.marketdata.app/v1/" + endpoint - - if params is None: - params = {} - - # set token param if it wasn't passed. - params["token"] = self.MARKETDATA_TOKEN - - # Undocumented query variable that ensures bot usage can be - # monitored even if someone doesn't make it through an affiliate link. - params["application"] = "simplestockbot" - - if headers is None: - headers = {} - headers = {"User-Agent": "Simple Stock Bot anson@ansonbiggs.com"} | headers - - resp = r.get(url, params=params, timeout=timeout, headers=headers) - - logging.error(resp.headers.items()) - - # Make sure API returned a proper status code - try: - resp.raise_for_status() - except r.exceptions.HTTPError as e: - logging.error(e) - return {} - - # Make sure API returned valid JSON - try: - resp_json = resp.json() - - match resp_json["s"]: - case "ok": - return resp_json - case "no_data": - return resp_json - case "error": - logging.error("MarketData Error:\n" + resp_json["errmsg"]) - return {} - - except r.exceptions.JSONDecodeError as e: - logging.error(e) - - return {} - - def symbol_id(self, symbol: str) -> Dict[str, Dict]: - return self.symbol_list.get(symbol.upper(), None) - - def get_symbol_list(self): - # Doesn't use `self.get`` since needs are much different - sec_resp = r.get( - "https://www.sec.gov/files/company_tickers.json", - headers={ - "User-Agent": "Simple Stock Bot anson@ansonbiggs.com", - "Accept-Encoding": "gzip, deflate", - "Host": "www.sec.gov", - }, - ) - sec_resp.raise_for_status() - sec_data = sec_resp.json() - - for rank, ticker_info in sec_data.items(): - self.symbol_list[ticker_info["ticker"]] = { - "ticker": ticker_info["ticker"], - "title": ticker_info["title"], - "mkt_cap_rank": rank, - } - - def clear_charts(self) -> None: - """ - Clears cache of chart data. - Charts are cached so that only 1 API call per 24 hours is needed since the - chart data is expensive and a large download. - """ - self.charts = {} - - def status(self) -> str: - # TODO: At the moment this API is poorly documented, this function likely needs to be revisited later. - - try: - status = r.get( - "https://stats.uptimerobot.com/api/getMonitorList/6Kv3zIow0A", - timeout=5, - ) - status.raise_for_status() - except r.HTTPError: - return f"API returned an HTTP error code {status.status_code} in {status.elapsed.total_seconds()} seconds." - except r.Timeout: - return "API timed out before it was able to give status. This is likely due to a surge in usage or a complete outage." - - statusJSON = status.json() - - if statusJSON["status"] == "ok": - return ( - f"CoinGecko API responded that it was OK with a {status.status_code} in {status.elapsed.total_seconds()} seconds." - ) - else: - return f"MarketData.app is currently reporting the following status: {statusJSON['status']}" - - def price_reply(self, symbol: Stock) -> str: - """Returns price movement of Stock for the last market day, or after hours. - - Parameters - ---------- - symbol : Stock - - Returns - ------- - str - Formatted markdown - """ - - if quoteResp := self.get(f"stocks/quotes/{symbol.symbol}/"): - price = round(quoteResp["last"][0], 2) - - try: - changePercent = round(quoteResp["changepct"][0], 2) - except TypeError: - return f"The price of {symbol.name} is ${price}" - - message = f"The current price of {symbol.name} is ${price} and " - - if changePercent > 0.0: - message += f"is currently up {changePercent}% for the day." - elif changePercent < 0.0: - message += f"is currently down {changePercent}% for the day." - else: - message += "hasn't shown any movement for the day." - - return message - else: - return f"Getting a quote for {symbol} encountered an error." - - def spark_reply(self, symbol: Stock) -> str: - if quoteResp := self.get(f"stocks/quotes/{symbol}/"): - try: - changePercent = round(quoteResp["changepct"][0], 2) - return f"`{symbol.tag}`: {changePercent}%" - except TypeError: - pass - - return f"`{symbol.tag}`" - - def intra_reply(self, symbol: Stock) -> 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. - """ - schedule.run_pending() - - try: - return self.charts[symbol.id.upper()] - except KeyError: - pass - - resolution = "15" # minutes - now = dt.datetime.now(self.marketTimeZone) - - if self.openTime < now.time(): - startTime = now.replace(hour=9, minute=30) - else: - startTime = now - dt.timedelta(days=1) - - if data := self.get( - f"stocks/candles/{resolution}/{symbol}", - params={"from": startTime.timestamp(), "to": now.timestamp(), "extended": True}, - ): - data.pop("s") - df = pd.DataFrame(data) - df["t"] = pd.to_datetime(df["t"], unit="s", utc=True) - df.set_index("t", inplace=True) - - df.rename( - columns={ - "o": "Open", - "h": "High", - "l": "Low", - "c": "Close", - "v": "Volume", - }, - inplace=True, - ) - - self.charts[symbol.id.upper()] = df - return df - - return pd.DataFrame() - - def chart_reply(self, symbol: Stock) -> 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. - """ - schedule.run_pending() - - try: - return self.charts[symbol.id.upper()] - except KeyError: - pass - - to_date = dt.datetime.today().strftime("%Y-%m-%d") - from_date = (dt.datetime.today() - dt.timedelta(days=30)).strftime("%Y-%m-%d") - resultion = "daily" - - if data := self.get( - f"stocks/candles/{resultion}/{symbol}", - params={ - "from": from_date, - "to": to_date, - }, - ): - data.pop("s") - - df = pd.DataFrame(data) - df["t"] = pd.to_datetime(df["t"], unit="s") - df.set_index("t", inplace=True) - - df.rename( - columns={ - "o": "Open", - "h": "High", - "l": "Low", - "c": "Close", - "v": "Volume", - }, - inplace=True, - ) - - self.charts[symbol.id.upper()] = df - return df - - return pd.DataFrame() - - def options_reply(self, request: str) -> str: - """Undocumented API Usage!""" - - options_data = self.get(f"options/quotes/{request}") - - for key in options_data.keys(): - options_data[key] = options_data[key][0] - - options_data["underlying"] = "$" + options_data["underlying"] - - options_data["updated"] = humanize.naturaltime(dt.datetime.now() - dt.datetime.fromtimestamp(options_data["updated"])) - - options_data["expiration"] = humanize.naturaltime( - dt.datetime.now() - dt.datetime.fromtimestamp(options_data["expiration"]) - ) - - options_data["firstTraded"] = humanize.naturaltime( - dt.datetime.now() - dt.datetime.fromtimestamp(options_data["firstTraded"]) - ) - - rename = { - "optionSymbol": "Option Symbol", - "underlying": "Underlying", - "expiration": "Expiration", - "side": "side", - "strike": "strike", - "firstTraded": "First Traded", - "updated": "Last Updated", - "bid": "bid", - "bidSize": "bidSize", - "mid": "mid", - "ask": "ask", - "askSize": "askSize", - "last": "last", - "openInterest": "Open Interest", - "volume": "Volume", - "inTheMoney": "inTheMoney", - "intrinsicValue": "Intrinsic Value", - "extrinsicValue": "Extrinsic Value", - "underlyingPrice": "Underlying Price", - "iv": "Implied Volatility", - "delta": "delta", - "gamma": "gamma", - "theta": "theta", - "vega": "vega", - "rho": "rho", - } - - options_cleaned = OrderedDict() - for old, new in rename.items(): - if old in options_data: - options_cleaned[new] = options_data[old] - - return options_cleaned +import datetime as dt +import logging +import os +from collections import OrderedDict +from typing import Dict + +import humanize +import pandas as pd +import pytz +import requests as r +import schedule + + +from common.Symbol import Stock + +log = logging.getLogger(__name__) + + +class MarketData: + """ + Functions for finding stock market information about symbols from MarkData.app + """ + + SYMBOL_REGEX = "[$]([a-zA-Z]{1,4})" + + symbol_list: Dict[str, Dict] = {} + charts: Dict[Stock, pd.DataFrame] = {} + + openTime = dt.time(hour=9, minute=30, second=0) + marketTimeZone = pytz.timezone("US/Eastern") + + def __init__(self) -> None: + """Creates a Symbol Object + + Parameters + ---------- + MARKETDATA_TOKEN : str + MarketData.app API Token + """ + + try: + self.MARKETDATA_TOKEN = os.environ["MARKETDATA"] + + if self.MARKETDATA_TOKEN == "TOKEN": + self.MARKETDATA_TOKEN = "" + except KeyError: + self.MARKETDATA_TOKEN = "" + log.warning("Starting without an MarketData.app Token will not allow you to get market data!") + log.warning("Use this affiliate link so that the bot can stay free:") + log.warning("https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=repo") + + if self.MARKETDATA_TOKEN != "": + schedule.every().day.do(self.clear_charts) + + self.get_symbol_list() + schedule.every().day.do(self.get_symbol_list) + + def get(self, endpoint, params=None, timeout=10, headers=None) -> dict: + url = "https://api.marketdata.app/v1/" + endpoint + + if params is None: + params = {} + + # set token param if it wasn't passed. + params["token"] = self.MARKETDATA_TOKEN + + # Undocumented query variable that ensures bot usage can be + # monitored even if someone doesn't make it through an affiliate link. + params["application"] = "simplestockbot" + + if headers is None: + headers = {} + headers = {"User-Agent": "Simple Stock Bot anson@ansonbiggs.com"} | headers + + resp = r.get(url, params=params, timeout=timeout, headers=headers) + + logging.error(resp.headers.items()) + + # Make sure API returned a proper status code + try: + resp.raise_for_status() + except r.exceptions.HTTPError as e: + logging.error(e) + return {} + + # Make sure API returned valid JSON + try: + resp_json = resp.json() + + match resp_json["s"]: + case "ok": + return resp_json + case "no_data": + return resp_json + case "error": + logging.error("MarketData Error:\n" + resp_json["errmsg"]) + return {} + + except r.exceptions.JSONDecodeError as e: + logging.error(e) + + return {} + + def symbol_id(self, symbol: str) -> Dict[str, Dict]: + return self.symbol_list.get(symbol.upper(), None) + + def get_symbol_list(self): + # Doesn't use `self.get()` since needs are much different + sec_resp = r.get( + "https://www.sec.gov/files/company_tickers.json", + headers={ + "User-Agent": "Simple Stock Bot anson@ansonbiggs.com", + "Accept-Encoding": "gzip, deflate", + "Host": "www.sec.gov", + }, + ) + sec_resp.raise_for_status() + sec_data = sec_resp.json() + + for rank, ticker_info in sec_data.items(): + self.symbol_list[ticker_info["ticker"]] = { + "ticker": ticker_info["ticker"], + "title": ticker_info["title"], + "mkt_cap_rank": rank, + } + + def clear_charts(self) -> None: + """ + Clears cache of chart data. + Charts are cached so that only 1 API call per 24 hours is needed since the + chart data is expensive and a large download. + """ + self.charts = {} + + def status(self) -> str: + # TODO: At the moment this API is poorly documented, this function likely needs to be revisited later. + + try: + status = r.get( + "https://stats.uptimerobot.com/api/getMonitorList/6Kv3zIow0A", + timeout=5, + ) + status.raise_for_status() + except r.HTTPError: + return f"API returned an HTTP error code {status.status_code} in {status.elapsed.total_seconds()} seconds." + except r.Timeout: + return "API timed out before it was able to give status. This is likely due to a surge in usage or a complete outage." + + statusJSON = status.json() + + if statusJSON["status"] == "ok": + return ( + f"CoinGecko API responded that it was OK with a {status.status_code} in {status.elapsed.total_seconds()} seconds." + ) + else: + return f"MarketData.app is currently reporting the following status: {statusJSON['status']}" + + def price_reply(self, symbol: Stock) -> str: + """Returns price movement of Stock for the last market day, or after hours. + + Parameters + ---------- + symbol : Stock + + Returns + ------- + str + Formatted markdown + """ + + if quoteResp := self.get(f"stocks/quotes/{symbol.symbol}/"): + price = round(quoteResp["last"][0], 2) + + try: + changePercent = round(quoteResp["changepct"][0], 2) + except TypeError: + return f"The price of {symbol.name} is ${price}" + + message = f"The current price of {symbol.name} is ${price} and " + + if changePercent > 0.0: + message += f"is currently up {changePercent}% for the day." + elif changePercent < 0.0: + message += f"is currently down {changePercent}% for the day." + else: + message += "hasn't shown any movement for the day." + + return message + else: + return f"Getting a quote for {symbol} encountered an error." + + def spark_reply(self, symbol: Stock) -> str: + if quoteResp := self.get(f"stocks/quotes/{symbol}/"): + try: + changePercent = round(quoteResp["changepct"][0], 2) + return f"`{symbol.tag}`: {changePercent}%" + except TypeError: + pass + + return f"`{symbol.tag}`" + + def intra_reply(self, symbol: Stock) -> 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. + """ + schedule.run_pending() + + try: + return self.charts[symbol.id.upper()] + except KeyError: + pass + + resolution = "15" # minutes + now = dt.datetime.now(self.marketTimeZone) + + if self.openTime < now.time(): + startTime = now.replace(hour=9, minute=30) + else: + startTime = now - dt.timedelta(days=1) + + if data := self.get( + f"stocks/candles/{resolution}/{symbol}", + params={ + "from": startTime.timestamp(), + "to": now.timestamp(), + "extended": True, + }, + ): + data.pop("s") + df = pd.DataFrame(data) + df["t"] = pd.to_datetime(df["t"], unit="s", utc=True) + df.set_index("t", inplace=True) + + df.rename( + columns={ + "o": "Open", + "h": "High", + "l": "Low", + "c": "Close", + "v": "Volume", + }, + inplace=True, + ) + + self.charts[symbol.id.upper()] = df + return df + + return pd.DataFrame() + + def chart_reply(self, symbol: Stock) -> 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. + """ + schedule.run_pending() + + try: + return self.charts[symbol.id.upper()] + except KeyError: + pass + + to_date = dt.datetime.today().strftime("%Y-%m-%d") + from_date = (dt.datetime.today() - dt.timedelta(days=30)).strftime("%Y-%m-%d") + resultion = "daily" + + if data := self.get( + f"stocks/candles/{resultion}/{symbol}", + params={ + "from": from_date, + "to": to_date, + }, + ): + data.pop("s") + + df = pd.DataFrame(data) + df["t"] = pd.to_datetime(df["t"], unit="s") + df.set_index("t", inplace=True) + + df.rename( + columns={ + "o": "Open", + "h": "High", + "l": "Low", + "c": "Close", + "v": "Volume", + }, + inplace=True, + ) + + self.charts[symbol.id.upper()] = df + return df + + return pd.DataFrame() + + def options_reply(self, request: str) -> str: + """Undocumented API Usage!""" + + options_data = self.get(f"options/quotes/{request}") + + for key in options_data.keys(): + options_data[key] = options_data[key][0] + + options_data["underlying"] = "$" + options_data["underlying"] + + options_data["updated"] = humanize.naturaltime(dt.datetime.now() - dt.datetime.fromtimestamp(options_data["updated"])) + + options_data["expiration"] = humanize.naturaltime( + dt.datetime.now() - dt.datetime.fromtimestamp(options_data["expiration"]) + ) + + options_data["firstTraded"] = humanize.naturaltime( + dt.datetime.now() - dt.datetime.fromtimestamp(options_data["firstTraded"]) + ) + + rename = { + "optionSymbol": "Option Symbol", + "underlying": "Underlying", + "expiration": "Expiration", + "side": "side", + "strike": "strike", + "firstTraded": "First Traded", + "updated": "Last Updated", + "bid": "bid", + "bidSize": "bidSize", + "mid": "mid", + "ask": "ask", + "askSize": "askSize", + "last": "last", + "openInterest": "Open Interest", + "volume": "Volume", + "inTheMoney": "inTheMoney", + "intrinsicValue": "Intrinsic Value", + "extrinsicValue": "Extrinsic Value", + "underlyingPrice": "Underlying Price", + "iv": "Implied Volatility", + "delta": "delta", + "gamma": "gamma", + "theta": "theta", + "vega": "vega", + "rho": "rho", + } + + options_cleaned = OrderedDict() + for old, new in rename.items(): + if old in options_data: + options_cleaned[new] = options_data[old] + + return options_cleaned diff --git a/common/Symbol.py b/common/Symbol.py index cdd8602..d7d502e 100644 --- a/common/Symbol.py +++ b/common/Symbol.py @@ -1,52 +1,55 @@ -import logging - -import pandas as pd - - -class Symbol: - """ - symbol: What the user calls it. ie tsla or btc - id: What the api expects. ie tsla or bitcoin - name: Human readable. ie Tesla or Bitcoin - tag: Uppercase tag to call the symbol. ie $TSLA or $$BTC - """ - - currency = "usd" - pass - - def __init__(self, symbol) -> None: - self.symbol = symbol - self.id = symbol - self.name = symbol - self.tag = "$" + symbol - - def __repr__(self) -> str: - return f"<{self.__class__.__name__} instance of {self.id} at {id(self)}>" - - def __str__(self) -> str: - return self.id - - -class Stock(Symbol): - """Stock Market Object. Gets data from MarketData""" - - def __init__(self, symbol_info: dict) -> None: - self.symbol = symbol_info["ticker"] - self.id = symbol_info["ticker"] - self.name = symbol_info["title"] - self.tag = "$" + symbol_info["ticker"] - self.market_cap_rank = symbol_info["mkt_cap_rank"] - - -class Coin(Symbol): - """Cryptocurrency Object. Gets data from CoinGecko.""" - - def __init__(self, symbol: pd.DataFrame) -> None: - if len(symbol) > 1: - logging.info(f"Crypto with shared id:\n\t{symbol.id}") - symbol = symbol.head(1) - - self.symbol = symbol.symbol.values[0] - self.id = symbol.id.values[0] - self.name = symbol.name.values[0] - self.tag = symbol.type_id.values[0].upper() +import logging + +import pandas as pd + + +class Symbol: + """ + symbol: What the user calls it. ie tsla or btc + id: What the api expects. ie tsla or bitcoin + name: Human readable. ie Tesla or Bitcoin + tag: Uppercase tag to call the symbol. ie $TSLA or $$BTC + """ + + currency = "usd" + pass + + def __init__(self, symbol) -> None: + self.symbol = symbol + self.id = symbol + self.name = symbol + self.tag = "$" + symbol + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} instance of {self.id} at {id(self)}>" + + def __str__(self) -> str: + return self.id + + def __hash__(self): + return hash(self.id) + + +class Stock(Symbol): + """Stock Market Object. Gets data from MarketData""" + + def __init__(self, symbol_info: dict) -> None: + self.symbol = symbol_info["ticker"] + self.id = symbol_info["ticker"] + self.name = symbol_info["title"] + self.tag = "$" + symbol_info["ticker"] + self.market_cap_rank = symbol_info["mkt_cap_rank"] + + +class Coin(Symbol): + """Cryptocurrency Object. Gets data from CoinGecko.""" + + def __init__(self, symbol: pd.DataFrame) -> None: + if len(symbol) > 1: + logging.info(f"Crypto with shared id:\n\t{symbol.id}") + symbol = symbol.head(1) + + self.symbol = symbol.symbol.values[0] + self.id = symbol.id.values[0] + self.name = symbol.name.values[0] + self.tag = symbol.type_id.values[0].upper() diff --git a/common/cg_Crypto.py b/common/cg_Crypto.py index 4ba41a0..6056b5b 100644 --- a/common/cg_Crypto.py +++ b/common/cg_Crypto.py @@ -1,381 +1,380 @@ -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 -from common.utilities import rate_limited - -import time - -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) - - # Coingecko's rate limit is 30 requests per minute. - # Since there are two bots sharing the same IP, we allocate half of that limit to each bot. - # This results in a rate limit of 15 requests per minute for each bot. - # Given this, the rate limit effectively becomes 1 request every 4 seconds for each bot. - @rate_limited(0.25) - 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 - - if resp.status_code == 429: - log.warning(f"CoinGecko returned 429 - Too Many Requests for endpoint: {endpoint}. Sleeping and trying again.") - time.sleep(10) - return self.get(endpoint=endpoint, params=params, timeout=timeout) - - 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 common.Symbol import Coin +from common.utilities import rate_limited + +import time + +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) + + # Coingecko's rate limit is 30 requests per minute. + # Since there are two bots sharing the same IP, we allocate half of that limit to each bot. + # This results in a rate limit of 15 requests per minute for each bot. + # Given this, the rate limit effectively becomes 1 request every 4 seconds for each bot. + @rate_limited(0.25) + 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 + + if resp.status_code == 429: + log.warning(f"CoinGecko returned 429 - Too Many Requests for endpoint: {endpoint}. Sleeping and trying again.") + time.sleep(10) + return self.get(endpoint=endpoint, params=params, timeout=timeout) + + 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/common/symbol_router.py b/common/symbol_router.py index e09a453..044f64c 100644 --- a/common/symbol_router.py +++ b/common/symbol_router.py @@ -1,407 +1,407 @@ -"""Function that routes symbols to the correct API provider. -""" - -import datetime -import logging -import random -import re -from typing import Dict - -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 - -log = logging.getLogger(__name__) - - -class Router: - STOCK_REGEX = "(?:^|[^\\$])\\$([a-zA-Z.]{1,6})" - CRYPTO_REGEX = "[$]{2}([a-zA-Z]{1,20})" - trending_count: Dict[str, float] = {} - - def __init__(self): - self.stock = MarketData() - self.crypto = cg_Crypto() - - schedule.every().hour.do(self.trending_decay) - - def trending_decay(self, decay=0.5): - """Decays the value of each trending stock by a multiplier""" - t_copy = {} - dead_keys = [] - if self.trending_count: - t_copy = self.trending_count.copy() - for key in t_copy.keys(): - if t_copy[key] < 0.01: - # Prune Keys - dead_keys.append(key) - else: - t_copy[key] = t_copy[key] * decay - for dead in dead_keys: - t_copy.pop(dead) - - self.trending_count = t_copy.copy() - log.info("Decayed trending symbols.") - - def find_symbols(self, text: str, *, trending_weight: int = 1) -> list[Stock | Coin]: - """Finds stock tickers starting with a dollar sign, and cryptocurrencies with two dollar signs - in a blob of text and returns them in a list. - - Parameters - ---------- - text : str - Blob of text. - - Returns - ------- - list[Symbol] - List of stock symbols as Symbol objects - """ - schedule.run_pending() - - symbols: list[Symbol] = [] - stock_matches = set(re.findall(self.STOCK_REGEX, text)) - coin_matches = set(re.findall(self.CRYPTO_REGEX, text)) - - for stock_match in stock_matches: - # Market data lacks tools to check if a symbol is valid. - if stock_info := self.stock.symbol_id(stock_match): - symbols.append(Stock(stock_info)) - else: - log.info(f"{stock_match} is not in list of stocks") - - for coin_match in coin_matches: - sym = self.crypto.symbol_list[self.crypto.symbol_list["symbol"].str.fullmatch(coin_match.lower(), case=False)] - if sym.empty: - log.info(f"{coin_match} is not in list of coins") - else: - symbols.append(Coin(sym)) - if symbols: - for symbol in symbols: - self.trending_count[symbol.tag] = self.trending_count.get(symbol.tag, 0) + trending_weight - log.debug(self.trending_count) - - return symbols - - def status(self, bot_resp) -> str: - """Checks for any issues with APIs. - - Returns - ------- - str - Human readable text on status of the bot and relevant APIs - """ - - stats = f""" - Bot Status: - {bot_resp} - - Stock Market Data: - {self.stock.status()} - - Cryptocurrency Data: - {self.crypto.status()} - """ - - log.warning(stats) - - return stats - - def inline_search(self, search: str, matches: int = 5) -> pd.DataFrame: - """Searches based on the shortest symbol that contains the same string as the search. - Should be very fast compared to a fuzzy search. - - Parameters - ---------- - search : str - String used to match against symbols. - - Returns - ------- - list[tuple[str, str]] - Each tuple contains: (Symbol, Issue Name). - """ - - # df = pd.concat([self.stock.symbol_list, self.crypto.symbol_list]) - df = self.crypto.symbol_list - - df = df[df["description"].str.contains(search, regex=False, case=False)].sort_values( - by="type_id", key=lambda x: x.str.len() - ) - - symbols = df.head(matches) - symbols["price_reply"] = symbols["type_id"].apply( - lambda sym: self.price_reply(self.find_symbols(sym, trending_weight=0))[0] - ) - - return symbols - - def price_reply(self, symbols: list[Symbol]) -> list[str]: - """Returns current market price or after hours if its available for a given stock symbol. - - Parameters - ---------- - symbols : list - List of stock 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. - """ - replies = [] - - for symbol in symbols: - log.info(symbol) - if isinstance(symbol, Stock): - replies.append(self.stock.price_reply(symbol)) - elif isinstance(symbol, Coin): - replies.append(self.crypto.price_reply(symbol)) - else: - log.info(f"{symbol} is not a Stock or Coin") - - return replies - - def info_reply(self, symbols: list) -> list[str]: - """Gets information on stock symbols. - - Parameters - ---------- - symbols : list[str] - List of stock symbols. - - Returns - ------- - Dict[str, str] - Each symbol passed in is a key with its value being a human readable formatted - string of the symbols information. - """ - replies = [] - - for symbol in symbols: - if isinstance(symbol, Stock): - replies.append(self.stock.info_reply(symbol)) - elif isinstance(symbol, Coin): - replies.append(self.crypto.info_reply(symbol)) - else: - log.debug(f"{symbol} is not a Stock or Coin") - - return replies - - def intra_reply(self, symbol: Symbol) -> 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 isinstance(symbol, Stock): - return self.stock.intra_reply(symbol) - elif isinstance(symbol, Coin): - return self.crypto.intra_reply(symbol) - else: - log.debug(f"{symbol} is not a Stock or Coin") - return pd.DataFrame() - - def chart_reply(self, symbol: Symbol) -> 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 isinstance(symbol, Stock): - return self.stock.chart_reply(symbol) - elif isinstance(symbol, Coin): - return self.crypto.chart_reply(symbol) - else: - log.debug(f"{symbol} is not a Stock or Coin") - return pd.DataFrame() - - def stat_reply(self, symbols: list[Symbol]) -> list[str]: - """Gets key statistics for each symbol in the list - - Parameters - ---------- - symbols : list[str] - List of stock symbols - - Returns - ------- - Dict[str, str] - Each symbol passed in is a key with its value being a human readable - formatted string of the symbols statistics. - """ - replies = [] - - for symbol in symbols: - if isinstance(symbol, Stock): - replies.append(self.stock.stat_reply(symbol)) - elif isinstance(symbol, Coin): - replies.append(self.crypto.stat_reply(symbol)) - else: - log.debug(f"{symbol} is not a Stock or Coin") - - return replies - - def cap_reply(self, symbols: list[Symbol]) -> list[str]: - """Gets market cap for each symbol in the list - - Parameters - ---------- - symbols : list[str] - List of stock symbols - - Returns - ------- - Dict[str, str] - Each symbol passed in is a key with its value being a human readable - formatted string of the symbols market cap. - """ - replies = [] - - for symbol in symbols: - if isinstance(symbol, Stock): - replies.append(self.stock.cap_reply(symbol)) - elif isinstance(symbol, Coin): - replies.append(self.crypto.cap_reply(symbol)) - else: - log.debug(f"{symbol} is not a Stock or Coin") - - return replies - - def spark_reply(self, symbols: list[Symbol]) -> list[str]: - """Gets change for each symbol and returns it in a compact format - - Parameters - ---------- - symbols : list[str] - List of stock symbols - - Returns - ------- - list[str] - List of human readable strings. - """ - replies = [] - - for symbol in symbols: - if isinstance(symbol, Stock): - replies.append(self.stock.spark_reply(symbol)) - elif isinstance(symbol, Coin): - replies.append(self.crypto.spark_reply(symbol)) - else: - log.debug(f"{symbol} is not a Stock or Coin") - - return replies - - @cached(cache=TTLCache(maxsize=1024, ttl=600)) - def trending(self) -> str: - """Checks APIs for trending symbols. - - Returns - ------- - list[str] - List of preformatted strings to be sent to user. - """ - - # stocks = self.stock.trending() - coins = self.crypto.trending() - - reply = "" - - log.warning(self.trending_count) - if self.trending_count: - reply += "🔥Trending on the Stock Bot:\n`" - reply += "━" * len("Trending on the Stock Bot:") + "`\n" - - sorted_trending = [s[0] for s in sorted(self.trending_count.items(), key=lambda item: item[1])][::-1][0:5] - log.warning(sorted_trending) - for t in sorted_trending: - reply += self.spark_reply(self.find_symbols(t))[0] + "\n" - - if coins: - reply += "\n\n🦎Trending on CoinGecko:\n`" - reply += "━" * len("Trending on CoinGecko:") + "`\n" - for coin in coins: - reply += coin + "\n" - - if "`$GME" in reply: - reply = reply.replace("🔥", "🦍") - - if reply: - return reply - else: - log.warning("Failed to collect trending data.") - return "Trending data is not currently available." - - def random_pick(self) -> str: - # choice = random.choice(list(self.stock.symbol_list["description"]) + list(self.crypto.symbol_list["description"])) - choice = random.choice(list(self.crypto.symbol_list["description"])) - hold = (datetime.date.today() + datetime.timedelta(random.randint(1, 365))).strftime("%b %d, %Y") - - return f"{choice}\nBuy and hold until: {hold}" - - def batch_price_reply(self, symbols: list[Symbol]) -> list[str]: - """Returns current market price or after hours if its available for a given stock symbol. - - Parameters - ---------- - symbols : list - List of stock 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. - """ - replies = [] - stocks = [] - coins = [] - - for symbol in symbols: - if isinstance(symbol, Stock): - stocks.append(symbol) - elif isinstance(symbol, Coin): - coins.append(symbol) - else: - log.debug(f"{symbol} is not a Stock or Coin") - - if stocks: - for stock in stocks: - replies.append(self.stock.price_reply(stock)) - if coins: - replies = replies + self.crypto.batch_price(coins) - - return replies - - def options(self, request: str, symbols: list[Symbol]) -> Dict: - request = request.lower() - if len(symbols) == 1: - symbol = symbols[0] - request = request.replace(symbol.tag.lower(), symbol.symbol.lower()) - return self.stock.options_reply(request) - else: - return self.stock.options_reply(request) +"""Function that routes symbols to the correct API provider. +""" + +import datetime +import logging +import random +import re +from typing import Dict + +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 + +log = logging.getLogger(__name__) + + +class Router: + STOCK_REGEX = "(?:^|[^\\$])\\$([a-zA-Z.]{1,6})" + CRYPTO_REGEX = "[$]{2}([a-zA-Z]{1,20})" + trending_count: Dict[str, float] = {} + + def __init__(self): + self.stock = MarketData() + self.crypto = cg_Crypto() + + schedule.every().hour.do(self.trending_decay) + + def trending_decay(self, decay=0.5): + """Decays the value of each trending stock by a multiplier""" + t_copy = {} + dead_keys = [] + if self.trending_count: + t_copy = self.trending_count.copy() + for key in t_copy.keys(): + if t_copy[key] < 0.01: + # Prune Keys + dead_keys.append(key) + else: + t_copy[key] = t_copy[key] * decay + for dead in dead_keys: + t_copy.pop(dead) + + self.trending_count = t_copy.copy() + log.info("Decayed trending symbols.") + + def find_symbols(self, text: str, *, trending_weight: int = 1) -> list[Stock | Coin]: + """Finds stock tickers starting with a dollar sign, and cryptocurrencies with two dollar signs + in a blob of text and returns them in a list. + + Parameters + ---------- + text : str + Blob of text. + + Returns + ------- + list[Symbol] + List of stock symbols as Symbol objects + """ + schedule.run_pending() + + symbols: list[Symbol] = [] + stock_matches = set(re.findall(self.STOCK_REGEX, text)) + coin_matches = set(re.findall(self.CRYPTO_REGEX, text)) + + for stock_match in stock_matches: + # Market data lacks tools to check if a symbol is valid. + if stock_info := self.stock.symbol_id(stock_match): + symbols.append(Stock(stock_info)) + else: + log.info(f"{stock_match} is not in list of stocks") + + for coin_match in coin_matches: + sym = self.crypto.symbol_list[self.crypto.symbol_list["symbol"].str.fullmatch(coin_match.lower(), case=False)] + if sym.empty: + log.info(f"{coin_match} is not in list of coins") + else: + symbols.append(Coin(sym)) + if symbols: + for symbol in symbols: + self.trending_count[symbol.tag] = self.trending_count.get(symbol.tag, 0) + trending_weight + log.debug(self.trending_count) + + return symbols + + def status(self, bot_resp) -> str: + """Checks for any issues with APIs. + + Returns + ------- + str + Human readable text on status of the bot and relevant APIs + """ + + stats = f""" + Bot Status: + {bot_resp} + + Stock Market Data: + {self.stock.status()} + + Cryptocurrency Data: + {self.crypto.status()} + """ + + log.warning(stats) + + return stats + + def inline_search(self, search: str, matches: int = 5) -> pd.DataFrame: + """Searches based on the shortest symbol that contains the same string as the search. + Should be very fast compared to a fuzzy search. + + Parameters + ---------- + search : str + String used to match against symbols. + + Returns + ------- + list[tuple[str, str]] + Each tuple contains: (Symbol, Issue Name). + """ + + # df = pd.concat([self.stock.symbol_list, self.crypto.symbol_list]) + df = self.crypto.symbol_list + + df = df[df["description"].str.contains(search, regex=False, case=False)].sort_values( + by="type_id", key=lambda x: x.str.len() + ) + + symbols = df.head(matches) + symbols["price_reply"] = symbols["type_id"].apply( + lambda sym: self.price_reply(self.find_symbols(sym, trending_weight=0))[0] + ) + + return symbols + + def price_reply(self, symbols: list[Symbol]) -> list[str]: + """Returns current market price or after hours if its available for a given stock symbol. + + Parameters + ---------- + symbols : list + List of stock 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. + """ + replies = [] + + for symbol in symbols: + log.info(symbol) + if isinstance(symbol, Stock): + replies.append(self.stock.price_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.price_reply(symbol)) + else: + log.info(f"{symbol} is not a Stock or Coin") + + return replies + + def info_reply(self, symbols: list) -> list[str]: + """Gets information on stock symbols. + + Parameters + ---------- + symbols : list[str] + List of stock symbols. + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable formatted + string of the symbols information. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.info_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.info_reply(symbol)) + else: + log.debug(f"{symbol} is not a Stock or Coin") + + return replies + + def intra_reply(self, symbol: Symbol) -> 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 isinstance(symbol, Stock): + return self.stock.intra_reply(symbol) + elif isinstance(symbol, Coin): + return self.crypto.intra_reply(symbol) + else: + log.debug(f"{symbol} is not a Stock or Coin") + return pd.DataFrame() + + def chart_reply(self, symbol: Symbol) -> 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 isinstance(symbol, Stock): + return self.stock.chart_reply(symbol) + elif isinstance(symbol, Coin): + return self.crypto.chart_reply(symbol) + else: + log.debug(f"{symbol} is not a Stock or Coin") + return pd.DataFrame() + + def stat_reply(self, symbols: list[Symbol]) -> list[str]: + """Gets key statistics for each symbol in the list + + Parameters + ---------- + symbols : list[str] + List of stock symbols + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + formatted string of the symbols statistics. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.stat_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.stat_reply(symbol)) + else: + log.debug(f"{symbol} is not a Stock or Coin") + + return replies + + def cap_reply(self, symbols: list[Symbol]) -> list[str]: + """Gets market cap for each symbol in the list + + Parameters + ---------- + symbols : list[str] + List of stock symbols + + Returns + ------- + Dict[str, str] + Each symbol passed in is a key with its value being a human readable + formatted string of the symbols market cap. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.cap_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.cap_reply(symbol)) + else: + log.debug(f"{symbol} is not a Stock or Coin") + + return replies + + def spark_reply(self, symbols: list[Symbol]) -> list[str]: + """Gets change for each symbol and returns it in a compact format + + Parameters + ---------- + symbols : list[str] + List of stock symbols + + Returns + ------- + list[str] + List of human readable strings. + """ + replies = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + replies.append(self.stock.spark_reply(symbol)) + elif isinstance(symbol, Coin): + replies.append(self.crypto.spark_reply(symbol)) + else: + log.debug(f"{symbol} is not a Stock or Coin") + + return replies + + @cached(cache=TTLCache(maxsize=1024, ttl=600)) + def trending(self) -> str: + """Checks APIs for trending symbols. + + Returns + ------- + list[str] + List of preformatted strings to be sent to user. + """ + + # stocks = self.stock.trending() + coins = self.crypto.trending() + + reply = "" + + log.warning(self.trending_count) + if self.trending_count: + reply += "🔥Trending on the Stock Bot:\n`" + reply += "━" * len("Trending on the Stock Bot:") + "`\n" + + sorted_trending = [s[0] for s in sorted(self.trending_count.items(), key=lambda item: item[1])][::-1][0:5] + log.warning(sorted_trending) + for t in sorted_trending: + reply += self.spark_reply(self.find_symbols(t))[0] + "\n" + + if coins: + reply += "\n\n🦎Trending on CoinGecko:\n`" + reply += "━" * len("Trending on CoinGecko:") + "`\n" + for coin in coins: + reply += coin + "\n" + + if "`$GME" in reply: + reply = reply.replace("🔥", "🦍") + + if reply: + return reply + else: + log.warning("Failed to collect trending data.") + return "Trending data is not currently available." + + def random_pick(self) -> str: + # choice = random.choice(list(self.stock.symbol_list["description"]) + list(self.crypto.symbol_list["description"])) + choice = random.choice(list(self.crypto.symbol_list["description"])) + hold = (datetime.date.today() + datetime.timedelta(random.randint(1, 365))).strftime("%b %d, %Y") + + return f"{choice}\nBuy and hold until: {hold}" + + def batch_price_reply(self, symbols: list[Symbol]) -> list[str]: + """Returns current market price or after hours if its available for a given stock symbol. + + Parameters + ---------- + symbols : list + List of stock 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. + """ + replies = [] + stocks = [] + coins = [] + + for symbol in symbols: + if isinstance(symbol, Stock): + stocks.append(symbol) + elif isinstance(symbol, Coin): + coins.append(symbol) + else: + log.debug(f"{symbol} is not a Stock or Coin") + + if stocks: + for stock in stocks: + replies.append(self.stock.price_reply(stock)) + if coins: + replies = replies + self.crypto.batch_price(coins) + + return replies + + def options(self, request: str, symbols: list[Symbol]) -> Dict: + request = request.lower() + if len(symbols) == 1: + symbol = symbols[0] + request = request.replace(symbol.tag.lower(), symbol.symbol.lower()) + return self.stock.options_reply(request) + else: + return self.stock.options_reply(request) diff --git a/common/utilities.py b/common/utilities.py index 6e0e5ca..6a19019 100644 --- a/common/utilities.py +++ b/common/utilities.py @@ -1,31 +1,31 @@ -import time -import logging - -log = logging.getLogger(__name__) - - -def rate_limited(max_per_second): - """ - Decorator that ensures the wrapped function is called at most `max_per_second` times per second. - """ - min_interval = 1.0 / max_per_second - - def decorate(func): - last_called = [0.0] - - def rate_limited_function(*args, **kwargs): - elapsed = time.time() - last_called[0] - left_to_wait = min_interval - elapsed - - if left_to_wait > 0: - log.info(f"Rate limit exceeded. Waiting for {left_to_wait:.2f} seconds.") - time.sleep(left_to_wait) - - ret = func(*args, **kwargs) - last_called[0] = time.time() - - return ret - - return rate_limited_function - - return decorate +import time +import logging + +log = logging.getLogger(__name__) + + +def rate_limited(max_per_second): + """ + Decorator that ensures the wrapped function is called at most `max_per_second` times per second. + """ + min_interval = 1.0 / max_per_second + + def decorate(func): + last_called = [0.0] + + def rate_limited_function(*args, **kwargs): + elapsed = time.time() - last_called[0] + left_to_wait = min_interval - elapsed + + if left_to_wait > 0: + log.info(f"Rate limit exceeded. Waiting for {left_to_wait:.2f} seconds.") + time.sleep(left_to_wait) + + ret = func(*args, **kwargs) + last_called[0] = time.time() + + return ret + + return rate_limited_function + + return decorate diff --git a/dev-reqs.txt b/dev-reqs.txt index 77b12b9..afba48d 100644 --- a/dev-reqs.txt +++ b/dev-reqs.txt @@ -1,6 +1,5 @@ -r common/requirements.txt -r site/requirements.txt -black==23.9.1 ipython==8.16.1 jupyter_client==8.4.0 jupyter_core==5.4.0 @@ -8,5 +7,4 @@ pylama==8.4.1 mypy==1.5.1 types-cachetools==5.3.0.6 types-pytz==2023.3.1.1 -ruff==0.0.292 -isort==5.12.0 \ No newline at end of file +ruff==0.1.6 \ No newline at end of file diff --git a/discord/D_info.py b/discord/D_info.py index 34dadb6..1508136 100644 --- a/discord/D_info.py +++ b/discord/D_info.py @@ -1,59 +1,59 @@ -"""Functions and Info specific to the discord Bot -""" - -import re - -import requests as r - - -class D_info: - license = re.sub( - r"\b\n", - " ", - r.get("https://gitlab.com/simple-stock-bots/simple-stock-bot/-/raw/master/LICENSE").text, - ) - - help_text = """ -Thanks for using this bot. If you like it, [support me with a beer](https://www.buymeacoffee.com/Anson). 🍻 - -For stock data or hosting your own bot, use my link. This helps keep the bot free: -[marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=discord). - -**Updates**: Join the bot's discord: https://t.me/simplestockbotnews. - -**Documentation**: All details about the bot are at [docs](https://simplestockbot.com). - -The bot reads _"Symbols"_. Use `$` for stock tickers and `$$` for cryptocurrencies. For example: -- `/chart $$eth` gives Ethereum's monthly chart. -- `/dividend $psec` shows Prospect Capital's dividend. - -Type any symbol, and the bot shows its price. Like: `Is $$btc rising since $tsla accepts it?` will give Bitcoin and Tesla prices. - -**Commands** -- `/donate [USD amount]`: Support the bot. 🎗️ -- `/intra $[symbol]`: See stock's latest movement. 📈 -- `/chart $[symbol]`: View a month's stock activity. 📊 -- `/trending`: Check trending stocks and cryptos. 💬 -- `/help`: Need help? Ask here. 🆘 - -**Inline Features** -Type @SimpleStockBot `[search]` anywhere to find and get stock/crypto prices. Note: Prices might be delayed up to an hour. - -Data from: [marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=discord). - -Issues with the bot? Use `/status` or [contact us](https://simplestockbot.com/contact). - """ - - donate_text = """ -Simple Stock Bot runs purely on [donations.](https://www.buymeacoffee.com/Anson) -Every donation supports server costs and -[marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=discord) provides our data. - -**How to Donate?** -1. Use `/donate [amount in USD]` command. - - E.g., `/donate 2` donates 2 USD. -2. Or, donate at [buymeacoffee](https://www.buymeacoffee.com/Anson). - - It's quick, doesn't need an account, and accepts Paypal or Credit card. - -Questions? Visit our [website](https://simplestockbot.com). -""" +"""Functions and Info specific to the discord Bot +""" + +import re + +import requests as r + + +class D_info: + license = re.sub( + r"\b\n", + " ", + r.get("https://gitlab.com/simple-stock-bots/simple-stock-bot/-/raw/master/LICENSE").text, + ) + + help_text = """ +Thanks for using this bot. If you like it, [support me with a beer](https://www.buymeacoffee.com/Anson). 🍻 + +For stock data or hosting your own bot, use my link. This helps keep the bot free: +[marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=discord). + +**Updates**: Join the bot's discord: https://t.me/simplestockbotnews. + +**Documentation**: All details about the bot are at [docs](https://simplestockbot.com). + +The bot reads _"Symbols"_. Use `$` for stock tickers and `$$` for cryptocurrencies. For example: +- `/chart $$eth` gives Ethereum's monthly chart. +- `/dividend $psec` shows Prospect Capital's dividend. + +Type any symbol, and the bot shows its price. Like: `Is $$btc rising since $tsla accepts it?` will give Bitcoin and Tesla prices. + +**Commands** +- `/donate [USD amount]`: Support the bot. 🎗️ +- `/intra $[symbol]`: See stock's latest movement. 📈 +- `/chart $[symbol]`: View a month's stock activity. 📊 +- `/trending`: Check trending stocks and cryptos. 💬 +- `/help`: Need help? Ask here. 🆘 + +**Inline Features** +Type @SimpleStockBot `[search]` anywhere to find and get stock/crypto prices. Note: Prices might be delayed up to an hour. + +Data from: [marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=discord). + +Issues with the bot? Use `/status` or [contact us](https://simplestockbot.com/contact). + """ + + donate_text = """ +Simple Stock Bot runs purely on [donations.](https://www.buymeacoffee.com/Anson) +Every donation supports server costs and +[marketdata.app](https://dashboard.marketdata.app/marketdata/aff/go/misterbiggs?keyword=discord) provides our data. + +**How to Donate?** +1. Use `/donate [amount in USD]` command. + - E.g., `/donate 2` donates 2 USD. +2. Or, donate at [buymeacoffee](https://www.buymeacoffee.com/Anson). + - It's quick, doesn't need an account, and accepts Paypal or Credit card. + +Questions? Visit our [website](https://simplestockbot.com). +""" diff --git a/discord/bot.py b/discord/bot.py index eeb9cab..3054400 100644 --- a/discord/bot.py +++ b/discord/bot.py @@ -1,256 +1,256 @@ -import datetime -import io -import logging -import os - -import mplfinance as mpf -import nextcord -from D_info import D_info -from nextcord.ext import commands - -from common.symbol_router import Router - -DISCORD_TOKEN = os.environ["DISCORD"] - -s = Router() -d = D_info() - - -intents = nextcord.Intents.default() - - -client = nextcord.Client(intents=intents) -bot = commands.Bot(command_prefix="/", description=d.help_text, intents=intents) - -logger = logging.getLogger("nextcord") -logger.setLevel(logging.INFO) -handler = logging.FileHandler(filename="nextcord.log", encoding="utf-8", mode="w") -handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) -logger.addHandler(handler) - - -@bot.event -async def on_ready(): - logging.info("Starting Simple Stock Bot") - logging.info(f"Logged in as {bot.user.name} {bot.user.id}") - - -@bot.command() -async def status(ctx: commands): - """Debug command for diagnosing if the bot is experiencing any issues.""" - logging.info(f"Status command ran by {ctx.message.author}") - message = "" - try: - message = "Contact MisterBiggs#0465 if you need help.\n" - message += s.status(f"Bot recieved your message in: {bot.latency*10:.4f} seconds") + "\n" - - except Exception as ex: - logging.critical(ex) - message += ( - f"*\n\nERROR ENCOUNTERED:*\n{ex}\n\n" - + "*The bot encountered an error while attempting to find errors. Please contact the bot admin.*" - ) - await ctx.send(message) - - -@bot.command() -async def license(ctx: commands): - """Returns the bots license agreement.""" - await ctx.send(d.license) - - -@bot.command() -async def donate(ctx: commands): - """Details on how to support the development and hosting of the bot.""" - await ctx.send(d.donate_text) - - -@bot.command() -async def search(ctx: commands, *, query: str): - """Search for a stock symbol using either symbol of company name.""" - results = s.search_symbols(query) - if results: - reply = "*Search Results:*\n`$ticker: Company Name`\n" - for query in results: - reply += "`" + query[1] + "`\n" - await ctx.send(reply) - - -@bot.command() -async def crypto(ctx: commands, _: str): - """Get the price of a cryptocurrency using in USD.""" - await ctx.send("Crypto now has native support. Any crypto can be called using two dollar signs: `$$eth` `$$btc` `$$doge`") - - -@bot.command() -async def intra(ctx: commands, sym: str): - """Get a chart for the stocks movement since market open.""" - symbols = s.find_symbols(sym) - - if len(symbols): - symbol = symbols[0] - else: - await ctx.send("No symbols or coins found.") - return - - df = s.intra_reply(symbol) - if df.empty: - await ctx.send("Invalid symbol please see `/help` for usage details.") - return - with ctx.channel.typing(): - buf = io.BytesIO() - mpf.plot( - df, - type="renko", - title=f"\n{symbol.name}", - volume="volume" in df.keys(), - style="yahoo", - savefig=dict(fname=buf, dpi=400, bbox_inches="tight"), - ) - - buf.seek(0) - - # Get price so theres no request lag after the image is sent - price_reply = s.price_reply([symbol])[0] - await ctx.send( - file=nextcord.File( - buf, - filename=f"{symbol.name}:intra{datetime.date.today().strftime('%S%M%d%b%Y')}.png", - ), - content=f"\nIntraday chart for {symbol.name} from {df.first_valid_index().strftime('%d %b at %H:%M')} to" - + f" {df.last_valid_index().strftime('%d %b at %H:%M')}", - ) - await ctx.send(price_reply) - - -@bot.command() -async def chart(ctx: commands, sym: str): - """returns a chart of the past month of data for a symbol""" - - symbols = s.find_symbols(sym) - - if len(symbols): - symbol = symbols[0] - else: - await ctx.send("No symbols or coins found.") - return - - df = s.chart_reply(symbol) - if df.empty: - await ctx.send("Invalid symbol please see `/help` for usage details.") - return - with ctx.channel.typing(): - buf = io.BytesIO() - mpf.plot( - df, - type="candle", - title=f"\n{symbol.name}", - volume="volume" in df.keys(), - style="yahoo", - savefig=dict(fname=buf, dpi=400, bbox_inches="tight"), - ) - buf.seek(0) - - # Get price so theres no request lag after the image is sent - price_reply = s.price_reply([symbol])[0] - await ctx.send( - file=nextcord.File( - buf, - filename=f"{symbol.name}:1M{datetime.date.today().strftime('%d%b%Y')}.png", - ), - content=f"\n1 Month chart for {symbol.name} from {df.first_valid_index().strftime('%d, %b %Y')}" - + f" to {df.last_valid_index().strftime('%d, %b %Y')}", - ) - await ctx.send(price_reply) - - -@bot.command() -async def cap(ctx: commands, sym: str): - """Get the market cap of a symbol""" - symbols = s.find_symbols(sym) - if symbols: - with ctx.channel.typing(): - for reply in s.cap_reply(symbols): - await ctx.send(reply) - - -@bot.command() -async def trending(ctx: commands): - """Get a list of Trending Stocks and Coins""" - with ctx.channel.typing(): - await ctx.send(s.trending()) - - -@bot.event -async def on_message(message): - # Ignore messages from the bot itself - if message.author.id == bot.user.id: - return - - content_lower = message.content.lower() - - # Process commands starting with "/" - if message.content.startswith("/"): - await bot.process_commands(message) - return - - symbols = None - if "$" in message.content: - symbols = s.find_symbols(message.content) - - if "call" in content_lower or "put" in content_lower: - await handle_options(message, symbols) - return - - if symbols: - for reply in s.price_reply(symbols): - await message.channel.send(reply) - return - - -async def handle_options(message, symbols): - logging.info("Options detected") - try: - options_data = s.options(message.content.lower(), symbols) - - # Create the embed directly within the function - embed = nextcord.Embed(title=options_data["Option Symbol"], description=options_data["Underlying"], color=0x3498DB) - - # Key details - details = ( - f"Expiration: {options_data['Expiration']}\n" f"Side: {options_data['side']}\n" f"Strike: {options_data['strike']}" - ) - embed.add_field(name="Details", value=details, inline=False) - - # Pricing info - pricing_info = ( - f"Bid: {options_data['bid']} (Size: {options_data['bidSize']})\n" - f"Mid: {options_data['mid']}\n" - f"Ask: {options_data['ask']} (Size: {options_data['askSize']})\n" - f"Last: {options_data['last']}" - ) - embed.add_field(name="Pricing", value=pricing_info, inline=False) - - # Volume and open interest - volume_info = f"Open Interest: {options_data['Open Interest']}\n" f"Volume: {options_data['Volume']}" - embed.add_field(name="Activity", value=volume_info, inline=False) - - # Greeks - greeks_info = ( - f"IV: {options_data['Implied Volatility']}\n" - f"Delta: {options_data['delta']}\n" - f"Gamma: {options_data['gamma']}\n" - f"Theta: {options_data['theta']}\n" - f"Vega: {options_data['vega']}\n" - f"Rho: {options_data['rho']}" - ) - embed.add_field(name="Greeks", value=greeks_info, inline=False) - - # Send the created embed - await message.channel.send(embed=embed) - - except KeyError as ex: - logging.warning(f"KeyError processing options for message {message.content}: {ex}") - - -bot.run(DISCORD_TOKEN) +import datetime +import io +import logging +import os + +import mplfinance as mpf +import nextcord +from D_info import D_info +from nextcord.ext import commands + +from common.symbol_router import Router + +DISCORD_TOKEN = os.environ["DISCORD"] + +s = Router() +d = D_info() + + +intents = nextcord.Intents.default() + + +client = nextcord.Client(intents=intents) +bot = commands.Bot(command_prefix="/", description=d.help_text, intents=intents) + +logger = logging.getLogger("nextcord") +logger.setLevel(logging.INFO) +handler = logging.FileHandler(filename="nextcord.log", encoding="utf-8", mode="w") +handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) +logger.addHandler(handler) + + +@bot.event +async def on_ready(): + logging.info("Starting Simple Stock Bot") + logging.info(f"Logged in as {bot.user.name} {bot.user.id}") + + +@bot.command() +async def status(ctx: commands): + """Debug command for diagnosing if the bot is experiencing any issues.""" + logging.info(f"Status command ran by {ctx.message.author}") + message = "" + try: + message = "Contact MisterBiggs#0465 if you need help.\n" + message += s.status(f"Bot recieved your message in: {bot.latency*10:.4f} seconds") + "\n" + + except Exception as ex: + logging.critical(ex) + message += ( + f"*\n\nERROR ENCOUNTERED:*\n{ex}\n\n" + + "*The bot encountered an error while attempting to find errors. Please contact the bot admin.*" + ) + await ctx.send(message) + + +@bot.command() +async def license(ctx: commands): + """Returns the bots license agreement.""" + await ctx.send(d.license) + + +@bot.command() +async def donate(ctx: commands): + """Details on how to support the development and hosting of the bot.""" + await ctx.send(d.donate_text) + + +@bot.command() +async def search(ctx: commands, *, query: str): + """Search for a stock symbol using either symbol of company name.""" + results = s.search_symbols(query) + if results: + reply = "*Search Results:*\n`$ticker: Company Name`\n" + for query in results: + reply += "`" + query[1] + "`\n" + await ctx.send(reply) + + +@bot.command() +async def crypto(ctx: commands, _: str): + """Get the price of a cryptocurrency using in USD.""" + await ctx.send("Crypto now has native support. Any crypto can be called using two dollar signs: `$$eth` `$$btc` `$$doge`") + + +@bot.command() +async def intra(ctx: commands, sym: str): + """Get a chart for the stocks movement since market open.""" + symbols = s.find_symbols(sym) + + if len(symbols): + symbol = symbols[0] + else: + await ctx.send("No symbols or coins found.") + return + + df = s.intra_reply(symbol) + if df.empty: + await ctx.send("Invalid symbol please see `/help` for usage details.") + return + with ctx.channel.typing(): + buf = io.BytesIO() + mpf.plot( + df, + type="renko", + title=f"\n{symbol.name}", + volume="volume" in df.keys(), + style="yahoo", + savefig=dict(fname=buf, dpi=400, bbox_inches="tight"), + ) + + buf.seek(0) + + # Get price so theres no request lag after the image is sent + price_reply = s.price_reply([symbol])[0] + await ctx.send( + file=nextcord.File( + buf, + filename=f"{symbol.name}:intra{datetime.date.today().strftime('%S%M%d%b%Y')}.png", + ), + content=f"\nIntraday chart for {symbol.name} from {df.first_valid_index().strftime('%d %b at %H:%M')} to" + + f" {df.last_valid_index().strftime('%d %b at %H:%M')}", + ) + await ctx.send(price_reply) + + +@bot.command() +async def chart(ctx: commands, sym: str): + """returns a chart of the past month of data for a symbol""" + + symbols = s.find_symbols(sym) + + if len(symbols): + symbol = symbols[0] + else: + await ctx.send("No symbols or coins found.") + return + + df = s.chart_reply(symbol) + if df.empty: + await ctx.send("Invalid symbol please see `/help` for usage details.") + return + with ctx.channel.typing(): + buf = io.BytesIO() + mpf.plot( + df, + type="candle", + title=f"\n{symbol.name}", + volume="volume" in df.keys(), + style="yahoo", + savefig=dict(fname=buf, dpi=400, bbox_inches="tight"), + ) + buf.seek(0) + + # Get price so theres no request lag after the image is sent + price_reply = s.price_reply([symbol])[0] + await ctx.send( + file=nextcord.File( + buf, + filename=f"{symbol.name}:1M{datetime.date.today().strftime('%d%b%Y')}.png", + ), + content=f"\n1 Month chart for {symbol.name} from {df.first_valid_index().strftime('%d, %b %Y')}" + + f" to {df.last_valid_index().strftime('%d, %b %Y')}", + ) + await ctx.send(price_reply) + + +@bot.command() +async def cap(ctx: commands, sym: str): + """Get the market cap of a symbol""" + symbols = s.find_symbols(sym) + if symbols: + with ctx.channel.typing(): + for reply in s.cap_reply(symbols): + await ctx.send(reply) + + +@bot.command() +async def trending(ctx: commands): + """Get a list of Trending Stocks and Coins""" + with ctx.channel.typing(): + await ctx.send(s.trending()) + + +@bot.event +async def on_message(message): + # Ignore messages from the bot itself + if message.author.id == bot.user.id: + return + + content_lower = message.content.lower() + + # Process commands starting with "/" + if message.content.startswith("/"): + await bot.process_commands(message) + return + + symbols = None + if "$" in message.content: + symbols = s.find_symbols(message.content) + + if "call" in content_lower or "put" in content_lower: + await handle_options(message, symbols) + return + + if symbols: + for reply in s.price_reply(symbols): + await message.channel.send(reply) + return + + +async def handle_options(message, symbols): + logging.info("Options detected") + try: + options_data = s.options(message.content.lower(), symbols) + + # Create the embed directly within the function + embed = nextcord.Embed(title=options_data["Option Symbol"], description=options_data["Underlying"], color=0x3498DB) + + # Key details + details = ( + f"Expiration: {options_data['Expiration']}\n" f"Side: {options_data['side']}\n" f"Strike: {options_data['strike']}" + ) + embed.add_field(name="Details", value=details, inline=False) + + # Pricing info + pricing_info = ( + f"Bid: {options_data['bid']} (Size: {options_data['bidSize']})\n" + f"Mid: {options_data['mid']}\n" + f"Ask: {options_data['ask']} (Size: {options_data['askSize']})\n" + f"Last: {options_data['last']}" + ) + embed.add_field(name="Pricing", value=pricing_info, inline=False) + + # Volume and open interest + volume_info = f"Open Interest: {options_data['Open Interest']}\n" f"Volume: {options_data['Volume']}" + embed.add_field(name="Activity", value=volume_info, inline=False) + + # Greeks + greeks_info = ( + f"IV: {options_data['Implied Volatility']}\n" + f"Delta: {options_data['delta']}\n" + f"Gamma: {options_data['gamma']}\n" + f"Theta: {options_data['theta']}\n" + f"Vega: {options_data['vega']}\n" + f"Rho: {options_data['rho']}" + ) + embed.add_field(name="Greeks", value=greeks_info, inline=False) + + # Send the created embed + await message.channel.send(embed=embed) + + except KeyError as ex: + logging.warning(f"KeyError processing options for message {message.content}: {ex}") + + +bot.run(DISCORD_TOKEN) diff --git a/docker-compose.yaml b/docker-compose.yaml index ac7190c..2aceb0b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,12 +1,12 @@ -version: "3" -services: - telegram: - build: - context: . - dockerfile: telegram/Dockerfile - env_file: .env - discord: - build: - context: . - dockerfile: discord/Dockerfile - env_file: .env +version: "3" +services: + telegram: + build: + context: . + dockerfile: telegram/Dockerfile + env_file: .env + discord: + build: + context: . + dockerfile: discord/Dockerfile + env_file: .env diff --git a/package-lock.json b/package-lock.json index db1dce8..a7a469d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ -{ - "name": "simple-stock-bot", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "devDependencies": { - "prettier": "3.0.3" - } - }, - "node_modules/prettier": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", - "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - } - } -} +{ + "name": "simple-stock-bot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "prettier": "3.0.3" + } + }, + "node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json index debdc5f..4763dcc 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ -{ - "devDependencies": { - "prettier": "3.0.3" - } -} +{ + "devDependencies": { + "prettier": "3.0.3" + } +} diff --git a/pyproject.toml b/pyproject.toml index 8c189a5..c4799a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,2 @@ -[tool.black] -line-length = 130 - -[tool.flake8] -max-line-length = 130 - -[tool.pycodestyle] -max_line_length = 130 - [tool.ruff] line-length = 130 \ No newline at end of file diff --git a/site/.gitlab-ci.yml b/site/.gitlab-ci.yml index a6c1060..04f3121 100644 --- a/site/.gitlab-ci.yml +++ b/site/.gitlab-ci.yml @@ -1,23 +1,23 @@ -image: python:3.11 - -build_mkdocs: - stage: build_site - script: - - cd ./site - - pip install -r requirements.txt - - mkdocs build --site-dir ../public --verbose - artifacts: - paths: - - public - -pages: - stage: deploy - script: - - echo "Publishing site..." - dependencies: - - build_mkdocs - artifacts: - paths: - - public - rules: - - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" +image: python:3.11 + +build_mkdocs: + stage: build_site + script: + - cd ./site + - pip install -r requirements.txt + - mkdocs build --site-dir ../public --verbose + artifacts: + paths: + - public + +pages: + stage: deploy + script: + - echo "Publishing site..." + dependencies: + - build_mkdocs + artifacts: + paths: + - public + rules: + - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" diff --git a/site/.vscode/settings.json b/site/.vscode/settings.json index 188058d..a496243 100644 --- a/site/.vscode/settings.json +++ b/site/.vscode/settings.json @@ -1,12 +1,12 @@ -{ - "yaml.schemas": { - "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" - }, - "yaml.customTags": [ - "!ENV scalar", - "!ENV sequence", - "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", - "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", - "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" - ] -} +{ + "yaml.schemas": { + "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" + }, + "yaml.customTags": [ + "!ENV scalar", + "!ENV sequence", + "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", + "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", + "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" + ] +} diff --git a/site/docs/LICENSE.md b/site/docs/LICENSE.md index b32cd29..6f0d1b9 100644 --- a/site/docs/LICENSE.md +++ b/site/docs/LICENSE.md @@ -1,23 +1,23 @@ -``` -MIT License - -Copyright (c) 2019 Anson Biggs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` +``` +MIT License + +Copyright (c) 2019 Anson Biggs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/site/docs/blog/.authors.yml b/site/docs/blog/.authors.yml index 54912d9..d680290 100644 --- a/site/docs/blog/.authors.yml +++ b/site/docs/blog/.authors.yml @@ -1,5 +1,5 @@ -authors: - Anson: - name: Anson Biggs - description: Creator - avatar: https://gitlab.com/uploads/-/system/user/avatar/1377308/avatar.png +authors: + Anson: + name: Anson Biggs + description: Creator + avatar: https://gitlab.com/uploads/-/system/user/avatar/1377308/avatar.png diff --git a/site/docs/blog/index.md b/site/docs/blog/index.md index 05761ac..d93b343 100644 --- a/site/docs/blog/index.md +++ b/site/docs/blog/index.md @@ -1 +1 @@ -# Blog +# Blog diff --git a/site/docs/blog/posts/intro.md b/site/docs/blog/posts/intro.md index 0b8c652..763c77b 100644 --- a/site/docs/blog/posts/intro.md +++ b/site/docs/blog/posts/intro.md @@ -1,66 +1,66 @@ ---- -title: "Introducing Simple Stock Bot: Your Chat Group's Financial Whiz" -date: 2023-10-10 -tags: [Simple Stock Bot, Introduction, Telegram, Discord, Financial Insights] -authors: [Anson] -description: > - Welcome to the world of Simple Stock Bot, where real-time financial insights are integrated seamlessly into your group chats on Telegram and Discord! ---- - -## What is Simple Stock Bot? - -Simple Stock Bot is a chatbot designed to enrich your financial discussions on Telegram and Discord. Regardless of whether you're a seasoned trader or a beginner in the stock market, this bot is here to make your discussions more informative and engaging. - -
This project is now archived!
++ Please + click here for more information. +
+