├── src ├── api │ ├── __init__.py │ ├── coin360.py │ ├── benzinga.py │ ├── fear_greed.py │ ├── barchart.py │ ├── farside.py │ ├── unusualwhales.py │ ├── play2earn.py │ ├── timeline.py │ ├── stocktwits.py │ ├── opensea.py │ ├── http_client.py │ ├── ccxt.py │ ├── investing.py │ ├── cryptocraft.py │ ├── cmc.py │ ├── nasdaq.py │ └── reddit.py ├── cogs │ ├── __init__.py │ ├── loops │ │ ├── __init__.py │ │ ├── losers.py │ │ ├── funding.py │ │ ├── stock_halts.py │ │ ├── stocktwits.py │ │ ├── gainers.py │ │ ├── spy_heatmap.py │ │ ├── earnings_overview.py │ │ ├── trades.py │ │ ├── sector_snapshot.py │ │ ├── events.py │ │ ├── treemap.py │ │ ├── index.py │ │ ├── yield.py │ │ ├── listings.py │ │ ├── reddit.py │ │ ├── ideas.py │ │ └── liquidations.py │ ├── commands │ │ ├── __init__.py │ │ ├── restart.py │ │ ├── earnings.py │ │ ├── analyze.py │ │ └── help.py │ └── listeners │ │ ├── __init__.py │ │ ├── on_member_join.py │ │ └── on_raw_reaction_add.py ├── models │ ├── __init__.py │ ├── chart.py │ └── sentiment.py ├── util │ ├── __init__.py │ ├── vars.py │ ├── afterhours.py │ ├── confirm_stock.py │ ├── ticker_classifier.py │ ├── exchange_data.py │ └── trades_msg.py ├── constants │ ├── __init__.py │ ├── config.py │ ├── stable_coins.py │ ├── logger.py │ ├── sources.py │ └── tradingview.py └── main.py ├── img ├── icons │ ├── cmc.ico │ ├── finviz.png │ ├── kucoin.png │ ├── reddit.png │ ├── yahoo.png │ ├── barchart.png │ ├── binance.png │ ├── coin360.png │ ├── coinbase.png │ ├── opensea.png │ ├── twitter.png │ ├── coingecko.png │ ├── coinglass.png │ ├── cryptocraft.png │ ├── investing.png │ ├── playtoearn.png │ ├── stocktwits.png │ ├── tradingview.png │ ├── nasdaqtrader.png │ └── unusualwhales.png ├── emojis │ ├── reply.png │ ├── binance.png │ ├── kucoin.png │ ├── retweet.png │ └── quote_tweet.png ├── logo │ ├── fintwit.png │ ├── fintwit-nobg.png │ ├── fintwit_old.png │ └── fintwit-banner.png └── examples │ └── tweet_example.png ├── .github ├── ISSUE_TEMPLATE │ ├── regular-issue.md │ ├── bug_report.md │ └── feature_request.md ├── workflows │ ├── isort.yaml │ ├── black-check.yml │ ├── dependency-review.yml │ ├── release-notes.yml │ └── codeql.yml ├── dependabot.yml └── FUNDING.yml ├── example.env ├── pyproject.toml ├── curl_example.txt ├── requirements.txt ├── LICENSE ├── .gitignore ├── CONTRIBUTING.md └── config.yaml /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cogs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cogs/loops/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/constants/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cogs/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cogs/listeners/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/cmc.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/cmc.ico -------------------------------------------------------------------------------- /img/emojis/reply.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/emojis/reply.png -------------------------------------------------------------------------------- /img/icons/finviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/finviz.png -------------------------------------------------------------------------------- /img/icons/kucoin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/kucoin.png -------------------------------------------------------------------------------- /img/icons/reddit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/reddit.png -------------------------------------------------------------------------------- /img/icons/yahoo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/yahoo.png -------------------------------------------------------------------------------- /img/logo/fintwit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/logo/fintwit.png -------------------------------------------------------------------------------- /img/emojis/binance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/emojis/binance.png -------------------------------------------------------------------------------- /img/emojis/kucoin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/emojis/kucoin.png -------------------------------------------------------------------------------- /img/emojis/retweet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/emojis/retweet.png -------------------------------------------------------------------------------- /img/icons/barchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/barchart.png -------------------------------------------------------------------------------- /img/icons/binance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/binance.png -------------------------------------------------------------------------------- /img/icons/coin360.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/coin360.png -------------------------------------------------------------------------------- /img/icons/coinbase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/coinbase.png -------------------------------------------------------------------------------- /img/icons/opensea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/opensea.png -------------------------------------------------------------------------------- /img/icons/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/twitter.png -------------------------------------------------------------------------------- /img/icons/coingecko.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/coingecko.png -------------------------------------------------------------------------------- /img/icons/coinglass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/coinglass.png -------------------------------------------------------------------------------- /img/icons/cryptocraft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/cryptocraft.png -------------------------------------------------------------------------------- /img/icons/investing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/investing.png -------------------------------------------------------------------------------- /img/icons/playtoearn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/playtoearn.png -------------------------------------------------------------------------------- /img/icons/stocktwits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/stocktwits.png -------------------------------------------------------------------------------- /img/icons/tradingview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/tradingview.png -------------------------------------------------------------------------------- /img/logo/fintwit-nobg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/logo/fintwit-nobg.png -------------------------------------------------------------------------------- /img/logo/fintwit_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/logo/fintwit_old.png -------------------------------------------------------------------------------- /img/emojis/quote_tweet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/emojis/quote_tweet.png -------------------------------------------------------------------------------- /img/icons/nasdaqtrader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/nasdaqtrader.png -------------------------------------------------------------------------------- /img/icons/unusualwhales.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/icons/unusualwhales.png -------------------------------------------------------------------------------- /img/logo/fintwit-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/logo/fintwit-banner.png -------------------------------------------------------------------------------- /img/examples/tweet_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/HEAD/img/examples/tweet_example.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/regular-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Regular Issue 3 | about: Any issue that's not a bug or feature request 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # Discord Bot 2 | DISCORD_TOKEN = 3 | DISCORD_GUILD = 4 | 5 | # REDDIT API 6 | REDDIT_USERNAME = 7 | REDDIT_PASSWORD = 8 | REDDIT_APP_NAME = 9 | REDDIT_PERSONAL_USE = 10 | REDDIT_SECRET = -------------------------------------------------------------------------------- /src/api/coin360.py: -------------------------------------------------------------------------------- 1 | from api.http_client import get_json_data 2 | 3 | 4 | async def get_treemap(): 5 | return await get_json_data( 6 | "https://coin360.com/site-api/coins?currency=USD&period=24h&ranking=top100" 7 | ) 8 | -------------------------------------------------------------------------------- /src/constants/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import yaml 4 | 5 | config_path = os.path.join(os.path.dirname(__file__), "..", "..", "config.yaml") 6 | with open(config_path, "r", encoding="utf-8") as f: 7 | config = yaml.full_load(f) 8 | -------------------------------------------------------------------------------- /.github/workflows/isort.yaml: -------------------------------------------------------------------------------- 1 | name: Run isort 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: isort/isort-action@v1 11 | with: 12 | requirements-files: "requirements.txt requirements-test.txt" 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | multi_line_output = 3 3 | include_trailing_comma = true 4 | force_grid_wrap = 0 5 | line_length = 88 6 | profile = "black" 7 | 8 | [tool.ruff] 9 | line-length = 88 10 | #select = ["I001"] 11 | 12 | [tool.ruff.lint.pydocstyle] 13 | # Use Google-style docstrings. 14 | convention = "numpy" 15 | -------------------------------------------------------------------------------- /.github/workflows/black-check.yml: -------------------------------------------------------------------------------- 1 | name: Run Black formatter 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: psf/black@stable 11 | with: 12 | options: "--check --verbose" 13 | src: "./src" 14 | jupyter: false 15 | -------------------------------------------------------------------------------- /curl_example.txt: -------------------------------------------------------------------------------- 1 | Replace all contents of this file with the following: 2 | 1. Go to x.com (using Chrome as browser, otherwise the cURL will be a different format) 3 | 2. Login 4 | 3. On the home timeline (click on following) press F12, this will open devtools in Chrome 5 | 4. Go to the Network section 6 | 5. Locate HomeTimeline or HomeLatestTimeline (when using Following tab) 7 | 6. Right click on HomeTimeline, copy, copy as cURL (bash) 8 | 7. Paste the contents in this .txt and rename to curl.txt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.26.4 2 | yfinance==0.2.43 3 | pandas==2.2.2 4 | requests==2.32.3 5 | PyYAML==6.0.2 6 | tradingview-ta==3.3.0 7 | aiohttp==3.10.5 8 | asyncpraw==7.7.1 9 | ccxt==4.4.2 10 | transformers==4.43.3 # Not upgrading yet 11 | matplotlib==3.9.2 12 | scipy==1.13.1 # Not upgrading to 1.14.0 13 | brotli==1.1.0 14 | py-cord==2.6.0 15 | python-dotenv==1.0.1 16 | tls-client==1.0.1 17 | uncurl==0.0.11 18 | nltk==3.9.1 19 | timm==1.0.9 20 | seaborn==0.13.2 21 | plotly==5.24.0 22 | kaleido==0.2.1 -------------------------------------------------------------------------------- /src/util/vars.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | # Init global database vars 4 | assets_db = None 5 | portfolio_db = None 6 | cg_db = None 7 | tweets_db = None 8 | options_db = None 9 | latest_tweet_id = 0 10 | 11 | # These variables save the TradingView tickers 12 | stocks = None 13 | crypto = None 14 | forex = None 15 | cfd = None 16 | 17 | nasdaq_tickers = None 18 | 19 | reddit_ids = pd.DataFrame() 20 | ideas_ids = pd.DataFrame() 21 | classified_tickers = pd.DataFrame() 22 | 23 | custom_emojis = {} 24 | -------------------------------------------------------------------------------- /src/constants/stable_coins.py: -------------------------------------------------------------------------------- 1 | # Stable coins 2 | # Could update this on startup: 3 | # https://www.binance.com/bapi/composite/v1/public/promo/cmc/cryptocurrency/category?id=604f2753ebccdd50cd175fc1&limit=10 4 | # Get info stored in ["data"]["body"]["data"]["coins"] to get this list 5 | stables = [ 6 | "USDT", 7 | "USDC", 8 | "BUSD", 9 | "DAI", 10 | "FRAX", 11 | "TUSD", 12 | "USDP", 13 | "USDD", 14 | "USDN", 15 | "FEI", 16 | "USD", 17 | "USDTPERP", 18 | "EUR", 19 | ] 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'Bug :bug:' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ### To reproduce 14 | Steps to reproduce the behavior: 15 | 1. Go to ... 16 | 2. Click on ... 17 | 3. Scroll down to ... 18 | 4. See error. 19 | 20 | ### Expected behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | ### Screenshots 24 | If applicable, add screenshots to help explain your problem. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'New feature :star:' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Request reason 11 | Describe the reason for this request in more detail. What goes wrong without this feature? What are the benefits for the user? 12 | 13 | ### Additional context 14 | Add any other context or screenshots about the feature request here. 15 | 16 | ### Describe the solution you'd like 17 | A clear and concise description of what you want to happen. 18 | 19 | ### Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/api/benzinga.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | import pandas as pd 4 | from discord.ext import commands 5 | 6 | from api.http_client import get_json_data 7 | 8 | 9 | async def get_benzinga_data(stock: str) -> list: 10 | req = await get_json_data( 11 | f"https://www.benzinga.com/quote/{stock}/analyst-ratings", text=True 12 | ) 13 | 14 | try: 15 | df = pd.read_html(StringIO(req))[0] 16 | except Exception: 17 | raise commands.UserInputError 18 | 19 | # Drop the 4rd row 20 | df = df.drop(3) 21 | 22 | # Drop 'Buy Now', 'Analyst Firm▲▼', 'Analyst & % Accurate▲▼','Get Alert' columns 23 | df = df.drop( 24 | columns=["Buy Now", "Analyst Firm▲▼", "Analyst & % Accurate▲▼", "Get Alert"] 25 | ) 26 | 27 | return df 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [StephanAkkerman] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /src/cogs/listeners/on_member_join.py: -------------------------------------------------------------------------------- 1 | ##> Imports 2 | # > Discord dependencies 3 | from discord.ext import commands 4 | 5 | 6 | class On_member_join(commands.Cog): 7 | def __init__(self, bot): 8 | self.bot = bot 9 | 10 | @commands.Cog.listener() 11 | async def on_member_join(self, member) -> None: 12 | """Sends a private message to the member when they join the server""" 13 | 14 | await member.send( 15 | """Welcome to the server! You can use `/help` to get a list of all commands available to you. 16 | For more information about a specific command, use `/help `. 17 | Be sure to add your portfolio API read-only keys to your profile using `/portfolio`.""" 18 | ) 19 | 20 | 21 | def setup(bot): 22 | bot.add_cog(On_member_join(bot)) 23 | -------------------------------------------------------------------------------- /src/api/fear_greed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from api.http_client import get_json_data 4 | 5 | 6 | async def get_feargread() -> tuple[int, str] | None: 7 | """ 8 | Gets the last 2 Fear and Greed indices from the API. 9 | 10 | Returns 11 | ------- 12 | int 13 | Today's Fear and Greed index. 14 | str 15 | The percentual change compared to yesterday's Fear and Greed index. 16 | """ 17 | 18 | response = await get_json_data("https://api.alternative.me/fng/?limit=2") 19 | 20 | if "data" in response.keys(): 21 | today = int(response["data"][0]["value"]) 22 | yesterday = int(response["data"][1]["value"]) 23 | 24 | change = round((today - yesterday) / yesterday * 100, 2) 25 | change = f"+{change}% 📈" if change > 0 else f"{change}% 📉" 26 | 27 | return today, change 28 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v2 21 | -------------------------------------------------------------------------------- /src/api/barchart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from io import StringIO 4 | 5 | import pandas as pd 6 | 7 | from api.http_client import get_json_data 8 | 9 | 10 | async def get_data(): 11 | headers = { 12 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3" 13 | } 14 | r = await get_json_data( 15 | url="https://www.barchart.com/stocks/market-performance", 16 | headers=headers, 17 | text=True, 18 | ) 19 | df = pd.read_html(StringIO(r))[0] 20 | 21 | # Remove % from all rows 22 | df = df.replace("%", "", regex=True) 23 | 24 | # convert columns to numeric 25 | num_cols = [ 26 | "5 Day Mov Avg", 27 | "20 Day Mov Avg", 28 | "50 Day Mov Avg", 29 | "100 Day Mov Avg", 30 | "150 Day Mov Avg", 31 | "200 Day Mov Avg", 32 | ] 33 | df[num_cols] = df[num_cols].apply(pd.to_numeric, errors="coerce") 34 | 35 | return df 36 | -------------------------------------------------------------------------------- /src/constants/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from constants.config import config 5 | 6 | 7 | class UTF8StreamHandler(logging.StreamHandler): 8 | def __init__(self, stream=None): 9 | super().__init__(stream) 10 | self.setStream(sys.stdout) 11 | self.stream = open(sys.stdout.fileno(), "w", encoding="utf-8", closefd=False) 12 | 13 | 14 | # Configure the root logger 15 | logging.basicConfig( 16 | format="%(asctime)s - %(levelname)s - %(message)s", 17 | datefmt="%d/%m/%Y %H:%M:%S", 18 | level=logging.WARNING, # Set a higher level for the root logger 19 | handlers=[ 20 | logging.FileHandler( 21 | "logs/fintwit-bot.log", encoding="utf-8" 22 | ), # Ensure FileHandler uses UTF-8 encoding 23 | UTF8StreamHandler(), # Use the custom UTF-8 StreamHandler 24 | ], 25 | ) 26 | 27 | logging_lvl = config.get("LOGGING_LEVEL", "INFO") 28 | if "-debug" in sys.argv: 29 | logging_lvl = "DEBUG" 30 | logger = logging.getLogger("fintwit-logger") 31 | logger.setLevel(logging_lvl) 32 | logger.info(f"LOGGING_LEVEL is set to {logging_lvl}") 33 | -------------------------------------------------------------------------------- /src/cogs/commands/restart.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from discord.commands.context import ApplicationContext 5 | from discord.ext import commands 6 | 7 | from constants.config import config 8 | from util.disc import conditional_role_decorator, log_command_usage 9 | 10 | 11 | class Restart(commands.Cog): 12 | """ 13 | This class is used to handle the /restart command. 14 | You can enable / disable this command in the config, under ["COMMANDS"]["RESTART"]. 15 | """ 16 | 17 | def __init__(self, bot: commands.Bot) -> None: 18 | self.bot = bot 19 | 20 | def restart_bot(self): 21 | os.execv(sys.executable, ["python"] + sys.argv) 22 | 23 | @commands.slash_command(description="Restarts the FinTwit bot.") 24 | @conditional_role_decorator(config["COMMANDS"]["RESTART"]["ROLE"]) 25 | @log_command_usage 26 | async def restart( 27 | self, 28 | ctx: ApplicationContext, 29 | ) -> None: 30 | await ctx.respond("Restarting bot...") 31 | self.restart_bot() 32 | 33 | 34 | def setup(bot: commands.Bot) -> None: 35 | bot.add_cog(Restart(bot)) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Stephan Akkerman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release-notes.yml: -------------------------------------------------------------------------------- 1 | name: Generate Release Notes 2 | 3 | on: 4 | workflow_dispatch: # Allows manual trigger of the workflow 5 | 6 | jobs: 7 | generate_release_notes: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Get latest release 15 | run: | 16 | latest_release=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/releases/latest") 17 | echo "latest_release_date=$(echo $latest_release | jq -r .published_at)" >> $GITHUB_ENV 18 | 19 | - name: Fetch closed issues since latest release 20 | run: | 21 | issues=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/issues?state=closed&since=${{ env.latest_release_date }}&per_page=100") 22 | echo "$issues" > closed_issues.json 23 | 24 | - name: Generate release notes 25 | run: | 26 | release_notes="# Release Notes\n\n" 27 | release_notes+="$(jq -r '.[] | select(has("pull_request") | not) | "- #\(.number) \(.title) (closed on \(.closed_at[:10]))"' closed_issues.json)" 28 | echo "$release_notes" 29 | -------------------------------------------------------------------------------- /src/api/farside.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | import pandas as pd 4 | 5 | from api.http_client import get_json_data 6 | from constants.logger import logger 7 | 8 | 9 | async def get_etf_inflow(coin: str = "btc") -> float: 10 | data = await get_json_data(f"https://farside.co.uk/{coin}/", text=True) 11 | try: 12 | df = pd.read_html(StringIO(data))[1] 13 | except ValueError: 14 | logger.error(f"Failed to parse ETF inflow data for {coin}") 15 | return 0.0 16 | 17 | # Use only top row for columns 18 | df.columns = [col[0] for col in df.columns] 19 | 20 | # Drop last 4 rows 21 | df = df[:-4] 22 | 23 | # Replace parentheses with a negative sign and remove commas 24 | df.replace(to_replace={r"\((.*?)\)": r"-\1", ",": ""}, regex=True, inplace=True) 25 | 26 | # Convert the 'Total' column to numeric values, treating "-" as 0.0 27 | df["Total"] = df["Total"].replace("-", "0.0").astype(float) 28 | 29 | # Filter out rows where 'Total' is not 0.0 30 | df_filtered = df[df["Total"] != 0.0] 31 | 32 | # Get the last row from the filtered DataFrame 33 | last_valid_row = df_filtered.iloc[-1] if not df_filtered.empty else None 34 | 35 | # Get total value from the last row 36 | return last_valid_row["Total"] if last_valid_row is not None else 0.0 37 | -------------------------------------------------------------------------------- /src/util/afterhours.py: -------------------------------------------------------------------------------- 1 | # Standard libraries 2 | import datetime 3 | 4 | # 3rd party libraries 5 | from pandas.tseries.holiday import USFederalHolidayCalendar 6 | 7 | # Get the public holidays 8 | cal = USFederalHolidayCalendar() 9 | us_holidays = cal.holidays( 10 | start=datetime.date(datetime.date.today().year, 1, 1).strftime("%Y-%m-%d"), 11 | end=datetime.date(datetime.date.today().year, 12, 31).strftime("%Y-%m-%d"), 12 | ).to_pydatetime() 13 | 14 | 15 | def afterHours() -> bool: 16 | """ 17 | Simple code to check if the current time is after hours in the US. 18 | Source: https://www.reddit.com/r/algotrading/comments/9x9xho/python_code_to_check_if_market_is_open_in_your/ 19 | 20 | Return 21 | ------ 22 | bool 23 | True if it is currently after-hours, False otherwise. 24 | """ 25 | 26 | now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=-5), "EST")) 27 | openTime = datetime.time(hour=9, minute=30, second=0) 28 | closeTime = datetime.time(hour=16, minute=0, second=0) 29 | 30 | # If a holiday 31 | if now.strftime("%Y-%m-%d") in us_holidays: 32 | return True 33 | 34 | # If before 0930 or after 1600 35 | if (now.time() < openTime) or (now.time() > closeTime): 36 | return True 37 | 38 | # If it's a weekend 39 | if now.date().weekday() > 4: 40 | return True 41 | 42 | # Otherwise the market is open 43 | return False 44 | -------------------------------------------------------------------------------- /src/constants/sources.py: -------------------------------------------------------------------------------- 1 | icon_url = ( 2 | "https://raw.githubusercontent.com/StephanAkkerman/fintwit-bot/main/img/icons/" 3 | ) 4 | data_sources = { 5 | "twitter": {"color": 0x1DA1F2, "icon": icon_url + "twitter.png"}, 6 | "yahoo": {"color": 0x720E9E, "icon": icon_url + "yahoo.png"}, 7 | "binance": {"color": 0xF0B90B, "icon": icon_url + "binance.png"}, 8 | "investing": {"color": 0xDC8F02, "icon": icon_url + "investing.png"}, 9 | "coingecko": {"color": 0x8AC14B, "icon": icon_url + "coingecko.png"}, 10 | "opensea": {"color": 0x3685DF, "icon": icon_url + "opensea.png"}, 11 | "coinmarketcap": {"color": 0x0D3EFD, "icon": icon_url + "cmc.ico"}, 12 | "playtoearn": {"color": 0x4792C9, "icon": icon_url + "playtoearn.png"}, 13 | "tradingview": {"color": 0x131722, "icon": icon_url + "tradingview.png"}, 14 | "coinglass": {"color": 0x000000, "icon": icon_url + "coinglass.png"}, 15 | "kucoin": {"color": 0x24AE8F, "icon": icon_url + "kucoin.png"}, 16 | "coinbase": {"color": 0x245CFC, "icon": icon_url + "coinbase.png"}, 17 | "unusualwhales": {"color": 0x000000, "icon": icon_url + "unusualwhales.png"}, 18 | "reddit": {"color": 0xFF3F18, "icon": icon_url + "reddit.png"}, 19 | "nasdaqtrader": {"color": 0x0996C7, "icon": icon_url + "nasdaqtrader.png"}, 20 | "stocktwits": {"color": 0xFFFFFF, "icon": icon_url + "stocktwits.png"}, 21 | "cryptocraft": {"color": 0x634C7B, "icon": icon_url + "cryptocraft.png"}, 22 | "barchart": {"color": 0x84C8C, "icon": icon_url + "barchart.png"}, 23 | "finviz": {"color": 0xFFFFFF, "icon": icon_url + "finviz.png"}, 24 | "coin360": {"color": 0x383434, "icon": icon_url + "coin360.png"}, 25 | } 26 | -------------------------------------------------------------------------------- /src/api/unusualwhales.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pandas as pd 4 | 5 | from api.http_client import get_json_data 6 | 7 | 8 | async def get_spy_heatmap(date: str = "one_day") -> pd.DataFrame: 9 | """ 10 | Fetches the S&P 500 heatmap data from Unusual Whales API. 11 | 12 | Parameters 13 | ---------- 14 | date : str, optional 15 | Options are: one_day, after_hours, yesterday, one_week, one_month, ytd, one_year, by default "one_day" 16 | 17 | Returns 18 | ------- 19 | pd.DataFrame 20 | The S&P 500 heatmap data as a DataFrame. 21 | """ 22 | data = await get_json_data( 23 | f"https://phx.unusualwhales.com/api/etf/SPY/heatmap?date_range={date}", 24 | headers={ 25 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.54 Safari/537.36" 26 | }, 27 | ) 28 | 29 | # Create DataFrame 30 | df = pd.DataFrame(data["data"]) 31 | 32 | # Convert relevant columns to numeric types 33 | df["call_premium"] = pd.to_numeric(df["call_premium"]) 34 | df["close"] = pd.to_numeric(df["close"]) 35 | df["high"] = pd.to_numeric(df["high"]) 36 | df["low"] = pd.to_numeric(df["low"]) 37 | df["marketcap"] = pd.to_numeric(df["marketcap"]) 38 | df["open"] = pd.to_numeric(df["open"]) 39 | df["prev_close"] = pd.to_numeric(df["prev_close"]) 40 | df["put_premium"] = pd.to_numeric(df["put_premium"]) 41 | 42 | # Add change column 43 | df["percentage_change"] = (df["close"] - df["prev_close"]) / df["prev_close"] * 100 44 | 45 | # Drop rows where the marketcap == 0 46 | df = df[df["marketcap"] > 0] 47 | 48 | return df 49 | -------------------------------------------------------------------------------- /src/api/play2earn.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pandas as pd 4 | from bs4 import BeautifulSoup 5 | 6 | from api.http_client import get_json_data 7 | from util.formatting import format_change 8 | 9 | 10 | async def p2e_games(): 11 | URL = "https://playtoearn.net/blockchaingames/All-Blockchain/All-Genre/All-Status/All-Device/NFT/nft-crypto-PlayToEarn/nft-required-FreeToPlay" 12 | 13 | html = await get_json_data(URL, text=True) 14 | soup = BeautifulSoup(html, "html.parser") 15 | items = soup.find("table", class_="table table-bordered mainlist") 16 | 17 | if items is None: 18 | return pd.DataFrame() 19 | 20 | allItems = items.find_all("tr") 21 | 22 | p2e_games = [] 23 | 24 | # Skip header + ad 25 | iterator = 2 26 | for iterator in range(2, 12): 27 | data = {} 28 | 29 | allItems_td = allItems[iterator].find_all("td") 30 | if len(allItems_td) < 11: 31 | continue 32 | 33 | name = allItems_td[2].find("div", class_="dapp_name").find_next("span").text 34 | url = allItems_td[2].find_next("a")["href"] 35 | status = allItems_td[6].get_text("title") 36 | social_24h_change = allItems_td[10].find_all("span") 37 | social_24h = social_24h_change[0].text 38 | if len(social_24h_change) > 1: 39 | social_change = social_24h_change[1].text.replace("%", "").replace(",", "") 40 | else: 41 | social_change = 0 42 | 43 | data["name"] = f"[{name}]({url})" 44 | data["status"] = status 45 | data["social"] = f"{social_24h} ({format_change(float(social_change))})" 46 | 47 | p2e_games.append(data) 48 | 49 | return pd.DataFrame(p2e_games) 50 | -------------------------------------------------------------------------------- /src/util/confirm_stock.py: -------------------------------------------------------------------------------- 1 | ## > Imports 2 | # > 3rd Party Dependencies 3 | import discord 4 | from discord.ext import commands 5 | from discord.ui import Button, View 6 | 7 | from api.yahoo import get_stock_details 8 | 9 | 10 | async def confirm_stock(bot: commands.Bot, ctx: commands.Context, ticker: str) -> bool: 11 | # Check if this ticker exists 12 | stock_info = await get_stock_details(ticker) 13 | 14 | # If it does not exist let the user know 15 | if stock_info["chart"]["result"] is None: 16 | confirm_button = Button( 17 | label="Confirm", 18 | style=discord.ButtonStyle.green, 19 | emoji="✅", 20 | custom_id="confirm", 21 | ) 22 | cancel_button = Button( 23 | label="Cancel", 24 | style=discord.ButtonStyle.red, 25 | emoji="❌", 26 | custom_id="cancel", 27 | ) 28 | 29 | view = View() 30 | view.add_item(confirm_button) 31 | view.add_item(cancel_button) 32 | 33 | # Can also use ctx.followup.send 34 | await ctx.respond( 35 | ( 36 | f"Are you sure {ticker.upper()} is correct? We could not find it on Yahoo Finance.\n" 37 | "Click on \N{WHITE HEAVY CHECK MARK} to continue and on \N{CROSS MARK} to cancel." 38 | ), 39 | view=view, 40 | ) 41 | 42 | res = await bot.wait_for( 43 | "interaction", check=lambda i: i.custom_id == "confirm" 44 | ) 45 | 46 | # If the confirm button was pressed, return True 47 | if res.data["custom_id"] == "confirm": 48 | return True 49 | else: 50 | return False 51 | 52 | return True 53 | -------------------------------------------------------------------------------- /src/api/timeline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import uncurl 6 | 7 | from api.http_client import get_json_data 8 | from constants.logger import logger 9 | 10 | # Read curl.txt 11 | try: 12 | with open("curl.txt", "r", encoding="utf-8") as file: 13 | cURL = uncurl.parse_context("".join([line.strip() for line in file])) 14 | except Exception as e: 15 | cURL = None 16 | logger.critical(f"Error: Could not read curl.txt: {e}") 17 | 18 | 19 | async def get_tweet(): 20 | if cURL is None: 21 | logger.critical("Error: no curl.txt file found. Timelines will not be updated.") 22 | return [] 23 | 24 | result = await get_json_data( 25 | cURL.url, 26 | headers=dict(cURL.headers), 27 | cookies=dict(cURL.cookies), 28 | json_data=json.loads(cURL.data), 29 | text=False, 30 | ) 31 | 32 | if result == {}: 33 | return [] 34 | 35 | # TODO: Ignore x-premium alerts 36 | if "data" in result: 37 | if "home" in result["data"]: 38 | if "home_timeline_urt" in result["data"]["home"]: 39 | if "instructions" in result["data"]["home"]["home_timeline_urt"]: 40 | if ( 41 | "entries" 42 | in result["data"]["home"]["home_timeline_urt"]["instructions"][ 43 | 0 44 | ] 45 | ): 46 | return result["data"]["home"]["home_timeline_urt"][ 47 | "instructions" 48 | ][0]["entries"] 49 | 50 | try: 51 | result["data"]["home"]["home_timeline_urt"]["instructions"][0]["entries"] 52 | except Exception as e: 53 | logger.error(f"Error in get_tweet(): {e}") 54 | with open("logs/get_tweet_error.json", "w") as f: 55 | json.dump(result, f, indent=4) 56 | 57 | return [] 58 | -------------------------------------------------------------------------------- /src/cogs/loops/losers.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from discord.ext import commands 3 | from discord.ext.tasks import loop 4 | 5 | from api.yahoo import get_losers 6 | from constants.config import config 7 | from constants.logger import logger 8 | from util.afterhours import afterHours 9 | from util.disc import get_channel, loop_error_catcher 10 | from util.formatting import format_embed 11 | 12 | 13 | class Losers(commands.Cog): 14 | """ 15 | This class contains the cog for posting the top stocks losers. 16 | The crypto losers can be found in gainers.py (because they are both done at the same time). 17 | It can be enabled / disabled in the config under ["LOOPS"]["LOSERS"]. 18 | """ 19 | 20 | def __init__(self, bot: commands.Bot) -> None: 21 | self.bot = bot 22 | 23 | if config["LOOPS"]["LOSERS"]["STOCKS"]["ENABLED"]: 24 | self.channel = None 25 | self.losers.start() 26 | 27 | @loop(hours=2) 28 | @loop_error_catcher 29 | async def losers(self) -> None: 30 | """ 31 | If the market is open, this function posts the top 50 losers for todays stocks. 32 | 33 | Returns 34 | ------- 35 | None 36 | """ 37 | if self.channel is None: 38 | self.channel = await get_channel( 39 | self.bot, 40 | config["LOOPS"]["LOSERS"]["CHANNEL"], 41 | config["CATEGORIES"]["STOCKS"], 42 | ) 43 | 44 | # Dont send if the market is closed 45 | if afterHours(): 46 | return 47 | 48 | try: 49 | losers = await get_losers(count=10) 50 | e = await format_embed(pd.DataFrame(losers), "Losers", "yahoo") 51 | await self.channel.send(embed=e) 52 | except Exception as e: 53 | logger.error(f"Error getting or posting stock losers, error: {e}") 54 | 55 | 56 | def setup(bot: commands.Bot) -> None: 57 | bot.add_cog(Losers(bot)) 58 | -------------------------------------------------------------------------------- /src/models/chart.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import requests 4 | import timm 5 | import torch 6 | from PIL import Image 7 | from timm.data import create_transform, resolve_data_config 8 | 9 | 10 | class CustomImagePipeline: 11 | def __init__(self, model, transform, labels): 12 | self.model = model 13 | self.transform = transform 14 | self.labels = labels 15 | 16 | def __call__(self, image): 17 | # Preprocess 18 | if isinstance(image, str): 19 | if image.startswith("http://") or image.startswith("https://"): 20 | response = requests.get(image) 21 | image = Image.open(BytesIO(response.content)).convert("RGB") 22 | else: 23 | image = Image.open(image).convert("RGB") 24 | elif isinstance(image, Image.Image): 25 | image = image.convert("RGB") 26 | else: 27 | raise ValueError("Unsupported image format") 28 | 29 | inputs = self.transform(image).unsqueeze(0) 30 | 31 | # Forward pass 32 | with torch.no_grad(): 33 | outputs = self.model(inputs) 34 | 35 | # Postprocess 36 | probabilities = torch.nn.functional.softmax(outputs[0], dim=0) 37 | return {label: prob.item() for label, prob in zip(self.labels, probabilities)} 38 | 39 | 40 | # Load the pretrained model 41 | model = timm.create_model("hf_hub:StephanAkkerman/chart-recognizer", pretrained=True) 42 | model.eval() 43 | 44 | # Create transform and get labels 45 | transform = create_transform(**resolve_data_config(model.pretrained_cfg, model=model)) 46 | labels = model.pretrained_cfg["label_names"] 47 | 48 | # Create the custom pipeline 49 | image_pipeline = CustomImagePipeline(model=model, transform=transform, labels=labels) 50 | 51 | 52 | def classify_img(image) -> str: 53 | probabilities = image_pipeline(image) 54 | # Return the max probability label 55 | return max(probabilities, key=probabilities.get) 56 | -------------------------------------------------------------------------------- /src/api/stocktwits.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pandas as pd 4 | 5 | from api.http_client import get_json_data 6 | 7 | 8 | async def get_data(keyword: str) -> pd.DataFrame: 9 | """ 10 | Gets the data from StockTwits based on the passed keywords and returns a discord.Embed. 11 | 12 | Parameters 13 | ---------- 14 | e : discord.Embed 15 | The discord.Embed where the data will be added to. 16 | keyword : str 17 | The specific keyword to get the data for. Options are: ts, m_day, wl_ct_day. 18 | 19 | Returns 20 | ------- 21 | discord.Embed 22 | The discord.Embed with the data added to it. 23 | """ 24 | 25 | # Keyword can be "ts", "m_day", "wl_ct_day" 26 | data = await get_json_data( 27 | "https://api.stocktwits.com/api/2/charts/" + keyword, 28 | headers={ 29 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", 30 | }, 31 | ) 32 | 33 | # If no data could be found, return the embed 34 | if data == {}: 35 | return pd.DataFrame() 36 | 37 | table = pd.DataFrame(data["table"][keyword]) 38 | stocks = pd.DataFrame(data["stocks"]).T 39 | stocks["stock_id"] = stocks.index.astype(int) 40 | full_df = pd.merge(stocks, table, on="stock_id") 41 | full_df.sort_values(by="val", ascending=False, inplace=True) 42 | 43 | # Set types 44 | full_df["price"] = full_df["price"].astype(float).fillna(0) 45 | full_df["change"] = full_df["change"].astype(float).fillna(0) 46 | full_df["symbol"] = full_df["symbol"].astype(str) 47 | full_df["name"] = full_df["name"].astype(str) 48 | 49 | # Format % change 50 | full_df["change"] = full_df["change"].apply( 51 | lambda x: f" (+{round(x,2)}% 📈)" if x > 0 else f" ({round(x,2)}% 📉)" 52 | ) 53 | 54 | # Format price 55 | full_df["price"] = full_df["price"].apply(lambda x: round(x, 3)) 56 | full_df["price"] = full_df["price"].astype(str) + full_df["change"] 57 | 58 | # Set values as string 59 | full_df["val"] = full_df["val"].astype(str) 60 | 61 | return full_df 62 | -------------------------------------------------------------------------------- /src/api/opensea.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | import pandas as pd 6 | 7 | from api.http_client import get_json_data 8 | from util.formatting import format_change 9 | 10 | 11 | async def get_opensea(url=""): 12 | """ 13 | _summary_ 14 | 15 | Parameters 16 | ---------- 17 | url : str, optional 18 | Can be either "trending" or empty, by default "" 19 | 20 | Returns 21 | ------- 22 | _type_ 23 | _description_ 24 | """ 25 | 26 | html_doc = await get_json_data( 27 | f"https://opensea.io/rankings/{url}", 28 | headers={"User-Agent": "Mozilla/5.0"}, 29 | text=True, 30 | ) 31 | 32 | html_doc = html_doc[html_doc.find(':pageInfo"}},') + len(':pageInfo"}},') :] 33 | html_doc = html_doc[: html_doc.find(":edges:10")] 34 | 35 | rows = html_doc.split('"node":{') 36 | 37 | opensea_nfts = [] 38 | 39 | for row in rows[1:]: 40 | nft_dict = {} 41 | 42 | name = re.search(r"\"name\":\"(.*?)\"", row).group(1) 43 | slug = re.search(r"\"slug\":\"(.*?)\"", row) 44 | 45 | if slug: 46 | slug = slug.group(1) 47 | else: 48 | slug = "" 49 | 50 | price_data = re.findall(r"\"unit\":\"(.*?)\"", row) 51 | change = re.search(r"\"volumeChange\":(.*?),", row) 52 | symbol = re.search(r"\"symbol\":\"(.*?)\"", row).group(1) 53 | 54 | if len(price_data) == 2: 55 | floor_price = f"{round(float(price_data[0]),3)} {symbol}" 56 | volume = price_data[1] 57 | else: 58 | floor_price = "?" 59 | volume = price_data[0] 60 | 61 | volume = f"{int(float(volume))} {symbol}" 62 | change = float(change.group(1)) * 100 63 | 64 | if change != 0: 65 | if change > 1: 66 | change = int(change) 67 | else: 68 | change = round(change, 2) 69 | volume = f"{volume} ({format_change(change)})" 70 | 71 | nft_dict["symbol"] = f"[{name}](https://opensea.io/collection/{slug})" 72 | nft_dict["price"] = floor_price 73 | nft_dict["volume"] = volume 74 | 75 | opensea_nfts.append(nft_dict) 76 | 77 | return pd.DataFrame(opensea_nfts) 78 | -------------------------------------------------------------------------------- /src/api/http_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import aiohttp 6 | import tls_client 7 | 8 | from constants.logger import logger 9 | 10 | 11 | async def get_json_data( 12 | url: str, 13 | headers: dict = None, 14 | cookies: dict = None, 15 | json_data: dict = None, 16 | text: bool = False, 17 | ) -> dict: 18 | """ 19 | Asynchronous function to get JSON data from a website. 20 | 21 | Parameters 22 | ---------- 23 | url : str 24 | The URL to get the data from. 25 | headers : dict, optional 26 | The headers send with the get request, by default None. 27 | 28 | Returns 29 | ------- 30 | dict 31 | The response as a dict. 32 | """ 33 | 34 | try: 35 | async with aiohttp.ClientSession(headers=headers, cookies=cookies) as session: 36 | async with session.get(url, json=json_data) as r: 37 | if text: 38 | return await r.text() 39 | else: 40 | return await r.json() 41 | except aiohttp.ClientError as e: 42 | logger.error(f"Error with get request for {url}.\nError: {e}") 43 | except json.JSONDecodeError as e: 44 | logger.error(f"Error decoding JSON from {url}.\nError: {e}") 45 | logger.error(f"Response: {await r.text()}") 46 | return {} 47 | 48 | 49 | async def post_json_data( 50 | url: str, 51 | headers: dict = None, 52 | data: dict = None, 53 | json: dict = None, 54 | ) -> dict: 55 | """ 56 | Asynchronous function to post JSON data from a website. 57 | 58 | Parameters 59 | ---------- 60 | url : str 61 | The URL to get the data from. 62 | headers : dict, optional 63 | The headers send with the post request, by default None. 64 | 65 | Returns 66 | ------- 67 | dict 68 | The response as a dict. 69 | """ 70 | 71 | try: 72 | async with aiohttp.ClientSession(headers=headers) as session: 73 | async with session.post(url, data=data, json=json) as r: 74 | return await r.json(content_type=None) 75 | except Exception as e: 76 | logger.error(f"Error with POST request for {url}.\nError: {e}") 77 | 78 | return {} 79 | 80 | 81 | session = tls_client.Session( 82 | client_identifier="chrome112", random_tls_extension_order=True 83 | ) 84 | -------------------------------------------------------------------------------- /src/cogs/loops/funding.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | # > Discord dependencies 4 | import discord 5 | 6 | # > 3rd party dependencies 7 | from discord.ext import commands 8 | from discord.ext.tasks import loop 9 | 10 | from api.binance import get_funding_rate 11 | from constants.config import config 12 | 13 | # Local dependencies 14 | from constants.sources import data_sources 15 | from util.disc import get_channel, loop_error_catcher 16 | 17 | 18 | class Funding(commands.Cog): 19 | """ 20 | This class is used to handle the funding loop. 21 | This can be enabled / disabled in the config, under ["LOOPS"]["FUNDING"]. 22 | """ 23 | 24 | def __init__(self, bot: commands.Bot) -> None: 25 | self.bot = bot 26 | self.channel = None 27 | self.funding.start() 28 | 29 | @loop(hours=4) 30 | @loop_error_catcher 31 | async def funding(self) -> None: 32 | """ 33 | This function gets the data from the funding API and posts it in the funding channel. 34 | 35 | Returns 36 | ------- 37 | None 38 | """ 39 | if self.channel is None: 40 | self.channel = await get_channel( 41 | self.bot, config["LOOPS"]["FUNDING"]["CHANNEL"] 42 | ) 43 | 44 | e = discord.Embed( 45 | title="Binance Top 15 Lowest Funding Rates", 46 | url="", 47 | description="", 48 | color=data_sources["binance"]["color"], 49 | timestamp=datetime.datetime.now(datetime.timezone.utc), 50 | ) 51 | 52 | lowest, timeToNextFunding = await get_funding_rate() 53 | 54 | # Set datetime and icon 55 | e.set_footer( 56 | text=f"Next funding in {str(timeToNextFunding).split('.')[0]}", 57 | icon_url=data_sources["binance"]["icon"], 58 | ) 59 | 60 | lowest_tickers = "\n".join(lowest["symbol"].tolist()) 61 | lowest_rates = "\n".join(lowest["lastFundingRate"].tolist()) 62 | 63 | e.add_field( 64 | name="Coin", 65 | value=lowest_tickers, 66 | inline=True, 67 | ) 68 | 69 | e.add_field( 70 | name="Funding Rate", 71 | value=lowest_rates, 72 | inline=True, 73 | ) 74 | 75 | # Post the embed in the channel 76 | await self.channel.purge(limit=1) 77 | await self.channel.send(embed=e) 78 | 79 | 80 | def setup(bot: commands.Bot) -> None: 81 | bot.add_cog(Funding(bot)) 82 | -------------------------------------------------------------------------------- /src/cogs/loops/stock_halts.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import discord 4 | from discord.ext import commands 5 | from discord.ext.tasks import loop 6 | 7 | from api.nasdaq import get_halt_data 8 | from constants.config import config 9 | from constants.sources import data_sources 10 | from util.afterhours import afterHours 11 | from util.disc import get_channel, get_tagged_users, loop_error_catcher 12 | 13 | 14 | class StockHalts(commands.Cog): 15 | """ 16 | This class contains the cog for posting the halted stocks. 17 | It can be configured in the config.yaml file under ["LOOPS"]["STOCK_HALTS"]. 18 | """ 19 | 20 | def __init__(self, bot: commands.Bot) -> None: 21 | self.bot = bot 22 | self.channel = None 23 | self.halt_embed.start() 24 | 25 | @loop(minutes=15) 26 | @loop_error_catcher 27 | async def halt_embed(self): 28 | # Dont send if the market is closed 29 | if afterHours(): 30 | return 31 | 32 | if self.channel is None: 33 | self.channel = await get_channel( 34 | self.bot, config["LOOPS"]["STOCK_HALTS"]["CHANNEL"] 35 | ) 36 | 37 | df = await get_halt_data() 38 | if df.empty: 39 | return 40 | 41 | # Remove previous message first 42 | await self.channel.purge(limit=1) 43 | 44 | # Create embed 45 | e = discord.Embed( 46 | title="Halted Stocks", 47 | url="https://www.nasdaqtrader.com/trader.aspx?id=tradehalts", 48 | description="", 49 | color=data_sources["nasdaqtrader"]["color"], 50 | timestamp=datetime.datetime.now(datetime.timezone.utc), 51 | ) 52 | 53 | # Get the values as string 54 | time = "\n".join(df["Time"].to_list()) 55 | symbol = "\n".join(df["Issue Symbol"].to_list()) 56 | 57 | # Add the values to the embed 58 | e.add_field(name="Time", value=time, inline=True) 59 | e.add_field(name="Symbol", value=symbol, inline=True) 60 | 61 | if "Resumption Time" in df.columns: 62 | resumption = "\n".join(df["Resumption Time"].to_list()) 63 | e.add_field(name="Resumption Time", value=resumption, inline=True) 64 | 65 | e.set_footer( 66 | text="\u200b", 67 | icon_url=data_sources["nasdaqtrader"]["icon"], 68 | ) 69 | 70 | tags = get_tagged_users(df["Issue Symbol"].to_list()) 71 | 72 | await self.channel.send(content=tags, embed=e) 73 | 74 | 75 | def setup(bot: commands.Bot) -> None: 76 | """ 77 | This is a necessary method to make the cog loadable. 78 | 79 | Returns 80 | ------- 81 | None 82 | """ 83 | bot.add_cog(StockHalts(bot)) 84 | -------------------------------------------------------------------------------- /src/models/sentiment.py: -------------------------------------------------------------------------------- 1 | ##> Imports 2 | # > Standard libaries 3 | from __future__ import annotations 4 | 5 | import re 6 | 7 | # > Third party libraries 8 | import discord 9 | from transformers import AutoTokenizer, BertForSequenceClassification, pipeline 10 | 11 | # Load model 12 | model = BertForSequenceClassification.from_pretrained( 13 | "StephanAkkerman/FinTwitBERT-sentiment", 14 | num_labels=3, 15 | id2label={0: "NEUTRAL", 1: "BULLISH", 2: "BEARISH"}, 16 | label2id={"NEUTRAL": 0, "BULLISH": 1, "BEARISH": 2}, 17 | cache_dir="models/", 18 | ) 19 | model.config.problem_type = "single_label_classification" 20 | tokenizer = AutoTokenizer.from_pretrained( 21 | "StephanAkkerman/FinTwitBERT-sentiment", 22 | cache_dir="models/", 23 | add_special_tokens=True, 24 | ) 25 | model.eval() 26 | pipe = pipeline("text-classification", model=model, tokenizer=tokenizer) 27 | 28 | label_to_emoji = { 29 | "NEUTRAL": "🦆", 30 | "BULLISH": "🐂", 31 | "BEARISH": "🐻", 32 | } 33 | 34 | color_table = { 35 | "🦆": discord.Colour.lighter_grey(), 36 | "🐂": discord.Colour.green(), 37 | "🐻": discord.Colour.red(), 38 | } 39 | 40 | 41 | def preprocess_text(tweet: str) -> str: 42 | # Replace URLs with URL token 43 | tweet = re.sub(r"http\S+", "[URL]", tweet) 44 | 45 | # Replace @mentions with @USER token 46 | tweet = re.sub(r"@\S+", "@USER", tweet) 47 | 48 | return tweet 49 | 50 | 51 | def classify_sentiment(text: str) -> str: 52 | """ 53 | Uses the text of a tweet to classify the sentiment of the tweet. 54 | 55 | Parameters 56 | ---------- 57 | text : str 58 | The text of the tweet. 59 | 60 | Returns 61 | ------- 62 | np.ndarray 63 | The probability of the tweet being bullish, neutral, or bearish. 64 | """ 65 | 66 | label = pipe(preprocess_text(text))[0].get("label") 67 | emoji = label_to_emoji[label] 68 | 69 | return emoji 70 | 71 | 72 | def add_sentiment(e: discord.Embed, text: str) -> tuple[discord.Embed, str]: 73 | """ 74 | Adds sentiment to a discord embed, based on the given text. 75 | 76 | Parameters 77 | ---------- 78 | e : discord.Embed 79 | The embed to add the sentiment to. 80 | text : str 81 | The text to classify the sentiment of. 82 | 83 | Returns 84 | ------- 85 | tuple[discord.Embed, str] 86 | discord.Embed 87 | The embed with the sentiment added. 88 | str 89 | The sentiment of the tweet. 90 | """ 91 | 92 | # Remove quote tweet formatting 93 | emoji = classify_sentiment(text.split("\n\n> [@")[0]) 94 | 95 | # Change color based on sentiment 96 | e.colour = color_table[emoji] 97 | 98 | return e, emoji 99 | -------------------------------------------------------------------------------- /src/cogs/loops/stocktwits.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import discord 4 | from discord.ext import commands 5 | from discord.ext.tasks import loop 6 | 7 | from api.stocktwits import get_data 8 | from constants.config import config 9 | from constants.sources import data_sources 10 | from util.disc import get_channel, loop_error_catcher 11 | 12 | 13 | class StockTwits(commands.Cog): 14 | """ 15 | This class contains the cog for posting the most discussed StockTwits tickers. 16 | It can be enabled / disabled in the config under ["LOOPS"]["STOCKTWITS"]. 17 | """ 18 | 19 | def __init__(self, bot: commands.Bot) -> None: 20 | self.bot = bot 21 | self.channel = None 22 | self.stocktwits.start() 23 | 24 | @loop(hours=6) 25 | @loop_error_catcher 26 | async def stocktwits(self) -> None: 27 | """ 28 | The function posts the StockTwits embeds in the configured channel. 29 | 30 | Returns 31 | ------- 32 | None 33 | """ 34 | if self.channel is None: 35 | self.channel = await get_channel( 36 | self.bot, 37 | config["LOOPS"]["STOCKTWITS"]["CHANNEL"], 38 | config["CATEGORIES"]["STOCKS"], 39 | ) 40 | 41 | for keyword in ["ts", "m_day", "wl_ct_day"]: 42 | df = await get_data(keyword) 43 | if df.empty: 44 | continue 45 | 46 | # Get the values as string 47 | assets = "\n".join(df["symbol"].to_list()) 48 | prices = "\n".join(df["price"].to_list()) 49 | values = "\n".join(df["val"].to_list()) 50 | 51 | e = discord.Embed( 52 | title="StockTwits Rankings", 53 | url="https://stocktwits.com/rankings/trending", 54 | description="", 55 | color=data_sources["stocktwits"]["color"], 56 | timestamp=datetime.datetime.now(datetime.timezone.utc), 57 | ) 58 | 59 | # Show Symbol, Price + change, Score / Count 60 | if keyword == "ts": 61 | name = "Trending" 62 | val = "Score" 63 | elif keyword == "m_day": 64 | name = "Most Active" 65 | val = "Count" 66 | else: 67 | name = "Most Watched" 68 | val = "Count" 69 | 70 | e.add_field(name=name, value=assets, inline=True) 71 | e.add_field(name="Price", value=prices, inline=True) 72 | e.add_field(name=val, value=values, inline=True) 73 | 74 | # Set datetime and icon 75 | e.set_footer( 76 | text="\u200b", 77 | icon_url=data_sources["stocktwits"]["icon"], 78 | ) 79 | 80 | await self.channel.send(embed=e) 81 | 82 | 83 | def setup(bot: commands.bot.Bot) -> None: 84 | bot.add_cog(StockTwits(bot)) 85 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '35 20 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /src/cogs/commands/earnings.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import discord 4 | import pandas as pd 5 | import pytz 6 | import yfinance 7 | from discord.commands.context import ApplicationContext 8 | from discord.ext import commands 9 | 10 | from constants.logger import logger 11 | from util.confirm_stock import confirm_stock 12 | from util.disc import log_command_usage 13 | 14 | 15 | class Earnings(commands.Cog): 16 | """ 17 | This class is used to handle the earnings command. 18 | You can enable / disable this command in the config, under ["COMMANDS"]["EARNINGS"]. 19 | """ 20 | 21 | def __init__(self, bot: commands.Bot): 22 | self.bot = bot 23 | 24 | @commands.slash_command( 25 | name="earnings", description="Gets next earnings date for a given stock." 26 | ) 27 | @discord.option( 28 | "stock", type=str, description="Stock ticker, e.g. AAPL.", required=True 29 | ) 30 | @log_command_usage 31 | async def earnings( 32 | self, 33 | ctx: ApplicationContext, 34 | stock: str, 35 | ): 36 | """ 37 | Gets next earnings date for a given stock. 38 | For instance `/earnings AAPL` will return the next earnings date for Apple. 39 | 40 | Parameters 41 | ---------- 42 | ctx : commands.Context 43 | Necessary Discord context object. 44 | stock : str 45 | The stock ticker to get the earnings date for. 46 | 47 | Raises 48 | ------ 49 | commands.UserInputError 50 | If the provided stock ticker is not valid. 51 | """ 52 | 53 | # Check if this stock exists 54 | if not await confirm_stock(self.bot, ctx, stock): 55 | return 56 | 57 | ticker = yfinance.Ticker(stock) 58 | df = ticker.get_earnings_dates() 59 | # Convert 'today' to a timezone-aware timestamp 60 | tz = pytz.timezone("America/New_York") 61 | today = pd.Timestamp(datetime.now(tz)) 62 | 63 | # Filter the DataFrame to include only future dates 64 | future_dates = df[df.index > today] 65 | 66 | # Find the closest date 67 | closest_date = future_dates.index.min() 68 | 69 | msg = f"The next earnings date for {stock.upper()} is ." 70 | await ctx.respond(msg) 71 | 72 | @earnings.error 73 | async def earnings_error(self, ctx: ApplicationContext, error: Exception): 74 | """ 75 | Catches the errors when using the `!earnings` command. 76 | 77 | Parameters 78 | ---------- 79 | ctx : commands.Context 80 | Necessary Discord context object. 81 | error : Exception 82 | The exception that was raised when using the `!earnings` command. 83 | """ 84 | logger.error(error) 85 | if isinstance(error, commands.UserInputError): 86 | await ctx.respond( 87 | f"{ctx.author.mention} You must specify a stock to request the next earnings of!" 88 | ) 89 | else: 90 | await ctx.respond( 91 | f"{ctx.author.mention} An error has occurred. Please try again later." 92 | ) 93 | 94 | 95 | def setup(bot: commands.Bot): 96 | bot.add_cog(Earnings(bot)) 97 | -------------------------------------------------------------------------------- /src/api/ccxt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import time 5 | 6 | import ccxt 7 | import pandas as pd 8 | from dateutil.parser import parse 9 | 10 | # Save the exchanges that are useful 11 | exchanges_with_ohlcv = [] 12 | 13 | for exchange_id in ccxt.exchanges: 14 | exchange = getattr(ccxt, exchange_id)() 15 | if exchange.has["fetchOHLCV"]: 16 | exchanges_with_ohlcv.append(exchange_id) 17 | 18 | 19 | def fetch_data( 20 | exchange: str = "binance", 21 | since=None, 22 | limit: int = None, 23 | ) -> pd.DataFrame: 24 | """ 25 | Pandas DataFrame with the latest OHLCV data from specified exchange. 26 | 27 | Parameters 28 | -------------- 29 | exchange : string, check the exchange_list to see the supported exchanges. For instance "binance". 30 | since: integer, UTC timestamp in milliseconds. Default is None, which means will not take the start date into account. 31 | The behavior of this parameter depends on the exchange. 32 | limit : integer, the amount of rows that should be returned. For instance 100, default is None, which means 500 rows. 33 | 34 | All the timeframe options are: '1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w', '1M' 35 | """ 36 | 37 | timeframe: str = "1d" 38 | symbol: str = "BTC/USDT" 39 | 40 | # If it is a string, convert it to a datetime object 41 | if isinstance(since, str): 42 | since = parse(since) 43 | 44 | if isinstance(since, datetime.datetime): 45 | since = int(since.timestamp() * 1000) 46 | 47 | # Always convert to lowercase 48 | exchange = exchange.lower() 49 | 50 | if exchange not in exchanges_with_ohlcv: 51 | raise ValueError( 52 | f"{exchange} is not a supported exchange. Please use one of the following: {exchanges_with_ohlcv}" 53 | ) 54 | 55 | exchange = getattr(ccxt, exchange)() 56 | 57 | # Convert ms to seconds, so we can use time.sleep() for multiple calls 58 | rate_limit = exchange.rateLimit / 1000 59 | 60 | # Get data 61 | data = exchange.fetch_ohlcv(symbol, timeframe, since, limit) 62 | 63 | while len(data) < limit: 64 | # If the data is less than the limit, we need to make multiple calls 65 | # Shift the since date to the last date of the data 66 | since = data[-1][0] + 86400000 67 | 68 | # Sleep to prevent rate limit errors 69 | time.sleep(rate_limit) 70 | 71 | # Get the remaining data 72 | new_data = exchange.fetch_ohlcv(symbol, timeframe, since, limit - len(data)) 73 | data += new_data 74 | 75 | if len(new_data) == 0: 76 | break 77 | 78 | df = pd.DataFrame( 79 | data, columns=["Timestamp", "open", "high", "low", "close", "volume"] 80 | ) 81 | 82 | # Convert Timestamp to date 83 | df.Timestamp = ( 84 | df.Timestamp / 1000 85 | ) # Timestamp is 1000 times bigger than it should be in this case 86 | df["Date"] = pd.to_datetime(df.Timestamp, unit="s") 87 | 88 | # The default values are string, so convert these to numeric values 89 | df["Value"] = pd.to_numeric(df["close"]) 90 | 91 | # Returned DataFrame should consists of columns: index starting from 0, date as datetime, open, high, low, close, volume in numbers 92 | return df[["Date", "Value"]] 93 | -------------------------------------------------------------------------------- /src/cogs/commands/analyze.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import discord 4 | from discord.commands.context import ApplicationContext 5 | from discord.ext import commands 6 | 7 | from api.benzinga import get_benzinga_data 8 | from constants.config import config 9 | from constants.logger import logger 10 | from util.disc import conditional_role_decorator, log_command_usage 11 | 12 | 13 | class Analyze(commands.Cog): 14 | """ 15 | This class is used to handle the analyze command. 16 | You can enable / disable this command in the config, under ["COMMANDS"]["ANALYZE"]. 17 | """ 18 | 19 | def __init__(self, bot: commands.Bot) -> None: 20 | self.bot = bot 21 | 22 | @commands.slash_command( 23 | description="Request the current analysis for a stock ticker." 24 | ) 25 | @discord.option( 26 | "stock", type=str, description="Stock ticker, e.g. AAPL.", required=True 27 | ) 28 | @log_command_usage 29 | @conditional_role_decorator(config["COMMANDS"]["ANALYZE"]["ROLE"]) 30 | async def analyze(self, ctx: ApplicationContext, stock: str) -> None: 31 | """ 32 | The analyze command is used to get the current analyst ratings for a stock ticker from benzinga.com. 33 | 34 | Parameters 35 | ---------- 36 | ctx : commands.Context 37 | Discord context object. 38 | stock : Option, optional 39 | The ticker of a stock, e.g. AAPL 40 | """ 41 | 42 | await ctx.response.defer(ephemeral=True) 43 | 44 | e = discord.Embed( 45 | title=f"Last 10 {stock.upper()} Analysist Ratings", 46 | url=f"https://www.benzinga.com/quote/{stock}/analyst-ratings", 47 | description="", 48 | color=0x1F7FC1, 49 | timestamp=datetime.datetime.now(datetime.timezone.utc), 50 | ) 51 | 52 | e.set_footer( 53 | text="\u200b", 54 | icon_url="https://www.benzinga.com/next-assets/images/apple-touch-icon.png", 55 | ) 56 | data = await get_benzinga_data(stock) 57 | # Only use top 10 58 | data = data.head(10) 59 | 60 | e.add_field(name="Date", value="\n".join(data["date▲▼"]), inline=True) 61 | e.add_field( 62 | name="Price Target", 63 | value="\n".join(data["Price Target Change▲▼"]), 64 | inline=True, 65 | ) 66 | e.add_field( 67 | name="Rate", 68 | value="\n".join(data["Previous / Current Rating▲▼"]), 69 | inline=True, 70 | ) 71 | 72 | await ctx.respond(embed=e) 73 | 74 | @analyze.error 75 | async def analyze_error(self, ctx: ApplicationContext, error: Exception): 76 | """ 77 | Catches the errors when using the `/analyze` command. 78 | 79 | Parameters 80 | ---------- 81 | ctx : commands.Context 82 | Necessary Discord context object. 83 | error : Exception 84 | The exception that was raised when using the `!earnings` command. 85 | """ 86 | if isinstance(error, commands.UserInputError): 87 | await ctx.respond("Could not find any data for the stock you provided.") 88 | else: 89 | await ctx.respond("An error has occurred. Please try again later.") 90 | logger.error(error) 91 | 92 | 93 | def setup(bot: commands.Bot) -> None: 94 | bot.add_cog(Analyze(bot)) 95 | -------------------------------------------------------------------------------- /src/api/investing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | import pandas as pd 6 | import pytz 7 | from lxml.html import fromstring 8 | 9 | from api.http_client import post_json_data 10 | 11 | 12 | async def get_events() -> pd.DataFrame: 13 | """ 14 | Gets the economic calendar from Investing.com for the next week. 15 | The data contains the most important information for the USA and EU. 16 | 17 | Forked from: https://github.com/alvarobartt/investpy/blob/master/investpy/news.py 18 | """ 19 | 20 | headers = { 21 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", 22 | "X-Requested-With": "XMLHttpRequest", 23 | } 24 | 25 | data = { 26 | "country[]": [72, 5], # USA and EU 27 | "importance[]": 3, # Highest importance, 3 stars 28 | "timeZone": 8, 29 | "timeFilter": "timeRemain", 30 | "currentTab": "thisWeek", 31 | "submitFilters": 1, 32 | "limit_from": 0, 33 | } 34 | 35 | url = "https://www.investing.com/economic-calendar/Service/getCalendarFilteredData" 36 | 37 | req = await post_json_data(url, headers=headers, data=data) 38 | root = fromstring(req["data"]) 39 | table = root.xpath(".//tr") 40 | 41 | results = [] 42 | 43 | for reversed_row in table[::-1]: 44 | id_ = reversed_row.get("id") 45 | if id_ is not None: 46 | id_ = id_.replace("eventRowId_", "") 47 | 48 | for row in table: 49 | id_ = row.get("id") 50 | if id_ is None: 51 | curr_timescope = int(row.xpath("td")[0].get("id").replace("theDay", "")) 52 | curr_date = datetime.datetime.fromtimestamp( 53 | curr_timescope, tz=pytz.timezone("GMT") 54 | ).strftime("%d/%m/%Y") 55 | else: 56 | id_ = id_.replace("eventRowId_", "") 57 | 58 | time = zone = currency = event = actual = forecast = previous = None 59 | 60 | if row.get("id").__contains__("eventRowId_"): 61 | for value in row.xpath("td"): 62 | if value.get("class").__contains__("first left"): 63 | time = value.text_content() 64 | elif value.get("class").__contains__("flagCur"): 65 | zone = value.xpath("span")[0].get("title").lower() 66 | currency = value.text_content().strip() 67 | elif value.get("class") == "left event": 68 | event = value.text_content().strip() 69 | elif value.get("id") == "eventActual_" + id_: 70 | actual = value.text_content().strip() 71 | elif value.get("id") == "eventForecast_" + id_: 72 | forecast = value.text_content().strip() 73 | elif value.get("id") == "eventPrevious_" + id_: 74 | previous = value.text_content().strip() 75 | 76 | results.append( 77 | { 78 | "id": id_, 79 | "date": curr_date, 80 | "time": time, 81 | "zone": zone, 82 | "currency": None if currency == "" else currency, 83 | "event": event, 84 | "actual": None if actual == "" else actual, 85 | "forecast": None if forecast == "" else forecast, 86 | "previous": None if previous == "" else previous, 87 | } 88 | ) 89 | 90 | return pd.DataFrame(results) 91 | -------------------------------------------------------------------------------- /src/constants/tradingview.py: -------------------------------------------------------------------------------- 1 | # This file is used to store all the symbols used in the TV module. 2 | # Use format EXCHANGE:INDEX for TradingView symbols 3 | crypto_indices = [ 4 | "CRYPTOCAP:TOTAL", 5 | "CRYPTOCAP:TOTAL2", 6 | "CRYPTOCAP:TOTAL3", 7 | "CRYPTOCAP:BTC.D", 8 | "CRYPTOCAP:ETH.D", 9 | "CRYPTOCAP:OTHERS.D", 10 | "CRYPTOCAP:TOTALDEFI.D", 11 | "CRYPTOCAP:USDT.D", 12 | "CRYPTOCAP:USDC.D", 13 | ] 14 | 15 | # https://www.tradingview.com/markets/currencies/indices-all/ 16 | forex_indices = [ 17 | "TVC:DXY", 18 | "TVC:EXY", 19 | "TVC:BXY", 20 | "TVC:JXY", 21 | ] 22 | 23 | US_bonds = [ 24 | "TVC:US01MY", 25 | "TVC:US02MY", 26 | "TVC:US03MY", 27 | "TVC:US06MY", 28 | "TVC:US01Y", 29 | "TVC:US02Y", 30 | "TVC:US03Y", 31 | "TVC:US05Y", 32 | "TVC:US07Y", 33 | "TVC:US10Y", 34 | "TVC:US20Y", 35 | "TVC:US30Y", 36 | ] 37 | 38 | EU_bonds = [ 39 | "TVC:EU03MY", 40 | "TVC:EU06MY", 41 | "TVC:EU09MY", 42 | "TVC:EU01Y", 43 | "TVC:EU02Y", 44 | "TVC:EU03Y", 45 | "TVC:EU04Y", 46 | "TVC:EU05Y", 47 | "TVC:EU06Y", 48 | "TVC:EU07Y", 49 | "TVC:EU08Y", 50 | "TVC:EU09Y", 51 | "TVC:EU10Y", 52 | "TVC:EU15Y", 53 | "TVC:EU20Y", 54 | "TVC:EU25Y", 55 | "TVC:EU30Y", 56 | ] 57 | 58 | # https://www.tradingview.com/markets/cfds/quotes-world-indices/ 59 | # https://www.tradingview.com/markets/indices/quotes-major/ 60 | stock_indices = [ 61 | "AMEX:SPY", 62 | "NASDAQ:NDX", 63 | "USI:PCC", 64 | "USI:PCCE", 65 | "TVC:VIX", 66 | "TVC:SPX", 67 | ] 68 | 69 | all_forex_indices = forex_indices + EU_bonds + US_bonds 70 | 71 | currencies = [ 72 | "ALL", 73 | "AFN", 74 | "ARS", 75 | "AWG", 76 | "AUD", 77 | "AZN", 78 | "BSD", 79 | "BBD", 80 | "BDT", 81 | "BYR", 82 | "BZD", 83 | "BMD", 84 | "BOB", 85 | "BAM", 86 | "BWP", 87 | "BGN", 88 | "BRL", 89 | "BND", 90 | "KHR", 91 | "CAD", 92 | "KYD", 93 | "CLP", 94 | "CNY", 95 | "COP", 96 | "CRC", 97 | "HRK", 98 | "CUP", 99 | "CZK", 100 | "DKK", 101 | "DOP", 102 | "XCD", 103 | "EGP", 104 | "SVC", 105 | "EEK", 106 | "EUR", 107 | "FKP", 108 | "FJD", 109 | "GHC", 110 | "GIP", 111 | "GTQ", 112 | "GGP", 113 | "GYD", 114 | "HNL", 115 | "HKD", 116 | "HUF", 117 | "ISK", 118 | "INR", 119 | "IDR", 120 | "IRR", 121 | "IMP", 122 | "ILS", 123 | "JMD", 124 | "JPY", 125 | "JEP", 126 | "KZT", 127 | "KPW", 128 | "KRW", 129 | "KGS", 130 | "LAK", 131 | "LVL", 132 | "LBP", 133 | "LRD", 134 | "LTL", 135 | "MKD", 136 | "MYR", 137 | "MUR", 138 | "MXN", 139 | "MNT", 140 | "MZN", 141 | "NAD", 142 | "NPR", 143 | "ANG", 144 | "NZD", 145 | "NIO", 146 | "NGN", 147 | "NOK", 148 | "OMR", 149 | "PKR", 150 | "PAB", 151 | "PYG", 152 | "PEN", 153 | "PHP", 154 | "PLN", 155 | "QAR", 156 | "RON", 157 | "RUB", 158 | "SHP", 159 | "SAR", 160 | "RSD", 161 | "SCR", 162 | "SGD", 163 | "SBD", 164 | "SOS", 165 | "ZAR", 166 | "LKR", 167 | "SEK", 168 | "CHF", 169 | "SRD", 170 | "SYP", 171 | "TWD", 172 | "THB", 173 | "TTD", 174 | "TRY", 175 | "TRL", 176 | "TVD", 177 | "UAH", 178 | "GBP", 179 | "USD", 180 | "UYU", 181 | "UZS", 182 | "VEF", 183 | "VND", 184 | "YER", 185 | "ZWD", 186 | ] 187 | -------------------------------------------------------------------------------- /src/api/cryptocraft.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from io import StringIO 4 | 5 | import pandas as pd 6 | from bs4 import BeautifulSoup 7 | 8 | from api.http_client import get_json_data 9 | from constants.logger import logger 10 | 11 | 12 | async def get_crypto_calendar() -> pd.DataFrame: 13 | """ 14 | Gets the economic calendar from CryptoCraft.com for the next week. 15 | 16 | Returns 17 | ------- 18 | pd.DataFrame 19 | The formatted DataFrame containing the economic calendar. 20 | """ 21 | html = await get_json_data( 22 | url="https://www.cryptocraft.com/calendar", 23 | headers={ 24 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4240.193 Safari/537.36" 25 | }, 26 | text=True, 27 | ) 28 | 29 | soup = BeautifulSoup(html, "html.parser") 30 | 31 | # Get the first table 32 | table = soup.find("table") 33 | 34 | impact_emoji = { 35 | "yel": "🟨", 36 | "ora": "🟧", 37 | "red": "🟥", 38 | "gra": "⬜", 39 | } 40 | 41 | if table is None: 42 | logger.error("No table found in the CryptoCraft calendar.") 43 | return pd.DataFrame() 44 | 45 | impacts = [] 46 | for row in table.find_all("tr")[2:]: # Skip the header row 47 | # Get the impact value from the span class including "impact" 48 | impact = row.find("span", class_=lambda s: s and "impact" in s) 49 | if impact: 50 | impact = impact.get("class", [])[-1][-3:] 51 | impacts.append(impact_emoji[impact]) 52 | 53 | # Convert the table to a string and read it into a DataFrame 54 | df = pd.read_html(StringIO(str(table)))[0] 55 | 56 | # Drop the first row 57 | df = df.iloc[1:] 58 | 59 | # Drop rows where the first and second column values are the same 60 | df = df[df.iloc[:, 0] != df.iloc[:, 1]] 61 | 62 | # Convert MultiIndex columns to regular columns 63 | df.columns = ["_".join(col).strip() for col in df.columns.values] 64 | 65 | # Rename second column to time and fifth column to event 66 | df.rename( 67 | columns={ 68 | df.columns[0]: "date", 69 | df.columns[1]: "time", 70 | df.columns[4]: "event", 71 | df.columns[6]: "actual", 72 | df.columns[7]: "forecast", 73 | df.columns[8]: "previous", 74 | }, 75 | inplace=True, 76 | ) 77 | 78 | # Drop third and fourth column 79 | df.drop(df.columns[[2, 3, 5, 9]], axis=1, inplace=True) 80 | 81 | # Remove rows where event is NaN 82 | df = df[df["event"].notna()] 83 | 84 | # Reset index 85 | df.reset_index(drop=True, inplace=True) 86 | 87 | # Add impact column 88 | df["impact"] = impacts 89 | 90 | # Use ffill() for forward fill 91 | df["time"] = df["time"].ffill() 92 | 93 | # Mask for entries where 'time' does not match common time patterns (only checks for absence of typical hour-minute time format) 94 | mask_no_time_pattern = df["time"].str.contains( 95 | r"^\D*$|day", flags=re.IGNORECASE, na=False 96 | ) 97 | # Mask for entries with specific time (i.e., typical time patterns are present) 98 | mask_time_specific = ~mask_no_time_pattern 99 | 100 | # Convert 'All Day' entries by appending the current year and no specific time 101 | df.loc[mask_no_time_pattern, "datetime"] = pd.to_datetime( 102 | df.loc[mask_no_time_pattern, "date"] + " " + str(datetime.datetime.now().year), 103 | format="%a %b %d %Y", 104 | errors="coerce", 105 | ) 106 | 107 | # Convert specific time entries by appending the current year and the specific time 108 | df.loc[mask_time_specific, "datetime"] = pd.to_datetime( 109 | df.loc[mask_time_specific, "date"] 110 | + " " 111 | + str(datetime.datetime.now().year) 112 | + " " 113 | + df.loc[mask_time_specific, "time"], 114 | format="%a %b %d %Y %I:%M%p", 115 | errors="coerce", 116 | ) 117 | 118 | return df 119 | -------------------------------------------------------------------------------- /src/cogs/loops/gainers.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from discord.ext import commands 3 | from discord.ext.tasks import loop 4 | 5 | from api.binance import get_gainers_losers 6 | from api.yahoo import get_gainers 7 | from constants.config import config 8 | from constants.logger import logger 9 | from util.afterhours import afterHours 10 | from util.disc import get_channel, loop_error_catcher 11 | from util.formatting import format_embed 12 | 13 | 14 | class Gainers(commands.Cog): 15 | """ 16 | This class contains the cog for posting the top crypto and stocks gainers. 17 | It can be enabled / disabled in the config under ["LOOPS"]["GAINERS"]. 18 | """ 19 | 20 | def __init__(self, bot: commands.Bot) -> None: 21 | self.bot = bot 22 | 23 | if config["LOOPS"]["GAINERS"]["STOCKS"]["ENABLED"]: 24 | self.stocks_channel = None 25 | self.stocks.start() 26 | 27 | if config["LOOPS"]["GAINERS"]["CRYPTO"]["ENABLED"]: 28 | self.crypto_gainers_channel = None 29 | 30 | if config["LOOPS"]["LOSERS"]["CRYPTO"]["ENABLED"]: 31 | self.crypto_losers_channel = None 32 | 33 | if ( 34 | config["LOOPS"]["GAINERS"]["CRYPTO"]["ENABLED"] 35 | or config["LOOPS"]["LOSERS"]["CRYPTO"]["ENABLED"] 36 | ): 37 | self.crypto.start() 38 | 39 | @loop(hours=1) 40 | @loop_error_catcher 41 | async def crypto(self) -> None: 42 | """ 43 | This function will check the gainers and losers on Binance, using USDT as the base currency. 44 | To prevent too many calls the losers are also done in this section. 45 | 46 | Returns 47 | ------- 48 | None 49 | """ 50 | 51 | gainers, losers = await get_gainers_losers() 52 | 53 | # Format the embed 54 | e_gainers = await format_embed(gainers, "Gainers", "binance") 55 | e_losers = await format_embed(losers, "Losers", "binance") 56 | 57 | # Post the embed in the channel 58 | if config["LOOPS"]["GAINERS"]["CRYPTO"]["ENABLED"]: 59 | if self.crypto_gainers_channel is None: 60 | self.crypto_gainers_channel = await get_channel( 61 | self.bot, 62 | config["LOOPS"]["GAINERS"]["CHANNEL"], 63 | config["CATEGORIES"]["CRYPTO"], 64 | ) 65 | await self.crypto_gainers_channel.purge(limit=1) 66 | await self.crypto_gainers_channel.send(embed=e_gainers) 67 | 68 | if config["LOOPS"]["LOSERS"]["CRYPTO"]["ENABLED"]: 69 | if self.crypto_losers_channel is None: 70 | self.crypto_losers_channel = await get_channel( 71 | self.bot, 72 | config["LOOPS"]["LOSERS"]["CHANNEL"], 73 | config["CATEGORIES"]["CRYPTO"], 74 | ) 75 | await self.crypto_losers_channel.purge(limit=1) 76 | await self.crypto_losers_channel.send(embed=e_losers) 77 | 78 | @loop(hours=1) 79 | @loop_error_catcher 80 | async def stocks(self) -> None: 81 | """ 82 | Gets the top 10 gainers for the day and posts them in the channel. 83 | 84 | Returns 85 | ------- 86 | None 87 | """ 88 | if self.stocks_channel is None: 89 | self.stocks_channel = await get_channel( 90 | self.bot, 91 | config["LOOPS"]["GAINERS"]["CHANNEL"], 92 | config["CATEGORIES"]["STOCKS"], 93 | ) 94 | 95 | # Dont send if the market is closed 96 | if afterHours(): 97 | return 98 | 99 | try: 100 | gainers = await get_gainers(count=10) 101 | e = await format_embed(pd.DataFrame(gainers), "Gainers", "yahoo") 102 | await self.stocks_channel.purge(limit=1) 103 | await self.stocks_channel.send(embed=e) 104 | except Exception as e: 105 | logger.error(f"Error posting stocks gainers: {e}") 106 | 107 | 108 | def setup(bot: commands.Bot) -> None: 109 | bot.add_cog(Gainers(bot)) 110 | -------------------------------------------------------------------------------- /src/api/cmc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from api.http_client import get_json_data 7 | from constants.logger import logger 8 | from util.formatting import format_change 9 | 10 | 11 | async def top_cmc(): 12 | data = await get_json_data( 13 | "https://api.coinmarketcap.com/nft/v3/nft/collectionsv2?start=0&limit=100&category=&collection=&blockchain=&sort=volume&desc=true&period=1" 14 | ) 15 | 16 | # Convert to dataframe 17 | if "data" not in data: 18 | logger.error("No data found in CoinMarketCap response") 19 | return pd.DataFrame() 20 | if "collections" not in data["data"]: 21 | logger.error("No collections found in CoinMarketCap response") 22 | return pd.DataFrame() 23 | 24 | df = pd.DataFrame(data["data"]["collections"]) 25 | 26 | df = df.head(10) 27 | 28 | # Unpack all oneDay data 29 | df = pd.concat([df.drop(["oneDay"], axis=1), df["oneDay"].apply(pd.Series)], axis=1) 30 | 31 | # name, url, price, volume, volume change 32 | # Conditionally concatenate "name" and "website" only when "website" is not NaN 33 | df["symbol"] = np.where( 34 | df["website"].notna() & (df["website"] != ""), 35 | "[" + df["name"] + "]" + "(" + df["website"] + ")", 36 | df["name"], 37 | ) 38 | df["price"] = df["floorPriceUsd"].apply(lambda x: f"${x:,.2f}") 39 | df["change"] = df["averagePriceChangePercentage"].apply(lambda x: format_change(x)) 40 | df["price"] = df["price"] + " (" + df["change"] + ")" 41 | df["volume"] = df["volume"].apply(lambda x: f"{x:,.0f} ETH") 42 | df["volume_change"] = df["volumeChangePercentage"].apply(lambda x: format_change(x)) 43 | df["volume"] = df["volume"] + " (" + df["volume_change"] + ")" 44 | 45 | return df 46 | 47 | 48 | async def upcoming_cmc(): 49 | # Could remove category and expire from URL 50 | data = await get_json_data( 51 | "https://api.coinmarketcap.com/nft/v3/nft/upcoming-drops?start=0&limit=20&category=Popular&expire=30" 52 | ) 53 | 54 | # Convert the data to a pandas DataFrame 55 | df = pd.DataFrame(data["data"]["data"]) 56 | 57 | df = df.head(10) 58 | 59 | # name, websiteUrl, price, dropDate 60 | # Filter out the columns that actually exist in the DataFrame 61 | existing_columns = [ 62 | col for col in ["name", "websiteUrl", "price", "dropType"] if col in df.columns 63 | ] 64 | 65 | # Use only the existing columns to filter the DataFrame 66 | df = df[existing_columns] 67 | 68 | # Use same method as #events channel time 69 | # Rename to start_time 70 | # df["start_time"] = df["dropDate"].apply( 71 | # lambda x: f"" if pd.notnull(x) else "" 72 | # ) 73 | 74 | # Conditionally concatenate "name" and "website" only when "website" is not NaN 75 | df["symbol"] = np.where( 76 | df["websiteUrl"].notna() & (df["websiteUrl"] != ""), 77 | "[" + df["name"] + "]" + "(" + df["websiteUrl"] + ")", 78 | df["name"], 79 | ) 80 | 81 | return df 82 | 83 | 84 | async def trending(): 85 | cmc_data = await get_json_data( 86 | "https://api.coinmarketcap.com/data-api/v3/topsearch/rank" 87 | ) 88 | 89 | # Convert to dataframe 90 | cmc_df = pd.DataFrame(cmc_data["data"]["cryptoTopSearchRanks"]) 91 | 92 | # Only save [[symbol, price + pricechange, volume]] 93 | cmc_df = cmc_df[["symbol", "slug", "priceChange"]] 94 | 95 | # Rename symbol 96 | cmc_df.rename(columns={"symbol": "Symbol"}, inplace=True) 97 | 98 | # Add website to symbol 99 | cmc_df["Website"] = "https://coinmarketcap.com/currencies/" + cmc_df["slug"] 100 | # Format the symbol 101 | cmc_df["Symbol"] = "[" + cmc_df["Symbol"] + "](" + cmc_df["Website"] + ")" 102 | 103 | # Get important information from priceChange dictionary 104 | cmc_df["Price"] = cmc_df["priceChange"].apply(lambda x: x["price"]) 105 | cmc_df["% Change"] = cmc_df["priceChange"].apply(lambda x: x["priceChange24h"]) 106 | cmc_df["Volume"] = cmc_df["priceChange"].apply(lambda x: x["volume24h"]) 107 | 108 | return cmc_df 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | authentication.json 3 | *.bat 4 | *.ps1 5 | models/* 6 | *.lnk 7 | yield.png 8 | src/cogs/loops/option_alert.py 9 | .vscode/ 10 | *.db 11 | scraped/* 12 | scraped_old/* 13 | logs/* 14 | curl.txt 15 | data/ 16 | temp/ 17 | 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | /output/* 24 | /baseline/* 25 | /wandb/* 26 | 27 | # C extensions 28 | *.so 29 | 30 | # Distribution / packaging 31 | .Python 32 | build/ 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | share/python-wheels/ 45 | *.egg-info/ 46 | .installed.cfg 47 | *.egg 48 | MANIFEST 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .nox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *.cover 70 | *.py,cover 71 | .hypothesis/ 72 | .pytest_cache/ 73 | cover/ 74 | 75 | # Translations 76 | *.mo 77 | *.pot 78 | 79 | # Django stuff: 80 | *.log 81 | local_settings.py 82 | db.sqlite3 83 | db.sqlite3-journal 84 | 85 | # Flask stuff: 86 | instance/ 87 | .webassets-cache 88 | 89 | # Scrapy stuff: 90 | .scrapy 91 | 92 | # Sphinx documentation 93 | docs/_build/ 94 | 95 | # PyBuilder 96 | .pybuilder/ 97 | target/ 98 | 99 | # Jupyter Notebook 100 | .ipynb_checkpoints 101 | 102 | # IPython 103 | profile_default/ 104 | ipython_config.py 105 | 106 | # pyenv 107 | # For a library or package, you might want to ignore these files since the code is 108 | # intended to run in multiple environments; otherwise, check them in: 109 | # .python-version 110 | 111 | # pipenv 112 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 113 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 114 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 115 | # install all needed dependencies. 116 | #Pipfile.lock 117 | 118 | # poetry 119 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 120 | # This is especially recommended for binary packages to ensure reproducibility, and is more 121 | # commonly ignored for libraries. 122 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 123 | #poetry.lock 124 | 125 | # pdm 126 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 127 | #pdm.lock 128 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 129 | # in version control. 130 | # https://pdm.fming.dev/#use-with-ide 131 | .pdm.toml 132 | 133 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 134 | __pypackages__/ 135 | 136 | # Celery stuff 137 | celerybeat-schedule 138 | celerybeat.pid 139 | 140 | # SageMath parsed files 141 | *.sage.py 142 | 143 | # Environments 144 | .env 145 | .venv 146 | env/ 147 | venv/ 148 | ENV/ 149 | env.bak/ 150 | venv.bak/ 151 | 152 | # Spyder project settings 153 | .spyderproject 154 | .spyproject 155 | 156 | # Rope project settings 157 | .ropeproject 158 | 159 | # mkdocs documentation 160 | /site 161 | 162 | # mypy 163 | .mypy_cache/ 164 | .dmypy.json 165 | dmypy.json 166 | 167 | # Pyre type checker 168 | .pyre/ 169 | 170 | # pytype static type analyzer 171 | .pytype/ 172 | 173 | # Cython debug symbols 174 | cython_debug/ 175 | 176 | # PyCharm 177 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 178 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 179 | # and can be added to the global gitignore or merged into this file. For a more nuclear 180 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 181 | #.idea/ 182 | job.slurm 183 | ssh.txt 184 | wandb_key.txt 185 | -------------------------------------------------------------------------------- /src/cogs/loops/spy_heatmap.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import discord 5 | import pandas as pd 6 | import plotly.express as px 7 | from discord.ext import commands 8 | from discord.ext.tasks import loop 9 | 10 | from api.unusualwhales import get_spy_heatmap 11 | from constants.config import config 12 | from constants.sources import data_sources 13 | from util.afterhours import afterHours 14 | from util.disc import get_channel, loop_error_catcher 15 | 16 | 17 | class SPY_heatmap(commands.Cog): 18 | """ 19 | This class contains the cog for posting the S&P 500 heatmap. 20 | It can be enabled / disabled in the config under ["LOOPS"]["SPY_HEATMAP"]. 21 | """ 22 | 23 | def __init__(self, bot: commands.Bot) -> None: 24 | self.bot = bot 25 | self.channel = None 26 | self.post_heatmap.start() 27 | 28 | @loop(hours=2) 29 | @loop_error_catcher 30 | async def post_heatmap(self): 31 | if afterHours(): 32 | return 33 | 34 | if self.channel is None: 35 | self.channel = await get_channel( 36 | self.bot, 37 | config["LOOPS"]["SPY_HEATMAP"]["CHANNEL"], 38 | config["CATEGORIES"]["STOCKS"], 39 | ) 40 | 41 | df = await get_spy_heatmap() 42 | create_treemap(df) 43 | 44 | e = discord.Embed( 45 | title="The S&P 500 Heatmap", 46 | description="", 47 | color=data_sources["unusualwhales"]["color"], 48 | timestamp=datetime.datetime.now(datetime.timezone.utc), 49 | url="https://unusualwhales.com/heatmaps", 50 | ) 51 | 52 | file_name = "spy_heatmap.png" 53 | file_path = os.path.join("temp", file_name) 54 | file = discord.File(file_path, filename=file_name) 55 | e.set_image(url=f"attachment://{file_name}") 56 | e.set_footer( 57 | text="\u200b", 58 | icon_url=data_sources["unusualwhales"]["icon"], 59 | ) 60 | 61 | await self.channel.purge(limit=1) 62 | await self.channel.send(file=file, embed=e) 63 | 64 | # Delete temp file 65 | os.remove(file_path) 66 | 67 | 68 | def create_treemap(df: pd.DataFrame, save_img: bool = True) -> None: 69 | """ 70 | Creates a treemap of the S&P 500 heatmap data. 71 | 72 | Parameters 73 | ---------- 74 | df : pd.DataFrame 75 | The input DataFrame containing the S&P 500 heatmap data. 76 | save_img : bool, optional 77 | Saves the heatmap as a image, by default False 78 | """ 79 | 80 | # Custom color scale 81 | color_scale = [ 82 | (0, "#ff2c1c"), # Bright red at -5% 83 | (0.5, "#484454"), # Grey around 0% 84 | (1, "#30dc5c"), # Bright green at 5% 85 | ] 86 | 87 | # Generate the treemap 88 | fig = px.treemap( 89 | df, 90 | path=[px.Constant("Sectors"), "sector", "industry", "ticker"], 91 | values="marketcap", 92 | color="percentage_change", 93 | hover_data=["percentage_change", "ticker", "marketcap"], 94 | color_continuous_scale=color_scale, 95 | range_color=(-5, 5), 96 | color_continuous_midpoint=0, 97 | ) 98 | 99 | # Removes background colors to improve saved image 100 | fig.update_layout( 101 | margin=dict(t=30, l=10, r=10, b=10), 102 | font_size=20, 103 | coloraxis_colorbar=None, 104 | paper_bgcolor="rgba(0,0,0,0)", 105 | plot_bgcolor="rgba(0,0,0,0)", 106 | ) 107 | 108 | fig.data[0].texttemplate = "%{customdata[1]}
%{customdata[0]:.2f}%" 109 | 110 | # Set the text position to the middle of the treemap 111 | # and add a black border around each box 112 | fig.update_traces( 113 | textposition="middle center", 114 | marker=dict(line=dict(color="black", width=1)), 115 | ) 116 | 117 | # Disable the color bar 118 | fig.update(layout_coloraxis_showscale=False) 119 | 120 | # Save the figure as an image 121 | # Increase the width and height for better quality 122 | if save_img: 123 | fig.write_image( 124 | file="temp/spy_heatmap.png", format="png", width=1920, height=1080 125 | ) 126 | 127 | 128 | def setup(bot: commands.Bot) -> None: 129 | bot.add_cog(SPY_heatmap(bot)) 130 | -------------------------------------------------------------------------------- /src/cogs/commands/help.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.commands.context import ApplicationContext 3 | from discord.ext import commands 4 | 5 | from util.disc import get_guild, log_command_usage 6 | 7 | 8 | class Help(commands.Cog): 9 | """ 10 | Custom help command. 11 | """ 12 | 13 | def __init__(self, bot): 14 | self.bot = bot 15 | self.cmd_dict = {} 16 | self.guild = None 17 | 18 | @commands.slash_command(description="Receive information about a command.") 19 | @discord.option( 20 | "command", str, description="Command to get help for.", required=False 21 | ) 22 | @log_command_usage 23 | async def help( 24 | self, 25 | ctx: ApplicationContext, 26 | command: str = None, 27 | ): 28 | """ 29 | Receive information about a command or channel 30 | Usage: `/help ` 31 | List all commands available to you. If you want more information about a specific command, simply type that command after `/help`. 32 | 33 | 34 | Parameters 35 | ---------- 36 | ctx : commands.Context 37 | The context of the command. 38 | command : Option, optional 39 | A specific command, by default it will show all commands, required=False) 40 | """ 41 | await ctx.response.defer(ephemeral=True) 42 | 43 | if self.cmd_dict == {}: 44 | self.get_cmd_dict() 45 | 46 | if not self.guild: 47 | self.guild = get_guild(self.bot) 48 | 49 | # List all commands 50 | if not command: 51 | e = discord.Embed( 52 | title="Available commands", 53 | color=self.guild.self_role.color, 54 | description="Use `/help ` to get more information about a command!", 55 | ) 56 | 57 | cmd_mentions = [] 58 | cmd_descs = [] 59 | 60 | for v in list(self.cmd_dict.values()): 61 | cmd_mentions.append(v[0]) 62 | cmd_descs.append(v[1]) 63 | 64 | e.add_field(name="Commands", value="\n".join(cmd_mentions), inline=True) 65 | e.add_field(name="Description", value="\n".join(cmd_descs), inline=True) 66 | 67 | await ctx.respond(embed=e) 68 | else: 69 | command = command.lower() 70 | 71 | if command in self.cmd_dict.keys(): 72 | e = discord.Embed( 73 | title=f"The {command} command", 74 | color=self.guild.self_role.color, 75 | description="", 76 | ) 77 | 78 | options = [] 79 | for option in self.cmd_dict[command][2]: 80 | options.append(f"**{option.name}**: {option.description}") 81 | 82 | e.add_field(name="Command", value=self.cmd_dict[command][0]) 83 | e.add_field(name="Description", value=self.cmd_dict[command][1]) 84 | e.add_field(name="Parameters", value="\n".join(options)) 85 | await ctx.respond(embed=e) 86 | else: 87 | await ctx.respond(f"The {command} command was not found.") 88 | 89 | def get_cmd_dict(self): 90 | self.cmd_dict = {} 91 | 92 | # Iterate through all commands 93 | for _, cog in self.bot.cogs.items(): 94 | commands = cog.get_commands() 95 | # https://docs.pycord.dev/en/stable/api.html?highlight=slashcommand#discord.SlashCommand 96 | for command in commands: 97 | # https://docs.pycord.dev/en/stable/api.html?highlight=slashcommand#slashcommandgroup 98 | if isinstance(command, discord.SlashCommandGroup): 99 | for subcommand in command.walk_commands(): 100 | self.cmd_dict[f"{command.name} {subcommand.name}"] = [ 101 | subcommand.mention, 102 | subcommand.description, 103 | subcommand.options, 104 | ] 105 | 106 | elif isinstance(command, discord.SlashCommand): 107 | self.cmd_dict[command.name] = [ 108 | command.mention, 109 | command.description, 110 | command.options, 111 | ] 112 | 113 | 114 | def setup(bot: commands.Bot) -> None: 115 | bot.add_cog(Help(bot)) 116 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # FinTwit-Bot Guidelines 2 | 3 | ## Code Formatting and Linting 4 | 5 | FinTwit-Bot uses [Black](https://black.readthedocs.io/en/stable/index.html) with default values for automatic code formatting, along with [ruff](https://docs.astral.sh/ruff/). We also use [NumPy-style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html) for documenting the code. As part of the checks on pull requests, it is checked whether the code still adheres to the code style. To ensure you don't need to worry about formatting and linting when contributing, it is recommended to set up the following: 6 | 7 | - Integration in VS Code: 8 | - For [Black](https://marketplace.visualstudio.com/items?itemName=ms-python.black-formatter) 9 | - For [ruff](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) 10 | - For automated [NumPy-style docstrings](https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring) 11 | - You need to change the `docstringFormat` setting to NumPy (see below) 12 | 13 | ### VS Code Settings 14 | 15 | To enable organzing the import on save we need to change the settings. 16 | You can find this by pressing `F1` or `Ctrl+Shift+P` in VS code and typing `Preferences: Open User Settings (JSON)`. 17 | Paste the contents of the following JSON document in there. 18 | 19 | ```json 20 | { 21 | "autoDocstring.docstringFormat": "numpy", 22 | "[python]": { 23 | "editor.codeActionsOnSave": { 24 | "source.organizeImports": "always" 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | ## Submitting Pull Requests 31 | 32 | When submitting a pull request, please follow these guidelines: 33 | 34 | 1. Fork the repository and create your branch from `main`. 35 | 2. Ensure your branch is up to date with the latest `main` branch. 36 | 3. Write clear and descriptive commit messages. 37 | 4. Include a detailed description of your changes in the pull request and the issues that the PR closes. 38 | 5. Ensure that your code passes all tests and linter checks. 39 | 40 | ## Pull Request Merging Policy 41 | 42 | To ensure a smooth and transparent merging process, we have established the following policy for merging pull requests: 43 | 44 | 1. **Review and Approval**: 45 | 46 | - Pull requests must be reviewed and approved by at least one project maintainer. 47 | - Reviews from other contributors are encouraged but not required. 48 | 49 | 2. **Passing Checks**: 50 | 51 | - All pull requests must pass continuous integration (CI) checks, including tests and linters. 52 | - Ensure there are no merge conflicts with the `main` branch. 53 | 54 | 3. **Documentation**: 55 | 56 | - If your pull request introduces new features or changes existing functionality, update the relevant documentation. 57 | 58 | 4. **Labeling**: 59 | 60 | - Use appropriate labels (e.g., `bug`, `enhancement`, `documentation`) to categorize the pull request. 61 | 62 | 5. **Merging**: 63 | - Once approved, passing all checks, and reviewed by the required number of maintainers, the pull request can be merged. 64 | - Preferably use "Squash and merge" to maintain a clean commit history, unless the commit history is meaningful and should be preserved. 65 | 66 | ## Branch Management Policy 67 | 68 | 1. **Branch Naming Conventions**: 69 | 70 | - Use descriptive names for your branches to make it clear what feature or fix is being worked on. 71 | - Common prefixes include `feature/`, `bugfix/`, `hotfix/`, and `chore/`. 72 | - Example: `feature/add-login-page`, `bugfix/fix-navbar-issue`. 73 | 74 | 2. **Creating Branches**: 75 | 76 | - Always create branches from the latest version of the `main` branch. 77 | - Use short-lived branches for new features, bug fixes, or any changes to the codebase. 78 | 79 | 3. **Pull Requests**: 80 | 81 | - Ensure your branch is up-to-date with `main` before opening a pull request. 82 | - Provide a clear and detailed description of the changes in your pull request. 83 | - Link to any relevant issues or discussions. 84 | 85 | 4. **Merging Branches**: 86 | 87 | - Ensure all CI checks pass before merging. 88 | - Preferably use "Squash and merge" to maintain a clean commit history. 89 | - Obtain necessary approvals from maintainers or reviewers before merging. 90 | 91 | 5. **Deleting Merged Branches**: 92 | 93 | - After a branch has been successfully merged into `main`, it should be deleted. 94 | - This helps keep the repository clean and reduces clutter. 95 | - You can delete the branch directly on GitHub after merging, or use the following Git command: 96 | 97 | ```bash 98 | git branch -d your-branch-name 99 | git push origin --delete your-branch-name 100 | ``` 101 | -------------------------------------------------------------------------------- /src/cogs/loops/earnings_overview.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import discord 4 | import pandas as pd 5 | from discord.ext import commands 6 | from discord.ext.tasks import loop 7 | 8 | from api.nasdaq import get_earnings_for_date 9 | from constants.config import config 10 | from constants.logger import logger 11 | from constants.sources import data_sources 12 | from util.disc import get_channel, get_tagged_users, loop_error_catcher 13 | 14 | 15 | class Earnings_Overview(commands.Cog): 16 | """ 17 | This class is responsible for sending weekly overview of upcoming earnings. 18 | You can enable / disable this command in the config, under ["LOOPS"]["EARNINGS_OVERVIEW"]. 19 | """ 20 | 21 | def __init__(self, bot: commands.Bot) -> None: 22 | self.bot = bot 23 | self.channel = None 24 | 25 | self.earnings.start() 26 | 27 | def earnings_embed(self, df: pd.DataFrame, date: str) -> tuple[str, discord.Embed]: 28 | # Create lists of the important info 29 | tickers = "\n".join(df["symbol"].to_list()) 30 | time_type = "\n".join(df["time"].to_list()) 31 | epsestimate = "\n".join(df["epsForecast"].replace("nan", "N/A").to_list()) 32 | 33 | # Make an embed with these tickers and their earnings date + estimation 34 | e = discord.Embed( 35 | title=f"Earnings for {date}", 36 | url=f"https://finance.yahoo.com/calendar/earnings?day={date}", 37 | description="", 38 | color=data_sources["yahoo"]["color"], 39 | timestamp=datetime.datetime.now(datetime.timezone.utc), 40 | ) 41 | 42 | e.add_field(name="Stock", value=tickers, inline=True) 43 | e.add_field(name="Time", value=time_type, inline=True) 44 | e.add_field(name="Estimate", value=epsestimate, inline=True) 45 | 46 | e.set_footer( 47 | text="\u200b", 48 | icon_url=data_sources["yahoo"]["icon"], 49 | ) 50 | 51 | tags = get_tagged_users(df["symbol"].to_list()) 52 | 53 | return tags, e 54 | 55 | async def get_earnings_in_date_range( 56 | self, start_date, end_date 57 | ) -> list[pd.DataFrame]: 58 | dfs = [] 59 | for i in range((end_date - start_date).days + 1): 60 | date = start_date + datetime.timedelta(days=i) 61 | df = await get_earnings_for_date(date) 62 | dfs.append(df) 63 | 64 | return dfs 65 | 66 | def date_check(self) -> bool: 67 | """ 68 | Check if today is a sunday and if it's 12 o'clock. 69 | 70 | Returns 71 | ---------- 72 | bool: 73 | True if today is a sunday and the market is closed. 74 | """ 75 | 76 | if ( 77 | datetime.datetime.today().weekday() == 6 78 | and datetime.datetime.utcnow().hour == 12 79 | ): 80 | return True 81 | return False 82 | 83 | @loop(hours=1) 84 | @loop_error_catcher 85 | async def earnings(self) -> None: 86 | """ 87 | Checks every hour if today is a sunday and if the market is closed. 88 | If that is the case a overview will be posted with the upcoming earnings. 89 | 90 | Returns 91 | ---------- 92 | None 93 | """ 94 | if self.channel is None: 95 | self.channel = await get_channel( 96 | self.bot, config["LOOPS"]["EARNINGS_OVERVIEW"]["CHANNEL"] 97 | ) 98 | 99 | # Send this message every sunday at 12:00 UTC 100 | if self.date_check(): 101 | end_date = datetime.datetime.now() + datetime.timedelta(days=6) 102 | start_date = datetime.datetime.now() + datetime.timedelta(days=1) 103 | earnings_dfs = await self.get_earnings_in_date_range( 104 | start_date, 105 | end_date, 106 | ) 107 | 108 | for earnings_df, i in zip( 109 | earnings_dfs, range((end_date - start_date).days + 1) 110 | ): 111 | date = start_date + datetime.timedelta(days=i) 112 | date_string = date.strftime("%Y-%m-%d") 113 | 114 | if earnings_df.empty: 115 | logger.warn(f"No earnings found for {date_string}") 116 | continue 117 | 118 | # Only use the top 10 per dataframe 119 | # Could change this in min. 1 billion USD market cap 120 | 121 | tags, e = self.earnings_embed(earnings_df.head(10), date_string) 122 | await self.channel.send(content=tags, embed=e) 123 | 124 | 125 | def setup(bot: commands.Bot) -> None: 126 | bot.add_cog(Earnings_Overview(bot)) 127 | -------------------------------------------------------------------------------- /src/cogs/loops/trades.py: -------------------------------------------------------------------------------- 1 | ##> Imports 2 | import asyncio 3 | 4 | import ccxt.pro as ccxt 5 | 6 | # > 3rd Party Dependencies 7 | import pandas as pd 8 | 9 | # > Discord dependencies 10 | from discord.ext import commands 11 | 12 | import util.vars 13 | 14 | # Local dependencies 15 | from constants.config import config 16 | from constants.logger import logger 17 | from util.db import get_db, update_db 18 | from util.disc import get_channel, get_user, loop_error_catcher 19 | from util.trades_msg import on_msg 20 | 21 | 22 | class Trades(commands.Cog): 23 | """ 24 | This class contains the cog for posting new trades done by users. 25 | It can be enabled / disabled in the config under ["LOOPS"]["TRADES"]. 26 | """ 27 | 28 | def __init__( 29 | self, bot: commands.Bot, db: pd.DataFrame = get_db("portfolio") 30 | ) -> None: 31 | self.bot = bot 32 | self.trades_channel = None 33 | # Start getting trades 34 | asyncio.create_task(self.trades(db)) 35 | 36 | async def start_sockets(self, exchange, row, user) -> None: 37 | while True: 38 | try: 39 | msg = await exchange.watchMyTrades() 40 | await on_msg(msg, exchange, self.trades_channel, row, user) 41 | except ccxt.base.errors.AuthenticationError: 42 | # Send message to user and delete from database 43 | logger.error(row) 44 | break 45 | 46 | except Exception as e: 47 | # Maybe do: await exchange.close() and restart the socket 48 | logger.error( 49 | f"Error in trade websocket for {row['user']} and {exchange.id}: {e}" 50 | ) 51 | 52 | @loop_error_catcher 53 | async def trades(self, db: pd.DataFrame) -> None: 54 | """ 55 | Starts the websockets for each user in the database. 56 | 57 | Parameters 58 | ---------- 59 | db : pd.DataFrame 60 | The database containing all users. 61 | """ 62 | if self.trades_channel is None: 63 | self.trades_channel = await get_channel( 64 | self.bot, config["LOOPS"]["TRADES"]["CHANNEL"] 65 | ) 66 | 67 | tasks = [] 68 | exchanges = [] 69 | 70 | if not db.empty: 71 | # Divide per exchange 72 | binance = db.loc[db["exchange"] == "binance"] 73 | kucoin = db.loc[db["exchange"] == "kucoin"] 74 | 75 | if not binance.empty: 76 | for i, row in binance.iterrows(): 77 | # If using await, it will block other connections 78 | exchange = ccxt.binance( 79 | {"apiKey": row["key"], "secret": row["secret"]} 80 | ) 81 | user = await get_user(self.bot, row["id"]) 82 | 83 | # Make sure that the API keys are valid 84 | try: 85 | exchange.fetch_balance() 86 | except Exception: 87 | # Send message to user and delete from database 88 | await user.send( 89 | "Your Binance API key is invalid, we have removed it from our database." 90 | ) 91 | 92 | # Get the portfolio 93 | util.vars.portfolio_db.drop(i, inplace=True) 94 | 95 | update_db(util.vars.portfolio_db, "portfolio") 96 | 97 | logger.debug(f"Removed Binance API key for {row['user']}") 98 | 99 | task = asyncio.create_task(self.start_sockets(exchange, row, user)) 100 | tasks.append(task) 101 | exchanges.append(exchange) 102 | logger.info(f"Started Binance socket for {row['user']}") 103 | 104 | if not kucoin.empty: 105 | for _, row in kucoin.iterrows(): 106 | exchange = ccxt.kucoin( 107 | { 108 | "apiKey": row["key"], 109 | "secret": row["secret"], 110 | "password": row["passphrase"], 111 | } 112 | ) 113 | task = asyncio.create_task( 114 | self.start_sockets( 115 | exchange, row, await get_user(self.bot, row["id"]) 116 | ) 117 | ) 118 | tasks.append(task) 119 | exchanges.append(exchange) 120 | logger.debug(f"Started KuCoin socket for {row['user']}") 121 | 122 | # After 24 hours close the exchange and start again 123 | await asyncio.sleep(24 * 60 * 60) 124 | 125 | logger.debug("Stopping all sockets") 126 | for task, exchange in zip(tasks, exchanges): 127 | task.cancel() 128 | await exchange.close() 129 | await asyncio.sleep(10) 130 | 131 | # Restart the socket 132 | await self.trades(db) 133 | 134 | 135 | def setup(bot: commands.Bot) -> None: 136 | bot.add_cog(Trades(bot)) 137 | -------------------------------------------------------------------------------- /src/cogs/loops/sector_snapshot.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import discord 5 | import matplotlib.colors as mcolors 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | from discord.ext import commands 9 | from discord.ext.tasks import loop 10 | 11 | from api.barchart import get_data 12 | from constants.config import config 13 | from constants.sources import data_sources 14 | from util.disc import get_channel, loop_error_catcher 15 | 16 | 17 | class Sector_snapshot(commands.Cog): 18 | """ 19 | This class contains the cog for posting the sector snapshot. 20 | It can be enabled / disabled in the config under ["LOOPS"]["SECTOR_SNAPSHOT"]. 21 | """ 22 | 23 | def __init__(self, bot: commands.Bot) -> None: 24 | self.bot = bot 25 | self.channel = None 26 | self.post_snapshot.start() 27 | 28 | @loop(hours=12) 29 | @loop_error_catcher 30 | async def post_snapshot(self): 31 | if self.channel is None: 32 | self.channel = await get_channel( 33 | self.bot, 34 | config["LOOPS"]["SECTOR_SNAPSHOT"]["CHANNEL"], 35 | config["CATEGORIES"]["STOCKS"], 36 | ) 37 | 38 | df = await get_data() 39 | plot_data(df) 40 | 41 | # Save plot 42 | file_name = "sector_snap.png" 43 | file_path = os.path.join("temp", file_name) 44 | plt.savefig(file_path, bbox_inches="tight", dpi=300) 45 | plt.cla() 46 | plt.close() 47 | 48 | e = discord.Embed( 49 | title="Percentage Of Large Cap Stocks Above Their Moving Averages", 50 | description="", 51 | color=data_sources["barchart"]["color"], 52 | timestamp=datetime.datetime.now(datetime.timezone.utc), 53 | url="https://www.barchart.com/stocks/market-performance", 54 | ) 55 | file = discord.File(file_path, filename=file_name) 56 | e.set_image(url=f"attachment://{file_name}") 57 | e.set_footer( 58 | text="\u200b", 59 | icon_url=data_sources["barchart"]["icon"], 60 | ) 61 | 62 | await self.channel.purge(limit=1) 63 | await self.channel.send(file=file, embed=e) 64 | 65 | # Delete temp file 66 | os.remove(file_path) 67 | 68 | 69 | def plot_data(df): 70 | # Define custom colormap for each 10% increment 71 | colors = [ 72 | (0, "#620101"), # 0% 73 | (0.1, "#76030f"), # 10% 74 | (0.2, "#801533"), # 20% 75 | (0.3, "#620000"), # 30% 76 | (0.4, "#74274b"), # 40% 77 | (0.5, "#062f5b"), # 50% 78 | (0.6, "#174f52"), # 60% 79 | (0.7, "#113b35"), # 70% 80 | (0.8, "#074510"), # 80% 81 | (0.9, "#03360c"), # 90% 82 | (1.0, "#012909"), # 100% 83 | ] 84 | 85 | # Create the custom colormap 86 | cmap = mcolors.LinearSegmentedColormap.from_list("custom_cmap", colors) 87 | 88 | # Normalize data to [0, 1] for color mapping 89 | norm = plt.Normalize(0, 100) 90 | 91 | # Apply custom colormap 92 | values = df.drop(columns="Name").values 93 | colors = cmap(norm(values)) 94 | 95 | # Create a white color array for the 'Name' column 96 | name_colors = np.ones((df.shape[0], 1, 4)) 97 | 98 | # Concatenate the name colors with the data colors 99 | colors = np.concatenate((name_colors, colors), axis=1) 100 | 101 | # Create the table 102 | fig, ax = plt.subplots(figsize=(14, 6)) 103 | 104 | # Set the background color of the figure 105 | fig.patch.set_facecolor("#2e2e2e") 106 | 107 | ax.axis("off") 108 | 109 | # Add % to all values in the DF except Name column 110 | df = df.map(lambda x: f"{x}%" if isinstance(x, (int, float)) else x) 111 | 112 | # Create the table 113 | table = ax.table( 114 | cellText=df.values, 115 | colLabels=df.columns, 116 | cellColours=colors, 117 | cellLoc="center", 118 | loc="center", 119 | ) 120 | 121 | # Adjust column widths 122 | table.auto_set_column_width(list(range(df.shape[1]))) 123 | table.scale(1, 2) # Make the first column wider 124 | 125 | # Style the header 126 | for key, cell in table.get_celld().items(): 127 | cell.set_text_props(color="w") 128 | cell.set_edgecolor("#2b2f30") # Set the grid color to white 129 | # First row 130 | if key[0] == 0: 131 | cell.set_fontsize(10) 132 | cell.set_text_props(weight="bold") 133 | cell.set_facecolor("#181a1b") 134 | elif key[1] == 0: 135 | cell.set_fontsize(10) 136 | cell.set_facecolor("#181a1b") 137 | cell.set_text_props(ha="left") 138 | else: 139 | cell.set_fontsize(12) 140 | 141 | table.auto_set_font_size(False) 142 | 143 | # Add the title in the top left corner 144 | plt.text( 145 | 0.04, 146 | 1.05, 147 | "Percentage Of Large Cap Stocks Above Their Moving Averages", 148 | transform=ax.transAxes, 149 | fontsize=12, 150 | verticalalignment="top", 151 | horizontalalignment="left", 152 | color="white", 153 | weight="bold", 154 | ) 155 | 156 | 157 | def setup(bot: commands.Bot) -> None: 158 | bot.add_cog(Sector_snapshot(bot)) 159 | -------------------------------------------------------------------------------- /src/api/nasdaq.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import ftplib 5 | import io 6 | from io import StringIO 7 | 8 | import pandas as pd 9 | from dateutil import tz 10 | 11 | from api.http_client import get_json_data, post_json_data 12 | 13 | 14 | def tickers_nasdaq(include_company_data=False): 15 | """Downloads list of tickers currently listed in the NASDAQ 16 | source: https://github.com/atreadw1492/yahoo_fin/blob/master/yahoo_fin/stock_info.py#L151 17 | """ 18 | 19 | ftp = ftplib.FTP("ftp.nasdaqtrader.com") 20 | ftp.login() 21 | ftp.cwd("SymbolDirectory") 22 | 23 | r = io.BytesIO() 24 | ftp.retrbinary("RETR nasdaqlisted.txt", r.write) 25 | 26 | if include_company_data: 27 | r.seek(0) 28 | data = pd.read_csv(r, sep="|") 29 | return data 30 | 31 | info = r.getvalue().decode() 32 | splits = info.split("|") 33 | 34 | tickers = [x for x in splits if "\r\n" in x] 35 | tickers = [x.split("\r\n")[1] for x in tickers if "NASDAQ" not in x != "\r\n"] 36 | tickers = [ticker for ticker in tickers if "File" not in ticker] 37 | 38 | ftp.close() 39 | 40 | return tickers 41 | 42 | 43 | async def get_earnings_for_date(date: datetime.datetime) -> pd.DataFrame: 44 | # Convert datetime to string YYYY-MM-DD 45 | date = date.strftime("%Y-%m-%d") 46 | url = f"https://api.nasdaq.com/api/calendar/earnings?date={date}" 47 | # Add headers to avoid 403 error 48 | headers = { 49 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", 50 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 51 | "Accept-Language": "en,nl-NL;q=0.9,nl;q=0.8,en-CA;q=0.7,ja;q=0.6", 52 | "Accept-Encoding": "gzip, deflate, br, zstd", 53 | "Sec-Ch-Ua": '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"', 54 | } 55 | json = await get_json_data(url, headers=headers) 56 | # Automatically ordered from highest to lowest market cap 57 | if "data" not in json: 58 | return pd.DataFrame() 59 | df = pd.DataFrame(json["data"]["rows"]) 60 | if df.empty: 61 | return df 62 | # Replace time with emojis 63 | emoji_dict = { 64 | "time-after-hours": "🌙", 65 | "time-pre-market": "🌞", 66 | "time-not-supplied": "❓", 67 | } 68 | df["time"] = df["time"].replace(emoji_dict) 69 | return df 70 | 71 | 72 | async def get_halt_data(): 73 | html = await fetch_halt_data() 74 | if html == {}: 75 | return pd.DataFrame() 76 | 77 | df = pd.read_html(StringIO(html["result"]))[0] 78 | 79 | # Drop NaN columns 80 | df = df.dropna(axis=1, how="all") 81 | 82 | # Drop columns where halt date is not today 83 | df = df[df["Halt Date"] == pd.Timestamp.today().strftime("%m/%d/%Y")] 84 | 85 | # Combine columns into one singular datetime column 86 | df["Time"] = df["Halt Date"] + " " + df["Halt Time"] 87 | df["Time"] = pd.to_datetime(df["Time"], format="%m/%d/%Y %H:%M:%S") 88 | 89 | # Do for resumption as well if the column is not NaN 90 | if "Resumption Date" in df.columns and "Resumption Trade Time" in df.columns: 91 | # Combine columns into one singular datetime column 92 | df["Resumption Time"] = ( 93 | df["Resumption Date"] + " " + df["Resumption Trade Time"] 94 | ) 95 | df["Resumption Time"] = pd.to_datetime( 96 | df["Resumption Time"], format="%m/%d/%Y %H:%M:%S" 97 | ) 98 | 99 | df["Resumption Time"] = ( 100 | df["Resumption Time"] 101 | .dt.tz_localize("US/Eastern") 102 | .dt.tz_convert(tz.tzlocal()) 103 | ) 104 | 105 | df["Resumption Time"] = df["Resumption Time"].dt.strftime("%H:%M:%S") 106 | 107 | # Convert to my own timezone 108 | df["Time"] = df["Time"].dt.tz_localize("US/Eastern").dt.tz_convert(tz.tzlocal()) 109 | 110 | # Convert times to string 111 | df["Time"] = df["Time"].dt.strftime("%H:%M:%S") 112 | 113 | # Replace NaN with ? 114 | df = df.fillna("?") 115 | 116 | # Keep the necessary columns 117 | if "Resumption Time" in df.columns: 118 | df = df[["Time", "Issue Symbol", "Resumption Time"]] 119 | else: 120 | df = df[["Time", "Issue Symbol"]] 121 | 122 | return df 123 | 124 | 125 | async def fetch_halt_data() -> dict: 126 | # Based on https://github.com/reorx/nasdaqtrader-rss/blob/e675af99ace7d384950d6c75144e9efb1d80b5a7/rss/index.py#L18 127 | headers = { 128 | "Content-Type": "application/json", 129 | "Origin": "https://www.nasdaqtrader.com", 130 | "Referer": "https://www.nasdaqtrader.com/trader.aspx?id=tradehalts", 131 | "Sec-Ch-Ua": '"Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"', 132 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", 133 | } 134 | req_data = { 135 | "id": 3, 136 | "method": "BL_TradeHalt.GetTradeHalts", 137 | "params": "[]", 138 | "version": "1.1", 139 | } 140 | 141 | html = await post_json_data( 142 | "https://www.nasdaqtrader.com/RPCHandler.axd", 143 | headers=headers, 144 | json=req_data, 145 | ) 146 | 147 | # Convert to DataFrame 148 | return html 149 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import discord 5 | from discord.ext import commands 6 | from dotenv import load_dotenv 7 | 8 | # Load the .env file before importing the rest of the bot 9 | load_dotenv() 10 | 11 | from constants.config import config 12 | from constants.logger import logger 13 | from util.disc import get_guild, set_emoji 14 | 15 | bot = commands.Bot(intents=discord.Intents.all()) 16 | 17 | 18 | @bot.event 19 | async def on_ready() -> None: 20 | """This gets logger.infoed on boot up""" 21 | 22 | # Load the loops and listeners 23 | load_folder("loops") 24 | load_folder("listeners") 25 | 26 | guild = get_guild(bot) 27 | logger.info(f"{bot.user} is connected to {guild.name}") 28 | 29 | await set_emoji(guild) 30 | 31 | 32 | def is_cog_enabled(config_section, file): 33 | """ 34 | Checks if a cog is enabled in the configuration. 35 | 36 | Parameters 37 | ---------- 38 | config_section: dict 39 | The section of the config corresponding to the folder. 40 | file: str 41 | The name of the file to check. 42 | 43 | Returns 44 | ------- 45 | bool 46 | True if the cog is enabled, False otherwise. 47 | """ 48 | cog_config = config_section.get(file) 49 | if cog_config is None: 50 | return False 51 | if isinstance(cog_config, bool): 52 | return cog_config 53 | return cog_config.get("ENABLED", False) 54 | 55 | 56 | def load_cog(filename, foldername): 57 | """ 58 | Loads a single cog by filename. 59 | 60 | Parameters 61 | ---------- 62 | filename: str 63 | The name of the file to load. 64 | foldername: str 65 | The name of the folder containing the cog. 66 | 67 | Returns 68 | ------- 69 | None 70 | """ 71 | try: 72 | logger.info(f"Loading: {filename}") 73 | bot.load_extension(f"cogs.{foldername}.{filename[:-3]}") 74 | except discord.ExtensionAlreadyLoaded: 75 | logger.debug(f"Extension already loaded: {filename}") 76 | except discord.ExtensionNotFound: 77 | logger.warning(f"Cog was not found: {filename}") 78 | except Exception as e: 79 | logger.error(f"Failed to load cog {filename}: {e}", exc_info=True) 80 | 81 | 82 | def load_folder(foldername: str) -> None: 83 | """ 84 | Loads all the cogs in the given folder. 85 | Only loads the cogs if the config allows it. 86 | 87 | Parameters 88 | ---------- 89 | foldername: str 90 | The name of the folder to load the cogs from. 91 | 92 | Returns 93 | ------- 94 | None 95 | """ 96 | logger.info(f"Loading cogs from folder: {foldername}") 97 | folder_config = config.get(foldername.upper(), {}) 98 | 99 | debug_mode = False 100 | if "-debug" in sys.argv: 101 | debug_mode = True 102 | debug_mode_type = config.get("DEBUG_MODE_TYPE", "include_only") 103 | debug_cogs = config.get("DEBUG_COGS", []) 104 | 105 | enabled_cogs = [] 106 | 107 | if debug_mode: 108 | if debug_mode_type == "include_only": 109 | if isinstance(debug_cogs, list): 110 | enabled_cogs = [cog + ".py" for cog in debug_cogs] 111 | if isinstance(debug_cogs, str): 112 | enabled_cogs = [debug_cogs + ".py"] 113 | elif debug_mode_type == "exclude": 114 | if debug_cogs is None: 115 | enabled_cogs = [ 116 | file.lower() + ".py" 117 | for file in folder_config 118 | if is_cog_enabled(folder_config, file) 119 | ] 120 | else: 121 | enabled_cogs = [ 122 | file.lower() + ".py" 123 | for file in folder_config 124 | if is_cog_enabled(folder_config, file) 125 | and file.lower() not in debug_cogs 126 | ] 127 | else: 128 | enabled_cogs = [ 129 | file.lower() + ".py" 130 | for file in folder_config 131 | if is_cog_enabled(folder_config, file) 132 | ] 133 | 134 | # Load all enabled cogs 135 | for filename in os.listdir(f"./src/cogs/{foldername}"): 136 | if filename.endswith(".py") and filename in enabled_cogs: 137 | load_cog(filename, foldername) 138 | 139 | 140 | def get_token(): 141 | debug_mode = False 142 | if "-debug" in sys.argv: 143 | debug_mode = True 144 | 145 | if debug_mode: 146 | logger.info("DEBUG_MODE is enabled") 147 | logger.info( 148 | f"DEBUG_MODE_TYPE is set to: {config.get('DEBUG_MODE_TYPE', 'include_only')}" 149 | ) 150 | 151 | # Read the token from the config 152 | token = os.getenv("DEBUG_TOKEN") if debug_mode else os.getenv("DISCORD_TOKEN") 153 | 154 | if not token: 155 | logger.critical("No Discord token found. Exiting...") 156 | sys.exit(1) 157 | 158 | return token 159 | 160 | 161 | if __name__ == "__main__": 162 | # Start by loading the database 163 | bot.load_extension("util.db") 164 | 165 | # Ensure the all directories exist 166 | os.makedirs("logs", exist_ok=True) 167 | os.makedirs("temp", exist_ok=True) 168 | os.makedirs("data", exist_ok=True) 169 | 170 | token = get_token() 171 | 172 | # Load commands first 173 | load_folder("commands") 174 | 175 | # Main event loop 176 | try: 177 | bot.run(token) 178 | except Exception as e: 179 | logger.critical(f"Bot crashed: {e}") 180 | -------------------------------------------------------------------------------- /src/util/ticker_classifier.py: -------------------------------------------------------------------------------- 1 | ##> Imports 2 | # > Standard libaries 3 | from __future__ import annotations 4 | 5 | from typing import List, Optional, Tuple 6 | 7 | from api.coingecko import get_coin_info 8 | 9 | # Local dependencies 10 | from api.tradingview import tv 11 | from api.yahoo import get_stock_info 12 | from constants.logger import logger 13 | 14 | 15 | async def get_financials(ticker: str, website: str): 16 | """ 17 | Get financial data (price, change, and technical analysis) for a given ticker. 18 | 19 | Parameters 20 | ---------- 21 | ticker : str 22 | The ticker of the asset. 23 | website : str 24 | The source website (e.g., CoinGecko, Yahoo Finance, etc.). 25 | 26 | Returns 27 | ------- 28 | tuple 29 | price, change, four_h_ta, one_d_ta 30 | """ 31 | 32 | asset_type_mapping = { 33 | "coingecko": "crypto", 34 | "yahoo": "stock", 35 | } 36 | 37 | # Determine the asset type based on the website 38 | asset_type = next((v for k, v in asset_type_mapping.items() if k in website), None) 39 | 40 | if not asset_type: 41 | logger.error(f"Unknown website: {website} for ticker: {ticker}") 42 | 43 | # Get financial info based on asset type 44 | if asset_type == "crypto": 45 | _, _, _, price, change, _ = await get_coin_info(ticker) 46 | else: 47 | _, _, _, price, change, _ = await get_stock_info(ticker, asset_type) 48 | 49 | # Get technical analysis (TA) data 50 | four_h_ta, one_d_ta = tv.get_tv_TA(ticker, asset_type) 51 | 52 | return price, change, four_h_ta, one_d_ta 53 | 54 | 55 | async def fetch_asset_info(ticker: str, asset_type: str) -> Tuple: 56 | """ 57 | Fetches information for the given ticker and asset type. 58 | """ 59 | if asset_type == "crypto": 60 | return await get_coin_info(ticker) 61 | elif asset_type == "stock": 62 | return await get_stock_info(ticker) 63 | else: 64 | return await get_stock_info(ticker, asset_type) 65 | 66 | 67 | async def perform_ta(ticker: str, base_sym: str, asset_type: str, get_TA: bool): 68 | """ 69 | Perform technical analysis if required. 70 | """ 71 | if get_TA: 72 | if base_sym is None: 73 | logger.warning(f"No base symbol found for {ticker}") 74 | base_sym = ticker 75 | return tv.get_tv_TA(base_sym, asset_type) 76 | return None, None 77 | 78 | 79 | async def get_best_guess(ticker: str, asset_type: str) -> Tuple: 80 | """ 81 | Gets the best guess of the ticker. 82 | 83 | Parameters 84 | ---------- 85 | ticker : str 86 | The ticker mentioned in a tweet, e.g. BTC. 87 | asset_type : str 88 | The guessed asset type, this can be crypto or stock. 89 | 90 | Returns 91 | ------- 92 | tuple 93 | The data of the best guess. 94 | """ 95 | 96 | get_TA = False 97 | if asset_type == "crypto" and ticker.endswith("BTC") and ticker != "BTC": 98 | get_TA = True 99 | ticker = ticker[:-3] 100 | 101 | volume, website, exchange, price, change, base_sym = await fetch_asset_info( 102 | ticker, asset_type 103 | ) 104 | 105 | # Perform technical analysis if necessary 106 | if volume > 1000000 or get_TA: 107 | four_h_ta, one_d_ta = await perform_ta(ticker, base_sym, asset_type, True) 108 | else: 109 | four_h_ta, one_d_ta = None, None 110 | 111 | return ( 112 | volume, 113 | website, 114 | exchange, 115 | price, 116 | change, 117 | four_h_ta, 118 | one_d_ta, 119 | base_sym, 120 | get_TA, 121 | ) 122 | 123 | 124 | async def classify_ticker( 125 | ticker: str, majority: str 126 | ) -> Optional[Tuple[float, str, List[str], float, str, str]]: 127 | """ 128 | Classify the ticker as crypto, stock, or forex based on the best guess. 129 | 130 | Parameters 131 | ---------- 132 | ticker : str 133 | The ticker of the coin or stock. 134 | majority : str 135 | The guessed majority of the ticker. 136 | 137 | Returns 138 | ------- 139 | Optional[tuple] 140 | The classified asset data. 141 | """ 142 | 143 | if majority == "crypto": 144 | crypto_data = await get_best_guess(ticker, "crypto") 145 | if crypto_data[-1]: # If TA exists 146 | return crypto_data[:-1] 147 | stock_data = await get_best_guess(ticker, "stock") 148 | elif majority == "stocks": 149 | stock_data = await get_best_guess(ticker, "stock") 150 | if stock_data[-1]: # If TA exists 151 | return stock_data[:-1] 152 | crypto_data = await get_best_guess(ticker, "crypto") 153 | else: 154 | crypto_data = await get_best_guess(ticker, "crypto") 155 | stock_data = await get_best_guess(ticker, "stock") 156 | 157 | # Compare volumes and determine best guess 158 | c_volume, s_volume = crypto_data[0], stock_data[0] 159 | 160 | if c_volume > s_volume and c_volume > 50000: 161 | if not crypto_data[5]: # No TA data yet 162 | crypto_data = list(crypto_data) 163 | crypto_data[5], crypto_data[6] = tv.get_tv_TA(ticker, "crypto") 164 | crypto_data = tuple(crypto_data) 165 | return crypto_data[:-1] 166 | else: 167 | if not stock_data[5]: # No TA data yet 168 | stock_data = list(stock_data) 169 | stock_data[5], stock_data[6] = tv.get_tv_TA(ticker, "stock") 170 | stock_data = tuple(stock_data) 171 | return stock_data[:-1] 172 | -------------------------------------------------------------------------------- /src/cogs/loops/events.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import discord 4 | import pandas as pd 5 | from discord.ext import commands 6 | from discord.ext.tasks import loop 7 | 8 | from api.cryptocraft import get_crypto_calendar 9 | from api.investing import get_events 10 | from constants.config import config 11 | from constants.sources import data_sources 12 | from util.disc import get_channel, loop_error_catcher 13 | 14 | 15 | class Events(commands.Cog): 16 | """ 17 | This class is responsible for sending weekly overview of upcoming events. 18 | You can enable / disable this command in the config, under ["LOOPS"]["EVENTS"]. 19 | """ 20 | 21 | def __init__(self, bot: commands.Bot) -> None: 22 | self.bot = bot 23 | 24 | if config["LOOPS"]["EVENTS"]["STOCKS"]["ENABLED"]: 25 | self.stocks_channel = None 26 | self.post_events.start() 27 | 28 | if config["LOOPS"]["EVENTS"]["CRYPTO"]["ENABLED"]: 29 | self.crypto_channel = None 30 | self.post_crypto_events.start() 31 | 32 | @loop(hours=6) 33 | @loop_error_catcher 34 | async def post_events(self): 35 | """ 36 | Checks every hour if today is a friday and if the market is closed. 37 | If that is the case a overview will be posted with the upcoming earnings. 38 | 39 | Returns 40 | ---------- 41 | None 42 | """ 43 | if self.stocks_channel is None: 44 | self.stocks_channel = await get_channel( 45 | self.bot, 46 | config["LOOPS"]["EVENTS"]["CHANNEL"], 47 | config["CATEGORIES"]["STOCKS"], 48 | ) 49 | 50 | df = await get_events() 51 | 52 | # If time == "All Day" convert it to 00:00 53 | df["time"] = df["time"].str.replace("All Day", "00:00") 54 | 55 | # Create datetime 56 | df["datetime"] = pd.to_datetime( 57 | df["date"] + " " + df["time"], 58 | format="%d/%m/%Y %H:%M", 59 | ) 60 | 61 | # Convert datetime to unix timestamp 62 | df["timestamp"] = df["datetime"].astype("int64") // 10**9 63 | 64 | # Convert timestamp to Discord timestamp using mode F 65 | df["timestamp"] = df["timestamp"].apply(lambda x: f"") 66 | 67 | # Replace zone names with emojis 68 | df["zone"] = df["zone"].replace({"euro zone": "🇪🇺", "united states": "🇺🇸"}) 69 | time = "\n".join(df["timestamp"]) 70 | 71 | # Do this if both forecast and previous are not NaN 72 | # Combine 'actual', 'forecast', and 'previous' into a single column 73 | df["Actual | Forecast | Previous"] = df.apply( 74 | lambda row: f"{row['actual'] or '~'} | {row['forecast'] or '~'} | {row['previous'] or '~'}", 75 | axis=1, 76 | ) 77 | for_prev = "\n".join(df["Actual | Forecast | Previous"]) 78 | 79 | df["info"] = df["zone"] + " " + df["event"] 80 | info = "\n".join(df["info"]) 81 | 82 | # Make an embed with these tickers and their earnings date + estimation 83 | e = discord.Embed( 84 | title="Events This Week", 85 | url="https://www.investing.com/economic-calendar/", 86 | description="", 87 | color=data_sources["investing"]["color"], 88 | timestamp=datetime.datetime.now(datetime.timezone.utc), 89 | ) 90 | 91 | e.add_field(name="Date", value=time, inline=True) 92 | e.add_field(name="Event", value=info, inline=True) 93 | e.add_field(name="Actual | Forecast | Previous", value=for_prev, inline=True) 94 | 95 | e.set_footer( 96 | text="\u200b", 97 | icon_url=data_sources["investing"]["icon"], 98 | ) 99 | 100 | # Remove the previous message 101 | await self.stocks_channel.purge() 102 | await self.stocks_channel.send(embed=e) 103 | 104 | @loop(hours=24) 105 | @loop_error_catcher 106 | async def post_crypto_events(self): 107 | if self.crypto_channel is None: 108 | self.crypto_channel = await get_channel( 109 | self.bot, 110 | config["LOOPS"]["EVENTS"]["CHANNEL"], 111 | config["CATEGORIES"]["CRYPTO"], 112 | ) 113 | 114 | df = await get_crypto_calendar() 115 | 116 | if df.empty: 117 | return 118 | 119 | # Make an embed with these tickers and their earnings date + estimation 120 | e = discord.Embed( 121 | title="Upcoming Crypto Events", 122 | url="https://www.cryptocraft.com/calendar", 123 | description="", 124 | color=data_sources["cryptocraft"]["color"], 125 | timestamp=datetime.datetime.now(datetime.timezone.utc), 126 | ) 127 | 128 | # Convert datetime to unix timestamp 129 | df["timestamp"] = df["datetime"].astype("int64") // 10**9 130 | 131 | # Convert timestamp to Discord timestamp using mode F 132 | df["timestamp"] = df["timestamp"].apply(lambda x: f"") 133 | 134 | date = "\n".join(df["timestamp"]) 135 | event = "\n".join(df["event"]) 136 | impact = "\n".join(df["impact"]) 137 | 138 | e.add_field(name="Date", value=date, inline=True) 139 | e.add_field(name="Event", value=event, inline=True) 140 | e.add_field(name="Impact", value=impact, inline=True) 141 | 142 | e.set_footer( 143 | text="\u200b", 144 | icon_url=data_sources["cryptocraft"]["icon"], 145 | ) 146 | 147 | await self.crypto_channel.send(embed=e) 148 | 149 | 150 | def setup(bot: commands.Bot) -> None: 151 | bot.add_cog(Events(bot)) 152 | -------------------------------------------------------------------------------- /src/cogs/loops/treemap.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import discord 5 | import pandas as pd 6 | import plotly.express as px 7 | from discord.ext import commands 8 | from discord.ext.tasks import loop 9 | 10 | from api.coin360 import get_treemap 11 | from constants.config import config 12 | from constants.sources import data_sources 13 | from util.disc import get_channel, loop_error_catcher 14 | 15 | 16 | class Treemap(commands.Cog): 17 | """ 18 | This class contains the cog for posting the S&P 500 heatmap. 19 | It can be enabled / disabled in the config under ["LOOPS"]["SPY_HEATMAP"]. 20 | """ 21 | 22 | def __init__(self, bot: commands.Bot) -> None: 23 | self.bot = bot 24 | self.channel = None 25 | self.file_name = "treemap.png" 26 | self.dir = "temp" 27 | self.post_treemap.start() 28 | 29 | @loop(hours=2) 30 | @loop_error_catcher 31 | async def post_treemap(self): 32 | if self.channel is None: 33 | self.channel = await get_channel( 34 | self.bot, 35 | config["LOOPS"]["TREEMAP"]["CHANNEL"], 36 | config["CATEGORIES"]["CRYPTO"], 37 | ) 38 | 39 | await self.make_treemap() 40 | 41 | e = discord.Embed( 42 | title="Cryptocurrency Treemap", 43 | description="", 44 | color=data_sources["coin360"]["color"], 45 | timestamp=datetime.datetime.now(datetime.timezone.utc), 46 | url="https://coin360.com/", 47 | ) 48 | 49 | file_path = os.path.join(self.dir, self.file_name) 50 | file = discord.File(file_path, filename=self.file_name) 51 | e.set_image(url=f"attachment://{self.file_name}") 52 | e.set_footer( 53 | text="\u200b", 54 | icon_url=data_sources["coin360"]["icon"], 55 | ) 56 | 57 | await self.channel.purge(limit=1) 58 | await self.channel.send(file=file, embed=e) 59 | 60 | # Delete temp file 61 | os.remove(file_path) 62 | 63 | async def make_treemap(self) -> None: 64 | response = await get_treemap() 65 | 66 | # Get the categories 67 | categories: dict = response["categories"] 68 | 69 | # Flatten the data to repeat rows for each category in 'ca' 70 | expanded_data = [] 71 | 72 | for entry in response["data"]: 73 | # Determine the categories to use 74 | category_list = entry.get("ca", ["Others"]) 75 | 76 | for category in category_list: 77 | new_entry = entry.copy() 78 | new_entry["ca"] = categories.get(category, {"title": "Others"})["title"] 79 | expanded_data.append(new_entry) 80 | 81 | # Create a dataframe from the expanded data 82 | df = pd.DataFrame(expanded_data) 83 | 84 | # Create custom text that includes the name, percentage change, and price 85 | df["text"] = ( 86 | '' 87 | + df["s"] 88 | + "" # Name in larger font and bold 89 | + "
" 90 | + '' 91 | + "$" 92 | + df["p"].round(2).astype(str) 93 | + "" # Price in smaller font 94 | + "
" 95 | + '' 96 | + df["ch"].round(2).astype(str) 97 | + "%" # Percentage change in smaller font 98 | ) 99 | # Create the treemap 100 | fig = px.treemap( 101 | df, 102 | path=["ca", "n"], # Divide by category and then by coin name 103 | values="mc", # The size of each block is determined by market cap 104 | color="ch", # Color by the percentage change in price 105 | hover_data=["p", "v", "ts"], # Information to show on hover 106 | color_continuous_scale=[ 107 | (0, "#ed7171"), # Bright red at -5% 108 | (0.5, "grey"), # Grey around 0% 109 | (1, "#80c47c"), # Bright green at 5% 110 | ], 111 | range_color=(-1, 1), 112 | color_continuous_midpoint=0, 113 | custom_data=["text"], # Provide the custom text data for display 114 | ) 115 | 116 | # Removes background colors to improve saved image 117 | fig.update_layout( 118 | margin=dict(t=30, l=10, r=10, b=10), 119 | font_size=20, 120 | coloraxis_colorbar=None, 121 | paper_bgcolor="rgba(0,0,0,0)", 122 | plot_bgcolor="rgba(0,0,0,0)", 123 | ) 124 | 125 | # Adjust the layout for better visualization of the text 126 | fig.update_traces( 127 | texttemplate="%{customdata[0]}", # Use the custom HTML-styled data for the text template 128 | textposition="middle center", # Center the text in the middle of each block 129 | textfont=dict(color="white"), # Set all text color to white 130 | marker=dict( 131 | line=dict(color="black", width=1) 132 | ), # Add a black border around each block for better visibility 133 | ) 134 | 135 | # Disable the color bar 136 | fig.update(layout_coloraxis_showscale=False) 137 | 138 | # Save the figure as an image 139 | # Increase the width and height for better quality 140 | fig.write_image( 141 | file=os.path.join(self.dir, self.file_name), 142 | format="png", 143 | width=1920, 144 | height=1080, 145 | ) 146 | 147 | 148 | def setup(bot: commands.Bot) -> None: 149 | bot.add_cog(Treemap(bot)) 150 | -------------------------------------------------------------------------------- /src/util/exchange_data.py: -------------------------------------------------------------------------------- 1 | import ccxt.async_support as ccxt 2 | import numpy as np 3 | import pandas as pd 4 | 5 | from constants.logger import logger 6 | from constants.stable_coins import stables 7 | 8 | 9 | async def get_data(row) -> pd.DataFrame: 10 | exchange_info = {"apiKey": row["key"], "secret": row["secret"]} 11 | 12 | if row["exchange"] == "binance": 13 | exchange = ccxt.binance(exchange_info) 14 | exchange.options["recvWindow"] = 60000 15 | elif row["exchange"] == "kucoin": 16 | exchange_info["password"] = row["passphrase"] 17 | exchange = ccxt.kucoin(exchange_info) 18 | 19 | try: 20 | balances = await get_balance(exchange) 21 | 22 | if balances == "invalid API key": 23 | await exchange.close() 24 | return "invalid API key" 25 | 26 | # Create a list of dictionaries 27 | owned = [] 28 | 29 | for symbol, amount in balances.items(): 30 | usd_val, percentage = await get_usd_price(exchange, symbol) 31 | worth = amount * usd_val 32 | 33 | # Add price change 34 | 35 | if worth < 5: 36 | continue 37 | 38 | buying_price = await get_buying_price(exchange, symbol) 39 | 40 | # If buying price is 0 then it is not known what the price was 41 | owned.append( 42 | { 43 | "asset": symbol, 44 | "buying_price": buying_price, 45 | "owned": amount, 46 | "exchange": exchange.id, 47 | "id": row["id"], 48 | "user": row["user"], 49 | "worth": round(worth, 2), 50 | "price": usd_val, 51 | "change": percentage, 52 | } 53 | ) 54 | 55 | df = pd.DataFrame(owned) 56 | 57 | # Se tthe types 58 | if not df.empty: 59 | df = df.astype( 60 | { 61 | "asset": str, 62 | "buying_price": float, 63 | "owned": float, 64 | "exchange": str, 65 | "id": np.int64, 66 | "user": str, 67 | "worth": float, 68 | "price": float, 69 | "change": float, 70 | } 71 | ) 72 | 73 | await exchange.close() 74 | return df 75 | except Exception as e: 76 | await exchange.close() 77 | logger.error(f"Error in get_data(). Error: {e}") 78 | 79 | 80 | async def get_balance(exchange) -> dict: 81 | try: 82 | balances = await exchange.fetchBalance() 83 | total_balance = balances["total"] 84 | if total_balance is None: 85 | return "invalid API key" 86 | return {k: v for k, v in total_balance.items() if v > 0} 87 | except Exception: 88 | return {} 89 | 90 | 91 | async def get_usd_price(exchange, symbol: str) -> tuple[float, float]: 92 | """ 93 | Returns the price of the symbol in USD. 94 | Symbol must be in the format 'BTC/USDT'. 95 | """ 96 | # Directly return for USDT or when symbol is a known stable coin 97 | if symbol == "USDT" or symbol in stables: 98 | return 1.0, 0.0 99 | 100 | # Helper function to fetch price and change 101 | async def fetch_price(symbol_pair: str): 102 | try: 103 | price = await exchange.fetchTicker(symbol_pair) 104 | exchange_price = price.get("last", 0) 105 | if exchange_price is None: 106 | exchange_price = 0 107 | exchange_price = float(exchange_price) 108 | exchange_change = price.get("percentage", 0) 109 | if exchange_change is None: 110 | exchange_change = 0 111 | exchange_change = float(exchange_change) 112 | return exchange_price, exchange_change 113 | except (ccxt.BadSymbol, ccxt.RequestTimeout): 114 | return None # Use None to indicate a failed fetch 115 | except ccxt.ExchangeError as e: 116 | logger.error(f"Exchange error for {symbol_pair} on {exchange.id}: {e}") 117 | return None 118 | except Exception as e: 119 | logger.error(f"Error fetching {symbol_pair} on {exchange.id}: {e}") 120 | return None 121 | 122 | # Attempt to fetch price for each stable coin pairing 123 | for usd in stables: 124 | result = await fetch_price(f"{symbol}/{usd}") 125 | if result: 126 | return result 127 | 128 | # Fallback if no price found for any stable pairing 129 | return 0.0, 0.0 130 | 131 | 132 | async def get_buying_price(exchange, symbol, full_sym: bool = False) -> float: 133 | # Maybe try different quote currencies when returned list is empty 134 | if symbol in stables: 135 | return 1 136 | 137 | symbol = symbol + "/USDT" if not full_sym else symbol 138 | 139 | params = {} 140 | if exchange.id == "kucoin": 141 | params = {"side": "buy"} 142 | try: 143 | trades = await exchange.fetchClosedOrders(symbol, params=params) 144 | except ccxt.BadSymbol: 145 | return 0 146 | except ccxt.RequestTimeout: 147 | return 0 148 | if type(trades) == list: 149 | if len(trades) > 0: 150 | if exchange.id == "binance": 151 | # Filter list for side:buy 152 | trades = [trade for trade in trades if trade["info"]["side"] == "BUY"] 153 | if len(trades) == 0: 154 | return 0 155 | 156 | return float(trades[-1]["price"]) 157 | 158 | return 0 159 | -------------------------------------------------------------------------------- /src/cogs/loops/index.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | import discord 6 | from discord.ext import commands 7 | from discord.ext.tasks import loop 8 | 9 | from api.farside import get_etf_inflow 10 | from api.fear_greed import get_feargread 11 | from api.tradingview import tv 12 | from constants.config import config 13 | from constants.sources import data_sources 14 | from constants.tradingview import crypto_indices, forex_indices, stock_indices 15 | from util.afterhours import afterHours 16 | from util.disc import get_channel, loop_error_catcher 17 | from util.formatting import human_format 18 | 19 | 20 | async def create_embed(title: str, indices: list, data_type: str) -> discord.Embed: 21 | e = discord.Embed( 22 | title=title, 23 | description="", 24 | color=data_sources["tradingview"]["color"], 25 | timestamp=datetime.datetime.now(datetime.timezone.utc), 26 | ) 27 | 28 | ticker, prices, changes = [], [], [] 29 | 30 | for index in indices: 31 | price, change, _, exchange, _ = await tv.get_tv_data(index, data_type) 32 | 33 | if price == 0: 34 | continue 35 | 36 | change = round(change, 2) 37 | change = f"+{change}% 📈" if change > 0 else f"{change}% 📉" 38 | 39 | # Special price formatting for crypto 40 | if data_type == "crypto" and index in ["TOTAL", "TOTAL2", "TOTAL3"]: 41 | price = f"{human_format(price)}" 42 | elif data_type == "stock" and index in ["SPY", "NDX"]: 43 | price = f"${round(price, 2)}" 44 | else: 45 | price = f"{round(price, 2)}" 46 | 47 | ticker.append( 48 | f"[{index}](https://www.tradingview.com/symbols/{exchange}-{index}/)" 49 | ) 50 | prices.append(price) 51 | changes.append(change) 52 | 53 | # Handle special Fear & Greed index for crypto 54 | if data_type == "crypto": 55 | fear_greed_data = await get_feargread() 56 | if fear_greed_data is not None: 57 | value, change = fear_greed_data 58 | ticker.append( 59 | "[Fear&Greed](https://alternative.me/crypto/fear-and-greed-index/)" 60 | ) 61 | prices.append(str(value)) 62 | changes.append(change) 63 | 64 | # Add etf inflow 65 | for coin in ["BTC", "ETH"]: 66 | inflow = await get_etf_inflow(coin) 67 | ticker.append(f"{coin} ETF Inflow") 68 | prices.append(f"{inflow}M") 69 | changes.append("N/A") 70 | 71 | if not ticker or not prices or not changes: 72 | return None 73 | 74 | e.add_field(name="Index", value="\n".join(ticker), inline=True) 75 | e.add_field(name="Value", value="\n".join(prices), inline=True) 76 | e.add_field(name="% Change", value="\n".join(changes), inline=True) 77 | 78 | e.set_footer(text="\u200b", icon_url=data_sources["tradingview"]["icon"]) 79 | return e 80 | 81 | 82 | class Index(commands.Cog): 83 | """ 84 | This class contains the cog for posting the crypto and stocks indices. 85 | It can be enabled / disabled in the config under ["LOOPS"]["INDEX"]. 86 | """ 87 | 88 | def __init__(self, bot: commands.Bot) -> None: 89 | self.bot = bot 90 | 91 | if config["LOOPS"]["INDEX"]["CRYPTO"]["ENABLED"]: 92 | self.crypto_channel = None 93 | self.crypto_indices = [sym.split(":")[1] for sym in crypto_indices] 94 | self.crypto.start() 95 | 96 | if config["LOOPS"]["INDEX"]["STOCKS"]["ENABLED"]: 97 | self.stocks_channel = None 98 | self.stock_indices = [sym.split(":")[1] for sym in stock_indices] + [ 99 | sym.split(":")[1] for sym in forex_indices 100 | ] 101 | self.forex_indices = [sym.split(":")[1] for sym in forex_indices] 102 | self.stocks.start() 103 | 104 | @loop(hours=1) 105 | @loop_error_catcher 106 | async def crypto(self) -> None: 107 | """ 108 | This function will get the current prices of crypto indices on TradingView and the Fear and Greed index. 109 | It will then post the prices in the configured channel. 110 | 111 | Returns 112 | ------- 113 | None 114 | """ 115 | if self.crypto_channel is None: 116 | self.crypto_channel = await get_channel( 117 | self.bot, 118 | config["LOOPS"]["INDEX"]["CHANNEL"], 119 | config["CATEGORIES"]["CRYPTO"], 120 | ) 121 | e = await create_embed("Crypto Indices", self.crypto_indices, "crypto") 122 | 123 | await self.crypto_channel.purge(limit=1) 124 | await self.crypto_channel.send(embed=e) 125 | 126 | @loop(hours=1) 127 | @loop_error_catcher 128 | async def stocks(self) -> None: 129 | """ 130 | Posts the stock indices in the configured channel, only posts if the market is open. 131 | 132 | Returns 133 | ------- 134 | None 135 | """ 136 | if self.stocks_channel is None: 137 | self.stocks_channel = await get_channel( 138 | self.bot, 139 | config["LOOPS"]["INDEX"]["CHANNEL"], 140 | config["CATEGORIES"]["STOCKS"], 141 | ) 142 | # Dont send if the market is closed 143 | if afterHours(): 144 | return 145 | 146 | indices = self.stock_indices + self.forex_indices 147 | 148 | stock_e = await create_embed("Stock & Forex Indices", indices, "stock") 149 | 150 | await self.stocks_channel.purge(limit=1) 151 | await self.stocks_channel.send(embed=stock_e) 152 | 153 | 154 | def setup(bot: commands.Bot) -> None: 155 | bot.add_cog(Index(bot)) 156 | -------------------------------------------------------------------------------- /src/cogs/loops/yield.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | import discord 5 | import matplotlib as mpl 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | from discord.ext import commands 9 | from discord.ext.tasks import loop 10 | from scipy.interpolate import make_interp_spline 11 | 12 | from api.tradingview import tv 13 | from constants.config import config 14 | from constants.tradingview import EU_bonds, US_bonds 15 | from util.disc import get_channel, loop_error_catcher 16 | 17 | 18 | class Yield(commands.Cog): 19 | """ 20 | This class contains the cog for posting the US and EU yield curve. 21 | It can be enabled / disabled in the config under ["LOOPS"]["YIELD"]. 22 | """ 23 | 24 | def __init__(self, bot: commands.Bot) -> None: 25 | self.bot = bot 26 | self.channel = None 27 | self.post_curve.start() 28 | 29 | @loop(hours=24) 30 | @loop_error_catcher 31 | async def post_curve(self) -> None: 32 | """ 33 | Posts the US and EU yield curve in the channel specified in the config. 34 | Charts based on http://www.worldgovernmentbonds.com/country/united-states/ 35 | 36 | Returns 37 | ------- 38 | None 39 | """ 40 | if self.channel is None: 41 | self.channel = await get_channel( 42 | self.bot, config["LOOPS"]["YIELD"]["CHANNEL"] 43 | ) 44 | 45 | plt.style.use("dark_background") # Set the style first 46 | 47 | mpl.rcParams["axes.spines.right"] = False 48 | mpl.rcParams["axes.spines.left"] = False 49 | mpl.rcParams["axes.spines.top"] = False 50 | mpl.rcParams["axes.spines.bottom"] = False 51 | 52 | mpl.rcParams["axes.edgecolor"] = "white" # Set edge color to white 53 | mpl.rcParams["xtick.color"] = "white" # Set x tick color to white 54 | mpl.rcParams["ytick.color"] = "white" # Set y tick color to white 55 | mpl.rcParams["axes.labelcolor"] = "white" # Set label color to white 56 | mpl.rcParams["text.color"] = "white" # Set text color to white 57 | 58 | await self.plot_US_yield() 59 | await self.plot_EU_yield() 60 | 61 | # Add gridlines 62 | plt.grid(axis="y", color="grey", linewidth=0.5, alpha=0.5) 63 | plt.tick_params(axis="y", which="both", left=False) 64 | 65 | frame = plt.gca() 66 | frame.axes.get_xaxis().set_major_formatter(lambda x, _: f"{int(x)}Y") 67 | 68 | frame.axes.set_ylim(0) 69 | frame.axes.get_yaxis().set_major_formatter(lambda x, _: f"{int(x)}%") 70 | 71 | # Set plot parameters 72 | plt.legend(loc="lower center", ncol=2) 73 | plt.xlabel("Residual Maturity") 74 | 75 | # Convert to plot to a temporary image 76 | file_name = "yield.png" 77 | file_path = os.path.join("temp", file_name) 78 | plt.savefig(file_path, bbox_inches="tight", dpi=300) 79 | plt.cla() 80 | plt.close() 81 | 82 | e = discord.Embed( 83 | title="US and EU Yield Curve Rates", 84 | description="", 85 | color=0x000000, 86 | timestamp=datetime.datetime.now(datetime.timezone.utc), 87 | ) 88 | file = discord.File(file_path, filename=file_name) 89 | e.set_image(url=f"attachment://{file_name}") 90 | 91 | await self.channel.purge(limit=1) 92 | await self.channel.send(file=file, embed=e) 93 | 94 | # Delete yield.png 95 | os.remove(file_path) 96 | 97 | async def plot_US_yield(self) -> None: 98 | """ 99 | Gets the US yield curve data from TradingView and plots it. 100 | """ 101 | 102 | years = np.array([0.08, 0.15, 0.25, 0.5, 1, 2, 3, 5, 7, 10, 20, 30]) 103 | yield_percentage = await self.get_yield(US_bonds) 104 | 105 | self.make_plot(years, yield_percentage, "c", "US") 106 | 107 | async def plot_EU_yield(self): 108 | """ 109 | Gets the EU yield curve data from TradingView and plots it. 110 | """ 111 | 112 | years = np.array( 113 | [0.25, 0.5, 0.75, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30] 114 | ) 115 | yield_percentage = await self.get_yield(EU_bonds) 116 | 117 | self.make_plot(years, yield_percentage, "r", "EU") 118 | 119 | async def get_yield(self, bonds: list) -> list: 120 | """ 121 | For each bond in the given list, it gets the yield from TradingView. 122 | 123 | Parameters 124 | ---------- 125 | bonds : list 126 | The names of the bonds to get the yield from. 127 | 128 | Returns 129 | ------- 130 | list 131 | The percentages of the yield for each bond. 132 | """ 133 | 134 | yield_percentage = [] 135 | for bond in bonds: 136 | no_exch = bond.split(":")[1] 137 | tv_data = await tv.get_tv_data(no_exch, "stock") 138 | yield_percentage.append(tv_data[0]) 139 | 140 | return yield_percentage 141 | 142 | def make_plot( 143 | self, years: list, yield_percentage: list, color: str, label: str 144 | ) -> None: 145 | """ 146 | Makes a matplotlib plot of the yield curve. 147 | Each dot is the yield for a specific bond. 148 | Connects a spline through the dots to make a smooth curve. 149 | 150 | Parameters 151 | ---------- 152 | years : list 153 | The years of the yield curve. 154 | yield_percentage : list 155 | The yield percentage for each year. 156 | color : str 157 | The color of the plotted line. 158 | label : str 159 | The label for the plotted line. 160 | """ 161 | 162 | new_X = np.linspace(years.min(), years.max(), 500) 163 | 164 | # Interpolation 165 | spl = make_interp_spline(years, yield_percentage, k=3) 166 | smooth = spl(new_X) 167 | 168 | # Make the plot 169 | plt.rcParams["figure.figsize"] = (10, 5) # Set the figure size 170 | plt.plot(new_X, smooth, color, label=label) 171 | plt.plot(years, yield_percentage, f"{color}o") 172 | 173 | 174 | def setup(bot: commands.Bot) -> None: 175 | bot.add_cog(Yield(bot)) 176 | -------------------------------------------------------------------------------- /src/util/trades_msg.py: -------------------------------------------------------------------------------- 1 | ##> Imports 2 | import datetime 3 | 4 | import ccxt.pro 5 | 6 | # > Discord dependencies 7 | import discord 8 | import pandas as pd 9 | 10 | import util.trades_msg 11 | 12 | # Local dependencies 13 | import util.vars 14 | from constants.stable_coins import stables 15 | from util.db import get_db, update_db 16 | from util.exchange_data import get_buying_price, get_data, get_usd_price 17 | from util.formatting import format_change 18 | 19 | 20 | async def on_msg( 21 | msg: list, 22 | exchange: ccxt.pro.Exchange, 23 | trades_channel: discord.TextChannel, 24 | row: pd.Series, 25 | user: discord.User, 26 | ) -> None: 27 | """ 28 | This function is used to handle the incoming messages from the binance websocket. 29 | 30 | Parameters 31 | ---------- 32 | msg : str 33 | The message that is received from the binance websocket. 34 | 35 | Returns 36 | ------- 37 | None 38 | """ 39 | 40 | msg = msg[0] 41 | sym = msg["symbol"] # BNB/USDT 42 | orderType = msg["type"] # market, limit, stop, stop limit 43 | side = msg["side"] # buy, sell 44 | price = float(round(msg["price"], 4)) 45 | amount = float(round(msg["amount"], 4)) 46 | cost = float(round(msg["cost"], 4)) # If /USDT, then this is the USD value 47 | 48 | # Get the value in USD 49 | usd = price 50 | base = sym.split("/")[0] 51 | quote = sym.split("/")[1] 52 | if quote not in stables: 53 | usd, change = await get_usd_price(exchange, base) 54 | 55 | # Get profit / loss if it is a sell 56 | buying_price = None 57 | if side == "sell": 58 | buying_price = await get_buying_price(exchange, sym, True) 59 | 60 | # Send it in the discord channel 61 | await util.trades_msg.trades_msg( 62 | exchange.id, 63 | trades_channel, 64 | user, 65 | sym, 66 | side, 67 | orderType, 68 | price, 69 | amount, 70 | round(usd * amount, 2), 71 | buying_price, 72 | ) 73 | 74 | # Assets db: asset, owned (quantity), exchange, id, user 75 | assets_db = get_db("assets") 76 | 77 | # Drop all rows for this user and exchange 78 | updated_assets_db = assets_db.drop( 79 | assets_db[ 80 | (assets_db["id"] == row["id"]) & (assets_db["exchange"] == exchange.id) 81 | ].index 82 | ) 83 | 84 | assets_db = pd.concat([updated_assets_db, await get_data(row)]).reset_index( 85 | drop=True 86 | ) 87 | 88 | update_db(assets_db, "assets") 89 | util.vars.assets_db = assets_db 90 | # Maybe post the updated assets of this user as well 91 | 92 | 93 | async def trades_msg( 94 | exchange: str, 95 | channel: discord.TextChannel, 96 | user: discord.User, 97 | symbol: str, 98 | side: str, 99 | orderType: str, 100 | price: float, 101 | quantity: float, 102 | usd: float, 103 | buying_price: float = None, 104 | ) -> None: 105 | """ 106 | Formats the Discord embed that will be send to the dedicated trades channel. 107 | 108 | Parameters 109 | ---------- 110 | exchange : str 111 | The name of the exchange, currently only supports "binance", "kucoin" and "stocks". 112 | channel : discord.TextChannel 113 | The channel that the message will be sent to. 114 | user : discord.User 115 | The user that the message will be sent from. 116 | symbol : str 117 | The symbol that has been traded. 118 | side : str 119 | The side of the trade, either "BUY" or "SELL". 120 | orderType : str 121 | The type of order, for instance "LIMIT" or "MARKET". 122 | price : float 123 | The price of the trade. 124 | quantity : float 125 | The amount traded. 126 | usd : float 127 | The worth of the trade in US dollar. 128 | 129 | Returns 130 | ------- 131 | None 132 | """ 133 | 134 | # Same as in formatting.py 135 | if exchange == "binance": 136 | color = 0xF0B90B 137 | icon_url = ( 138 | "https://upload.wikimedia.org/wikipedia/commons/5/57/Binance_Logo.png" 139 | ) 140 | url = f"https://www.binance.com/en/trade/{symbol}" 141 | elif exchange == "kucoin": 142 | color = 0x24AE8F 143 | icon_url = "https://yourcryptolibrary.com/wp-content/uploads/2021/12/Kucoin-exchange-logo-1.png" 144 | url = f"https://www.kucoin.com/trade/{symbol}" 145 | else: 146 | color = 0x720E9E 147 | icon_url = ( 148 | "https://s.yimg.com/cv/apiv2/myc/finance/Finance_icon_0919_250x252.png" 149 | ) 150 | url = f"https://finance.yahoo.com/quote/{symbol}" 151 | 152 | e = discord.Embed( 153 | title=f"{orderType.capitalize()} {side.lower()} {quantity} {symbol}", 154 | description="", 155 | color=color, 156 | url=url, 157 | timestamp=datetime.datetime.now(datetime.timezone.utc), 158 | ) 159 | 160 | # Set the embed fields 161 | e.set_author(name=user.name, icon_url=user.display_avatar.url) 162 | 163 | # If the quote is USD, then the price is the USD value 164 | e.add_field( 165 | name="Price", 166 | value=f"${price}" if symbol.endswith(tuple(stables)) else price, 167 | inline=True, 168 | ) 169 | 170 | if buying_price and buying_price != 0: 171 | price_change = price - buying_price 172 | 173 | if price_change != 0: 174 | percent_change = round((price_change / buying_price) * 100, 2) 175 | else: 176 | percent_change = 0 177 | 178 | percent_change = format_change(percent_change) 179 | profit_loss = f"${round(price_change * quantity, 2)} ({percent_change})" 180 | 181 | e.add_field( 182 | name="Profit / Loss", 183 | value=profit_loss, 184 | inline=True, 185 | ) 186 | else: 187 | e.add_field(name="Amount", value=quantity, inline=True) 188 | 189 | # If we know the USD value, then add it 190 | if usd != 0: 191 | e.add_field( 192 | name="$ Worth", 193 | value=f"${usd}", 194 | inline=True, 195 | ) 196 | 197 | e.set_footer(text="\u200b", icon_url=icon_url) 198 | 199 | await channel.send(embed=e) 200 | 201 | # Tag the person 202 | if orderType.upper() != "MARKET": 203 | await channel.send(f"<@{user.id}>") 204 | -------------------------------------------------------------------------------- /src/api/reddit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import html 4 | import re 5 | from datetime import datetime, timedelta 6 | 7 | import asyncpraw 8 | import pandas as pd 9 | 10 | import util.vars 11 | from constants.logger import logger 12 | from util.db import update_db 13 | 14 | URL_REGEX = r"(?Phttps?://[^\s]+)" 15 | MARKDOWN_LINK_REGEX = r"\[(?P[^\]]+)\]\((?Phttps?://[^\s]+)\)" 16 | 17 | 18 | def add_id_to_db(id: str) -> None: 19 | """ 20 | Adds the given id to the database. 21 | """ 22 | 23 | util.vars.reddit_ids = pd.concat( 24 | [ 25 | util.vars.reddit_ids, 26 | pd.DataFrame( 27 | [ 28 | { 29 | "id": id, 30 | "timestamp": datetime.now(), 31 | } 32 | ] 33 | ), 34 | ], 35 | ignore_index=True, 36 | ) 37 | 38 | 39 | def update_reddit_ids(): 40 | """ 41 | Update the list of reddit IDs, removing those older than 72 hours. 42 | """ 43 | if not util.vars.reddit_ids.empty: 44 | util.vars.reddit_ids = util.vars.reddit_ids.astype( 45 | {"id": str, "timestamp": "datetime64[ns]"} 46 | ) 47 | util.vars.reddit_ids = util.vars.reddit_ids[ 48 | util.vars.reddit_ids["timestamp"] > datetime.now() - timedelta(hours=72) 49 | ] 50 | 51 | 52 | async def reddit_scraper( 53 | limit: int = 15, 54 | subreddit_name: str = "WallStreetBets", 55 | reddit_client: asyncpraw.Reddit = None, 56 | ) -> None: 57 | """ 58 | Scrapes the top reddit posts from the wallstreetbets subreddit and posts them in the wallstreetbets channel. 59 | 60 | Parameters 61 | ---------- 62 | reddit : asyncpraw.Reddit 63 | The reddit instance using the bot's credentials. 64 | limit : int 65 | The number of posts to scrape. 66 | subreddit_name : str 67 | The name of the subreddit to scrape. 68 | 69 | Returns 70 | ------- 71 | None 72 | """ 73 | update_reddit_ids() 74 | subreddit = await reddit_client.subreddit(subreddit_name) 75 | 76 | posts = [] 77 | try: 78 | async for submission in subreddit.hot(limit=limit): 79 | if submission.stickied or is_submission_processed(submission.id): 80 | continue 81 | 82 | add_id_to_db(submission.id) 83 | 84 | descr = truncate_text(html.unescape(submission.selftext), 4000) 85 | descr = process_description(descr) # Process the description for URLs 86 | 87 | title = truncate_text(html.unescape(submission.title), 250) 88 | img_urls, title = process_submission_media(submission, title) 89 | 90 | posts.append((submission, title, descr, img_urls)) 91 | update_db(util.vars.reddit_ids, "reddit_ids") 92 | except Exception as e: 93 | logger.error(f"Error scraping Reddit: {e}") 94 | 95 | return posts 96 | 97 | 98 | def process_description(description): 99 | """ 100 | Process the description to convert URLs to plain text links unless they are part of a hyperlink with custom text. 101 | 102 | Parameters 103 | ---------- 104 | description : str 105 | The original description text. 106 | 107 | Returns 108 | ------- 109 | str 110 | The processed description. 111 | """ 112 | 113 | # Replace Markdown links with just the URL if the text matches the URL 114 | def replace_markdown_link(match): 115 | text = match.group("text") 116 | url = match.group("url") 117 | if text == url: 118 | return url 119 | return match.group(0) 120 | 121 | description = re.sub(MARKDOWN_LINK_REGEX, replace_markdown_link, description) 122 | 123 | # Replace remaining URLs with just the URL 124 | def replace_url(match): 125 | return match.group("url") 126 | 127 | processed_description = re.sub(URL_REGEX, replace_url, description) 128 | 129 | return processed_description 130 | 131 | 132 | def is_submission_processed(submission_id: str) -> bool: 133 | """ 134 | Check if a submission has already been processed. 135 | 136 | Parameters 137 | ---------- 138 | submission_id : str 139 | The ID of the submission. 140 | 141 | Returns 142 | ------- 143 | bool 144 | True if the submission has been processed, False otherwise. 145 | """ 146 | if ( 147 | not util.vars.reddit_ids.empty 148 | and submission_id in util.vars.reddit_ids["id"].tolist() 149 | ): 150 | return True 151 | return False 152 | 153 | 154 | def truncate_text(text: str, max_length: int) -> str: 155 | """ 156 | Truncate text to a maximum length, adding ellipsis if truncated. 157 | 158 | Parameters 159 | ---------- 160 | text : str 161 | The text to truncate. 162 | max_length : int 163 | The maximum length of the text. 164 | 165 | Returns 166 | ------- 167 | str 168 | The truncated text. 169 | """ 170 | if len(text) > max_length: 171 | return text[:max_length] + "..." 172 | return text 173 | 174 | 175 | def process_submission_media(submission, title: str) -> tuple: 176 | """ 177 | Process the media in a submission, updating the title and extracting image URLs. 178 | 179 | Parameters 180 | ---------- 181 | submission : asyncpraw.models.Submission 182 | The reddit submission. 183 | title : str 184 | The title of the submission. 185 | 186 | Returns 187 | ------- 188 | tuple 189 | A tuple containing the list of image URLs and the updated title. 190 | """ 191 | img_urls = [] 192 | if not submission.is_self: 193 | url = submission.url 194 | if url.endswith((".jpg", ".png", ".gif")): 195 | img_urls.append(url) 196 | title = "🖼️ " + title 197 | elif "gallery" in url: 198 | for image_item in submission.media_metadata.values(): 199 | img_urls.append(image_item["s"]["u"]) 200 | title = "📸🖼️ " + title 201 | elif "v.redd.it" in url: 202 | title = "🎥 " + title 203 | if "images" in submission.preview: 204 | img_urls.append(submission.preview["images"][0]["source"]["url"]) 205 | else: 206 | logger.warn("No image found for Reddit video post") 207 | return img_urls, title 208 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # This is used for the user channels 2 | # For instance 👨┃elonmusk 3 | CHANNEL_SEPARATOR: ┃ 4 | 5 | CATEGORIES: 6 | INFORMATION: ▬▬ 🔑 Information ▬▬ 7 | TWITTER: ▬▬▬ 🐦Twitter ▬▬▬ 8 | STOCKS: ▬▬▬ 💵 Stocks ▬▬▬ 9 | CRYPTO: ▬▬▬ 🎰 Crypto ▬▬▬ 10 | FOREX: ▬▬▬ 💱 Forex ▬▬▬ 11 | USERS: ▬▬▬ 👨 Users ▬▬▬ 12 | REDDIT: ▬▬▬ 👽 Reddit ▬▬▬ 13 | NFTS: ▬▬▬ 🐒 NFTs ▬▬▬ 14 | 15 | ############### 16 | ### LOOPS ### 17 | ############### 18 | LOOPS: 19 | # The main function of this program 20 | TIMELINE: 21 | ENABLED: True 22 | CHARTS_CHANNEL: 📈┃charts 23 | TEXT_CHANNEL: 💬┃text 24 | UNKNOWN_CHARTS: 📈┃unknown-charts 25 | 26 | # The channels related to crypto 27 | CRYPTO: 28 | ENABLED: True 29 | 30 | # The channels related to stocks 31 | STOCKS: 32 | ENABLED: True 33 | 34 | FOREX: 35 | ENABLED: True 36 | 37 | # Tweets that contain images without financial info 38 | IMAGES: 39 | ENABLED: True 40 | CHANNEL: 📷┃images 41 | 42 | # Tweets that contain text without financial info 43 | OTHER: 44 | ENABLED: True 45 | CHANNEL: ❓┃other 46 | 47 | NEWS: 48 | ENABLED: True 49 | # https://twitter.com/DeItaone 50 | # https://twitter.com/FirstSquawk 51 | # https://twitter.com/EPSGUID 52 | # https://twitter.com/eWhispers 53 | # Make sure to follow these accounts to get the tweets 54 | FOLLOWING: ["DeItaone", "FirstSquawk", "EPSGUID", "eWhispers"] 55 | CHANNEL: 📰┃news 56 | 57 | CRYPTO: 58 | ENABLED: True 59 | FOLLOWING: ["BrieflyCrypto"] 60 | 61 | ASSETS: 62 | ENABLED: True 63 | CHANNEL_PREFIX: 🌟┃ 64 | 65 | CRYPTO_CATEGORIES: 66 | ENABLED: True 67 | CHANNEL: 📚┃categories 68 | 69 | EARNINGS_OVERVIEW: 70 | ENABLED: True 71 | CHANNEL: 📅┃earnings 72 | 73 | EVENTS: 74 | ENABLED: True 75 | CHANNEL: 📣┃events 76 | 77 | STOCKS: 78 | ENABLED: True 79 | 80 | CRYPTO: 81 | ENABLED: True 82 | 83 | FUNDING: 84 | ENABLED: True 85 | CHANNEL: 🏦┃funding 86 | 87 | FUNDING_HEATMAP: 88 | ENABLED: True 89 | CHANNEL: 🧮┃funding-heatmap 90 | 91 | GAINERS: 92 | ENABLED: True 93 | CHANNEL: 🚀┃gainers 94 | 95 | CRYPTO: 96 | ENABLED: True 97 | 98 | STOCKS: 99 | ENABLED: True 100 | 101 | IDEAS: 102 | ENABLED: True 103 | CHANNEL: 💡┃ideas 104 | 105 | CRYPTO: 106 | ENABLED: True 107 | 108 | STOCKS: 109 | ENABLED: True 110 | 111 | FOREX: 112 | ENABLED: True 113 | 114 | INDEX: 115 | ENABLED: True 116 | CHANNEL: 📊┃index 117 | 118 | STOCKS: 119 | ENABLED: True 120 | 121 | CRYPTO: 122 | ENABLED: True 123 | 124 | FOREX: 125 | ENABLED: True 126 | 127 | LIQUIDATIONS: 128 | ENABLED: True 129 | CHANNEL: 💸┃liquidations 130 | 131 | LOSERS: 132 | ENABLED: True 133 | CHANNEL: 💩┃losers 134 | 135 | CRYPTO: 136 | ENABLED: True 137 | 138 | STOCKS: 139 | ENABLED: True 140 | 141 | NEW_LISTINGS: 142 | ENABLED: True 143 | CHANNEL: 🆕┃listings 144 | 145 | NFTS: 146 | ENABLED: True 147 | 148 | TOP: 149 | ENABLED: True 150 | CHANNEL: 🏆┃top 151 | 152 | UPCOMING: 153 | ENABLED: False 154 | CHANNEL: 🌠┃upcoming 155 | 156 | P2E: 157 | ENABLED: True 158 | CHANNEL: 🎮┃p2e 159 | 160 | OVERVIEW: 161 | ENABLED: True 162 | CHANNEL: 🏆┃overview 163 | 164 | STOCKS: 165 | ENABLED: True 166 | 167 | CRYPTO: 168 | ENABLED: True 169 | 170 | RAINBOW_CHART: 171 | ENABLED: True 172 | CHANNEL: 🌈┃rainbow-chart 173 | 174 | REDDIT: 175 | ENABLED: True 176 | 177 | WALLSTREETBETS: 178 | ENABLED: True 179 | CHANNEL: 🤑┃wallstreetbets 180 | 181 | CRYPTOMOONSHOTS: 182 | ENABLED: True 183 | CHANNEL: 🚀┃cryptomoonshots 184 | 185 | RSI_HEATMAP: 186 | ENABLED: True 187 | CHANNEL: 🚥┃rsi-heatmap 188 | 189 | SECTOR_SNAPSHOT: 190 | ENABLED: True 191 | CHANNEL: 📸┃spy-sectors 192 | 193 | SPY_HEATMAP: 194 | ENABLED: True 195 | CHANNEL: 📊┃spy-heatmap 196 | 197 | STOCK_HALTS: 198 | ENABLED: True 199 | CHANNEL: 🛑┃halted 200 | 201 | STOCKTWITS: 202 | ENABLED: True 203 | CHANNEL: 🎤┃stocktwits 204 | 205 | TREEMAP: 206 | ENABLED: True 207 | CHANNEL: 📊┃treemap 208 | 209 | TRENDING: 210 | ENABLED: True 211 | CHANNEL: 🔥┃trending 212 | 213 | CRYPTO: 214 | ENABLED: True 215 | 216 | STOCKS: 217 | ENABLED: True 218 | 219 | NFTS: 220 | ENABLED: True 221 | 222 | AFTERHOURS: 223 | ENABLED: True 224 | CHANNEL: 🌃┃after-hours 225 | 226 | PREMARKET: 227 | ENABLED: True 228 | CHANNEL: 🌇┃pre-market 229 | 230 | TRADES: 231 | ENABLED: False 232 | CHANNEL: 💲┃trades 233 | 234 | YIELD: 235 | ENABLED: True 236 | CHANNEL: 🏢┃yield 237 | 238 | ################## 239 | ### COMMANDS ### 240 | ################## 241 | 242 | # The options for ROLE are: 243 | # - None (all users can use this command) 244 | # - Admin (Only users with the discord admin role) 245 | # You can also fill in a custom role name that you use in your server to only enable it for users that have that role 246 | # If you want multiple roles then use a comma like `ROLE: Admin, Pro` 247 | 248 | COMMANDS: 249 | ANALYZE: 250 | ENABLED: True 251 | ROLE: None 252 | 253 | EARNINGS: 254 | ENABLED: True 255 | ROLE: None 256 | 257 | HELP: 258 | ENABLED: True 259 | ROLE: None 260 | 261 | PORTFOLIO: 262 | ENABLED: True 263 | ROLE: None 264 | 265 | RESTART: 266 | ENABLED: True 267 | ROLE: Admin 268 | 269 | SENTIMENT: 270 | ENABLED: True 271 | ROLE: None 272 | 273 | STOCK: 274 | ENABLED: True 275 | ROLE: None 276 | 277 | ################# 278 | ### Listeners ### 279 | ################# 280 | 281 | # Reports command usage in console 282 | LISTENERS: 283 | # This allows people to highlight tweets 284 | ON_RAW_REACTION_ADD: 285 | ENABLED: True 286 | CHANNEL: 💸┃highlights 287 | ROLE: Admin 288 | 289 | # Sends a custom message when a member joins 290 | ON_MEMBER_JOIN: 291 | ENABLED: True 292 | 293 | # Set to "INFO" if you want less clutter in your terminal 294 | LOGGING_LEVEL: INFO 295 | 296 | # Debug mode is enabled when using the flag `--debug` 297 | 298 | # Choose the debug mode: "include_only" to enable only DEBUG_COGS, "exclude" to enable everything except DEBUG_COGS 299 | DEBUG_MODE_TYPE: "exclude" 300 | 301 | # Add file names of the cogs that should be enabled, for instance "events" 302 | DEBUG_COGS: 303 | -------------------------------------------------------------------------------- /src/cogs/loops/listings.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | 4 | import discord 5 | from discord.ext import commands 6 | from discord.ext.tasks import loop 7 | 8 | from api.http_client import get_json_data 9 | from constants.config import config 10 | from constants.sources import data_sources 11 | from util.disc import get_channel, loop_error_catcher 12 | 13 | 14 | class Exchange_Listings(commands.Cog): 15 | """ 16 | This class contains the cog for posting the new Binance listings 17 | It can be enabled / disabled in the config under ["LOOPS"]["NEW_LISTINGS"]. 18 | """ 19 | 20 | def __init__(self, bot: commands.Bot) -> None: 21 | self.bot = bot 22 | self.exchanges = config["LOOPS"]["LISTINGS"]["EXCHANGES"] 23 | self.old_symbols = {} 24 | 25 | self.listings_channel = None 26 | self.delistings_channel = None 27 | 28 | asyncio.create_task(self.set_old_symbols()) 29 | 30 | async def get_symbols(self, exchange: str) -> list: 31 | """ 32 | Gets the symbols currently listed on the exchange. 33 | 34 | Returns 35 | ------- 36 | list 37 | The symbols currently listed on the exchange 38 | """ 39 | # TODO: move this to api folder 40 | 41 | if exchange == "binance": 42 | url = "https://api.binance.com/api/v3/exchangeInfo" 43 | key1 = "symbols" 44 | key2 = "symbol" 45 | elif exchange == "kucoin": 46 | url = "https://api.kucoin.com/api/v1/symbols" 47 | key1 = "data" 48 | key2 = "symbol" 49 | elif exchange == "coinbase": 50 | url = "https://api.exchange.coinbase.com/currencies" 51 | key2 = "id" 52 | 53 | # Check if there have been new listings 54 | response = await get_json_data(url) 55 | 56 | # Get the symbols 57 | if exchange == "coinbase": 58 | return [x[key2] for x in response] 59 | 60 | return [x[key2] for x in response[key1]] 61 | 62 | def create_embed( 63 | self, ticker: str, exchange: str, is_listing: bool 64 | ) -> discord.Embed: 65 | """ 66 | Creates a styled embed for the newly listed ticker. 67 | 68 | Parameters 69 | ---------- 70 | ticker : str 71 | The ticker that was newly listed. 72 | 73 | Returns 74 | ------- 75 | discord.embeds.Embed 76 | The styled embed for the newly listed ticker. 77 | """ 78 | 79 | if exchange == "binance": 80 | color = data_sources["binance"]["color"] 81 | icon_url = data_sources["binance"]["icon"] 82 | url = f"https://www.{exchange}.com/en/trade/{ticker}" 83 | elif exchange == "kucoin": 84 | color = data_sources["kucoin"]["color"] 85 | icon_url = data_sources["kucoin"]["icon"] 86 | url = f"https://www.{exchange}.com/trade/{ticker}" 87 | elif exchange == "coinbase": 88 | color = data_sources["coinbase"]["color"] 89 | icon_url = data_sources["coinbase"]["icon"] 90 | url = f"https://www.pro.{exchange}.com/trade/{ticker}" 91 | 92 | title = f"{ticker} {'Listed' if is_listing else 'Delisted'} on {exchange.capitalize()}" 93 | 94 | e = discord.Embed( 95 | title=title, 96 | url=url, 97 | description="", 98 | color=color, 99 | timestamp=datetime.datetime.now(datetime.timezone.utc), 100 | ) 101 | 102 | # Set datetime and binance icon 103 | e.set_footer(text="\u200b", icon_url=icon_url) 104 | 105 | return e 106 | 107 | async def set_old_symbols(self) -> None: 108 | """ 109 | Function to set the old symbols from the JSON response. 110 | This will be used to compare the new symbols to the old symbols. 111 | 112 | Returns 113 | ------- 114 | None 115 | """ 116 | 117 | # Set the old symbols 118 | for exchange in self.exchanges: 119 | self.old_symbols[exchange] = await self.get_symbols(exchange) 120 | 121 | # Start after setting all the symbols 122 | self.new_listings.start() 123 | 124 | @loop(hours=6) 125 | @loop_error_catcher 126 | async def new_listings(self) -> None: 127 | """ 128 | This function will be called every 6 hours to check for new listings. 129 | It will compare the currently listed symbols with the old symbols. 130 | If there is a difference, it will post a message to the channel. 131 | 132 | Returns 133 | ------- 134 | None 135 | """ 136 | if self.listings_channel is None: 137 | self.listings_channel = await get_channel( 138 | self.bot, 139 | config["LOOPS"]["LISTINGS"]["LISTINGS"]["CHANNEL"], 140 | config["CATEGORIES"]["CRYPTO"], 141 | ) 142 | if self.delistings_channel is None: 143 | self.delistings_channel = await get_channel( 144 | self.bot, 145 | config["LOOPS"]["LISTINGS"]["DELISTINGS"]["CHANNEL"], 146 | config["CATEGORIES"]["CRYPTO"], 147 | ) 148 | 149 | # Do this for all exchanges 150 | for exchange in self.exchanges: 151 | # Get the new symbols 152 | new_symbols = await self.get_symbols(exchange) 153 | 154 | new_listings = [] 155 | delistings = [] 156 | 157 | if self.old_symbols[exchange] == []: 158 | await self.set_old_symbols() 159 | 160 | # If there is a new symbol, send a message 161 | if len(new_symbols) > len(self.old_symbols): 162 | new_listings = list(set(new_symbols) - set(self.old_symbols)) 163 | 164 | # If symbols got removed do nothing 165 | if len(new_symbols) < len(self.old_symbols): 166 | delistings = list(set(self.old_symbols) - set(new_symbols)) 167 | 168 | # Update old_symbols 169 | self.old_symbols[exchange] = new_symbols 170 | 171 | # Create the embed and post it 172 | for ticker in new_listings: 173 | await self.listings_channel.send( 174 | embed=self.create_embed(ticker, exchange, True) 175 | ) 176 | 177 | for ticker in delistings: 178 | await self.delistings_channel.send( 179 | embed=self.create_embed(ticker, exchange, False) 180 | ) 181 | 182 | 183 | def setup(bot: commands.Bot) -> None: 184 | bot.add_cog(Exchange_Listings(bot)) 185 | -------------------------------------------------------------------------------- /src/cogs/loops/reddit.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | 4 | import asyncpraw 5 | from discord import Embed 6 | from discord.ext import commands 7 | from discord.ext.tasks import loop 8 | 9 | from api.reddit import reddit_scraper 10 | from constants.config import config 11 | from constants.logger import logger 12 | from constants.sources import data_sources 13 | from util.disc import get_channel, get_webhook, loop_error_catcher 14 | 15 | 16 | class Reddit(commands.Cog): 17 | """ 18 | This class contains the cog for posting the top reddit posts. 19 | It can be enabled / disabled in the config under ["LOOPS"]["REDDIT"]. 20 | """ 21 | 22 | def __init__(self, bot: commands.bot.Bot) -> None: 23 | self.bot = bot 24 | self.first_time = True 25 | 26 | self.reddit = asyncpraw.Reddit( 27 | client_id=os.getenv("REDDIT_PERSONAL_USE"), 28 | client_secret=os.getenv("REDDIT_SECRET"), 29 | user_agent=os.getenv("REDDIT_APP_NAME"), 30 | username=os.getenv("REDDIT_USERNAME"), 31 | password=os.getenv("REDDIT_PASSWORD"), 32 | ) 33 | 34 | # Setup configuration for subreddits 35 | self.subreddits = { 36 | "WallStreetBets": { 37 | "enabled": config["LOOPS"]["REDDIT"]["WALLSTREETBETS"]["ENABLED"], 38 | "channel_id": config["LOOPS"]["REDDIT"]["WALLSTREETBETS"]["CHANNEL"], 39 | "first_time": True, 40 | "channel": None, 41 | "scraper": self.wsb_scraper, 42 | }, 43 | "CryptoMoonShots": { 44 | "enabled": config["LOOPS"]["REDDIT"]["CRYPTOMOONSHOTS"]["ENABLED"], 45 | "channel_id": config["LOOPS"]["REDDIT"]["CRYPTOMOONSHOTS"]["CHANNEL"], 46 | "first_time": True, 47 | "channel": None, 48 | "scraper": self.cms_scraper, 49 | }, 50 | } 51 | 52 | # Start the scrapers for enabled subreddits 53 | for subreddit, settings in self.subreddits.items(): 54 | if settings["enabled"]: 55 | settings["scraper"].start() 56 | 57 | async def load_channel(self, subreddit_name): 58 | """ 59 | Helper function to load the channel for a subreddit. 60 | """ 61 | subreddit_info = self.subreddits[subreddit_name] 62 | if subreddit_info["channel"] is None: 63 | subreddit_info["channel"] = await get_channel( 64 | self.bot, subreddit_info["channel_id"] 65 | ) 66 | subreddit_info["first_time"] = False 67 | 68 | async def scrape_and_send_posts(self, subreddit_name): 69 | """ 70 | Helper function to scrape Reddit posts and send them to the appropriate channel. 71 | """ 72 | subreddit_info = self.subreddits[subreddit_name] 73 | 74 | # Load channel if it's the first time 75 | if subreddit_info["first_time"]: 76 | await self.load_channel(subreddit_name) 77 | 78 | # Scrape posts and send them to the channel 79 | posts = await reddit_scraper( 80 | subreddit_name=subreddit_name, reddit_client=self.reddit 81 | ) 82 | await self.send_posts(posts, subreddit_name) 83 | 84 | @loop(hours=12) 85 | @loop_error_catcher 86 | async def wsb_scraper(self): 87 | """ 88 | Scraper for WallStreetBets subreddit. 89 | """ 90 | await self.scrape_and_send_posts("WallStreetBets") 91 | 92 | @loop(hours=12) 93 | @loop_error_catcher 94 | async def cms_scraper(self): 95 | """ 96 | Scraper for CryptoMoonShots subreddit. 97 | """ 98 | await self.scrape_and_send_posts("CryptoMoonShots") 99 | 100 | async def send_posts(self, posts: list, subreddit_name: str): 101 | channel = self.subreddits[subreddit_name]["channel"] 102 | if channel: 103 | for counter, post in enumerate(posts): 104 | submission, title, descr, img_urls = post 105 | embed = create_embed(submission, title, descr, img_urls) 106 | await self.send_embed(embed, img_urls, channel) 107 | 108 | if counter > 10: 109 | return 110 | else: 111 | logger.error(f"Channel not found for {subreddit_name}.") 112 | 113 | async def send_embed(self, embed: Embed, img_urls: list, channel) -> None: 114 | """ 115 | Send a discord embed, handling multiple images if necessary. 116 | 117 | Parameters 118 | ---------- 119 | embed : discord.Embed 120 | The embed to send. 121 | img_urls : list 122 | The list of image URLs. 123 | channel : discord.TextChannel 124 | The channel to send the embed to. 125 | 126 | Returns 127 | ------- 128 | None 129 | """ 130 | if len(img_urls) > 1: 131 | image_embeds = [embed] + [ 132 | Embed(url=embed.url).set_image(url=img) for img in img_urls[1:10] 133 | ] 134 | webhook = await get_webhook(channel) 135 | await webhook.send( 136 | embeds=image_embeds, 137 | username="FinTwit", 138 | wait=True, 139 | avatar_url=self.bot.user.avatar.url, 140 | ) 141 | else: 142 | await channel.send(embed=embed) 143 | 144 | 145 | def create_embed(submission, title: str, descr: str, img_urls: list) -> Embed: 146 | """ 147 | Create a discord embed for a reddit submission. 148 | 149 | Parameters 150 | ---------- 151 | submission : asyncpraw.models.Submission 152 | The reddit submission. 153 | title : str 154 | The title of the submission. 155 | descr : str 156 | The description of the submission. 157 | img_urls : list 158 | The list of image URLs. 159 | 160 | Returns 161 | ------- 162 | discord.Embed 163 | The created embed. 164 | """ 165 | embed = Embed( 166 | title=title, 167 | url="https://www.reddit.com" + submission.permalink, 168 | description=descr, 169 | color=data_sources["reddit"]["color"], 170 | timestamp=datetime.utcfromtimestamp(submission.created_utc), 171 | ) 172 | if img_urls: 173 | embed.set_image(url=img_urls[0]) 174 | embed.set_footer( 175 | text=f"🔼 {submission.score} | 💬 {submission.num_comments} | {submission.link_flair_text}", 176 | icon_url=data_sources["reddit"]["icon"], 177 | ) 178 | return embed 179 | 180 | 181 | def setup(bot: commands.bot.Bot) -> None: 182 | bot.add_cog(Reddit(bot)) 183 | -------------------------------------------------------------------------------- /src/cogs/listeners/on_raw_reaction_add.py: -------------------------------------------------------------------------------- 1 | ##> Imports 2 | # > Standard libraries 3 | from csv import writer 4 | 5 | # > Discord dependencies 6 | import discord 7 | from discord.ext import commands 8 | 9 | # > Local dependencies 10 | from constants.config import config 11 | from constants.logger import logger 12 | from util.disc import get_channel, get_webhook 13 | 14 | 15 | class On_raw_reaction_add(commands.Cog): 16 | """ 17 | This class is used to handle the on_raw_reaction_add event. 18 | You can enable / disable this command in the config, under ["LISTENERS"]["ON_RAW_REACTION_ADD"]. 19 | """ 20 | 21 | def __init__(self, bot): 22 | self.bot = bot 23 | self.channel = None 24 | 25 | @commands.Cog.listener() 26 | async def on_raw_reaction_add( 27 | self, reaction: discord.RawReactionActionEvent 28 | ) -> None: 29 | """ 30 | This function is called when a reaction is added to a message. 31 | 32 | Parameters 33 | ---------- 34 | reaction : discord.RawReactionActionEvent 35 | The information about the reaction that was added. 36 | 37 | Returns 38 | ------- 39 | None 40 | """ 41 | if self.channel is None: 42 | self.channel = await get_channel( 43 | self.bot, config["LISTENERS"]["ON_RAW_REACTION_ADD"]["CHANNEL"] 44 | ) 45 | 46 | # Ignore private messages 47 | if reaction.guild_id is None: 48 | return 49 | 50 | try: 51 | # Load necessary variables 52 | channel = self.bot.get_channel(reaction.channel_id) 53 | try: 54 | message = discord.utils.get( 55 | await channel.history(limit=100).flatten(), id=reaction.message_id 56 | ) 57 | except Exception as e: 58 | logger.error(f"Error getting channel.history for {channel}. Error: {e}") 59 | return 60 | 61 | if reaction.user_id != self.bot.user.id: 62 | if ( 63 | str(reaction.emoji) == "🐻" 64 | or str(reaction.emoji) == "🐂" 65 | or str(reaction.emoji) == "🦆" 66 | ): 67 | await self.classify_reaction(reaction, message) 68 | elif str(reaction.emoji) == "💸": 69 | # Check if user has the role or is an admin 70 | if config["LISTENERS"]["ON_RAW_REACTION_ADD"]["ROLE"] != "None": 71 | if ( 72 | config["LISTENERS"]["ON_RAW_REACTION_ADD"]["ROLE"] 73 | in reaction.member.roles 74 | or reaction.member.guild_permissions.administrator 75 | ): 76 | await self.highlight(message, reaction.member) 77 | else: 78 | await self.highlight(message, reaction.member) 79 | elif str(reaction.emoji) == "❤️": 80 | await self.send_dm(message, reaction.member) 81 | 82 | except commands.CommandError as e: 83 | logger.error(e) 84 | 85 | async def classify_reaction( 86 | self, reaction: discord.RawReactionActionEvent, message: discord.Message 87 | ) -> None: 88 | """ 89 | This function gets called if a reaction was used for classifying a tweet. 90 | 91 | Parameters 92 | ---------- 93 | reaction : discord.RawReactionActionEvent 94 | The information about the reaction that was added. 95 | message : discord.Message 96 | The message that the reaction was added to. 97 | 98 | Returns 99 | ------- 100 | None 101 | """ 102 | 103 | with open("data/sentiment_data.csv", "a", newline="") as file: 104 | writer_object = writer(file) 105 | if str(reaction.emoji) == "🐻": 106 | writer_object.writerow( 107 | [message.embeds[0].description.replace("\n", " "), -1] 108 | ) 109 | elif str(reaction.emoji) == "🐂": 110 | writer_object.writerow( 111 | [message.embeds[0].description.replace("\n", " "), 1] 112 | ) 113 | elif str(reaction.emoji) == "🦆": 114 | writer_object.writerow( 115 | [message.embeds[0].description.replace("\n", " "), 0] 116 | ) 117 | 118 | async def highlight(self, message: discord.Message, user: discord.User) -> None: 119 | """ 120 | This function gets called if a reaction was used for highlighting a tweet. 121 | 122 | Parameters 123 | ---------- 124 | message : discord.Message 125 | The tweet that should be posted in the highlight channel. 126 | user : discord.User 127 | The user that added this reaction to the tweet. 128 | 129 | Returns 130 | ------- 131 | None 132 | """ 133 | 134 | # Get the old embed 135 | e = message.embeds[0] 136 | 137 | # Get the Discord name of the user 138 | e.set_footer( 139 | text=f"{e.footer.text} | Highlighted by {str(user).split('#')[0]}", 140 | icon_url=e.footer.icon_url, 141 | ) 142 | 143 | if len(message.embeds) > 1: 144 | image_e = [e] + [ 145 | discord.Embed(url=em.url).set_image(url=em.image.url) 146 | for em in message.embeds[1:] 147 | ] 148 | 149 | webhook = await get_webhook(self.channel) 150 | 151 | # Wait so we can use this message as reference 152 | await webhook.send( 153 | embeds=image_e, 154 | username="FinTwit", 155 | wait=True, 156 | avatar_url=self.bot.user.avatar.url, 157 | ) 158 | 159 | else: 160 | await self.channel.send(embed=e) 161 | 162 | async def send_dm(self, message: discord.Message, user: discord.User) -> None: 163 | """ 164 | This function gets called if a reaction was used for sending a tweet via DM. 165 | 166 | Parameters 167 | ---------- 168 | message : discord.Message 169 | The tweet that should be send to the DM of the user. 170 | user : discord.User 171 | The user that added this reaction to the tweet. 172 | 173 | Returns 174 | ------- 175 | None 176 | """ 177 | 178 | # Check if the message has an embed 179 | if message.embeds == []: 180 | return 181 | 182 | # Get the old embed 183 | e = message.embeds[0] 184 | 185 | # Send the embed to the user 186 | await user.send(embed=e) 187 | 188 | 189 | def setup(bot): 190 | bot.add_cog(On_raw_reaction_add(bot)) 191 | -------------------------------------------------------------------------------- /src/cogs/loops/ideas.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import discord 4 | import pandas as pd 5 | from discord.ext import commands 6 | from discord.ext.tasks import loop 7 | 8 | import util.vars 9 | from api.tradingview_ideas import scraper 10 | from constants.config import config 11 | from constants.sources import data_sources 12 | from util.db import update_db 13 | from util.disc import get_channel, get_tagged_users, loop_error_catcher 14 | 15 | 16 | class TradingView_Ideas(commands.Cog): 17 | """ 18 | This class contains the cog for posting the latest Trading View ideas. 19 | It can be enabled / disabled in the config under ["LOOPS"]["TV_IDEAS"]. 20 | """ 21 | 22 | def __init__(self, bot: commands.Bot) -> None: 23 | self.bot = bot 24 | 25 | if config["LOOPS"]["IDEAS"]["CRYPTO"]["ENABLED"]: 26 | self.crypto_channel = None 27 | self.crypto_ideas.start() 28 | 29 | if config["LOOPS"]["IDEAS"]["STOCKS"]["ENABLED"]: 30 | self.stocks_channel = None 31 | self.stock_ideas.start() 32 | 33 | # if config["LOOPS"]["IDEAS"]["FOREX"]["ENABLED"]: 34 | # self.forex_channel = None 35 | # self.forex_ideas.start() 36 | 37 | def add_id_to_db(self, id: str) -> None: 38 | """ 39 | Adds the given id to the database. 40 | """ 41 | 42 | util.vars.ideas_ids = pd.concat( 43 | [ 44 | util.vars.ideas_ids, 45 | pd.DataFrame( 46 | [ 47 | { 48 | "id": id, 49 | "timestamp": datetime.datetime.now(), 50 | } 51 | ] 52 | ), 53 | ], 54 | ignore_index=True, 55 | ) 56 | 57 | async def send_embed(self, df: pd.DataFrame, type: str) -> None: 58 | """ 59 | Creates an embed based on the given DataFrame and type. 60 | Then sends this embed in the designated channel. 61 | 62 | Parameters 63 | ---------- 64 | df : pd.DataFrame 65 | The dataframe with the ideas. 66 | type : str 67 | The type of ideas, either "stocks" or "crypto". 68 | 69 | Returns 70 | ------- 71 | None 72 | """ 73 | 74 | # Get the database 75 | if not util.vars.ideas_ids.empty: 76 | # Set the types 77 | util.vars.ideas_ids = util.vars.ideas_ids.astype( 78 | { 79 | "id": str, 80 | "timestamp": "datetime64[ns]", 81 | } 82 | ) 83 | 84 | # Only keep ids that are less than 72 hours old 85 | util.vars.ideas_ids = util.vars.ideas_ids[ 86 | util.vars.ideas_ids["timestamp"] 87 | > datetime.datetime.now() - datetime.timedelta(hours=72) 88 | ] 89 | 90 | counter = 1 91 | for _, row in df.iterrows(): 92 | if not util.vars.ideas_ids.empty: 93 | if row["Url"] in util.vars.ideas_ids["id"].tolist(): 94 | counter += 1 95 | continue 96 | 97 | self.add_id_to_db(row["Url"]) 98 | 99 | if row["Label"] == "Long": 100 | color = 0x3CC474 101 | elif row["Label"] == "Short": 102 | color = 0xE40414 103 | else: 104 | color = 0x808080 105 | 106 | e = discord.Embed( 107 | title=row["Title"], 108 | url=row["Url"], 109 | description=row["Description"], 110 | color=color, 111 | timestamp=row["Timestamp"], 112 | ) 113 | 114 | e.set_image(url=row["ImageURL"]) 115 | 116 | e.add_field( 117 | name="Symbol", 118 | value=row["Symbol"] if row["Symbol"] is not None else "None", 119 | inline=True, 120 | ) 121 | e.add_field(name="Timeframe", value=row["Timeframe"], inline=True) 122 | e.add_field(name="Prediction", value=row["Label"], inline=True) 123 | 124 | e.set_footer( 125 | text=f"👍 {row['Likes']} | 💬 {row['Comments']}", 126 | icon_url=data_sources["tradingview"]["icon"], 127 | ) 128 | 129 | if type == "stocks": 130 | channel = self.stocks_channel 131 | elif type == "crypto": 132 | channel = self.crypto_channel 133 | elif type == "forex": 134 | channel = self.forex_channel 135 | 136 | await channel.send(content=get_tagged_users([row["Symbol"]]), embed=e) 137 | 138 | counter += 1 139 | 140 | # Only show the top 10 ideas 141 | if counter == 11: 142 | break 143 | # Write to db 144 | update_db(util.vars.ideas_ids, "ideas_ids") 145 | 146 | @loop(hours=24) 147 | @loop_error_catcher 148 | async def crypto_ideas(self) -> None: 149 | """ 150 | This function posts the crypto Trading View ideas. 151 | 152 | Returns 153 | ------- 154 | None 155 | """ 156 | if self.crypto_channel is None: 157 | self.crypto_channel = await get_channel( 158 | self.bot, 159 | config["LOOPS"]["IDEAS"]["CHANNEL"], 160 | config["CATEGORIES"]["CRYPTO"], 161 | ) 162 | 163 | df = await scraper("crypto") 164 | await self.send_embed(df, "crypto") 165 | 166 | @loop(hours=24) 167 | @loop_error_catcher 168 | async def stock_ideas(self) -> None: 169 | """ 170 | This function posts the stocks Trading View ideas. 171 | 172 | Returns 173 | ------- 174 | None 175 | """ 176 | if self.stocks_channel is None: 177 | self.stocks_channel = await get_channel( 178 | self.bot, 179 | config["LOOPS"]["IDEAS"]["CHANNEL"], 180 | config["CATEGORIES"]["STOCKS"], 181 | ) 182 | df = await scraper("stocks") 183 | await self.send_embed(df, "stocks") 184 | 185 | @loop(hours=24) 186 | @loop_error_catcher 187 | async def forex_ideas(self) -> None: 188 | """ 189 | This function posts the forex Trading View ideas. 190 | 191 | Returns 192 | ------- 193 | None 194 | """ 195 | if self.forex_channel is None: 196 | self.forex_channel = await get_channel( 197 | self.bot, 198 | config["LOOPS"]["IDEAS"]["CHANNEL"], 199 | config["CATEGORIES"]["FOREX"], 200 | ) 201 | df = await scraper("currencies") 202 | await self.send_embed(df, "forex") 203 | 204 | 205 | def setup(bot: commands.Bot) -> None: 206 | bot.add_cog(TradingView_Ideas(bot)) 207 | -------------------------------------------------------------------------------- /src/cogs/loops/liquidations.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timedelta, timezone 3 | 4 | import discord 5 | import matplotlib.dates as mdates 6 | import matplotlib.pyplot as plt 7 | import pandas as pd 8 | from discord.ext import commands 9 | from discord.ext.tasks import loop 10 | from matplotlib import ticker 11 | 12 | from api.binance import get_new_data, summarize_liquidations 13 | from constants.config import config 14 | from constants.logger import logger 15 | from constants.sources import data_sources 16 | from util.disc import get_channel, loop_error_catcher 17 | from util.formatting import human_format 18 | 19 | BACKGROUND_COLOR = "#0d1117" 20 | FIGURE_SIZE = (15, 7) 21 | COLORS_LABELS = {"#d9024b": "Shorts", "#45bf87": "Longs", "#f0b90b": "Price"} 22 | 23 | 24 | class Liquidations(commands.Cog): 25 | """ 26 | This class contains the cog for posting the Liquidations chart. 27 | It can be enabled / disabled in the config under ["LOOPS"]["LIQUIDATIONS"]. 28 | """ 29 | 30 | def __init__(self, bot: commands.Bot) -> None: 31 | self.bot = bot 32 | self.channel = None 33 | self.post_liquidations.start() 34 | 35 | @loop(hours=24) 36 | @loop_error_catcher 37 | async def post_liquidations(self): 38 | """ 39 | Copy chart like https://www.coinglass.com/LiquidationData 40 | """ 41 | if self.channel is None: 42 | self.channel = await get_channel( 43 | self.bot, config["LOOPS"]["LIQUIDATIONS"]["CHANNEL"] 44 | ) 45 | file_name: str = "liquidations.png" 46 | file_path = os.path.join("temp", file_name) 47 | await liquidations_chart(file_name) 48 | 49 | e = discord.Embed( 50 | title="Total Liquidations", 51 | description="", 52 | color=data_sources["coinglass"]["color"], 53 | timestamp=datetime.now(timezone.utc), 54 | url="https://www.coinglass.com/LiquidationData", 55 | ) 56 | file = discord.File(file_path, filename=file_name) 57 | e.set_image(url=f"attachment://{file_name}") 58 | e.set_footer( 59 | text="\u200b", 60 | icon_url=data_sources["coinglass"]["icon"], 61 | ) 62 | 63 | await self.channel.purge(limit=1) 64 | await self.channel.send(file=file, embed=e) 65 | 66 | # Delete yield.png 67 | os.remove(file_path) 68 | 69 | 70 | async def liquidations_chart(file_name: str = "liquidations.png"): 71 | coin = "BTCUSDT" 72 | market = "um" 73 | new_data = get_new_data(coin, market=market) 74 | if new_data: 75 | logger.info(f"Downloaded {len(new_data)} new files.") 76 | # Recreate the summaryf 77 | summarize_liquidations(coin=coin, market=market) 78 | # Load the summary 79 | df = pd.read_csv( 80 | f"data/summary/{coin}/{market}/liquidation_summary.csv", 81 | index_col=0, 82 | parse_dates=True, 83 | ) 84 | 85 | if df is None or df.empty: 86 | return 87 | 88 | df_price = df[["price"]].copy() 89 | df_without_price = df.drop("price", axis=1) 90 | df_without_price["Shorts"] = df_without_price["Shorts"] * -1 91 | 92 | # This plot has 2 axes 93 | fig, ax1 = plt.subplots() 94 | fig.patch.set_facecolor(BACKGROUND_COLOR) 95 | ax1.set_facecolor(BACKGROUND_COLOR) 96 | 97 | ax2 = ax1.twinx() 98 | 99 | plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%d %b")) 100 | plt.gca().xaxis.set_major_locator(mdates.DayLocator(interval=14)) 101 | 102 | ax1.bar( 103 | df_without_price.index, 104 | df_without_price["Shorts"], 105 | label="Shorts", 106 | color="#d9024b", 107 | ) 108 | 109 | ax1.bar( 110 | df_without_price.index, 111 | df_without_price["Longs"], 112 | label="Longs", 113 | color="#45bf87", 114 | ) 115 | 116 | ax1.get_yaxis().set_major_formatter( 117 | ticker.FuncFormatter(lambda x, _: f"${human_format(x, absolute=True)}") 118 | ) 119 | 120 | # Set price axis 121 | ax2.plot(df_price.index, df_price, color="#edba35", label="BTC Price") 122 | ax2.set_xlim([df_price.index[0], df_price.index[-1]]) 123 | ax2.set_ylim(bottom=df_price.min().values * 0.95, top=df_price.max().values * 1.05) 124 | ax2.get_yaxis().set_major_formatter(lambda x, _: f"${human_format(x)}") 125 | 126 | # Add combined legend using the custom add_legend function 127 | add_legend(ax2) 128 | 129 | # Add gridlines 130 | plt.grid(axis="y", color="grey", linestyle="-.", linewidth=0.5, alpha=0.5) 131 | 132 | # Remove spines 133 | ax1.spines["top"].set_visible(False) 134 | ax1.spines["bottom"].set_visible(False) 135 | ax1.spines["right"].set_visible(False) 136 | ax1.spines["left"].set_visible(False) 137 | ax1.tick_params(left=False, bottom=False, right=False, colors="white") 138 | 139 | ax2.spines["top"].set_visible(False) 140 | ax2.spines["bottom"].set_visible(False) 141 | ax2.spines["right"].set_visible(False) 142 | ax2.spines["left"].set_visible(False) 143 | ax2.tick_params(left=False, bottom=False, right=False, colors="white") 144 | 145 | # Fixes first and last bar not showing 146 | ax1.set_xlim( 147 | left=df_without_price.index[0] - timedelta(days=1), 148 | right=df_without_price.index[-1] + timedelta(days=1), 149 | ) 150 | ax2.set_xlim( 151 | left=df_without_price.index[0] - timedelta(days=1), 152 | right=df_without_price.index[-1] + timedelta(days=1), 153 | ) 154 | 155 | # Set correct size 156 | fig.set_size_inches(FIGURE_SIZE) 157 | 158 | # Add the title in the top left corner 159 | plt.text( 160 | -0.025, 161 | 1.125, 162 | "Total Liquidations Chart", 163 | transform=ax1.transAxes, 164 | fontsize=14, 165 | verticalalignment="top", 166 | horizontalalignment="left", 167 | color="white", 168 | weight="bold", 169 | ) 170 | 171 | # Convert to plot to a temporary image 172 | file_path = os.path.join("temp", file_name) 173 | plt.savefig(file_path, bbox_inches="tight", dpi=300) 174 | plt.cla() 175 | plt.close() 176 | 177 | 178 | def add_legend(ax): 179 | # Create custom legend handles with square markers, including BTC price 180 | legend_handles = [ 181 | plt.Line2D( 182 | [0], 183 | [0], 184 | marker="s", 185 | color=BACKGROUND_COLOR, 186 | markerfacecolor=color, 187 | markersize=10, 188 | label=label, 189 | ) 190 | for color, label in zip( 191 | list(COLORS_LABELS.keys()), list(COLORS_LABELS.values()) 192 | ) 193 | ] 194 | 195 | # Add legend 196 | legend = ax.legend( 197 | handles=legend_handles, 198 | loc="upper center", 199 | bbox_to_anchor=(0.5, 1.0), 200 | ncol=len(legend_handles), 201 | frameon=False, 202 | fontsize="small", 203 | labelcolor="white", 204 | ) 205 | 206 | # Make legend text bold 207 | for text in legend.get_texts(): 208 | text.set_fontweight("bold") 209 | 210 | # Adjust layout to reduce empty space around the plot 211 | plt.subplots_adjust(left=0.05, right=0.95, top=0.875, bottom=0.1) 212 | 213 | 214 | def setup(bot: commands.Bot) -> None: 215 | bot.add_cog(Liquidations(bot)) 216 | --------------------------------------------------------------------------------