Compare commits

..

75 Commits

Author SHA1 Message Date
437f7b055f Added beta wording. 2025-07-31 15:36:11 +02:00
262b4ee3cc Added beta wording. 2025-07-31 15:33:07 +02:00
ef288d565b Improve readiability on donation bar. 2025-07-31 15:27:55 +02:00
aa0a383b9c Added docker compose dev env, improved varnish VCL, and added favicons. 2025-07-31 15:13:19 +02:00
a17767f6e4 Update info. 2025-07-31 00:27:56 +02:00
703f52c217 Update info. 2025-07-31 00:15:51 +02:00
d85d372a65 Fix VCL. 2025-07-31 00:11:46 +02:00
885ddada71 Fix VCL. 2025-07-31 00:06:50 +02:00
8b6ad157dc Update info. 2025-07-31 00:02:51 +02:00
474431084e Correct db path again. 2025-07-30 23:59:33 +02:00
051e24878a Correct db path. 2025-07-30 23:57:55 +02:00
0fc88c17ef Remove unused file. 2025-07-30 23:54:50 +02:00
3beadf57a3 Major frontend overhaul. Added tailwindcss. 2025-07-30 23:51:33 +02:00
ff0f528d36 Added dogecoin donation footer.æ 2025-07-30 19:37:55 +02:00
0623770d10 Update templates/about.html 2025-07-30 11:27:21 +00:00
81338563a2 Added new and correct wallet address. 2025-07-30 13:23:09 +02:00
1cc7f556c4 Added new and correct wallet address. 2025-07-30 13:18:36 +02:00
30899d35b2 Add option to set flair. 2025-07-29 22:20:41 +02:00
26011eb170 Set correct path for prod. 2025-07-29 22:04:31 +02:00
9329c591b5 Added AS. 2025-07-29 21:57:42 +02:00
bae0d1902b No HTTPS lol. 2025-07-29 21:55:15 +02:00
9e5455592b Major improvement for discovering tickers. A GOLDEN TICKER IDEA! 2025-07-29 21:51:30 +02:00
8a80df5946 Mobile improvement. 2025-07-29 21:22:12 +02:00
712b12dc7f Lets export image run against local dashboard. 2025-07-29 20:32:54 +02:00
84486adb83 Production URL. 2025-07-29 20:20:25 +02:00
f7faebfc0d Mobile friendly. 2025-07-29 20:18:17 +02:00
d05b3a0cc7 prod docker compose fixes. 2025-07-29 20:01:25 +02:00
5d3e510f6b prod docker compose fixes. 2025-07-29 20:00:01 +02:00
c792ea0bf8 301 redirect. 2025-07-29 19:54:46 +02:00
319ee0f402 Correct path. 2025-07-29 19:52:41 +02:00
f314d57453 Correct path. 2025-07-29 19:51:54 +02:00
5ec49a53b5 Remove nginx config. 2025-07-29 19:49:23 +02:00
e0fe761c3d Config to gitignore. 2025-07-29 19:48:00 +02:00
2aa378f23b Added vhosts. 2025-07-29 19:41:51 +02:00
0acb8470c5 docker compose stuff. 2025-07-29 19:37:24 +02:00
f6536761bc Add public ignore. 2025-07-29 19:33:17 +02:00
776c8ff688 Improve doc. 2025-07-29 19:23:31 +02:00
ac7ae5e34a Added dogecoin public address. 2025-07-29 19:19:33 +02:00
f3d01e296f Minor about fix. 2025-07-29 19:07:52 +02:00
6d610c7c31 Dockerize dashboard. 2025-07-29 18:51:45 +02:00
6b2004cb27 Added about page. 2025-07-29 18:51:29 +02:00
6611999b5f Added gunicorn and uvicorn, and upgraded playwright. 2025-07-29 18:50:13 +02:00
a2459745c1 Visual improvisations and added link to Yahoo Finance for easier browsing. 2025-07-28 20:15:39 +02:00
e92a508be3 Added new words. 2025-07-28 13:28:26 +02:00
3c2a38d1a1 format doc. 2025-07-28 12:24:14 +02:00
55ea5d187f Functionality to fetch financial data in paralell. Improves speed a lot. 2025-07-28 12:15:46 +02:00
5319bc554a Very minor improvement. 2025-07-25 23:42:55 +02:00
9f49660970 Make path into env var. 2025-07-25 23:42:36 +02:00
afcba995f1 Added 5 more subreddits. 2025-07-25 23:36:16 +02:00
3499cecb8b Fixed color on link in the deep dive. 2025-07-25 23:31:05 +02:00
8448ff1897 Fixed deep dive. 2025-07-25 23:27:16 +02:00
56e0965a5f Format all code. 2025-07-25 23:22:37 +02:00
f940470de3 Refactored and redesigned dashboards to show button to show sharable image. 2025-07-25 23:12:31 +02:00
38e42efdef Improve function to update top tickers. 2025-07-25 22:50:53 +02:00
0dab12eb8c Refactored and redesigned dashboards. 2025-07-25 22:38:38 +02:00
44fc3ef533 Add functionality to only update top tickers. 2025-07-25 17:31:23 +02:00
67f627e7ea Add short command for getting no financial fetch. 2025-07-25 16:58:04 +02:00
c5a91c9d72 Refactor main reddit scraping logic. 2025-07-25 16:56:25 +02:00
eb6de197f0 pin version. 2025-07-25 16:55:56 +02:00
0d6d9516d7 Change dashboards to show daily only. 2025-07-25 16:55:37 +02:00
841f6a5305 Add option to not fetch financial data to make the script and process more effective. 2025-07-25 11:40:50 +02:00
c9e754c9c9 Remove duplicate definitions, add functionality to fetch market data for a single stock, and print default values for command line options. 2025-07-23 22:43:46 +02:00
07c1fd3841 Rewritten financial data fetching so it finally works again. 2025-07-23 13:41:40 +02:00
de57a5b26b Improve fetching of financial data. 2025-07-23 12:52:02 +02:00
bd27db49e7 Improve market data fetching routines. 2025-07-23 12:11:49 +02:00
fa7eddf02f Create script to extract words from the log, and add more words. 2025-07-23 12:11:31 +02:00
bd59092674 Added script to extract words from logs 2025-07-23 09:50:42 +02:00
2cb32bc1cb Added more words. 2025-07-22 22:08:23 +02:00
161502e214 Added example cronjob. 2025-07-22 21:25:16 +02:00
ab44bc0e96 Improved logging and added option for --stdout. 2025-07-22 21:14:11 +02:00
2688a7df44 Big improvements on image view. 2025-07-22 20:53:38 +02:00
45818046a2 Lowercase cleanup improvement and added more words again. 2025-07-22 20:03:39 +02:00
38e9ed9b01 Added blacklist words. 2025-07-22 19:30:50 +02:00
f248500d76 Markdown formatting. 2025-07-22 16:54:51 +02:00
b573b9d2f3 Added a script to post to Reddit + doc. 2025-07-22 16:34:42 +02:00
56 changed files with 5210 additions and 1239 deletions

94
.dockerignore Normal file
View File

@@ -0,0 +1,94 @@
# Git
.git
.gitignore
.gitattributes
# CI
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
docker-compose.yml
Dockerfile
.docker
.dockerignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Ignore Node.js dependencies (they will be installed inside the container)
node_modules/
# Ignore database and log files
*.db
*.log
*.db-journal
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
.env
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/

3
.gitignore vendored
View File

@@ -7,3 +7,6 @@ __pycache__/
*.log
reddit_stock_analyzer.egg-info/
images/
public/
config/certbot/
node_modules/

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:24-bookworm-slim AS builder
WORKDIR /usr/src/build
COPY package.json package-lock.json ./
RUN npm install
COPY tailwind.config.js ./
COPY templates/ ./templates/
COPY static/css/input.css ./static/css/input.css
RUN npx tailwindcss -i ./static/css/input.css -o ./static/css/style.css --minify
FROM python:3.13.5-slim
EXPOSE 5000
WORKDIR /usr/src/app
COPY requirements.txt .
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install --no-cache-dir -r requirements.txt
COPY . .
COPY --from=builder /usr/src/build/static/css/style.css ./static/css/style.css
RUN python3 -m pip install -e .
CMD [ "gunicorn", "--config", "rstat_tool/gunicorn-cfg.py", "-k", "sync", "rstat_tool.dashboard:app" ]

View File

@@ -99,7 +99,13 @@ Run the included setup script **once** to download the required `vader_lexicon`
python rstat_tool/setup_nltk.py
```
### 7. Build and Install the Commands
### 7. Set Up Playwright
Run the install routine for playwright. You might need to install some dependencies. Follow on-screen instruction if that's the case.
```bash
playwright install
```
### 8. Build and Install the Commands
Install the tool in "editable" mode. This creates the `rstat` and `rstat-dashboard` commands in your virtual environment and links them to your source code.
```bash
@@ -209,30 +215,47 @@ Open a **second terminal** in the same project directory. You can now run the `e
After running a command, a new `.png` file (e.g., `wallstreetbets_daily_1690000000.png`) will be saved in the images-directory in the root directory of the project.
## 4. Full Automation: Posting to Reddit via Cron Job
The final piece of the project is a script that automates the entire process: scraping data, generating an image, and posting it to a target subreddit like `r/rstat`. This is designed to be run via a scheduled task or cron job.
The final piece of the project is a script that automates the entire pipeline: scraping data, generating an image, and posting it to a target subreddit like `r/rstat`. This is designed to be run via a scheduled task or cron job.
### Prerequisites for Posting
### Prerequisites: One-Time Account Authorization (OAuth2)
The posting script needs to log in to your Reddit account. You must add your Reddit username and password to your `.env` file.
To post on your behalf, the script needs to be authorized with your Reddit account. This is done securely using OAuth2 and a `refresh_token`, which is compatible with 2-Factor Authentication (2FA). This is a **one-time setup process**.
**Add these two lines to your `.env` file:**
```
REDDIT_USERNAME=YourRedditUsername
REDDIT_PASSWORD=YourRedditPassword
```
*(For security, it's recommended to use a dedicated bot account for this, not your personal account.)*
**Step 1: Get Your Refresh Token**
1. First, ensure the "redirect uri" in your [Reddit App settings](https://www.reddit.com/prefs/apps) is set to **exactly** `http://localhost:8080`.
2. Run the temporary helper script included in the project:
```bash
python get_refresh_token.py
```
3. The script will print a unique URL. Copy this URL and paste it into your web browser.
4. Log in to the Reddit account you want to post from and click **"Allow"** when prompted.
5. You'll be redirected to a `localhost:8080` page that says "This site cant be reached". **This is normal and expected.**
6. Copy the **full URL** from your browser's address bar. It will look something like `http://localhost:8080/?state=...&code=...`.
7. Paste this full URL back into the terminal where the script is waiting and press Enter.
8. The script will output your unique **refresh token**.
**Step 2: Update Your `.env` File**
1. Open your `.env` file.
2. Add a new line and paste your refresh token into it.
3. Ensure your file now contains the following (your username and password are no longer needed):
```
REDDIT_CLIENT_ID=your_client_id_from_reddit
REDDIT_CLIENT_SECRET=your_client_secret_from_reddit
REDDIT_USER_AGENT=A custom user agent string (e.g., python:rstat:v1.2)
REDDIT_REFRESH_TOKEN=the_long_refresh_token_string_you_just_copied
```
You can now safely delete the `get_refresh_token.py` script. Your application is now authorized to post on your behalf indefinitely.
### The `post_to_reddit.py` Script
This is a standalone script located in the project's root directory that finds the most recently generated image and posts it to Reddit.
This is the standalone script that finds the most recently generated image and posts it to Reddit using your new authorization.
**Manual Usage:**
You can run this script manually from your terminal. This is great for testing or one-off posts.
* **Post the latest OVERALL summary image to `r/rstat`:**
```bash
python post_to_reddit.py
@@ -248,18 +271,13 @@ You can run this script manually from your terminal. This is great for testing o
python post_to_reddit.py --subreddit wallstreetbets --weekly
```
* **Post to a different target subreddit (e.g., a test subreddit):**
```bash
python post_to_reddit.py --target-subreddit MyTestSub
```
### Setting Up the Cron Job for Full Automation
### Setting Up the Cron Job
To run the entire pipeline automatically every day, you can use a simple shell script controlled by `cron`.
**Step 1: Create a Job Script**
Create a file named `run_daily_job.sh` in the root of your project directory. This script will run all the necessary commands in the correct order.
Create a file named `run_daily_job.sh` in the root of your project directory.
**`run_daily_job.sh`:**
```bash
@@ -274,49 +292,45 @@ source /path/to/your/project/reddit_stock_analyzer/.venv/bin/activate
echo "--- Starting RSTAT Daily Job on $(date) ---"
# 1. Scrape data from the last 24 hours for all subreddits in the config.
# 1. Scrape data from the last 24 hours.
echo "Step 1: Scraping new data..."
rstat --config subreddits.json --days 1
rstat --days 1
# 2. Start the dashboard in the background so the exporter can access it.
# 2. Start the dashboard in the background.
echo "Step 2: Starting dashboard in background..."
rstat-dashboard &
DASHBOARD_PID=$!
# Give the server a moment to start up.
sleep 10
# 3. Export the overall summary image.
echo "Step 3: Exporting overall summary image..."
python export_image.py --overall
# 4. Post the newly created overall summary image to r/rstat.
# 4. Post the image to r/rstat.
echo "Step 4: Posting image to Reddit..."
python post_to_reddit.py --target-subreddit rstat
# 5. Clean up by stopping the background dashboard server.
# 5. Clean up by stopping the dashboard server.
echo "Step 5: Stopping dashboard server..."
kill $DASHBOARD_PID
echo "--- RSTAT Daily Job Complete ---"
```**Before proceeding, you must edit the two absolute paths at the top of this script to match your system.**
```
**Before proceeding, you must edit the two absolute paths at the top of this script to match your system.**
**Step 2: Make the Script Executable**
In your terminal, run the following command:
```bash
chmod +x run_daily_job.sh
```
**Step 3: Schedule the Cron Job**
1. Open your crontab editor by running `crontab -e`.
2. Add a new line to the file to schedule the job. For example, to run the script **every day at 10:00 PM**, add the following line:
1. Run `crontab -e` to open your crontab editor.
2. Add the following line to run the script every day at 10:00 PM and log its output:
```
0 22 * * * /path/to/your/project/reddit_stock_analyzer/run_daily_job.sh >> /path/to/your/project/reddit_stock_analyzer/cron.log 2>&1
```
* `0 22 * * *` means at minute 0 of hour 22, every day, every month, every day of the week.
* `>> /path/to/your/.../cron.log 2>&1` is highly recommended. It redirects all output (both standard and error) from the script into a log file, so you can check if the job ran successfully.
Your project is now fully automated to scrape, analyze, visualize, and post data every day.
Your project is now fully and securely automated.

View File

@@ -0,0 +1,17 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
server_tokens off;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://varnish:80;
proxy_redirect off;
}
}

View File

@@ -0,0 +1,15 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name www.rstat.net rstat.net;
server_tokens off;
location /.well-known/acme-challenge/ {
root /usr/share/nginx/certbot;
}
location / {
return 301 https://rstat.net$request_uri;
}
}

View File

@@ -0,0 +1,67 @@
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.rstat.net;
server_tokens off;
http2 on;
ssl_certificate /etc/nginx/ssl/live/www.rstat.net/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/www.rstat.net/privkey.pem;
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
add_header Strict-Transport-Security "max-age=63072000" always;
return 301 https://rstat.net$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name rstat.net;
server_tokens off;
http2 on;
ssl_certificate /etc/nginx/ssl/live/www.rstat.net/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/www.rstat.net/privkey.pem;
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
add_header Strict-Transport-Security "max-age=63072000" always;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://varnish:80;
proxy_redirect off;
}
}
# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# see also ssl_session_ticket_key alternative to stateful session cache
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
# curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
# verify chain of trust of OCSP response using Root CA and Intermediate certs
ssl_trusted_certificate /etc/nginx/ssl/live/www.rstat.net/chain.pem;
# replace with the IP address of your resolver;
# async 'resolver' is important for proper operation of OCSP stapling
resolver 67.207.67.3;

105
config/varnish/default.vcl Normal file
View File

@@ -0,0 +1,105 @@
vcl 4.1;
# https://github.com/varnish/toolbox/tree/master/vcls/hit-miss
include "hit-miss.vcl";
import std;
backend default {
.host = "rstat-dashboard";
.port = "5000";
}
sub vcl_recv {
if (req.method != "GET" &&
req.method != "HEAD" &&
req.method != "PUT" &&
req.method != "POST" &&
req.method != "TRACE" &&
req.method != "OPTIONS" &&
req.method != "DELETE") {
/* Non-RFC2616 or CONNECT which is weird. */
return (pipe);
}
# We only deal with GET and HEAD by default
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
set req.url = regsub(req.url, "^http[s]?://", "");
# static files are always cacheable. remove SSL flag and cookie
if (req.url ~ "^/(pub/)?(media|static)/.*\.(ico|jpg|jpeg|png|gif|tiff|bmp|mp3|ogg|svg|swf|woff|woff2|eot|ttf|otf)$") {
unset req.http.Https;
unset req.http.X-Forwarded-Proto;
unset req.http.Cookie;
unset req.http.css;
unset req.http.js;
}
return (hash);
}
sub vcl_hash {
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
# To make sure http users don't see ssl warning
if (req.http.X-Forwarded-Proto) {
hash_data(req.http.X-Forwarded-Proto);
}
}
sub vcl_backend_response {
set beresp.http.X-Host = bereq.http.host;
set beresp.ttl = 1m;
# Enable stale content serving
set beresp.grace = 24h;
# Preserve the origin's Cache-Control header for client-side caching
if (beresp.http.Cache-Control) {
set beresp.http.X-Orig-Cache-Control = beresp.http.Cache-Control;
}
# validate if we need to cache it and prevent from setting cookie
# images, css and js are cacheable by default so we have to remove cookie also
if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) {
unset beresp.http.set-cookie;
unset beresp.http.set-css;
unset beresp.http.set-js;
if (bereq.url !~ "\.(ico|jpg|jpeg|png|gif|tiff|bmp|gz|tgz|bz2|tbz|mp3|ogg|svg|swf|woff|woff2|eot|ttf|otf)(\?|$)") {
set beresp.http.Pragma = "no-cache";
set beresp.http.Expires = "-1";
set beresp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0";
set beresp.grace = 1m;
}
}
# If page is not cacheable then bypass varnish for 2 minutes as Hit-For-Pass
if (beresp.ttl <= 0s ||
beresp.http.Surrogate-control ~ "no-store" ||
(!beresp.http.Surrogate-Control && beresp.http.Vary == "*")) {
# Mark as Hit-For-Pass for the next 2 minutes
set beresp.ttl = 120s;
set beresp.uncacheable = true;
}
return (deliver);
}
sub vcl_deliver {
# Restore the origin's Cache-Control header for the browser
if (resp.http.X-Orig-Cache-Control) {
set resp.http.Cache-Control = resp.http.X-Orig-Cache-Control;
unset resp.http.X-Orig-Cache-Control;
} else {
# If no Cache-Control was set by the origin, we'll set a default
set resp.http.Cache-Control = "no-cache, must-revalidate";
}
unset resp.http.Server;
unset resp.http.Via;
unset resp.http.Link;
}

105
config/varnish/dev.vcl Normal file
View File

@@ -0,0 +1,105 @@
vcl 4.1;
# https://github.com/varnish/toolbox/tree/master/vcls/hit-miss
include "hit-miss.vcl";
import std;
backend default {
.host = "rstat-dashboard";
.port = "5000";
}
sub vcl_recv {
if (req.method != "GET" &&
req.method != "HEAD" &&
req.method != "PUT" &&
req.method != "POST" &&
req.method != "TRACE" &&
req.method != "OPTIONS" &&
req.method != "DELETE") {
/* Non-RFC2616 or CONNECT which is weird. */
return (pipe);
}
# We only deal with GET and HEAD by default
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
set req.url = regsub(req.url, "^http[s]?://", "");
# static files are always cacheable. remove SSL flag and cookie
if (req.url ~ "^/(pub/)?(media|static)/.*\.(ico|jpg|jpeg|png|gif|tiff|bmp|mp3|ogg|svg|swf|woff|woff2|eot|ttf|otf)$") {
unset req.http.Https;
unset req.http.X-Forwarded-Proto;
unset req.http.Cookie;
unset req.http.css;
unset req.http.js;
}
return (hash);
}
sub vcl_hash {
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
# To make sure http users don't see ssl warning
if (req.http.X-Forwarded-Proto) {
hash_data(req.http.X-Forwarded-Proto);
}
}
sub vcl_backend_response {
set beresp.http.X-Host = bereq.http.host;
set beresp.ttl = 1m;
# Enable stale content serving
set beresp.grace = 24h;
# Preserve the origin's Cache-Control header for client-side caching
if (beresp.http.Cache-Control) {
set beresp.http.X-Orig-Cache-Control = beresp.http.Cache-Control;
}
# validate if we need to cache it and prevent from setting cookie
# images, css and js are cacheable by default so we have to remove cookie also
if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) {
unset beresp.http.set-cookie;
unset beresp.http.set-css;
unset beresp.http.set-js;
if (bereq.url !~ "\.(ico|jpg|jpeg|png|gif|tiff|bmp|gz|tgz|bz2|tbz|mp3|ogg|svg|swf|woff|woff2|eot|ttf|otf)(\?|$)") {
set beresp.http.Pragma = "no-cache";
set beresp.http.Expires = "-1";
set beresp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0";
set beresp.grace = 1m;
}
}
# If page is not cacheable then bypass varnish for 2 minutes as Hit-For-Pass
if (beresp.ttl <= 0s ||
beresp.http.Surrogate-control ~ "no-store" ||
(!beresp.http.Surrogate-Control && beresp.http.Vary == "*")) {
# Mark as Hit-For-Pass for the next 2 minutes
set beresp.ttl = 120s;
set beresp.uncacheable = true;
}
return (deliver);
}
sub vcl_deliver {
# Restore the origin's Cache-Control header for the browser
if (resp.http.X-Orig-Cache-Control) {
set resp.http.Cache-Control = resp.http.X-Orig-Cache-Control;
unset resp.http.X-Orig-Cache-Control;
} else {
# If no Cache-Control was set by the origin, we'll set a default
set resp.http.Cache-Control = "no-cache, must-revalidate";
}
unset resp.http.Server;
unset resp.http.Via;
unset resp.http.Link;
}

View File

@@ -0,0 +1,39 @@
sub vcl_recv {
unset req.http.x-cache;
}
sub vcl_hit {
set req.http.x-cache = "hit";
if (obj.ttl <= 0s && obj.grace > 0s) {
set req.http.x-cache = "hit graced";
}
}
sub vcl_miss {
set req.http.x-cache = "miss";
}
sub vcl_pass {
set req.http.x-cache = "pass";
}
sub vcl_pipe {
set req.http.x-cache = "pipe uncacheable";
}
sub vcl_synth {
set req.http.x-cache = "synth synth";
# comment the following line to omit the x-cache header in the response
set resp.http.x-cache = req.http.x-cache;
}
sub vcl_deliver {
if (obj.uncacheable) {
set req.http.x-cache = req.http.x-cache + " uncacheable" ;
} else {
set req.http.x-cache = req.http.x-cache + " cached" ;
}
# comment the following line to omit the x-cache header in the response
set resp.http.x-cache = req.http.x-cache;
}

31
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,31 @@
name: rstat
services:
rstat-dashboard:
build:
context: .
dockerfile: Dockerfile
restart: always
volumes:
- ./reddit_stocks.db:/usr/src/app/reddit_stocks.db:ro
ports:
- "5000:5000"
nginx:
image: nginx:1.29.0
restart: always
volumes:
- ./config/nginx/dev:/etc/nginx/conf.d:ro
- ./public:/usr/share/nginx:ro
ports:
- "80:80"
varnish:
image: varnish:7.7.1
restart: always
volumes:
- ./config/varnish/dev.vcl:/etc/varnish/default.vcl:ro"
- ./config/varnish/hit-miss.vcl:/etc/varnish/hit-miss.vcl:ro"
tmpfs:
- /var/lib/varnish/varnishd:exec

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
name: rstat
services:
rstat-dashboard:
build:
context: .
dockerfile: Dockerfile
restart: always
volumes:
- ./reddit_stocks.db:/usr/src/app/reddit_stocks.db:ro
ports:
- "5000:5000"
nginx:
image: nginx:1.29.0
restart: always
volumes:
- ./config/nginx:/etc/nginx/conf.d:ro
- ./config/certbot:/etc/nginx/ssl:ro
- ./public:/usr/share/nginx:ro
ports:
- "80:80"
- "443:443"
varnish:
image: varnish:7.7.1
restart: always
volumes:
- ./config/varnish/default.vcl:/etc/varnish/default.vcl:ro"
- ./config/varnish/hit-miss.vcl:/etc/varnish/hit-miss.vcl:ro"
tmpfs:
- /var/lib/varnish/varnishd:exec
certbot:
image: certbot/certbot:v4.1.1
volumes:
- ./config/certbot:/etc/letsencrypt:rw
- ./public/certbot:/usr/share/nginx/certbot:rw

View File

@@ -8,6 +8,7 @@ from playwright.sync_api import sync_playwright
# Define the output directory as a constant
OUTPUT_DIR = "images"
def export_image(url_path, filename_prefix):
"""
Launches a headless browser, navigates to a URL path, and screenshots
@@ -18,7 +19,7 @@ def export_image(url_path, filename_prefix):
# 1. Ensure the output directory exists
os.makedirs(OUTPUT_DIR, exist_ok=True)
base_url = "http://127.0.0.1:5000"
base_url = "http://localhost:5000"
url = f"{base_url}/{url_path}"
# 2. Construct the full output path including the new directory
@@ -45,7 +46,9 @@ def export_image(url_path, filename_prefix):
except Exception as e:
print(f"\nAn error occurred during export: {e}")
print("Please ensure the 'rstat-dashboard' server is running in another terminal.")
print(
"Please ensure the 'rstat-dashboard' server is running in another terminal."
)
if __name__ == "__main__":
@@ -53,21 +56,28 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Export subreddit sentiment images.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("-s", "--subreddit", help="The name of the subreddit to export.")
group.add_argument("-o", "--overall", action="store_true", help="Export the overall summary image.")
group.add_argument(
"-o", "--overall", action="store_true", help="Export the overall summary image."
)
parser.add_argument("-w", "--weekly", action="store_true", help="Export the weekly view instead of the daily view (only for --subreddit).")
parser.add_argument(
"-w",
"--weekly",
action="store_true",
help="Export the weekly view instead of the daily view (only for --subreddit).",
)
args = parser.parse_args()
# Determine the correct URL path and filename based on arguments
if args.subreddit:
view_type = "weekly" if args.weekly else "daily"
url_path_to_render = f"image/{view_type}/{args.subreddit}"
# Add ?view=... and the new &image=true parameter
url_path_to_render = f"subreddit/{args.subreddit}?view={view_type}&image=true"
filename_prefix_to_save = f"{args.subreddit}_{view_type}"
export_image(url_path_to_render, filename_prefix_to_save)
elif args.overall:
if args.weekly:
print("Warning: --weekly flag has no effect with --overall. Exporting overall summary.")
url_path_to_render = "image/overall"
filename_prefix_to_save = "overall_summary"
# For overall, we assume daily view for the image
url_path_to_render = "/?view=daily&image=true"
filename_prefix_to_save = "overall_summary_daily"
export_image(url_path_to_render, filename_prefix_to_save)

3
extract-words-from-log.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
grep possibly rstat.log | grep -v 'YFPricesMissingError' | awk '{print $6 }' | tr -d : | tr -d \$ | sort -u | awk '{printf "%s\"%s\"", sep, $0; sep=", "} END {print ""}'

37
fetch_close_price.py Normal file
View File

@@ -0,0 +1,37 @@
# fetch_close_price.py
# This script does ONLY ONE THING: gets the closing price using the stable Ticker.history() method.
import sys
import json
import yfinance as yf
import pandas as pd
import logging
# Suppress verbose yfinance logging in this isolated process
logging.getLogger("yfinance").setLevel(logging.CRITICAL)
if __name__ == "__main__":
if len(sys.argv) < 2:
# Exit with an error code if no ticker is provided
sys.exit(1)
ticker_symbol = sys.argv[1]
try:
# Instead of the global yf.download(), we use the Ticker object's .history() method.
# This uses a different internal code path that we have proven is stable.
ticker = yf.Ticker(ticker_symbol)
data = ticker.history(period="2d", auto_adjust=False)
closing_price = None
if not data.empty:
last_close_raw = data["Close"].iloc[-1]
if pd.notna(last_close_raw):
closing_price = float(last_close_raw)
# On success, print JSON to stdout and exit cleanly
print(json.dumps({"closing_price": closing_price}))
sys.exit(0)
except Exception:
# If any error occurs, print an empty JSON and exit with an error code
print(json.dumps({"closing_price": None}))
sys.exit(1)

28
fetch_market_cap.py Normal file
View File

@@ -0,0 +1,28 @@
# fetch_market_cap.py
# This script does ONLY ONE THING: gets the market cap.
import sys
import json
import yfinance as yf
import logging
# Suppress verbose yfinance logging in this isolated process
logging.getLogger("yfinance").setLevel(logging.CRITICAL)
if __name__ == "__main__":
if len(sys.argv) < 2:
# Exit with an error code if no ticker is provided
sys.exit(1)
ticker_symbol = sys.argv[1]
try:
# Directly get the market cap
market_cap = yf.Ticker(ticker_symbol).info.get("marketCap")
# On success, print JSON to stdout and exit cleanly
print(json.dumps({"market_cap": market_cap}))
sys.exit(0)
except Exception:
# If any error occurs, print an empty JSON and exit with an error code
print(json.dumps({"market_cap": None}))
sys.exit(1)

82
get_refresh_token.py Normal file
View File

@@ -0,0 +1,82 @@
# get_refresh_token.py
# A temporary, one-time-use script to get your OAuth2 refresh token.
import praw
from dotenv import load_dotenv
import os
import random
import socket
# --- IMPORTANT: Ensure this matches the "redirect uri" in your Reddit App settings ---
REDIRECT_URI = "http://localhost:5000"
def main():
print("--- RSTAT Refresh Token Generator ---")
load_dotenv()
client_id = os.getenv("REDDIT_CLIENT_ID")
client_secret = os.getenv("REDDIT_CLIENT_SECRET")
if not all([client_id, client_secret]):
print(
"Error: REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET must be set in your .env file."
)
return
# 1. Initialize PRAW
reddit = praw.Reddit(
client_id=client_id,
client_secret=client_secret,
redirect_uri=REDIRECT_URI,
user_agent="rstat_token_fetcher (by u/YourUsername)", # Can be anything
)
# 2. Generate the authorization URL
# Scopes define what our script is allowed to do. 'identity' and 'submit' are needed.
scopes = ["identity", "submit", "read"]
state = str(random.randint(0, 65536))
auth_url = reddit.auth.url(scopes, state, "permanent")
print("\nStep 1: Open this URL in your browser:\n")
print(auth_url)
print(
"\nStep 2: Log in to Reddit, click 'Allow', and you'll be redirected to a 'page not found'."
)
print(
"Step 3: Copy the ENTIRE URL from your browser's address bar after the redirect."
)
# 3. Get the redirected URL from the user
redirected_url = input(
"\nStep 4: Paste the full redirected URL here and press Enter:\n> "
)
# 4. Exchange the authorization code for a refresh token
try:
# The state is used to prevent CSRF attacks, we're just checking it matches
assert state == redirected_url.split("state=")[1].split("&")[0]
code = redirected_url.split("code=")[1].split("#_")[0]
print("\nAuthorization code received. Fetching refresh token...")
# This is the line that gets the key!
refresh_token = reddit.auth.authorize(code)
print("\n--- SUCCESS! ---")
print("Your Refresh Token is:\n")
print(refresh_token)
print(
"\nStep 5: Copy this token and add it to your .env file as REDDIT_REFRESH_TOKEN."
)
print(
"Step 6: You can now delete your REDDIT_USERNAME and REDDIT_PASSWORD from the .env file."
)
except Exception as e:
print(f"\nAn error occurred: {e}")
print("Please make sure you copied the full URL.")
if __name__ == "__main__":
main()

1294
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "reddit_stock_analyzer",
"version": "1.0.0",
"description": "A powerful, installable command-line tool and web dashboard to scan Reddit for stock ticker mentions, perform sentiment analysis, generate insightful reports, and create shareable summary images.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "ssh://git@git.pkhamre.com:43721/pkhamre/reddit_stock_analyzer.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@tailwindcss/cli": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^4.1.11"
},
"dependencies": {
"@tailwindplus/elements": "^1.0.3"
}
}

View File

@@ -6,31 +6,37 @@ import glob
from datetime import datetime, timezone
import praw
from dotenv import load_dotenv
from pathlib import Path
# --- CONFIGURATION ---
IMAGE_DIR = "images"
def get_reddit_instance():
"""Initializes and returns a PRAW Reddit instance from .env credentials."""
load_dotenv()
"""Initializes and returns a PRAW Reddit instance using OAuth2 refresh token."""
env_path = Path(__file__).parent / ".env"
load_dotenv(dotenv_path=env_path)
client_id = os.getenv("REDDIT_CLIENT_ID")
client_secret = os.getenv("REDDIT_CLIENT_SECRET")
user_agent = os.getenv("REDDIT_USER_AGENT")
username = os.getenv("REDDIT_USERNAME") # <-- Add your Reddit username to .env
password = os.getenv("REDDIT_PASSWORD") # <-- Add your Reddit password to .env
refresh_token = os.getenv("REDDIT_REFRESH_TOKEN")
if not all([client_id, client_secret, user_agent, username, password]):
print("Error: Reddit API credentials (including username/password) not found in .env file.")
if not all([client_id, client_secret, user_agent, refresh_token]):
print(
"Error: Reddit API credentials (including REDDIT_REFRESH_TOKEN) must be set in .env file."
)
return None
return praw.Reddit(
client_id=client_id,
client_secret=client_secret,
user_agent=user_agent,
username=username,
password=password
refresh_token=refresh_token,
)
def find_latest_image(pattern):
"""Finds the most recent file in the IMAGE_DIR that matches a given pattern."""
try:
@@ -45,12 +51,40 @@ def find_latest_image(pattern):
print(f"Error finding image file: {e}")
return None
def get_flair_id(subreddit, flair_text):
"""
Attempts to find the ID of a flair by its text.
Returns the ID string or None if not found or an error occurs.
"""
if not flair_text:
return None
print(f"Attempting to find Flair ID for text: '{flair_text}'...")
try:
flairs = subreddit.flair.link_templates
for flair in flairs:
if flair['text'].lower() == flair_text.lower():
print(f" -> Found Flair ID: {flair['id']}")
return flair['id']
print(" -> Warning: No matching flair text found.")
return None
except Exception as e:
print(f" -> Warning: Could not fetch flairs for this subreddit (Error: {e}). Proceeding without flair.")
return None
def main():
"""Main function to find an image and post it to Reddit."""
parser = argparse.ArgumentParser(description="Find the latest sentiment image and post it to a subreddit.")
parser = argparse.ArgumentParser(
description="Find the latest sentiment image and post it to a subreddit."
)
parser.add_argument("-s", "--subreddit", help="The source subreddit of the image to post. (Defaults to overall summary)")
parser.add_argument("-w", "--weekly", action="store_true", help="Post the weekly summary instead of the daily one.")
parser.add_argument("-t", "--target-subreddit", default="rstat", help="The subreddit to post the image to. (Default: rstat)")
parser.add_argument("--flair-text", help="The text of the flair to search for (e.g., 'Daily Summary').")
parser.add_argument("--flair-id", help="Manually provide a specific Flair ID (overrides --flair-text).")
args = parser.parse_args()
# --- 1. Determine filename pattern and post title ---
@@ -63,9 +97,13 @@ def main():
else:
# Default to the overall summary
if args.weekly:
print("Warning: --weekly flag has no effect for overall summary. Posting overall daily image.")
print(
"Warning: --weekly flag has no effect for overall summary. Posting overall daily image."
)
filename_pattern = "overall_summary_*.png"
post_title = f"Overall Top 10 Ticker Mentions Across Reddit ({current_date_str})"
post_title = (
f"Overall Top 10 Ticker Mentions Across Reddit ({current_date_str})"
)
print(f"Searching for image pattern: {filename_pattern}")
@@ -73,7 +111,9 @@ def main():
image_to_post = find_latest_image(filename_pattern)
if not image_to_post:
print(f"Error: No image found matching the pattern '{filename_pattern}'. Please run the scraper and exporter first.")
print(
f"Error: No image found matching the pattern '{filename_pattern}'. Please run the scraper and exporter first."
)
return
print(f"Found image: {image_to_post}")
@@ -85,12 +125,23 @@ def main():
try:
target_sub = reddit.subreddit(args.target_subreddit)
# --- NEW SMART FLAIR LOGIC ---
final_flair_id = None
if args.flair_id:
# If the user provides a specific ID, use it directly.
print(f"Using provided Flair ID: {args.flair_id}")
final_flair_id = args.flair_id
elif args.flair_text:
# If they provide text, try to find the ID automatically.
final_flair_id = get_flair_id(target_sub, args.flair_text)
print(f"Submitting '{post_title}' to r/{target_sub.display_name}...")
submission = target_sub.submit_image(
title=post_title,
image_path=image_to_post,
flair_id=None # Optional: You can add a flair ID here if you want
flair_id=final_flair_id # This will be the found ID or None
)
print("\n--- Post Successful! ---")
@@ -98,7 +149,8 @@ def main():
except Exception as e:
print(f"\nAn error occurred while posting to Reddit: {e}")
if 'FLAIR_REQUIRED' in str(e):
print("\nHint: This subreddit requires a flair. Try finding the flair text or ID and use the --flair-text or --flair-id argument.")
if __name__ == "__main__":
main()

View File

@@ -1,6 +1,8 @@
Flask
nltk
playwright
praw
python-dotenv
yfinance
Flask==3.1.1
gunicorn==23.0.0
nltk==3.9.1
playwright==1.54.0
praw==7.8.1
python-dotenv==1.1.1
uvicorn==0.35.0
yfinance==0.2.65

View File

@@ -2,30 +2,35 @@
import argparse
from . import database
from .logger_setup import get_logger
from .logger_setup import setup_logging, logger as log
# We can't reuse load_subreddits from main anymore if it's not in the same file
# So we will duplicate it here. It's small and keeps this script self-contained.
import json
log = get_logger()
def load_subreddits(filepath):
"""Loads a list of subreddits from a JSON file."""
try:
with open(filepath, 'r') as f:
with open(filepath, "r") as f:
data = json.load(f)
return data.get("subreddits", [])
except (FileNotFoundError, json.JSONDecodeError) as e:
log.error(f"Error loading config file '{filepath}': {e}")
return None
def run_cleanup():
"""Main function for the cleanup tool."""
parser = argparse.ArgumentParser(
description="A tool to clean stale data from the RSTAT database.",
formatter_class=argparse.RawTextHelpFormatter
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--tickers",
action="store_true",
help="Clean tickers that are in the blacklist.",
)
parser.add_argument("--tickers", action="store_true", help="Clean tickers that are in the blacklist.")
# --- UPDATED ARGUMENT DEFINITION ---
# nargs='?': Makes the argument optional.
@@ -33,18 +38,27 @@ def run_cleanup():
# default=None: The value if the flag is not present at all.
parser.add_argument(
"--subreddits",
nargs='?',
const='subreddits.json',
nargs="?",
const="subreddits.json",
default=None,
help="Clean data from subreddits NOT in the specified config file.\n(Defaults to 'subreddits.json' if flag is used without a value)."
help="Clean data from subreddits NOT in the specified config file.\n(Defaults to 'subreddits.json' if flag is used without a value).",
)
parser.add_argument("--all", action="store_true", help="Run all available cleanup tasks.")
parser.add_argument(
"--all", action="store_true", help="Run all available cleanup tasks."
)
parser.add_argument(
"--stdout", action="store_true", help="Print all log messages to the console."
)
args = parser.parse_args()
setup_logging(console_verbose=args.stdout)
run_any_task = False
log.critical("\n--- Starting Cleanup ---")
# --- UPDATED LOGIC TO HANDLE THE NEW ARGUMENT ---
if args.all or args.tickers:
run_any_task = True
@@ -54,7 +68,7 @@ def run_cleanup():
if args.all or args.subreddits is not None:
run_any_task = True
# If --all is used, default to 'subreddits.json' if --subreddits wasn't also specified
config_file = args.subreddits or 'subreddits.json'
config_file = args.subreddits or "subreddits.json"
log.info(f"\nCleaning subreddits based on active list in: {config_file}")
active_subreddits = load_subreddits(config_file)
if active_subreddits is not None:
@@ -62,10 +76,13 @@ def run_cleanup():
if not run_any_task:
parser.print_help()
log.error("\nError: Please provide at least one cleanup option (e.g., --tickers, --subreddits, --all).")
log.error(
"\nError: Please provide at least one cleanup option (e.g., --tickers, --subreddits, --all)."
)
return
log.info("\nCleanup finished.")
log.critical("\nCleanup finished.")
if __name__ == "__main__":
run_cleanup()

View File

@@ -1,22 +1,20 @@
# rstat_tool/dashboard.py
from flask import Flask, render_template
from flask import Flask, render_template, request
from datetime import datetime, timedelta, timezone
from .logger_setup import get_logger
from .logger_setup import logger as log
from .database import (
get_overall_summary,
get_subreddit_summary,
get_all_scanned_subreddits,
get_deep_dive_details,
get_daily_summary_for_subreddit,
get_weekly_summary_for_subreddit,
get_overall_image_view_summary
get_overall_daily_summary, # Now correctly imported
get_overall_weekly_summary, # Now correctly imported
)
log = get_logger()
app = Flask(__name__, template_folder='../templates')
app = Flask(__name__, template_folder='../templates', static_folder='../static')
@app.template_filter('format_mc')
@app.template_filter("format_mc")
def format_market_cap(mc):
"""Formats a large number into a readable market cap string."""
if mc is None or mc == 0:
@@ -30,23 +28,70 @@ def format_market_cap(mc):
else:
return f"${mc:,}"
@app.context_processor
def inject_subreddits():
"""Makes the list of all scanned subreddits available to every template."""
subreddits = get_all_scanned_subreddits()
return dict(subreddits=subreddits)
"""Makes the list of all subreddits available to every template for the navbar."""
return dict(all_subreddits=get_all_scanned_subreddits())
@app.route("/")
def index():
"""The handler for the main dashboard page."""
tickers = get_overall_summary(limit=10)
return render_template("index.html", tickers=tickers)
def overall_dashboard():
"""Handler for the main, overall dashboard."""
view_type = request.args.get("view", "daily")
is_image_mode = request.args.get("image") == "true"
if view_type == "weekly":
tickers, start, end = get_overall_weekly_summary()
date_string = f"{start.strftime('%b %d')} - {end.strftime('%b %d, %Y')}"
subtitle = "All Subreddits - Top 10 Weekly"
else: # Default to daily
tickers = get_overall_daily_summary()
date_string = datetime.now(timezone.utc).strftime("%Y-%m-%d")
subtitle = "All Subreddits - Top 10 Daily"
return render_template(
"dashboard_view.html",
title="Overall Dashboard",
subtitle=subtitle,
date_string=date_string,
tickers=tickers,
view_type=view_type,
subreddit_name=None,
is_image_mode=is_image_mode,
base_url="/",
)
@app.route("/subreddit/<name>")
def subreddit_dashboard(name):
"""A dynamic route for per-subreddit dashboards."""
tickers = get_subreddit_summary(name, limit=10)
return render_template("subreddit.html", tickers=tickers, subreddit_name=name)
"""Handler for per-subreddit dashboards."""
view_type = request.args.get("view", "daily")
is_image_mode = request.args.get("image") == "true"
if view_type == "weekly":
today = datetime.now(timezone.utc)
target_date = today - timedelta(days=7)
tickers, start, end = get_weekly_summary_for_subreddit(name, target_date)
date_string = f"{start.strftime('%b %d')} - {end.strftime('%b %d, %Y')}"
subtitle = f"r/{name} - Top 10 Weekly"
else: # Default to daily
tickers = get_daily_summary_for_subreddit(name)
date_string = datetime.now(timezone.utc).strftime("%Y-%m-%d")
subtitle = f"r/{name} - Top 10 Daily"
return render_template(
"dashboard_view.html",
title=f"r/{name} Dashboard",
subtitle=subtitle,
date_string=date_string,
tickers=tickers,
view_type=view_type,
subreddit_name=name,
is_image_mode=is_image_mode,
base_url=f"/subreddit/{name}",
)
@app.route("/deep-dive/<symbol>")
def deep_dive(symbol):
@@ -55,45 +100,13 @@ def deep_dive(symbol):
posts = get_deep_dive_details(symbol)
return render_template("deep_dive.html", posts=posts, symbol=symbol)
@app.route("/image/daily/<name>")
def daily_image_view(name):
"""The handler for the image-style dashboard."""
tickers = get_daily_summary_for_subreddit(name)
current_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return render_template(
"daily_image_view.html",
tickers=tickers,
subreddit_name=name,
current_date=current_date
)
@app.route("/image/weekly/<name>")
def weekly_image_view(name):
"""The handler for the WEEKLY image-style dashboard."""
tickers = get_weekly_summary_for_subreddit(name)
@app.route("/about")
def about_page():
"""Handler for the static About page."""
# We need to pass these so the navbar knows which items to highlight
return render_template("about.html", subreddit_name=None, view_type='daily')
# Create the date range string for the title
end_date = datetime.now(timezone.utc)
start_date = end_date - timedelta(days=7)
date_range_str = f"{start_date.strftime('%b %d')} - {end_date.strftime('%b %d, %Y')}"
return render_template(
"weekly_image_view.html",
tickers=tickers,
subreddit_name=name,
date_range=date_range_str
)
@app.route("/image/overall")
def overall_image_view():
"""The handler for the overall image-style dashboard."""
tickers = get_overall_image_view_summary()
current_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return render_template(
"overall_image_view.html",
tickers=tickers,
current_date=current_date
)
def start_dashboard():
"""The main function called by the 'rstat-dashboard' command."""
@@ -102,5 +115,6 @@ def start_dashboard():
log.info("Press CTRL+C to stop the server.")
app.run(debug=True)
if __name__ == "__main__":
start_dashboard()

View File

@@ -3,77 +3,12 @@
import sqlite3
import time
from .ticker_extractor import COMMON_WORDS_BLACKLIST
from .logger_setup import get_logger
from .logger_setup import logger as log
from datetime import datetime, timedelta, timezone
DB_FILE = "reddit_stocks.db"
log = get_logger()
MARKET_CAP_REFRESH_INTERVAL = 86400
def get_db_connection():
"""Establishes a connection to the SQLite database."""
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
def initialize_db():
"""
Initializes the database and creates the necessary tables if they don't exist.
"""
conn = get_db_connection()
cursor = conn.cursor()
# --- Create tickers table ---
cursor.execute("""
CREATE TABLE IF NOT EXISTS tickers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL UNIQUE,
market_cap INTEGER,
closing_price REAL,
last_updated INTEGER
)
""")
# --- Create subreddits table ---
cursor.execute("""
CREATE TABLE IF NOT EXISTS subreddits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
""")
# --- Create mentions table ---
cursor.execute("""
CREATE TABLE IF NOT EXISTS mentions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker_id INTEGER,
subreddit_id INTEGER,
post_id TEXT NOT NULL,
mention_type TEXT NOT NULL,
mention_sentiment REAL, -- Renamed from sentiment_score for clarity
post_avg_sentiment REAL, -- NEW: Stores the avg sentiment of the whole post
mention_timestamp INTEGER NOT NULL,
FOREIGN KEY (ticker_id) REFERENCES tickers (id),
FOREIGN KEY (subreddit_id) REFERENCES subreddits (id)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
post_url TEXT,
subreddit_id INTEGER,
post_timestamp INTEGER,
comment_count INTEGER,
avg_comment_sentiment REAL,
FOREIGN KEY (subreddit_id) REFERENCES subreddits (id)
)
""")
conn.commit()
conn.close()
log.info("Database initialized successfully.")
def clean_stale_tickers():
"""
@@ -84,7 +19,7 @@ def clean_stale_tickers():
conn = get_db_connection()
cursor = conn.cursor()
placeholders = ','.join('?' for _ in COMMON_WORDS_BLACKLIST)
placeholders = ",".join("?" for _ in COMMON_WORDS_BLACKLIST)
query = f"SELECT id, symbol FROM tickers WHERE symbol IN ({placeholders})"
cursor.execute(query, tuple(COMMON_WORDS_BLACKLIST))
@@ -96,8 +31,8 @@ def clean_stale_tickers():
return
for ticker in stale_tickers:
ticker_id = ticker['id']
ticker_symbol = ticker['symbol']
ticker_id = ticker["id"]
ticker_symbol = ticker["symbol"]
log.info(f"Removing stale ticker '{ticker_symbol}' (ID: {ticker_id})...")
cursor.execute("DELETE FROM mentions WHERE ticker_id = ?", (ticker_id,))
cursor.execute("DELETE FROM tickers WHERE id = ?", (ticker_id,))
@@ -107,6 +42,7 @@ def clean_stale_tickers():
conn.close()
log.info(f"Cleanup complete. Removed {deleted_count} records.")
def clean_stale_subreddits(active_subreddits):
"""
Removes all data associated with subreddits that are NOT in the active list.
@@ -114,13 +50,18 @@ def clean_stale_subreddits(active_subreddits):
log.info("\n--- Cleaning Stale Subreddits from Database ---")
conn = get_db_connection()
cursor = conn.cursor()
# Convert the list of active subreddits from the config file to a lowercase set for fast,
# case-insensitive lookups.
active_subreddits_lower = {sub.lower() for sub in active_subreddits}
cursor.execute("SELECT id, name FROM subreddits")
db_subreddits = cursor.fetchall()
stale_sub_ids = []
for sub in db_subreddits:
if sub['name'] not in active_subreddits:
if sub["name"] not in active_subreddits_lower:
log.info(f"Found stale subreddit to remove: r/{sub['name']}")
stale_sub_ids.append(sub['id'])
stale_sub_ids.append(sub["id"])
if not stale_sub_ids:
log.info("No stale subreddits to clean.")
conn.close()
@@ -134,15 +75,18 @@ def clean_stale_subreddits(active_subreddits):
conn.close()
log.info("Stale subreddit cleanup complete.")
def get_db_connection():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
def initialize_db():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS tickers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL UNIQUE,
@@ -150,14 +94,18 @@ def initialize_db():
closing_price REAL,
last_updated INTEGER
)
""")
cursor.execute("""
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS subreddits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
""")
cursor.execute("""
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS mentions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker_id INTEGER,
@@ -170,8 +118,10 @@ def initialize_db():
FOREIGN KEY (ticker_id) REFERENCES tickers (id),
FOREIGN KEY (subreddit_id) REFERENCES subreddits (id)
)
""")
cursor.execute("""
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id TEXT NOT NULL UNIQUE,
@@ -183,12 +133,23 @@ def initialize_db():
avg_comment_sentiment REAL,
FOREIGN KEY (subreddit_id) REFERENCES subreddits (id)
)
""")
"""
)
conn.commit()
conn.close()
log.info("Database initialized successfully.")
def add_mention(conn, ticker_id, subreddit_id, post_id, mention_type, timestamp, mention_sentiment, post_avg_sentiment=None):
def add_mention(
conn,
ticker_id,
subreddit_id,
post_id,
mention_type,
timestamp,
mention_sentiment,
post_avg_sentiment=None,
):
cursor = conn.cursor()
try:
cursor.execute(
@@ -196,85 +157,68 @@ def add_mention(conn, ticker_id, subreddit_id, post_id, mention_type, timestamp,
INSERT INTO mentions (ticker_id, subreddit_id, post_id, mention_type, mention_timestamp, mention_sentiment, post_avg_sentiment)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(ticker_id, subreddit_id, post_id, mention_type, timestamp, mention_sentiment, post_avg_sentiment)
(
ticker_id,
subreddit_id,
post_id,
mention_type,
timestamp,
mention_sentiment,
post_avg_sentiment,
),
)
conn.commit()
except sqlite3.IntegrityError:
pass
def get_or_create_entity(conn, table_name, column_name, value):
"""Generic function to get or create an entity and return its ID."""
cursor = conn.cursor()
cursor.execute(f"SELECT id FROM {table_name} WHERE {column_name} = ?", (value,))
result = cursor.fetchone()
if result:
return result['id']
return result["id"]
else:
cursor.execute(f"INSERT INTO {table_name} ({column_name}) VALUES (?)", (value,))
conn.commit()
return cursor.lastrowid
def update_ticker_financials(conn, ticker_id, market_cap, closing_price):
"""Updates the financials and timestamp for a specific ticker."""
cursor = conn.cursor()
current_timestamp = int(time.time())
cursor.execute(
"UPDATE tickers SET market_cap = ?, closing_price = ?, last_updated = ? WHERE id = ?",
(market_cap, closing_price, current_timestamp, ticker_id)
(market_cap, closing_price, current_timestamp, ticker_id),
)
conn.commit()
def get_ticker_info(conn, ticker_id):
"""Retrieves all info for a specific ticker by its ID."""
cursor = conn.cursor()
cursor.execute("SELECT * FROM tickers WHERE id = ?", (ticker_id,))
return cursor.fetchone()
def generate_summary_report(limit=20):
"""Queries the DB to generate a summary for the command-line tool."""
log.info(f"\n--- Top {limit} Tickers by Mention Count ---")
conn = get_db_connection()
cursor = conn.cursor()
# --- UPDATED QUERY: Changed m.sentiment_score to m.mention_sentiment ---
query = """
SELECT
t.symbol, t.market_cap, t.closing_price,
COUNT(m.id) as mention_count,
SUM(CASE WHEN m.mention_sentiment > 0.1 THEN 1 ELSE 0 END) as bullish_mentions,
SUM(CASE WHEN m.mention_sentiment < -0.1 THEN 1 ELSE 0 END) as bearish_mentions,
SUM(CASE WHEN m.mention_sentiment BETWEEN -0.1 AND 0.1 THEN 1 ELSE 0 END) as neutral_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY mention_count DESC
LIMIT ?;
def get_week_start_end(for_date):
"""
results = cursor.execute(query, (limit,)).fetchall()
Calculates the start (Monday, 00:00:00) and end (Sunday, 23:59:59)
of the week that a given date falls into.
Returns two datetime objects.
"""
# Monday is 0, Sunday is 6
start_of_week = for_date - timedelta(days=for_date.weekday())
end_of_week = start_of_week + timedelta(days=6)
header = f"{'Ticker':<8} | {'Mentions':<8} | {'Bullish':<8} | {'Bearish':<8} | {'Neutral':<8} | {'Market Cap':<15} | {'Close Price':<12}"
print(header)
print("-" * (len(header) + 2)) # Adjusted separator length
# Set time to the very beginning and very end of the day for an inclusive range
start_of_week = start_of_week.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_week = end_of_week.replace(hour=23, minute=59, second=59, microsecond=999999)
for row in results:
market_cap_str = "N/A"
if row['market_cap'] and row['market_cap'] > 0:
mc = row['market_cap']
if mc >= 1e12: market_cap_str = f"${mc/1e12:.2f}T"
elif mc >= 1e9: market_cap_str = f"${mc/1e9:.2f}B"
else: market_cap_str = f"${mc/1e6:.2f}M"
return start_of_week, end_of_week
closing_price_str = f"${row['closing_price']:.2f}" if row['closing_price'] else "N/A"
print(
f"{row['symbol']:<8} | "
f"{row['mention_count']:<8} | "
f"{row['bullish_mentions']:<8} | "
f"{row['bearish_mentions']:<8} | "
f"{row['neutral_mentions']:<8} | "
f"{market_cap_str:<15} | "
f"{closing_price_str:<12}"
)
conn.close()
def add_or_update_post_analysis(conn, post_data):
"""
@@ -291,94 +235,176 @@ def add_or_update_post_analysis(conn, post_data):
comment_count = excluded.comment_count,
avg_comment_sentiment = excluded.avg_comment_sentiment;
""",
post_data
post_data,
)
conn.commit()
def get_overall_summary(limit=50):
def get_overall_summary(limit=10):
"""
Gets the top tickers across all subreddits from the LAST 24 HOURS.
"""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol, t.market_cap, t.closing_price, COUNT(m.id) as mention_count,
SUM(CASE WHEN m.mention_sentiment > 0.1 THEN 1 ELSE 0 END) as bullish_mentions,
SUM(CASE WHEN m.mention_sentiment < -0.1 THEN 1 ELSE 0 END) as bearish_mentions,
SUM(CASE WHEN m.mention_sentiment BETWEEN -0.1 AND 0.1 THEN 1 ELSE 0 END) as neutral_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id
GROUP BY t.symbol, t.market_cap, t.closing_price ORDER BY mention_count DESC LIMIT ?;
WHERE m.mention_timestamp >= ? -- <-- ADDED TIME FILTER
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY mention_count DESC LIMIT ?;
"""
results = conn.execute(query, (limit,)).fetchall()
results = conn.execute(query, (one_day_ago_timestamp, limit)).fetchall()
conn.close()
return results
def get_subreddit_summary(subreddit_name, limit=50):
def get_subreddit_summary(subreddit_name, limit=10):
"""
Gets the top tickers for a specific subreddit from the LAST 24 HOURS.
"""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol, t.market_cap, t.closing_price, COUNT(m.id) as mention_count,
SUM(CASE WHEN m.mention_sentiment > 0.1 THEN 1 ELSE 0 END) as bullish_mentions,
SUM(CASE WHEN m.mention_sentiment < -0.1 THEN 1 ELSE 0 END) as bearish_mentions,
SUM(CASE WHEN m.mention_sentiment BETWEEN -0.1 AND 0.1 THEN 1 ELSE 0 END) as neutral_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id JOIN subreddits s ON m.subreddit_id = s.id
WHERE LOWER(s.name) = LOWER(?) GROUP BY t.symbol, t.market_cap, t.closing_price ORDER BY mention_count DESC LIMIT ?;
WHERE LOWER(s.name) = LOWER(?) AND m.mention_timestamp >= ? -- <-- ADDED TIME FILTER
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY mention_count DESC LIMIT ?;
"""
results = conn.execute(query, (subreddit_name, limit)).fetchall()
results = conn.execute(
query, (subreddit_name, one_day_ago_timestamp, limit)
).fetchall()
conn.close()
return results
def get_daily_summary_for_subreddit(subreddit_name):
""" Gets a summary for the DAILY image view (last 24 hours). """
"""Gets a summary for the DAILY image view (last 24 hours)."""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol,
COUNT(CASE WHEN m.mention_type = 'post' THEN 1 END) as post_mentions,
COUNT(CASE WHEN m.mention_type = 'comment' THEN 1 END) as comment_mentions,
SELECT
t.symbol, t.market_cap, t.closing_price,
COUNT(m.id) as total_mentions,
COUNT(CASE WHEN m.mention_sentiment > 0.1 THEN 1 END) as bullish_mentions,
COUNT(CASE WHEN m.mention_sentiment < -0.1 THEN 1 END) as bearish_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id JOIN subreddits s ON m.subreddit_id = s.id
WHERE LOWER(s.name) = LOWER(?) AND m.mention_timestamp >= ?
GROUP BY t.symbol ORDER BY (post_mentions + comment_mentions) DESC LIMIT 10;
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY total_mentions DESC LIMIT 10;
"""
results = conn.execute(query, (subreddit_name, one_day_ago_timestamp)).fetchall()
conn.close()
return results
def get_weekly_summary_for_subreddit(subreddit_name):
""" Gets a summary for the WEEKLY image view (last 7 days). """
def get_weekly_summary_for_subreddit(subreddit_name, for_date):
"""Gets a summary for the WEEKLY image view (full week)."""
conn = get_db_connection()
seven_days_ago = datetime.now(timezone.utc) - timedelta(days=7)
seven_days_ago_timestamp = int(seven_days_ago.timestamp())
start_of_week, end_of_week = get_week_start_end(for_date)
start_timestamp = int(start_of_week.timestamp())
end_timestamp = int(end_of_week.timestamp())
query = """
SELECT t.symbol,
COUNT(CASE WHEN m.mention_type = 'post' THEN 1 END) as post_mentions,
COUNT(CASE WHEN m.mention_type = 'comment' THEN 1 END) as comment_mentions,
SELECT
t.symbol, t.market_cap, t.closing_price,
COUNT(m.id) as total_mentions,
COUNT(CASE WHEN m.mention_sentiment > 0.1 THEN 1 END) as bullish_mentions,
COUNT(CASE WHEN m.mention_sentiment < -0.1 THEN 1 END) as bearish_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id JOIN subreddits s ON m.subreddit_id = s.id
WHERE LOWER(s.name) = LOWER(?) AND m.mention_timestamp >= ?
GROUP BY t.symbol ORDER BY (post_mentions + comment_mentions) DESC LIMIT 10;
WHERE LOWER(s.name) = LOWER(?) AND m.mention_timestamp BETWEEN ? AND ?
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY total_mentions DESC LIMIT 10;
"""
results = conn.execute(query, (subreddit_name, seven_days_ago_timestamp)).fetchall()
results = conn.execute(
query, (subreddit_name, start_timestamp, end_timestamp)
).fetchall()
conn.close()
return results
return results, start_of_week, end_of_week
def get_overall_image_view_summary():
""" Gets a summary of top tickers across ALL subreddits for the image view. """
"""
Gets a summary of top tickers across ALL subreddits for the DAILY image view (last 24 hours).
"""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol,
COUNT(CASE WHEN m.mention_type = 'post' THEN 1 END) as post_mentions,
COUNT(CASE WHEN m.mention_type = 'comment' THEN 1 END) as comment_mentions,
SELECT
t.symbol, t.market_cap, t.closing_price,
COUNT(m.id) as total_mentions,
COUNT(CASE WHEN m.mention_sentiment > 0.1 THEN 1 END) as bullish_mentions,
COUNT(CASE WHEN m.mention_sentiment < -0.1 THEN 1 END) as bearish_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id
GROUP BY t.symbol ORDER BY (post_mentions + comment_mentions) DESC LIMIT 10;
WHERE m.mention_timestamp >= ? -- <-- ADDED TIME FILTER
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY total_mentions DESC LIMIT 10;
"""
results = conn.execute(query).fetchall()
results = conn.execute(query, (one_day_ago_timestamp,)).fetchall()
conn.close()
return results
def get_overall_daily_summary():
"""
Gets the top tickers across all subreddits from the LAST 24 HOURS.
(This is a copy of get_overall_summary, renamed for clarity).
"""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol, t.market_cap, t.closing_price, COUNT(m.id) as total_mentions,
SUM(CASE WHEN m.mention_sentiment > 0.1 THEN 1 ELSE 0 END) as bullish_mentions,
SUM(CASE WHEN m.mention_sentiment < -0.1 THEN 1 ELSE 0 END) as bearish_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id
WHERE m.mention_timestamp >= ?
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY total_mentions DESC LIMIT 10;
"""
results = conn.execute(query, (one_day_ago_timestamp,)).fetchall()
conn.close()
return results
def get_overall_weekly_summary():
"""
Gets the top tickers across all subreddits for the LAST 7 DAYS.
"""
conn = get_db_connection()
today = datetime.now(timezone.utc)
start_of_week, end_of_week = get_week_start_end(
today - timedelta(days=7)
) # Get last week's boundaries
start_timestamp = int(start_of_week.timestamp())
end_timestamp = int(end_of_week.timestamp())
query = """
SELECT t.symbol, t.market_cap, t.closing_price, COUNT(m.id) as total_mentions,
SUM(CASE WHEN m.mention_sentiment > 0.1 THEN 1 ELSE 0 END) as bullish_mentions,
SUM(CASE WHEN m.mention_sentiment < -0.1 THEN 1 ELSE 0 END) as bearish_mentions
FROM mentions m JOIN tickers t ON m.ticker_id = t.id
WHERE m.mention_timestamp BETWEEN ? AND ?
GROUP BY t.symbol, t.market_cap, t.closing_price
ORDER BY total_mentions DESC LIMIT 10;
"""
results = conn.execute(query, (start_timestamp, end_timestamp)).fetchall()
conn.close()
return results, start_of_week, end_of_week
def get_deep_dive_details(ticker_symbol):
""" Gets all analyzed posts that mention a specific ticker. """
"""Gets all analyzed posts that mention a specific ticker."""
conn = get_db_connection()
query = """
SELECT DISTINCT p.*, s.name as subreddit_name FROM posts p
@@ -390,9 +416,109 @@ def get_deep_dive_details(ticker_symbol):
conn.close()
return results
def get_all_scanned_subreddits():
""" Gets a unique list of all subreddits we have data for. """
"""Gets a unique list of all subreddits we have data for."""
conn = get_db_connection()
results = conn.execute("SELECT DISTINCT name FROM subreddits ORDER BY name ASC;").fetchall()
results = conn.execute(
"SELECT DISTINCT name FROM subreddits ORDER BY name ASC;"
).fetchall()
conn.close()
return [row['name'] for row in results]
return [row["name"] for row in results]
def get_all_tickers():
"""Retrieves the ID and symbol of every ticker in the database."""
conn = get_db_connection()
results = conn.execute("SELECT id, symbol FROM tickers;").fetchall()
conn.close()
return results
def get_ticker_by_symbol(symbol):
"""
Retrieves a single ticker's ID and symbol from the database.
The search is case-insensitive. Returns a Row object or None if not found.
"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id, symbol FROM tickers WHERE LOWER(symbol) = LOWER(?)", (symbol,)
)
result = cursor.fetchone()
conn.close()
return result
def get_top_daily_ticker_symbols():
"""Gets a simple list of the Top 10 ticker symbols from the last 24 hours."""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol FROM mentions m JOIN tickers t ON m.ticker_id = t.id
WHERE m.mention_timestamp >= ?
GROUP BY t.symbol ORDER BY COUNT(m.id) DESC LIMIT 10;
"""
results = conn.execute(query, (one_day_ago_timestamp,)).fetchall()
conn.close()
return [row["symbol"] for row in results] # Return a simple list of strings
def get_top_weekly_ticker_symbols():
"""Gets a simple list of the Top 10 ticker symbols from the last 7 days."""
conn = get_db_connection()
seven_days_ago = datetime.now(timezone.utc) - timedelta(days=7)
seven_days_ago_timestamp = int(seven_days_ago.timestamp())
query = """
SELECT t.symbol FROM mentions m JOIN tickers t ON m.ticker_id = t.id
WHERE m.mention_timestamp >= ?
GROUP BY t.symbol ORDER BY COUNT(m.id) DESC LIMIT 10;
"""
results = conn.execute(query, (seven_days_ago_timestamp,)).fetchall()
conn.close()
return [row["symbol"] for row in results] # Return a simple list of strings
def get_top_daily_ticker_symbols_for_subreddit(subreddit_name):
"""Gets a list of the Top 10 daily ticker symbols for a specific subreddit."""
conn = get_db_connection()
one_day_ago = datetime.now(timezone.utc) - timedelta(days=1)
one_day_ago_timestamp = int(one_day_ago.timestamp())
query = """
SELECT t.symbol FROM mentions m JOIN tickers t ON m.ticker_id = t.id
JOIN subreddits s ON m.subreddit_id = s.id
WHERE LOWER(s.name) = LOWER(?) AND m.mention_timestamp >= ?
GROUP BY t.symbol ORDER BY COUNT(m.id) DESC LIMIT 10;
"""
results = conn.execute(
query,
(
subreddit_name,
one_day_ago_timestamp,
),
).fetchall()
conn.close()
return [row["symbol"] for row in results]
def get_top_weekly_ticker_symbols_for_subreddit(subreddit_name):
"""Gets a list of the Top 10 weekly ticker symbols for a specific subreddit."""
conn = get_db_connection()
seven_days_ago = datetime.now(timezone.utc) - timedelta(days=7)
seven_days_ago_timestamp = int(seven_days_ago.timestamp())
query = """
SELECT t.symbol FROM mentions m JOIN tickers t ON m.ticker_id = t.id
JOIN subreddits s ON m.subreddit_id = s.id
WHERE LOWER(s.name) = LOWER(?) AND m.mention_timestamp >= ?
GROUP BY t.symbol ORDER BY COUNT(m.id) DESC LIMIT 10;
"""
results = conn.execute(
query,
(
subreddit_name,
seven_days_ago_timestamp,
),
).fetchall()
conn.close()
return [row["symbol"] for row in results]

View File

@@ -1,90 +1,118 @@
# The initial unsorted set of words.
# Note: In Python, a 'set' is inherently unordered, but we define it here for clarity.
COMMON_WORDS_BLACKLIST = {
"401K", "403B", "457B", "ABC", "ABOUT", "ABOVE", "ADAM", "ADX", "AEDT", "AEST",
"AF", "AFAIK", "AFTER", "AH", "AI", "AINT", "ALD", "ALGOS", "ALIVE", "ALL",
"ALPHA", "ALSO", "AM", "AMA", "AMEX", "AND", "ANY", "APES", "APL", "APPL",
"APPLE", "AR", "ARE", "AREA", "ARK", "AROUND", "ASAP", "ASK", "ASS", "ASSET",
"AT", "ATH", "ATL", "ATM", "AUD", "AUM", "AV", "AVG", "AWS", "BABY",
"BAG", "BAGS", "BALLS", "BANG", "BBB", "BE", "BEAR", "BEARS", "BECN", "BEER",
"BELL", "BELOW", "BETA", "BETS", "BF", "BID", "BIG", "BIS", "BITCH", "BLEND",
"BNPL", "BOE", "BOJ", "BOLL", "BOMB", "BOND", "BORN", "BOTH", "BOTS", "BOYS",
"BRB", "BRICS", "BRK", "BRKB", "BRL", "BROKE", "BS", "BST", "BSU", "BT",
"BTC", "BTS", "BTW", "BULL", "BULLS", "BUST", "BUT", "BUY", "BUZZ", "CAD",
"CALL", "CAN", "CAP", "CASE", "CBD", "CBGM", "CBS", "CCI", "CD", "CEO",
"CEST", "CET", "CEX", "CFD", "CFO", "CHART", "CHF", "CHIPS", "CIA", "CLEAN",
"CLICK", "CLOSE", "CNBC", "CNN", "CNY", "COCK", "COGS", "COIL", "COKE", "COME",
"COST", "COULD", "COVID", "CPAP", "CPI", "CRV", "CSE", "CSS", "CST", "CTB",
"CTEP", "CTO", "CULT", "CYCLE", "CZK", "DA", "DAILY", "DAO", "DATE", "DAX",
"DAY", "DAYS", "DCA", "DCF", "DD", "DEBT", "DEEZ", "DEMO", "DEX", "DIA",
"DID", "DIDNT", "DIP", "DITM", "DIV", "DIY", "DJIA", "DKK", "DL", "DM",
"DMV", "DNI", "DO", "DOE", "DOES", "DOGE", "DOJ", "DOM", "DONT", "DOOR",
"DOWN", "DR", "DUDE", "DUMP", "DUTY", "DYI", "DYNK", "DYODD", "DYOR", "EACH",
"EARLY", "EARN", "EAST", "EASY", "ECB", "EDGAR", "EDIT", "EDT", "EJ", "EMA",
"EMJ", "END", "ENRON", "ENV", "EO", "EOD", "EOW", "EOY", "EPA", "EPS",
"ER", "ESG", "ESPP", "EST", "ETA", "ETF", "ETFS", "ETH", "EU", "EUR",
"EV", "EVEN", "EVERY", "EVTOL", "EXTRA", "EYES", "EZ", "FAANG", "FAFO", "FAQ",
"FAR", "FAST", "FBI", "FCFF", "FD", "FDA", "FEE", "FFH", "FFS", "FGMA",
"FIG", "FIGMA", "FIHTX", "FING", "FINRA", "FINT", "FINTX", "FINTY", "FIRST", "FKIN",
"FLT", "FLY", "FML", "FOLO", "FOMC", "FOMO", "FOR", "FOREX", "FRAUD", "FRG",
"FROM", "FSELK", "FSPSX", "FTD", "FTSE", "FUCK", "FUCKS", "FUD", "FULL", "FUND",
"FUNNY", "FVG", "FX", "FXAIX", "FXIAX", "FXROX", "FY", "FYI", "FZROX", "GAAP",
"GAIN", "GAVE", "GBP", "GDP", "GET", "GG", "GJ", "GL", "GLHF", "GMAT",
"GMI", "GMT", "GO", "GOAL", "GOAT", "GOD", "GOING", "GOLD", "GONE", "GONNA",
"GPT", "GPU", "GRAB", "GREAT", "GREEN", "GTA", "GTFO", "GTG", "GUH", "GUYS",
"HAD", "HAHA", "HALF", "HANDS", "HAS", "HATE", "HAVE", "HBAR", "HCOL", "HEAR",
"HEDGE", "HEGE", "HELP", "HEY", "HFCS", "HFT", "HIGH", "HIGHS", "HINT", "HIS",
"HKD", "HODL", "HODOR", "HOF", "HOLD", "HOLY", "HOME", "HOUR", "HS", "HSA",
"HT", "HTF", "HTML", "HUF", "HUGE", "HYPE", "IANAL", "IB", "IBS", "ICT",
"ID", "IDF", "IDK", "IF", "II", "IKKE", "IKZ", "IMHO", "IMO", "IN",
"INR", "INTO", "IP", "IPO", "IRA", "IRAS", "IRC", "IRISH", "IRS", "IS",
"ISA", "ISIN", "ISM", "ISN", "IST", "IT", "ITM", "ITS", "ITWN", "IUIT",
"IV", "IVV", "IWM", "JAVA", "JD", "JFC", "JK", "JLR", "JOIN", "JOKE",
"JP", "JPOW", "JPY", "JS", "JST", "JUN", "JUST", "KARMA", "KEEP", "KNEW",
"KNOW", "KO", "KPMG", "KRW", "LANGT", "LARGE", "LAST", "LATE", "LATER", "LBO",
"LDL", "LEADS", "LEAP", "LEAPS", "LEI", "LETS", "LFG", "LIFE", "LIG", "LIGMA",
"LIKE", "LIMIT", "LIST", "LLC", "LLM", "LMAO", "LOKO", "LOL", "LOLOL", "LONG",
"LOOK", "LOSE", "LOSS", "LOST", "LOVE", "LOW", "LOWER", "LOWS", "LTCG", "LUPD",
"LYING", "M&A", "MA", "MACD", "MAKE", "MAKES", "MANY", "MAX", "MBA", "MC",
"MCP", "ME", "MEME", "MERGE", "MERK", "MES", "MEXC", "MF", "MFER", "MID",
"MIGHT", "MIN", "MIND", "ML", "MLB", "MM", "MNQ", "MOASS", "MOM", "MONEY",
"MONTH", "MONY", "MOON", "MORE", "MOU", "MSK", "MUCH", "MUSIC", "MUST", "MXN",
"MY", "MYMD", "NASA", "NATO", "NBA", "NCR", "NEAR", "NEAT", "NEED", "NEVER",
"NEW", "NEWS", "NEXT", "NFA", "NFC", "NFL", "NFT", "NGMI", "NIGHT", "NIQ",
"NK", "NO", "NOK", "NONE", "NOPE", "NORTH", "NOT", "NOVA", "NOW", "NQ",
"NSA", "NTVS", "NULL", "NUT", "NUTS", "NUTZ", "NVM", "NW", "NY", "NYSE",
"NZ", "NZD", "OBBB", "OBI", "OBV", "OCF", "OCO", "OEM", "OF", "OFA",
"OFF", "OG", "OH", "OK", "OKAY", "OLD", "OMFG", "OMG", "ON", "ONE",
"ONLY", "OP", "OPEC", "OPENQ", "OPEX", "OPRN", "OR", "ORB", "OS", "OSCE",
"OT", "OTC", "OTM", "OUCH", "OUGHT", "OUT", "OVER", "OWN", "PA", "PANIC",
"PC", "PDT", "PE", "PEAK", "PEG", "PETA", "PEW", "PFC", "PGHL", "PITA",
"PLAN", "PLAYS", "PLN", "PM", "PMI", "PNL", "POC", "POMO", "POP", "POS",
"POSCO", "POV", "POW", "PPI", "PR", "PRICE", "PROFIT", "PS", "PSA", "PST",
"PT", "PTD", "PUSSY", "PUT", "PWC", "Q1", "Q2", "Q3", "Q4", "QE",
"QED", "QIMC", "QQQ", "QR", "RAM", "RBA", "RBNZ", "RE", "REACH", "READY",
"REAL", "RED", "REIT", "REITS", "REKT", "RFK", "RH", "RICO", "RIDE", "RIGHT",
"RIP", "RISK", "RISKY", "ROCE", "ROCK", "ROE", "ROFL", "ROI", "ROIC", "ROTH",
"RRSP", "RSD", "RSI", "RT", "RTD", "RUB", "RUG", "RULE", "RUST", "RVOL",
"SAGA", "SALES", "SAME", "SAVE", "SAYS", "SBF", "SBLOC", "SCALP", "SCAM", "SCHB",
"SCIF", "SEC", "SEE", "SEK", "SELL", "SELLL", "SEP", "SET", "SGD", "SHALL",
"SHARE", "SHELL", "SHIT", "SHORT", "SI", "SIGN", "SL", "SLIM", "SLOW", "SMA",
"SMALL", "SO", "SOLIS", "SOME", "SOON", "SOUTH", "SP", "SPAC", "SPDR", "SPEND",
"SPLG", "SPX", "SPY", "SS", "START", "STAY", "STEEL", "STILL", "STOCK", "STOOQ",
"STOP", "STOR", "STQQQ", "STUCK", "STUDY", "SUS", "SUV", "SWIFT", "SWING", "TA",
"TAG", "TAKE", "TAM", "TBTH", "TEAMS", "TERM", "TF", "TFSA", "THANK", "THAT",
"THATS", "THE", "THEIR", "THEN", "THERE", "THESE", "THEY", "THING", "THINK", "THIS",
"TIA", "TIKR", "TIME", "TITS", "TJR", "TL", "TL;DR", "TLDR", "TO", "TODAY",
"TOLD", "TOS", "TOT", "TOTAL", "TP", "TRADE", "TREND", "TRUE", "TRUMP", "TRUST",
"TRY", "TSA", "TSP", "TSX", "TSXV", "TTM", "TTYL", "TWO", "UCITS", "UGH",
"UI", "UK", "UNDER", "UNTIL", "UP", "US", "USA", "USD", "USSR", "UTC",
"VALID", "VALUE", "VERY", "VFMXX", "VIX", "VLI", "VOO", "VP", "VR", "VRVP",
"VSUS", "VTI", "VUAG", "VW", "VWAP", "VXN", "VXUX", "WAGMI", "WAIT", "WALL",
"WANT", "WATCH", "WAY", "WE", "WEB3", "WEEK", "WEST", "WHALE", "WHAT", "WHICH",
"WHO", "WHOS", "WHY", "WIDE", "WILL", "WIRE", "WIRED", "WITH", "WL", "WON",
"WOOPS", "WORDS", "WORTH", "WOULD", "WP", "WRONG", "WSB", "WSJ", "WTF", "WV",
"WWII", "WWIII", "XCUSE", "XD", "XMR", "XO", "XRP", "XX", "YEAH", "YEET",
"YES", "YET", "YIELD", "YM", "YMMV", "YOLO", "YOU", "YOUR", "YOY", "YT",
"YTD", "YUGE", "ZAR", "ZEN", "ZERO"
"401K", "403B", "457B", "AAVE", "ABC", "ABOUT", "ABOVE", "ACAT", "ADAM", "ADHD",
"ADR", "ADS", "ADX", "AEDT", "AEST", "AF", "AFAIK", "AFTER", "AGENT", "AH",
"AI", "AINT", "AK", "ALD", "ALGOS", "ALIVE", "ALL", "ALPHA", "ALSO", "AM",
"AMA", "AMEX", "AMK", "AMY", "AND", "ANSS", "ANY", "APES", "APL", "APPL",
"APPLE", "APR", "APUS", "APY", "AR", "ARBK", "ARE", "AREA", "ARH", "ARK",
"AROUND", "ART", "AS", "ASAP", "ASEAN", "ASK", "ASS", "ASSET", "AST", "AT",
"ATH", "ATL", "ATM", "AUD", "AUG", "AUM", "AV", "AVG", "AWS", "BABY",
"BAG", "BAGS", "BALLS", "BAN", "BANG", "BASIC", "BBB", "BBBY", "BE", "BEAR",
"BEARS", "BECN", "BEER", "BELL", "BELOW", "BETA", "BETS", "BF", "BID", "BIG",
"BIS", "BITCH", "BKEY", "BLEND", "BMW", "BNP", "BNPL", "BOE", "BOJ", "BOLL",
"BOMB", "BOND", "BONED", "BORN", "BOTH", "BOTS", "BOY", "BOYS", "BRB", "BRICS",
"BRK", "BRKA", "BRKB", "BRL", "BROKE", "BRRRR", "BS", "BSE", "BST", "BSU",
"BT", "BTC", "BTS", "BTW", "BUDDY", "BULL", "BULLS", "BUST", "BUT", "BUY",
"BUZZ", "CAD", "CAFE", "CAGR", "CALL", "CALLS", "CAN", "CAP", "CARB", "CARES",
"CASE", "CATL", "CBD", "CBGM", "CBS", "CCI", "CCP", "CD", "CDN", "CEO",
"CEST", "CET", "CEX", "CFD", "CFO", "CFPB", "CHART", "CHASE", "CHATS", "CHECK",
"CHF", "CHICK", "CHIP", "CHIPS", "CIA", "CIC", "CLAIM", "CLEAN", "CLICK", "CLOSE",
"CMON", "CN", "CNBC", "CNN", "CNY", "COBRA", "COCK", "COGS", "COIL", "COKE",
"COME", "COST", "COULD", "COVID", "CPAP", "CPI", "CRA", "CRE", "CRO", "CRV",
"CSE", "CSP", "CSS", "CST", "CTB", "CTEP", "CTO", "CUCKS", "CULT", "CUM",
"CUSMA", "CUTS", "CUV", "CYCLE", "CZK", "DA", "DAILY", "DAO", "DATE", "DAX",
"DAY", "DAYS", "DCA", "DCF", "DD", "DEAL", "DEBT", "DEEZ", "DEMO", "DET",
"DEX", "DGAF", "DIA", "DID", "DIDNT", "DIP", "DITM", "DIV", "DIY", "DJI",
"DJIA", "DJTJ", "DKK", "DL", "DM", "DMV", "DNI", "DNUTZ", "DO", "DOD",
"DOE", "DOES", "DOGE", "DOING", "DOJ", "DOM", "DONNY", "DONT", "DONUT", "DOOR",
"DOWN", "DOZEN", "DPI", "DR", "DUDE", "DUMP", "DUNT", "DUT", "DUTY", "DXY",
"DXYXBT", "DYI", "DYNK", "DYODD", "DYOR", "EACH", "EARLY", "EARN", "EAST", "EASY",
"ECB", "EDGAR", "EDIT", "EDT", "EJ", "EMA", "EMJ", "EMT", "END", "ENRON",
"ENSI", "ENV", "EO", "EOD", "EOM", "EOW", "EOY", "EPA", "EPK", "EPS",
"ER", "ESG", "ESPP", "EST", "ETA", "ETF", "ETFS", "ETH", "ETL", "EU",
"EUR", "EV", "EVEN", "EVERY", "EVTOL", "EXTRA", "EYES", "EZ", "FAANG", "FAFO",
"FAQ", "FAR", "FAST", "FBI", "FCC", "FCFF", "FD", "FDA", "FEE", "FFH",
"FFS", "FGMA", "FIG", "FIGMA", "FIHTX", "FILES", "FINAL", "FIND", "FING", "FINRA",
"FINT", "FINTX", "FINTY", "FIRE", "FIRST", "FKIN", "FLRAA", "FLT", "FLY", "FML",
"FOLO", "FOMC", "FOMO", "FOR", "FOREX", "FRAUD", "FREAK", "FRED", "FRG", "FROM",
"FRP", "FRS", "FSBO", "FSD", "FSE", "FSELK", "FSPSX", "FTD", "FTSE", "FUCK",
"FUCKS", "FUD", "FULL", "FUND", "FUNNY", "FVG", "FWIW", "FX", "FXAIX", "FXIAX",
"FXROX", "FY", "FYI", "FZROX", "GAAP", "GAIN", "GAVE", "GBP", "GC", "GDP",
"GET", "GFC", "GG", "GGTM", "GIVES", "GJ", "GL", "GLHF", "GMAT", "GMI",
"GMT", "GO", "GOAL", "GOAT", "GOD", "GOING", "GOLD", "GONE", "GONNA", "GOODS",
"GOPRO", "GPT", "GPU", "GRAB", "GREAT", "GREEN", "GSOV", "GST", "GTA", "GTC",
"GTFO", "GTG", "GUH", "GUNS", "GUY", "GUYS", "HAD", "HAHA", "HALF", "HAM",
"HANDS", "HAS", "HATE", "HAVE", "HBAR", "HCOL", "HEAR", "HEDGE", "HEGE", "HELD",
"HELL", "HELP", "HERE", "HEY", "HFCS", "HFT", "HGTV", "HIGH", "HIGHS", "HINT",
"HIS", "HITID", "HK", "HKD", "HKEX", "HODL", "HODOR", "HOF", "HOLD", "HOLY",
"HOME", "HOT", "HOUR", "HOURS", "HOW", "HS", "HSA", "HSI", "HT", "HTCI",
"HTF", "HTML", "HUF", "HUGE", "HV", "HYPE", "IANAL", "IATF", "IB", "IBS",
"ICSID", "ICT", "ID", "IDF", "IDK", "IF", "II", "IIRC", "IKKE", "IKZ",
"IM", "IMHO", "IMI", "IMO", "IN", "INC", "INR", "INTEL", "INTO", "IP",
"IPO", "IQVIA", "IRA", "IRAS", "IRC", "IRISH", "IRMAA", "IRS", "IS", "ISA",
"ISIN", "ISM", "ISN", "IST", "IT", "ITC", "ITM", "ITS", "ITWN", "IUIT",
"IV", "IVV", "IWM", "IXL", "IXLH", "IYKYK", "JAVA", "JD", "JDG", "JDM",
"JE", "JFC", "JK", "JLR", "JMO", "JOBS", "JOIN", "JOKE", "JP", "JPOW",
"JPY", "JS", "JST", "JUN", "JUST", "KARMA", "KEEP", "KILL", "KING", "KK",
"KLA", "KLP", "KNEW", "KNOW", "KO", "KOHLS", "KPMG", "KRW", "LA", "LANGT",
"LARGE", "LAST", "LATE", "LATER", "LBO", "LBTC", "LCS", "LDL", "LEADS", "LEAP",
"LEAPS", "LEARN", "LEI", "LET", "LETF", "LETS", "LFA", "LFG", "LFP", "LG",
"LGEN", "LIFE", "LIG", "LIGMA", "LIKE", "LIMIT", "LIST", "LLC", "LLM", "LM",
"LMAO", "LMAOO", "LMM", "LMN", "LOANS", "LOKO", "LOL", "LOLOL", "LONG", "LONGS",
"LOOK", "LOSE", "LOSS", "LOST", "LOVE", "LOVES", "LOW", "LOWER", "LOWS", "LP",
"LSS", "LTCG", "LUCID", "LUPD", "LYC", "LYING", "M&A", "MA", "MACD", "MAIL",
"MAKE", "MAKES", "MANGE", "MANY", "MASON", "MAX", "MAY", "MAYBE", "MBA", "MC",
"MCAP", "MCNA", "MCP", "ME", "MEAN", "MEME", "MERGE", "MERK", "MES", "MEXC",
"MF", "MFER", "MID", "MIGHT", "MIN", "MIND", "MINS", "ML", "MLB", "MLS",
"MM", "MMF", "MNQ", "MOASS", "MODEL", "MODTX", "MOM", "MONEY", "MONTH", "MONY",
"MOON", "MORE", "MOST", "MOU", "MSK", "MTVGA", "MUCH", "MUSIC", "MUST", "MVA",
"MXN", "MY", "MYMD", "NASA", "NASDA", "NATO", "NAV", "NBA", "NBC", "NCAN",
"NCR", "NEAR", "NEAT", "NEED", "NEVER", "NEW", "NEWS", "NEXT", "NFA", "NFC",
"NFL", "NFT", "NGAD", "NGMI", "NIGHT", "NIQ", "NK", "NO", "NOK", "NON",
"NONE", "NOOO", "NOPE", "NORTH", "NOT", "NOVA", "NOW", "NQ", "NRI", "NSA",
"NSCLC", "NSLC", "NTG", "NTVS", "NULL", "NUT", "NUTS", "NUTZ", "NVM", "NW",
"NY", "NYSE", "NZ", "NZD", "OBBB", "OBI", "OBS", "OBV", "OCD", "OCF",
"OCO", "ODAT", "ODTE", "OEM", "OF", "OFA", "OFF", "OG", "OH", "OK",
"OKAY", "OL", "OLD", "OMFG", "OMG", "ON", "ONDAS", "ONE", "ONLY", "OP",
"OPEC", "OPENQ", "OPEX", "OPRN", "OR", "ORB", "ORDER", "ORTEX", "OS", "OSCE",
"OT", "OTC", "OTM", "OTOH", "OUCH", "OUGHT", "OUR", "OUT", "OVER", "OWN",
"OZZY", "PA", "PANIC", "PC", "PDT", "PE", "PEAK", "PEG", "PETA", "PEW",
"PFC", "PGHL", "PIMCO", "PITA", "PLAN", "PLAYS", "PLC", "PLN", "PM", "PMCC",
"PMI", "PNL", "POC", "POMO", "POP", "POS", "POSCO", "POTUS", "POV", "POW",
"PPI", "PR", "PRICE", "PRIME", "PROFIT", "PROXY", "PS", "PSA", "PST", "PT",
"PTD", "PUSSY", "PUT", "PUTS", "PWC", "Q1", "Q2", "Q3", "Q4", "QE",
"QED", "QIMC", "QQQ", "QR", "RAM", "RATM", "RBA", "RBNZ", "RE", "REACH",
"READY", "REAL", "RED", "REIT", "REITS", "REKT", "REPE", "RFK", "RH", "RICO",
"RIDE", "RIGHT", "RIP", "RISK", "RISKY", "RNDC", "ROCE", "ROCK", "ROE", "ROFL",
"ROI", "ROIC", "ROTH", "RPO", "RRSP", "RSD", "RSI", "RT", "RTD", "RUB",
"RUG", "RULE", "RUST", "RVOL", "SAGA", "SALES", "SAME", "SAVE", "SAYS", "SBF",
"SBLOC", "SC", "SCALP", "SCAM", "SCHB", "SCIF", "SEC", "SEE", "SEK", "SELL",
"SELLL", "SEP", "SESG", "SET", "SFOR", "SGD", "SHALL", "SHARE", "SHEIN", "SHELL",
"SHIT", "SHORT", "SHOW", "SHS", "SHTF", "SI", "SICK", "SIGN", "SL", "SLIM",
"SLOW", "SMA", "SMALL", "SMFH", "SNZ", "SO", "SOLD", "SOLIS", "SOME", "SOON",
"SOOO", "SOUTH", "SP", "SPAC", "SPDR", "SPEND", "SPLG", "SPX", "SPY", "SQUAD",
"SS", "SSA", "SSDI", "START", "STAY", "STEEL", "STFU", "STILL", "STO", "STOCK",
"STOOQ", "STOP", "STOR", "STQQQ", "STUCK", "STUDY", "SUS", "SUSHI", "SUV", "SWIFT",
"SWING", "TA", "TAG", "TAKE", "TAM", "TBTH", "TEAMS", "TED", "TEMU", "TERM",
"TESLA", "TEXT", "TF", "TFNA", "TFSA", "THAN", "THANK", "THAT", "THATS", "THE",
"THEIR", "THEM", "THEN", "THERE", "THESE", "THEY", "THING", "THINK", "THIS", "TI",
"TIA", "TIKR", "TIME", "TIMES", "TINA", "TITS", "TJR", "TL", "TL;DR", "TLDR",
"TNT", "TO", "TODAY", "TOLD", "TONS", "TOO", "TOS", "TOT", "TOTAL", "TP",
"TPU", "TRADE", "TREND", "TRUE", "TRUMP", "TRUST", "TRY", "TSA", "TSMC", "TSP",
"TSX", "TSXV", "TTIP", "TTM", "TTYL", "TURNS", "TWO", "UAW", "UCITS", "UGH",
"UI", "UK", "UNDER", "UNITS", "UNO", "UNTIL", "UP", "US", "USA", "USD",
"USMCA", "USSA", "USSR", "UTC", "VALID", "VALUE", "VAMOS", "VAT", "VEO", "VERY",
"VFMXX", "VFV", "VI", "VISA", "VIX", "VLI", "VOO", "VP", "VPAY", "VR",
"VRVP", "VSUS", "VTI", "VUAG", "VW", "VWAP", "VWCE", "VXN", "VXUX", "WAGER",
"WAGMI", "WAIT", "WALL", "WANT", "WAS", "WATCH", "WAY", "WBTC", "WE", "WEB",
"WEB3", "WEEK", "WENT", "WERO", "WEST", "WHALE", "WHAT", "WHEN", "WHERE", "WHICH",
"WHILE", "WHO", "WHOS", "WHY", "WIDE", "WILL", "WIRE", "WIRED", "WITH", "WL",
"WON", "WOOPS", "WORDS", "WORTH", "WOULD", "WP", "WRONG", "WSB", "WSJ", "WTF",
"WV", "WWII", "WWIII", "X", "XAU", "XCUSE", "XD", "XEQT", "XI", "XIV",
"XMR", "XO", "XRP", "XX", "YEAH", "YEET", "YES", "YET", "YIELD", "YM",
"YMMV", "YOIR", "YOLO", "YOU", "YOUR", "YOY", "YT", "YTD", "YUGE", "YUPPP",
"ZAR", "ZEN", "ZERO", "ZEV"
}
def format_and_print_list(word_set, words_per_line=10):
@@ -105,7 +133,7 @@ def format_and_print_list(word_set, words_per_line=10):
# 3. Iterate through the sorted list and print words, respecting the line limit
for i in range(0, len(sorted_words), words_per_line):
# Get a chunk of words for the current line
line_chunk = sorted_words[i:i + words_per_line]
line_chunk = sorted_words[i : i + words_per_line]
# Format each word with double quotes
formatted_words = [f'"{word}"' for word in line_chunk]
@@ -124,6 +152,7 @@ def format_and_print_list(word_set, words_per_line=10):
# 4. Print the closing brace
print("}")
# --- Main execution ---
if __name__ == "__main__":
format_and_print_list(COMMON_WORDS_BLACKLIST)

View File

@@ -0,0 +1,9 @@
# -*- encoding: utf-8 -*-
bind = '0.0.0.0:5000'
workers = 4
worker_class = 'uvicorn.workers.UvicornWorker'
accesslog = '-'
loglevel = 'debug'
capture_output = True
enable_stdio_inheritance = True

View File

@@ -3,45 +3,49 @@
import logging
import sys
# Get the root logger
logger = logging.getLogger("rstat_app")
logger.setLevel(logging.INFO) # Set the minimum level of messages to handle
# Prevent the logger from propagating messages to the parent (root) logger
logger.propagate = False
# Only add handlers if they haven't been added before
# This prevents duplicate log messages if this function is called multiple times.
if not logger.handlers:
# --- Console Handler ---
# This handler prints logs to the standard output (your terminal)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
# A simple formatter for the console
console_formatter = logging.Formatter('%(message)s')
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
def setup_logging(console_verbose=False, debug_mode=False):
"""
Configures the application's logger with a new DEBUG level.
"""
# The logger itself must be set to the lowest possible level (DEBUG).
log_level = logging.DEBUG if debug_mode else logging.INFO
logger.setLevel(log_level)
# --- File Handler ---
# This handler writes logs to a file
# 'a' stands for append mode
file_handler = logging.FileHandler("rstat.log", mode='a')
file_handler.setLevel(logging.INFO)
# A more detailed formatter for the file, including timestamp and log level
file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
logger.propagate = False
if logger.hasHandlers():
logger.handlers.clear()
# File Handler (Always verbose at INFO level or higher)
file_handler = logging.FileHandler("rstat.log", mode="a")
file_handler.setLevel(logging.INFO) # We don't need debug spam in the file usually
file_formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# Get the logger used by the yfinance library
yfinance_logger = logging.getLogger("yfinance")
# Set its level to capture warnings and errors
yfinance_logger.setLevel(logging.WARNING)
# Add our existing handlers to it. This tells yfinance's logger
# to send its messages to our console and our log file.
if not yfinance_logger.handlers:
# Console Handler (Verbosity is controlled)
console_handler = logging.StreamHandler(sys.stdout)
console_formatter = logging.Formatter("%(message)s")
console_handler.setFormatter(console_formatter)
if debug_mode:
console_handler.setLevel(logging.DEBUG)
elif console_verbose:
console_handler.setLevel(logging.INFO)
else:
console_handler.setLevel(logging.CRITICAL)
logger.addHandler(console_handler)
# YFINANCE LOGGER CAPTURE
yfinance_logger = logging.getLogger("yfinance")
yfinance_logger.propagate = False
if yfinance_logger.hasHandlers():
yfinance_logger.handlers.clear()
yfinance_logger.setLevel(logging.WARNING)
yfinance_logger.addHandler(console_handler)
yfinance_logger.addHandler(file_handler)
def get_logger():
"""A simple function to get our configured logger."""
return logger

View File

@@ -4,121 +4,126 @@ import argparse
import json
import os
import time
import sys
from dotenv import load_dotenv
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import praw
import yfinance as yf
from dotenv import load_dotenv
import pandas as pd
from . import database
from .ticker_extractor import extract_tickers
from .ticker_extractor import extract_golden_tickers, extract_potential_tickers
from .sentiment_analyzer import get_sentiment_score
from .logger_setup import get_logger
from .logger_setup import setup_logging, logger as log
load_dotenv()
MARKET_CAP_REFRESH_INTERVAL = 86400
POST_AGE_LIMIT = 86400
log = get_logger()
def load_subreddits(filepath):
"""Loads a list of subreddits from a JSON file."""
try:
with open(filepath, 'r') as f:
with open(filepath, "r") as f:
return json.load(f).get("subreddits", [])
except (FileNotFoundError, json.JSONDecodeError) as e:
log.error(f"Error loading config file '{filepath}': {e}")
return None
def get_financial_data(ticker_symbol):
try:
ticker = yf.Ticker(ticker_symbol)
data = { "market_cap": ticker.fast_info.get('marketCap'), "closing_price": ticker.fast_info.get('previousClose') }
return data
except Exception:
return {"market_cap": None, "closing_price": None}
def get_reddit_instance():
"""Initializes and returns a PRAW Reddit instance."""
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
client_id = os.getenv("REDDIT_CLIENT_ID")
client_secret = os.getenv("REDDIT_CLIENT_SECRET")
user_agent = os.getenv("REDDIT_USER_AGENT")
if not all([client_id, client_secret, user_agent]):
print("Error: Reddit API credentials not found in .env file.")
log.error("Error: Reddit API credentials not found in .env file.")
return None
return praw.Reddit(client_id=client_id, client_secret=client_secret, user_agent=user_agent)
return praw.Reddit(
client_id=client_id, client_secret=client_secret, user_agent=user_agent
)
def scan_subreddits(reddit, subreddits_list, post_limit=100, comment_limit=100, days_to_scan=1):
"""
Scans subreddits with a hybrid mention counting logic.
- If a ticker is in the title, it gets credit for all comments.
- If not, tickers only get credit for direct mentions in comments.
"""
conn = database.get_db_connection()
post_age_limit = days_to_scan * 86400
current_time = time.time()
log.info(f"\nScanning {len(subreddits_list)} subreddit(s) for NEW posts in the last {days_to_scan} day(s)...")
for subreddit_name in subreddits_list:
def fetch_financial_data(ticker_symbol):
"""
Fetches market cap and the most recent closing price for a single ticker.
This function is designed to be thread-safe and robust.
"""
try:
# Always use the lowercase version of the name for consistency.
normalized_sub_name = subreddit_name.lower()
ticker = yf.Ticker(ticker_symbol)
market_cap = ticker.info.get("marketCap")
data = ticker.history(period="2d", auto_adjust=False)
closing_price = None
if not data.empty:
last_close_raw = data["Close"].iloc[-1]
if pd.notna(last_close_raw):
closing_price = float(last_close_raw)
return ticker_symbol, {"market_cap": market_cap, "closing_price": closing_price}
except Exception:
return ticker_symbol, None
subreddit_id = database.get_or_create_entity(conn, 'subreddits', 'name', normalized_sub_name)
subreddit = reddit.subreddit(normalized_sub_name)
log.info(f"Scanning r/{normalized_sub_name}...")
for submission in subreddit.new(limit=post_limit):
if (current_time - submission.created_utc) > post_age_limit:
log.info(f" -> Reached posts older than the {days_to_scan}-day limit.")
break
def _process_submission(submission, subreddit_id, conn, comment_limit):
"""
Processes a single Reddit submission using the "Golden Ticker" logic.
- Prioritizes tickers with a '$' prefix.
- Falls back to potential tickers only if no '$' tickers are found.
"""
# 1. --- Golden Ticker Discovery ---
# First, search the entire post (title and body) for high-confidence '$' tickers.
post_text_for_discovery = submission.title + " " + submission.selftext
golden_tickers = extract_golden_tickers(post_text_for_discovery)
tickers_in_title = set(extract_tickers(submission.title))
all_tickers_found_in_post = set(tickers_in_title) # Start a set to track all tickers for financials
tickers_in_title = set()
comment_only_tickers = set()
all_tickers_found_in_post = set()
# 2. --- Apply Contextual Logic ---
if golden_tickers:
# --- CASE A: Golden Tickers were found ---
log.info(f" -> Golden Ticker(s) Found: {', '.join(golden_tickers)}. Prioritizing these.")
all_tickers_found_in_post.update(golden_tickers)
# We only care about which of the golden tickers appeared in the title for the hybrid logic.
tickers_in_title = {ticker for ticker in golden_tickers if ticker in extract_golden_tickers(submission.title)}
else:
# --- CASE B: No Golden Tickers, fall back to best-guess ---
log.info(" -> No Golden Tickers. Falling back to potential ticker search.")
# Now we search for potential tickers (e.g., 'GME' without a '$')
tickers_in_title = extract_potential_tickers(submission.title)
all_tickers_found_in_post.update(tickers_in_title)
# 3. --- Mention Processing (This logic remains the same, but uses our cleanly identified tickers) ---
ticker_id_cache = {}
submission.comments.replace_more(limit=0)
all_comments = submission.comments.list()[:comment_limit]
# --- CASE A: Tickers were found in the title ---
# Process title mentions
if tickers_in_title:
log.info(f" -> Title Mention(s): {', '.join(tickers_in_title)}. Attributing all comments.")
post_sentiment = get_sentiment_score(submission.title)
# Add one 'post' mention for each title ticker
for ticker_symbol in tickers_in_title:
ticker_id = database.get_or_create_entity(conn, 'tickers', 'symbol', ticker_symbol)
ticker_id_cache[ticker_symbol] = ticker_id
database.add_mention(conn, ticker_id, subreddit_id, submission.id, 'post', int(submission.created_utc), post_sentiment)
# Add one 'comment' mention for EACH comment FOR EACH title ticker
# Process comments
for comment in all_comments:
comment_sentiment = get_sentiment_score(comment.body)
if tickers_in_title:
for ticker_symbol in tickers_in_title:
ticker_id = database.get_or_create_entity(conn, 'tickers', 'symbol', ticker_symbol)
ticker_id = ticker_id_cache[ticker_symbol]
database.add_mention(conn, ticker_id, subreddit_id, submission.id, 'comment', int(comment.created_utc), comment_sentiment)
# --- CASE B: No tickers in the title, scan comments individually ---
else:
for comment in all_comments:
tickers_in_comment = set(extract_tickers(comment.body))
# If no title tickers, we must scan comments for potential tickers
tickers_in_comment = extract_potential_tickers(comment.body)
if tickers_in_comment:
all_tickers_found_in_post.update(tickers_in_comment) # Add to our set for financials
comment_sentiment = get_sentiment_score(comment.body)
all_tickers_found_in_post.update(tickers_in_comment)
for ticker_symbol in tickers_in_comment:
ticker_id = database.get_or_create_entity(conn, 'tickers', 'symbol', ticker_symbol)
database.add_mention(conn, ticker_id, subreddit_id, submission.id, 'comment', int(comment.created_utc), comment_sentiment)
# --- EFFICIENT FINANCIALS UPDATE ---
# Now, update market cap once for every unique ticker found in the whole post
for ticker_symbol in all_tickers_found_in_post:
ticker_id = database.get_or_create_entity(conn, 'tickers', 'symbol', ticker_symbol)
ticker_info = database.get_ticker_info(conn, ticker_id)
if not ticker_info['last_updated'] or (current_time - ticker_info['last_updated'] > MARKET_CAP_REFRESH_INTERVAL):
log.info(f" -> Fetching financial data for {ticker_symbol}...")
financials = get_financial_data(ticker_symbol)
database.update_ticker_financials(
conn, ticker_id,
financials['market_cap'] or ticker_info['market_cap'],
financials['closing_price'] or ticker_info['closing_price']
)
# --- DEEP DIVE SAVE (Still valuable) ---
# 4. --- Save Deep Dive and Return Tickers for Financial Update ---
# (This part is unchanged)
all_comment_sentiments = [get_sentiment_score(c.body) for c in all_comments]
avg_sentiment = sum(all_comment_sentiments) / len(all_comment_sentiments) if all_comment_sentiments else 0
post_analysis_data = {
@@ -129,53 +134,312 @@ def scan_subreddits(reddit, subreddits_list, post_limit=100, comment_limit=100,
}
database.add_or_update_post_analysis(conn, post_analysis_data)
return all_tickers_found_in_post
def scan_subreddits(
reddit,
subreddits_list,
post_limit=100,
comment_limit=100,
days_to_scan=1,
fetch_financials=True,
):
"""
Scans subreddits to discover mentions, then performs a single batch update for financials if enabled.
"""
conn = database.get_db_connection()
post_age_limit = days_to_scan * 86400
current_time = time.time()
all_tickers_to_update = set()
log.info(f"Scanning {len(subreddits_list)} subreddit(s) for NEW posts...")
if not fetch_financials:
log.warning("NOTE: Financial data fetching is disabled for this run.")
for subreddit_name in subreddits_list:
try:
normalized_sub_name = subreddit_name.lower()
subreddit_id = database.get_or_create_entity(
conn, "subreddits", "name", normalized_sub_name
)
subreddit = reddit.subreddit(normalized_sub_name)
log.info(f"Scanning r/{normalized_sub_name}...")
for submission in subreddit.new(limit=post_limit):
if (current_time - submission.created_utc) > post_age_limit:
log.info(
f" -> Reached posts older than the {days_to_scan}-day limit."
)
break
tickers_found = _process_submission(
submission, subreddit_id, conn, comment_limit
)
if tickers_found:
all_tickers_to_update.update(tickers_found)
except Exception as e:
log.error(f"Could not scan r/{subreddit_name}. Error: {e}")
log.error(
f"Could not scan r/{normalized_sub_name}. Error: {e}", exc_info=True
)
conn.close()
log.info("\n--- Scan Complete ---")
log.critical("\n--- Reddit Scan Complete ---")
if fetch_financials and all_tickers_to_update:
log.critical(
f"\n--- Starting Batch Financial Update for {len(all_tickers_to_update)} Discovered Tickers ---"
)
tickers_from_db = {t["symbol"]: t["id"] for t in database.get_all_tickers()}
tickers_needing_update_symbols = [
symbol for symbol in all_tickers_to_update if symbol in tickers_from_db
]
financial_data_batch = {}
with ThreadPoolExecutor(max_workers=10) as executor:
results = executor.map(fetch_financial_data, tickers_needing_update_symbols)
for symbol, data in results:
if data:
financial_data_batch[symbol] = data
if financial_data_batch:
conn = database.get_db_connection()
for symbol, financials in financial_data_batch.items():
database.update_ticker_financials(
conn,
tickers_from_db[symbol],
financials.get("market_cap"),
financials.get("closing_price"),
)
conn.close()
log.critical("--- Batch Financial Update Complete ---")
def main():
"""Main function to run the Reddit stock analysis tool."""
parser = argparse.ArgumentParser(description="Analyze stock ticker mentions on Reddit.", formatter_class=argparse.RawTextHelpFormatter)
parser = argparse.ArgumentParser(
description="Analyze stock ticker mentions on Reddit.",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"-f",
"--config",
default="subreddits.json",
help="Path to the JSON file for scanning. (Default: subreddits.json)",
)
parser.add_argument(
"-s", "--subreddit", help="Scan a single subreddit, ignoring the config file."
)
parser.add_argument(
"-d",
"--days",
type=int,
default=1,
help="Number of past days to scan for new posts. (Default: 1)",
)
parser.add_argument(
"-p",
"--posts",
type=int,
default=200,
help="Max posts to check per subreddit. (Default: 200)",
)
parser.add_argument(
"-c",
"--comments",
type=int,
default=100,
help="Number of comments to scan per post. (Default: 100)",
)
parser.add_argument(
"-n",
"--no-financials",
action="store_true",
help="Disable fetching of financial data during the Reddit scan.",
)
parser.add_argument(
"--update-top-tickers",
action="store_true",
help="Update financial data only for tickers currently in the Top 10 daily/weekly dashboards.",
)
parser.add_argument(
"-u",
"--update-financials-only",
nargs="?",
const="ALL_TICKERS", # A special value to signify "update all"
default=None,
metavar="TICKER",
help="Update financials. Provide a ticker symbol to update just one,\nor use the flag alone to update all tickers in the database.",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable detailed debug logging to the console.",
)
parser.add_argument(
"--stdout", action="store_true", help="Print all log messages to the console."
)
parser.add_argument("-f", "--config", default="subreddits.json", help="Path to the JSON file containing subreddits.\n(Default: subreddits.json)")
parser.add_argument("-s", "--subreddit", help="Scan a single subreddit, ignoring the config file.")
parser.add_argument("-d", "--days", type=int, default=1, help="Number of past days to scan for new posts.\n(Default: 1 for last 24 hours)")
parser.add_argument("-p", "--posts", type=int, default=200, help="Max posts to check per subreddit.\n(Default: 200)")
parser.add_argument("-c", "--comments", type=int, default=100, help="Number of comments to scan per post.\n(Default: 100)")
parser.add_argument("-l", "--limit", type=int, default=20, help="Number of tickers to show in the CLI report.\n(Default: 20)")
args = parser.parse_args()
setup_logging(console_verbose=args.stdout, debug_mode=args.debug)
database.initialize_db()
if args.update_top_tickers:
# --- Mode 1: Update Top Tickers ---
log.critical("--- Starting Financial Data Update for Top Tickers ---")
top_daily = database.get_top_daily_ticker_symbols()
top_weekly = database.get_top_weekly_ticker_symbols()
all_sub_names = database.get_all_scanned_subreddits()
for sub_name in all_sub_names:
top_daily.extend(
database.get_top_daily_ticker_symbols_for_subreddit(sub_name)
)
top_weekly.extend(
database.get_top_weekly_ticker_symbols_for_subreddit(sub_name)
)
tickers_to_update = sorted(list(set(top_daily + top_weekly)))
if not tickers_to_update:
log.info("No top tickers found in the last week. Nothing to update.")
else:
log.info(
f"Found {len(tickers_to_update)} unique top tickers to update. Fetching in parallel..."
)
financial_data_batch = {}
successful_updates = 0
failed_updates = 0
with ThreadPoolExecutor(max_workers=10) as executor:
results = executor.map(fetch_financial_data, tickers_to_update)
for symbol, data in results:
# A successful fetch is one where data is returned and has a closing price
if data and data.get("closing_price") is not None:
log.info(f" -> SUCCESS: Fetched data for {symbol}")
financial_data_batch[symbol] = data
successful_updates += 1
else:
log.warning(
f" -> FAILED: Could not fetch valid financial data for {symbol}"
)
failed_updates += 1
if not financial_data_batch:
log.error("Failed to fetch any batch financial data. Aborting update.")
else:
conn = database.get_db_connection()
all_tickers_from_db = database.get_all_tickers()
ticker_map = {t["symbol"]: t["id"] for t in all_tickers_from_db}
for symbol, financials in financial_data_batch.items():
if symbol in ticker_map:
database.update_ticker_financials(
conn,
ticker_map[symbol],
financials.get("market_cap"),
financials.get("closing_price"),
)
conn.close()
log.critical("--- Top Ticker Financial Data Update Complete ---")
log.critical(f" Successful updates: {successful_updates}")
log.critical(f" Failed updates: {failed_updates}")
elif args.update_financials_only:
# --- Mode 2: Update All or a Single Ticker ---
update_mode = args.update_financials_only
tickers_to_update = []
if update_mode == "ALL_TICKERS":
log.critical("--- Starting Financial Data Update for ALL tickers ---")
all_tickers_from_db = database.get_all_tickers()
tickers_to_update = [t["symbol"] for t in all_tickers_from_db]
else:
ticker_symbol_to_update = update_mode
log.critical(
f"--- Starting Financial Data Update for single ticker: {ticker_symbol_to_update} ---"
)
if database.get_ticker_by_symbol(ticker_symbol_to_update):
tickers_to_update = [ticker_symbol_to_update]
else:
log.error(
f"Ticker '{ticker_symbol_to_update}' not found in the database."
)
if tickers_to_update:
log.info(
f"Found {len(tickers_to_update)} unique tickers to update. Fetching in parallel..."
)
financial_data_batch = {}
successful_updates = 0
failed_updates = 0
with ThreadPoolExecutor(max_workers=10) as executor:
results = executor.map(fetch_financial_data, tickers_to_update)
for symbol, data in results:
# A successful fetch is one where data is returned and has a closing price
if data and data.get("closing_price") is not None:
log.info(f" -> SUCCESS: Fetched data for {symbol}")
financial_data_batch[symbol] = data
successful_updates += 1
else:
log.warning(
f" -> FAILED: Could not fetch valid financial data for {symbol}"
)
failed_updates += 1
if not financial_data_batch:
log.error("Failed to fetch any batch financial data. Aborting update.")
else:
conn = database.get_db_connection()
all_tickers_from_db = database.get_all_tickers()
ticker_map = {t["symbol"]: t["id"] for t in all_tickers_from_db}
for symbol, financials in financial_data_batch.items():
if symbol in ticker_map:
database.update_ticker_financials(
conn,
ticker_map[symbol],
financials.get("market_cap"),
financials.get("closing_price"),
)
conn.close()
log.critical("--- Financial Data Update Complete ---")
log.critical(f" Successful updates: {successful_updates}")
log.critical(f" Failed updates: {failed_updates}")
else:
# --- Mode 3: Default Reddit Scan ---
log.critical("--- Starting Reddit Scan Mode ---")
if args.subreddit:
# If --subreddit is used, create a list with just that one.
subreddits_to_scan = [args.subreddit]
log.info(f"Targeted Scan Mode: Focusing on r/{args.subreddit}")
else:
# Otherwise, load from the config file.
log.info(f"Config Scan Mode: Loading subreddits from {args.config}")
# Use the correct argument name: args.config
subreddits_to_scan = load_subreddits(args.config)
if not subreddits_to_scan:
log.error("Error: No subreddits to scan. Please check your config file or --subreddit argument.")
log.error("Error: No subreddits to scan.")
return
# --- Initialize and Run ---
database.initialize_db()
reddit = get_reddit_instance()
if not reddit: return
if not reddit:
return
scan_subreddits(
reddit,
subreddits_to_scan,
post_limit=args.posts,
comment_limit=args.comments,
days_to_scan=args.days
days_to_scan=args.days,
fetch_financials=(not args.no_financials),
)
database.generate_summary_report(limit=args.limit)
if __name__ == "__main__":
main()

View File

@@ -16,4 +16,4 @@ def get_sentiment_score(text):
# The polarity_scores() method returns a dictionary with 'neg', 'neu', 'pos', and 'compound' scores.
# We are most interested in the 'compound' score.
scores = _analyzer.polarity_scores(text)
return scores['compound']
return scores["compound"]

View File

@@ -3,9 +3,9 @@ import nltk
# This will download the 'vader_lexicon' dataset
# It only needs to be run once
try:
nltk.data.find('sentiment/vader_lexicon.zip')
nltk.data.find("sentiment/vader_lexicon.zip")
print("VADER lexicon is already downloaded.")
except LookupError:
print("Downloading VADER lexicon...")
nltk.download('vader_lexicon')
nltk.download("vader_lexicon")
print("Download complete.")

View File

@@ -5,110 +5,140 @@ import re
# A set of common English words and acronyms that look like stock tickers.
# This helps reduce false positives.
COMMON_WORDS_BLACKLIST = {
"401K", "403B", "457B", "ABC", "ABOUT", "ABOVE", "ADAM", "ADX", "AEDT", "AEST",
"AF", "AFAIK", "AFTER", "AH", "AI", "AINT", "ALD", "ALGOS", "ALIVE", "ALL",
"ALPHA", "ALSO", "AM", "AMA", "AMEX", "AND", "ANY", "APES", "APL", "APPL",
"APPLE", "AR", "ARE", "AREA", "ARK", "AROUND", "ASAP", "ASK", "ASS", "ASSET",
"AT", "ATH", "ATL", "ATM", "AUD", "AUM", "AV", "AVG", "AWS", "BABY",
"BAG", "BAGS", "BALLS", "BANG", "BBB", "BE", "BEAR", "BEARS", "BECN", "BEER",
"BELL", "BELOW", "BETA", "BETS", "BF", "BID", "BIG", "BIS", "BITCH", "BLEND",
"BNPL", "BOE", "BOJ", "BOLL", "BOMB", "BOND", "BORN", "BOTH", "BOTS", "BOYS",
"BRB", "BRICS", "BRK", "BRKB", "BRL", "BROKE", "BS", "BST", "BSU", "BT",
"BTC", "BTS", "BTW", "BULL", "BULLS", "BUST", "BUT", "BUY", "BUZZ", "CAD",
"CALL", "CAN", "CAP", "CASE", "CBD", "CBGM", "CBS", "CCI", "CD", "CEO",
"CEST", "CET", "CEX", "CFD", "CFO", "CHART", "CHF", "CHIPS", "CIA", "CLEAN",
"CLICK", "CLOSE", "CNBC", "CNN", "CNY", "COCK", "COGS", "COIL", "COKE", "COME",
"COST", "COULD", "COVID", "CPAP", "CPI", "CRV", "CSE", "CSS", "CST", "CTB",
"CTEP", "CTO", "CULT", "CYCLE", "CZK", "DA", "DAILY", "DAO", "DATE", "DAX",
"DAY", "DAYS", "DCA", "DCF", "DD", "DEBT", "DEEZ", "DEMO", "DEX", "DIA",
"DID", "DIDNT", "DIP", "DITM", "DIV", "DIY", "DJIA", "DKK", "DL", "DM",
"DMV", "DNI", "DO", "DOE", "DOES", "DOGE", "DOJ", "DOM", "DONT", "DOOR",
"DOWN", "DR", "DUDE", "DUMP", "DUTY", "DYI", "DYNK", "DYODD", "DYOR", "EACH",
"EARLY", "EARN", "EAST", "EASY", "ECB", "EDGAR", "EDIT", "EDT", "EJ", "EMA",
"EMJ", "END", "ENRON", "ENV", "EO", "EOD", "EOW", "EOY", "EPA", "EPS",
"ER", "ESG", "ESPP", "EST", "ETA", "ETF", "ETFS", "ETH", "EU", "EUR",
"EV", "EVEN", "EVERY", "EVTOL", "EXTRA", "EYES", "EZ", "FAANG", "FAFO", "FAQ",
"FAR", "FAST", "FBI", "FCFF", "FD", "FDA", "FEE", "FFH", "FFS", "FGMA",
"FIG", "FIGMA", "FIHTX", "FING", "FINRA", "FINT", "FINTX", "FINTY", "FIRST", "FKIN",
"FLT", "FLY", "FML", "FOLO", "FOMC", "FOMO", "FOR", "FOREX", "FRAUD", "FRG",
"FROM", "FSELK", "FSPSX", "FTD", "FTSE", "FUCK", "FUCKS", "FUD", "FULL", "FUND",
"FUNNY", "FVG", "FX", "FXAIX", "FXIAX", "FXROX", "FY", "FYI", "FZROX", "GAAP",
"GAIN", "GAVE", "GBP", "GDP", "GET", "GG", "GJ", "GL", "GLHF", "GMAT",
"GMI", "GMT", "GO", "GOAL", "GOAT", "GOD", "GOING", "GOLD", "GONE", "GONNA",
"GPT", "GPU", "GRAB", "GREAT", "GREEN", "GTA", "GTFO", "GTG", "GUH", "GUYS",
"HAD", "HAHA", "HALF", "HANDS", "HAS", "HATE", "HAVE", "HBAR", "HCOL", "HEAR",
"HEDGE", "HEGE", "HELP", "HEY", "HFCS", "HFT", "HIGH", "HIGHS", "HINT", "HIS",
"HKD", "HODL", "HODOR", "HOF", "HOLD", "HOLY", "HOME", "HOUR", "HS", "HSA",
"HT", "HTF", "HTML", "HUF", "HUGE", "HYPE", "IANAL", "IB", "IBS", "ICT",
"ID", "IDF", "IDK", "IF", "II", "IKKE", "IKZ", "IMHO", "IMO", "IN",
"INR", "INTO", "IP", "IPO", "IRA", "IRAS", "IRC", "IRISH", "IRS", "IS",
"ISA", "ISIN", "ISM", "ISN", "IST", "IT", "ITM", "ITS", "ITWN", "IUIT",
"IV", "IVV", "IWM", "JAVA", "JD", "JFC", "JK", "JLR", "JOIN", "JOKE",
"JP", "JPOW", "JPY", "JS", "JST", "JUN", "JUST", "KARMA", "KEEP", "KNEW",
"KNOW", "KO", "KPMG", "KRW", "LANGT", "LARGE", "LAST", "LATE", "LATER", "LBO",
"LDL", "LEADS", "LEAP", "LEAPS", "LEI", "LETS", "LFG", "LIFE", "LIG", "LIGMA",
"LIKE", "LIMIT", "LIST", "LLC", "LLM", "LMAO", "LOKO", "LOL", "LOLOL", "LONG",
"LOOK", "LOSE", "LOSS", "LOST", "LOVE", "LOW", "LOWER", "LOWS", "LTCG", "LUPD",
"LYING", "M&A", "MA", "MACD", "MAKE", "MAKES", "MANY", "MAX", "MBA", "MC",
"MCP", "ME", "MEME", "MERGE", "MERK", "MES", "MEXC", "MF", "MFER", "MID",
"MIGHT", "MIN", "MIND", "ML", "MLB", "MM", "MNQ", "MOASS", "MOM", "MONEY",
"MONTH", "MONY", "MOON", "MORE", "MOU", "MSK", "MUCH", "MUSIC", "MUST", "MXN",
"MY", "MYMD", "NASA", "NATO", "NBA", "NCR", "NEAR", "NEAT", "NEED", "NEVER",
"NEW", "NEWS", "NEXT", "NFA", "NFC", "NFL", "NFT", "NGMI", "NIGHT", "NIQ",
"NK", "NO", "NOK", "NONE", "NOPE", "NORTH", "NOT", "NOVA", "NOW", "NQ",
"NSA", "NTVS", "NULL", "NUT", "NUTS", "NUTZ", "NVM", "NW", "NY", "NYSE",
"NZ", "NZD", "OBBB", "OBI", "OBV", "OCF", "OCO", "OEM", "OF", "OFA",
"OFF", "OG", "OH", "OK", "OKAY", "OLD", "OMFG", "OMG", "ON", "ONE",
"ONLY", "OP", "OPEC", "OPENQ", "OPEX", "OPRN", "OR", "ORB", "OS", "OSCE",
"OT", "OTC", "OTM", "OUCH", "OUGHT", "OUT", "OVER", "OWN", "PA", "PANIC",
"PC", "PDT", "PE", "PEAK", "PEG", "PETA", "PEW", "PFC", "PGHL", "PITA",
"PLAN", "PLAYS", "PLN", "PM", "PMI", "PNL", "POC", "POMO", "POP", "POS",
"POSCO", "POV", "POW", "PPI", "PR", "PRICE", "PROFIT", "PS", "PSA", "PST",
"PT", "PTD", "PUSSY", "PUT", "PWC", "Q1", "Q2", "Q3", "Q4", "QE",
"QED", "QIMC", "QQQ", "QR", "RAM", "RBA", "RBNZ", "RE", "REACH", "READY",
"REAL", "RED", "REIT", "REITS", "REKT", "RFK", "RH", "RICO", "RIDE", "RIGHT",
"RIP", "RISK", "RISKY", "ROCE", "ROCK", "ROE", "ROFL", "ROI", "ROIC", "ROTH",
"RRSP", "RSD", "RSI", "RT", "RTD", "RUB", "RUG", "RULE", "RUST", "RVOL",
"SAGA", "SALES", "SAME", "SAVE", "SAYS", "SBF", "SBLOC", "SCALP", "SCAM", "SCHB",
"SCIF", "SEC", "SEE", "SEK", "SELL", "SELLL", "SEP", "SET", "SGD", "SHALL",
"SHARE", "SHELL", "SHIT", "SHORT", "SI", "SIGN", "SL", "SLIM", "SLOW", "SMA",
"SMALL", "SO", "SOLIS", "SOME", "SOON", "SOUTH", "SP", "SPAC", "SPDR", "SPEND",
"SPLG", "SPX", "SPY", "SS", "START", "STAY", "STEEL", "STILL", "STOCK", "STOOQ",
"STOP", "STOR", "STQQQ", "STUCK", "STUDY", "SUS", "SUV", "SWIFT", "SWING", "TA",
"TAG", "TAKE", "TAM", "TBTH", "TEAMS", "TERM", "TF", "TFSA", "THANK", "THAT",
"THATS", "THE", "THEIR", "THEN", "THERE", "THESE", "THEY", "THING", "THINK", "THIS",
"TIA", "TIKR", "TIME", "TITS", "TJR", "TL", "TL;DR", "TLDR", "TO", "TODAY",
"TOLD", "TOS", "TOT", "TOTAL", "TP", "TRADE", "TREND", "TRUE", "TRUMP", "TRUST",
"TRY", "TSA", "TSP", "TSX", "TSXV", "TTM", "TTYL", "TWO", "UCITS", "UGH",
"UI", "UK", "UNDER", "UNTIL", "UP", "US", "USA", "USD", "USSR", "UTC",
"VALID", "VALUE", "VERY", "VFMXX", "VIX", "VLI", "VOO", "VP", "VR", "VRVP",
"VSUS", "VTI", "VUAG", "VW", "VWAP", "VXN", "VXUX", "WAGMI", "WAIT", "WALL",
"WANT", "WATCH", "WAY", "WE", "WEB3", "WEEK", "WEST", "WHALE", "WHAT", "WHICH",
"WHO", "WHOS", "WHY", "WIDE", "WILL", "WIRE", "WIRED", "WITH", "WL", "WON",
"WOOPS", "WORDS", "WORTH", "WOULD", "WP", "WRONG", "WSB", "WSJ", "WTF", "WV",
"WWII", "WWIII", "XCUSE", "XD", "XMR", "XO", "XRP", "XX", "YEAH", "YEET",
"YES", "YET", "YIELD", "YM", "YMMV", "YOLO", "YOU", "YOUR", "YOY", "YT",
"YTD", "YUGE", "ZAR", "ZEN", "ZERO"
"401K", "403B", "457B", "AAVE", "ABC", "ABOUT", "ABOVE", "ACAT", "ADAM", "ADHD",
"ADR", "ADS", "ADX", "AEDT", "AEST", "AF", "AFAIK", "AFTER", "AGENT", "AH",
"AI", "AINT", "AK", "ALD", "ALGOS", "ALIVE", "ALL", "ALPHA", "ALSO", "AM",
"AMA", "AMEX", "AMK", "AMY", "AND", "ANSS", "ANY", "APES", "APL", "APPL",
"APPLE", "APR", "APUS", "APY", "AR", "ARBK", "ARE", "AREA", "ARH", "ARK",
"AROUND", "ART", "AS", "ASAP", "ASEAN", "ASK", "ASS", "ASSET", "AST", "AT",
"ATH", "ATL", "ATM", "AUD", "AUG", "AUM", "AV", "AVG", "AWS", "BABY",
"BAG", "BAGS", "BALLS", "BAN", "BANG", "BASIC", "BBB", "BBBY", "BE", "BEAR",
"BEARS", "BECN", "BEER", "BELL", "BELOW", "BETA", "BETS", "BF", "BID", "BIG",
"BIS", "BITCH", "BKEY", "BLEND", "BMW", "BNP", "BNPL", "BOE", "BOJ", "BOLL",
"BOMB", "BOND", "BONED", "BORN", "BOTH", "BOTS", "BOY", "BOYS", "BRB", "BRICS",
"BRK", "BRKA", "BRKB", "BRL", "BROKE", "BRRRR", "BS", "BSE", "BST", "BSU",
"BT", "BTC", "BTS", "BTW", "BUDDY", "BULL", "BULLS", "BUST", "BUT", "BUY",
"BUZZ", "CAD", "CAFE", "CAGR", "CALL", "CALLS", "CAN", "CAP", "CARB", "CARES",
"CASE", "CATL", "CBD", "CBGM", "CBS", "CCI", "CCP", "CD", "CDN", "CEO",
"CEST", "CET", "CEX", "CFD", "CFO", "CFPB", "CHART", "CHASE", "CHATS", "CHECK",
"CHF", "CHICK", "CHIP", "CHIPS", "CIA", "CIC", "CLAIM", "CLEAN", "CLICK", "CLOSE",
"CMON", "CN", "CNBC", "CNN", "CNY", "COBRA", "COCK", "COGS", "COIL", "COKE",
"COME", "COST", "COULD", "COVID", "CPAP", "CPI", "CRA", "CRE", "CRO", "CRV",
"CSE", "CSP", "CSS", "CST", "CTB", "CTEP", "CTO", "CUCKS", "CULT", "CUM",
"CUSMA", "CUTS", "CUV", "CYCLE", "CZK", "DA", "DAILY", "DAO", "DATE", "DAX",
"DAY", "DAYS", "DCA", "DCF", "DD", "DEAL", "DEBT", "DEEZ", "DEMO", "DET",
"DEX", "DGAF", "DIA", "DID", "DIDNT", "DIP", "DITM", "DIV", "DIY", "DJI",
"DJIA", "DJTJ", "DKK", "DL", "DM", "DMV", "DNI", "DNUTZ", "DO", "DOD",
"DOE", "DOES", "DOGE", "DOING", "DOJ", "DOM", "DONNY", "DONT", "DONUT", "DOOR",
"DOWN", "DOZEN", "DPI", "DR", "DUDE", "DUMP", "DUNT", "DUT", "DUTY", "DXY",
"DXYXBT", "DYI", "DYNK", "DYODD", "DYOR", "EACH", "EARLY", "EARN", "EAST", "EASY",
"ECB", "EDGAR", "EDIT", "EDT", "EJ", "EMA", "EMJ", "EMT", "END", "ENRON",
"ENSI", "ENV", "EO", "EOD", "EOM", "EOW", "EOY", "EPA", "EPK", "EPS",
"ER", "ESG", "ESPP", "EST", "ETA", "ETF", "ETFS", "ETH", "ETL", "EU",
"EUR", "EV", "EVEN", "EVERY", "EVTOL", "EXTRA", "EYES", "EZ", "FAANG", "FAFO",
"FAQ", "FAR", "FAST", "FBI", "FCC", "FCFF", "FD", "FDA", "FEE", "FFH",
"FFS", "FGMA", "FIG", "FIGMA", "FIHTX", "FILES", "FINAL", "FIND", "FING", "FINRA",
"FINT", "FINTX", "FINTY", "FIRE", "FIRST", "FKIN", "FLRAA", "FLT", "FLY", "FML",
"FOLO", "FOMC", "FOMO", "FOR", "FOREX", "FRAUD", "FREAK", "FRED", "FRG", "FROM",
"FRP", "FRS", "FSBO", "FSD", "FSE", "FSELK", "FSPSX", "FTD", "FTSE", "FUCK",
"FUCKS", "FUD", "FULL", "FUND", "FUNNY", "FVG", "FWIW", "FX", "FXAIX", "FXIAX",
"FXROX", "FY", "FYI", "FZROX", "GAAP", "GAIN", "GAVE", "GBP", "GC", "GDP",
"GET", "GFC", "GG", "GGTM", "GIVES", "GJ", "GL", "GLHF", "GMAT", "GMI",
"GMT", "GO", "GOAL", "GOAT", "GOD", "GOING", "GOLD", "GONE", "GONNA", "GOODS",
"GOPRO", "GPT", "GPU", "GRAB", "GREAT", "GREEN", "GSOV", "GST", "GTA", "GTC",
"GTFO", "GTG", "GUH", "GUNS", "GUY", "GUYS", "HAD", "HAHA", "HALF", "HAM",
"HANDS", "HAS", "HATE", "HAVE", "HBAR", "HCOL", "HEAR", "HEDGE", "HEGE", "HELD",
"HELL", "HELP", "HERE", "HEY", "HFCS", "HFT", "HGTV", "HIGH", "HIGHS", "HINT",
"HIS", "HITID", "HK", "HKD", "HKEX", "HODL", "HODOR", "HOF", "HOLD", "HOLY",
"HOME", "HOT", "HOUR", "HOURS", "HOW", "HS", "HSA", "HSI", "HT", "HTCI",
"HTF", "HTML", "HUF", "HUGE", "HV", "HYPE", "IANAL", "IATF", "IB", "IBS",
"ICSID", "ICT", "ID", "IDF", "IDK", "IF", "II", "IIRC", "IKKE", "IKZ",
"IM", "IMHO", "IMI", "IMO", "IN", "INC", "INR", "INTEL", "INTO", "IP",
"IPO", "IQVIA", "IRA", "IRAS", "IRC", "IRISH", "IRMAA", "IRS", "IS", "ISA",
"ISIN", "ISM", "ISN", "IST", "IT", "ITC", "ITM", "ITS", "ITWN", "IUIT",
"IV", "IVV", "IWM", "IXL", "IXLH", "IYKYK", "JAVA", "JD", "JDG", "JDM",
"JE", "JFC", "JK", "JLR", "JMO", "JOBS", "JOIN", "JOKE", "JP", "JPOW",
"JPY", "JS", "JST", "JUN", "JUST", "KARMA", "KEEP", "KILL", "KING", "KK",
"KLA", "KLP", "KNEW", "KNOW", "KO", "KOHLS", "KPMG", "KRW", "LA", "LANGT",
"LARGE", "LAST", "LATE", "LATER", "LBO", "LBTC", "LCS", "LDL", "LEADS", "LEAP",
"LEAPS", "LEARN", "LEI", "LET", "LETF", "LETS", "LFA", "LFG", "LFP", "LG",
"LGEN", "LIFE", "LIG", "LIGMA", "LIKE", "LIMIT", "LIST", "LLC", "LLM", "LM",
"LMAO", "LMAOO", "LMM", "LMN", "LOANS", "LOKO", "LOL", "LOLOL", "LONG", "LONGS",
"LOOK", "LOSE", "LOSS", "LOST", "LOVE", "LOVES", "LOW", "LOWER", "LOWS", "LP",
"LSS", "LTCG", "LUCID", "LUPD", "LYC", "LYING", "M&A", "MA", "MACD", "MAIL",
"MAKE", "MAKES", "MANGE", "MANY", "MASON", "MAX", "MAY", "MAYBE", "MBA", "MC",
"MCAP", "MCNA", "MCP", "ME", "MEAN", "MEME", "MERGE", "MERK", "MES", "MEXC",
"MF", "MFER", "MID", "MIGHT", "MIN", "MIND", "MINS", "ML", "MLB", "MLS",
"MM", "MMF", "MNQ", "MOASS", "MODEL", "MODTX", "MOM", "MONEY", "MONTH", "MONY",
"MOON", "MORE", "MOST", "MOU", "MSK", "MTVGA", "MUCH", "MUSIC", "MUST", "MVA",
"MXN", "MY", "MYMD", "NASA", "NASDA", "NATO", "NAV", "NBA", "NBC", "NCAN",
"NCR", "NEAR", "NEAT", "NEED", "NEVER", "NEW", "NEWS", "NEXT", "NFA", "NFC",
"NFL", "NFT", "NGAD", "NGMI", "NIGHT", "NIQ", "NK", "NO", "NOK", "NON",
"NONE", "NOOO", "NOPE", "NORTH", "NOT", "NOVA", "NOW", "NQ", "NRI", "NSA",
"NSCLC", "NSLC", "NTG", "NTVS", "NULL", "NUT", "NUTS", "NUTZ", "NVM", "NW",
"NY", "NYSE", "NZ", "NZD", "OBBB", "OBI", "OBS", "OBV", "OCD", "OCF",
"OCO", "ODAT", "ODTE", "OEM", "OF", "OFA", "OFF", "OG", "OH", "OK",
"OKAY", "OL", "OLD", "OMFG", "OMG", "ON", "ONDAS", "ONE", "ONLY", "OP",
"OPEC", "OPENQ", "OPEX", "OPRN", "OR", "ORB", "ORDER", "ORTEX", "OS", "OSCE",
"OT", "OTC", "OTM", "OTOH", "OUCH", "OUGHT", "OUR", "OUT", "OVER", "OWN",
"OZZY", "PA", "PANIC", "PC", "PDT", "PE", "PEAK", "PEG", "PETA", "PEW",
"PFC", "PGHL", "PIMCO", "PITA", "PLAN", "PLAYS", "PLC", "PLN", "PM", "PMCC",
"PMI", "PNL", "POC", "POMO", "POP", "POS", "POSCO", "POTUS", "POV", "POW",
"PPI", "PR", "PRICE", "PRIME", "PROFIT", "PROXY", "PS", "PSA", "PST", "PT",
"PTD", "PUSSY", "PUT", "PUTS", "PWC", "Q1", "Q2", "Q3", "Q4", "QE",
"QED", "QIMC", "QQQ", "QR", "RAM", "RATM", "RBA", "RBNZ", "RE", "REACH",
"READY", "REAL", "RED", "REIT", "REITS", "REKT", "REPE", "RFK", "RH", "RICO",
"RIDE", "RIGHT", "RIP", "RISK", "RISKY", "RNDC", "ROCE", "ROCK", "ROE", "ROFL",
"ROI", "ROIC", "ROTH", "RPO", "RRSP", "RSD", "RSI", "RT", "RTD", "RUB",
"RUG", "RULE", "RUST", "RVOL", "SAGA", "SALES", "SAME", "SAVE", "SAYS", "SBF",
"SBLOC", "SC", "SCALP", "SCAM", "SCHB", "SCIF", "SEC", "SEE", "SEK", "SELL",
"SELLL", "SEP", "SESG", "SET", "SFOR", "SGD", "SHALL", "SHARE", "SHEIN", "SHELL",
"SHIT", "SHORT", "SHOW", "SHS", "SHTF", "SI", "SICK", "SIGN", "SL", "SLIM",
"SLOW", "SMA", "SMALL", "SMFH", "SNZ", "SO", "SOLD", "SOLIS", "SOME", "SOON",
"SOOO", "SOUTH", "SP", "SPAC", "SPDR", "SPEND", "SPLG", "SPX", "SPY", "SQUAD",
"SS", "SSA", "SSDI", "START", "STAY", "STEEL", "STFU", "STILL", "STO", "STOCK",
"STOOQ", "STOP", "STOR", "STQQQ", "STUCK", "STUDY", "SUS", "SUSHI", "SUV", "SWIFT",
"SWING", "TA", "TAG", "TAKE", "TAM", "TBTH", "TEAMS", "TED", "TEMU", "TERM",
"TESLA", "TEXT", "TF", "TFNA", "TFSA", "THAN", "THANK", "THAT", "THATS", "THE",
"THEIR", "THEM", "THEN", "THERE", "THESE", "THEY", "THING", "THINK", "THIS", "TI",
"TIA", "TIKR", "TIME", "TIMES", "TINA", "TITS", "TJR", "TL", "TL;DR", "TLDR",
"TNT", "TO", "TODAY", "TOLD", "TONS", "TOO", "TOS", "TOT", "TOTAL", "TP",
"TPU", "TRADE", "TREND", "TRUE", "TRUMP", "TRUST", "TRY", "TSA", "TSMC", "TSP",
"TSX", "TSXV", "TTIP", "TTM", "TTYL", "TURNS", "TWO", "UAW", "UCITS", "UGH",
"UI", "UK", "UNDER", "UNITS", "UNO", "UNTIL", "UP", "US", "USA", "USD",
"USMCA", "USSA", "USSR", "UTC", "VALID", "VALUE", "VAMOS", "VAT", "VEO", "VERY",
"VFMXX", "VFV", "VI", "VISA", "VIX", "VLI", "VOO", "VP", "VPAY", "VR",
"VRVP", "VSUS", "VTI", "VUAG", "VW", "VWAP", "VWCE", "VXN", "VXUX", "WAGER",
"WAGMI", "WAIT", "WALL", "WANT", "WAS", "WATCH", "WAY", "WBTC", "WE", "WEB",
"WEB3", "WEEK", "WENT", "WERO", "WEST", "WHALE", "WHAT", "WHEN", "WHERE", "WHICH",
"WHILE", "WHO", "WHOS", "WHY", "WIDE", "WILL", "WIRE", "WIRED", "WITH", "WL",
"WON", "WOOPS", "WORDS", "WORTH", "WOULD", "WP", "WRONG", "WSB", "WSJ", "WTF",
"WV", "WWII", "WWIII", "X", "XAU", "XCUSE", "XD", "XEQT", "XI", "XIV",
"XMR", "XO", "XRP", "XX", "YEAH", "YEET", "YES", "YET", "YIELD", "YM",
"YMMV", "YOIR", "YOLO", "YOU", "YOUR", "YOY", "YT", "YTD", "YUGE", "YUPPP",
"ZAR", "ZEN", "ZERO", "ZEV"
}
def extract_tickers(text):
def extract_golden_tickers(text):
"""
Extracts potential stock tickers from a given piece of text.
A ticker is identified as a 1-5 character uppercase word, or a word prefixed with $.
Extracts ONLY tickers with a '$' prefix. This is the highest-confidence signal.
Returns a set of cleaned ticker symbols (e.g., {'TSLA', 'GME'}).
"""
# Regex to find potential tickers:
# 1. Words prefixed with $: $AAPL, $TSLA
# 2. All-caps words between 1 and 5 characters: GME, AMC
ticker_regex = r"\$[A-Z]{1,5}\b|\b[A-Z]{2,5}\b"
# Regex to find words prefixed with $: $AAPL, $TSLA
ticker_regex = r"\$[A-Z]{1,5}\b"
tickers = re.findall(ticker_regex, text)
# Clean the tickers by removing the '$' and return as a set
return {ticker.replace("$", "").upper() for ticker in tickers}
def extract_potential_tickers(text):
"""
Extracts potential tickers (all-caps words). This is a lower-confidence signal
used as a fallback when no golden tickers are present.
Returns a set of cleaned ticker symbols.
"""
# Regex to find all-caps words between 2 and 5 characters: GME, AMC
ticker_regex = r"\b[A-Z]{2,5}\b"
potential_tickers = re.findall(ticker_regex, text)
# Filter out common words and remove the '$' prefix
tickers = []
for ticker in potential_tickers:
cleaned_ticker = ticker.replace("$", "").upper()
if cleaned_ticker not in COMMON_WORDS_BLACKLIST:
tickers.append(cleaned_ticker)
return tickers
# Filter out common blacklisted words
return {ticker for ticker in potential_tickers if ticker not in COMMON_WORDS_BLACKLIST}

36
run_daily_job.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
BASE_DIR="/home/rstat/reddit_stock_analyzer"
# CRITICAL: Navigate to the project directory using an absolute path.
cd ${BASE_DIR}
# CRITICAL: Activate the virtual environment using an absolute path.
source ${BASE_DIR}/.venv/bin/activate
echo "--- Starting RSTAT Daily Job on $(date +%F) ---"
# 1. Scrape data from the last 24 hours and update price for top tickers.
echo "Step 1: Scraping new data..."
rstat --no-financials --comments 256
rstat --update-top-tickers
# 2. Start the dashboard in the background.
echo "Step 2: Starting dashboard in background..."
rstat-dashboard &
DASHBOARD_PID=$!
sleep 10
# 3. Export the overall summary image.
echo "Step 3: Exporting overall summary image..."
python export_image.py --overall
# 4. Post the image to r/rstat.
echo "Step 4: Posting image to Reddit..."
python post_to_reddit.py --target-subreddit rstat
# 5. Clean up by stopping the dashboard server.
echo "Step 5: Stopping dashboard server..."
kill ${DASHBOARD_PID}
echo "--- RSTAT Daily Job Complete ---"

View File

@@ -2,24 +2,24 @@
from setuptools import setup, find_packages
with open('requirements.txt') as f:
with open("requirements.txt") as f:
requirements = f.read().splitlines()
setup(
name='reddit-stock-analyzer',
version='0.0.1',
author='Pål-Kristian Hamre',
author_email='its@pkhamre.com',
description='A command-line tool to analyze stock ticker mentions on Reddit.',
name="reddit-stock-analyzer",
version="0.0.1",
author="Pål-Kristian Hamre",
author_email="its@pkhamre.com",
description="A command-line tool to analyze stock ticker mentions on Reddit.",
# This now correctly finds your 'rstat_tool' package
packages=find_packages(),
install_requires=requirements,
entry_points={
'console_scripts': [
"console_scripts": [
# The path is now 'package_name.module_name:function_name'
'rstat=rstat_tool.main:main',
'rstat-dashboard=rstat_tool.dashboard:start_dashboard',
'rstat-cleanup=rstat_tool.cleanup:run_cleanup',
"rstat=rstat_tool.main:main",
"rstat-dashboard=rstat_tool.dashboard:start_dashboard",
"rstat-cleanup=rstat_tool.cleanup:run_cleanup",
],
},
)

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

2
static/css/input.css Normal file
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";

1441
static/css/style.css Normal file

File diff suppressed because it is too large Load Diff

BIN
static/dogecoin_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
static/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
static/favicon.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200"><rect width="200" height="200" fill="url('#gradient')"></rect><defs><linearGradient id="SvgjsLinearGradient1001" gradientTransform="rotate(45 0.5 0.5)"><stop offset="0%" stop-color="#697f83"></stop><stop offset="100%" stop-color="#161f2f"></stop></linearGradient></defs><g><g fill="#b1d6bb" transform="matrix(12.518681318681319,0,0,12.518681318681319,14.808730859284879,189.00720071373405)" stroke="#498990" stroke-width="0.7"><path d="M8.87 0L6.36-5.02L4.50-5.02L4.50 0L1.07 0L1.07-14.22L6.67-14.22Q9.20-14.22 10.63-13.10Q12.05-11.97 12.05-9.92L12.05-9.92Q12.05-8.44 11.45-7.46Q10.85-6.48 9.57-5.88L9.57-5.88L12.54-0.15L12.54 0L8.87 0ZM4.50-11.57L4.50-7.67L6.67-7.67Q7.65-7.67 8.14-8.18Q8.63-8.69 8.63-9.61Q8.63-10.53 8.13-11.05Q7.64-11.57 6.67-11.57L6.67-11.57L4.50-11.57Z"></path></g></g></svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

21
static/site.webmanifest Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,10 +1,15 @@
{
"subreddits": [
"dividends",
"investing",
"options",
"pennystocks",
"SecurityAnalysis",
"Shortsqueeze",
"smallstreetbets",
"stockmarket",
"stocks",
"thetagang",
"Tollbugatabets",
"ValueInvesting",
"wallstreetbets",

27
tailwind.config.js Normal file
View File

@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
],
safelist: [
'text-violet-400',
'text-lime-400',
'text-cyan-400',
'text-yellow-400',
'text-red-400',
'text-orange-400',
'text-emerald-400',
'text-blue-400',
'text-gray-300',
'text-pink-400'
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
},
},
plugins: [
],
}

51
templates/about.html Normal file
View File

@@ -0,0 +1,51 @@
{% extends "dashboard_base.html" %}
{% block title %}About RSTAT{% endblock %}
{% block content %}
<!-- This outer div now handles the centering -->
<div class="flex flex-col items-center">
<div class="w-full max-w-3xl bg-slate-800/50 ring-1 ring-slate-700 rounded-2xl p-6 sm:p-10 shadow-2xl">
<div class="text-center mb-10">
<h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white">About RSTAT (beta)</h1>
</div>
<!-- The 'prose' class will now work correctly inside this standard block flow -->
<article class="prose prose-slate prose-invert max-w-none">
<h2>What is this?</h2>
<p>RSTAT (Reddit Stock Analysis Tool) is an automated data pipeline that scans popular financial communities on
Reddit to identify and analyze trending stock tickers. It provides a daily and weekly snapshot of the most
discussed stocks, their social sentiment, and key financial data.</p>
<h2>How does it work?</h2>
<ul>
<li>A <strong>scraper</strong> runs on a schedule to read new posts and comments from a predefined list of
subreddits.</li>
<li>A <strong>sentiment analyzer</strong> scores each mention as Bullish, Bearish, or Neutral using a natural
language processing model.</li>
<li>A <strong>data fetcher</strong> enriches the ticker data with the latest closing price and market
capitalization from Yahoo Finance.</li>
<li>All data is stored in a local <strong>SQLite database</strong>.</li>
<li>This <strong>web dashboard</strong> reads from the database to provide a clean, interactive visualization of
the results.</li>
</ul>
<h2>Supporting the Project</h2>
<p>RSTAT is a <b>soon-to-be</b>free and open-source project. To ensure the dashboard remains fast and reliable, it is hosted on a
small virtual server with running costs of approximately $6 per month. And about $30 per year for he domain.
If you find this tool useful, donations are gratefully accepted via Dogecoin (DOGE).</p>
<div class="not-prose bg-slate-900/50 ring-1 ring-slate-700 rounded-lg p-3 text-center">
<code class="text-sm text-slate-200 break-all">DRTLo2BsBijY4MrLmNNHzmjZ5tVvpTebFE</code>
</div>
</article>
<footer class="mt-12 text-center">
<div class="text-xl font-extrabold tracking-tight text-white">r/rstat</div>
<div class="text-sm text-slate-400">
<a href="https://www.reddit.com/r/rstat/" target="_blank" class="hover:text-white transition-colors">visit us
for more.</a>
</div>
</footer>
</div>
</div>
{% endblock %}

View File

@@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Reddit Stock Dashboard{% endblock %}</title>
<style>
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f4f7f6;
color: #333;
margin: 0;
line-height: 1.6;
}
.navbar {
background-color: #ffffff;
padding: 1rem 2rem;
border-bottom: 1px solid #e0e0e0;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.navbar a {
color: #555;
text-decoration: none;
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
.navbar a:hover {
background-color: #e9ecef;
color: #000;
}
.container {
max-width: 1000px;
margin: 2rem auto;
padding: 2rem;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-top: 0;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 2rem;
font-size: 0.95rem;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
color: #666;
}
tr:last-child td {
border-bottom: none;
}
.sentiment-bullish { color: #28a745; font-weight: 600; }
.sentiment-bearish { color: #dc3545; font-weight: 600; }
.sentiment-neutral { color: #6c757d; }
.post-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.post-card h3 {
margin-top: 0;
font-size: 1.2rem;
}
.post-card h3 a {
color: #0056b3;
text-decoration: none;
}
.post-card h3 a:hover {
text-decoration: underline;
}
.post-meta {
font-size: 0.9rem;
color: #666;
}
</style>
</head>
<body>
<header class="navbar">
<a href="/">Overall</a>
{% for sub in subreddits %}
<a href="/subreddit/{{ sub }}">r/{{ sub }}</a>
{% endfor %}
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>r/{{ subreddit_name }} Mentions</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 2rem;
font-family: 'Inter', sans-serif;
background: #1a1a1a;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.image-container {
width: 650px; /* Increased width to accommodate new column */
background: linear-gradient(145deg, #4d302d, #1f2128);
color: #ffffff;
border-radius: 16px;
padding: 2.5rem;
box-shadow: 0 10px B30px rgba(0,0,0,0.5);
text-align: center;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.title-block { text-align: left; }
.title-block h1 { font-size: 2.5rem; font-weight: 800; margin: 0; line-height: 1; }
.title-block h2 { font-size: 1.25rem; font-weight: 600; margin: 0.5rem 0 0; color: #b0b0b0; }
.date { font-size: 1.1rem; font-weight: 600; color: #c0c0c0; letter-spacing: 0.02em; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: 1rem 0.5rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
th { font-weight: 700; text-transform: uppercase; font-size: 0.8rem; color: #a0a0a0; }
td { font-size: 1.1rem; font-weight: 600; }
tr:last-child td { border-bottom: none; }
td.rank { font-weight: 700; color: #d0d0d0; width: 8%; }
td.ticker { width: 30%; }
td.mentions { text-align: center; width: 18%; }
td.sentiment { text-align: center; width: 26%; } /* New width */
/* Sentiment Colors */
.sentiment-bullish { color: #28a745; font-weight: 700; }
.sentiment-bearish { color: #dc3545; font-weight: 700; }
.sentiment-neutral { color: #9e9e9e; font-weight: 600; }
/* Row colors */
tr:nth-child(1) td.ticker { color: #d8b4fe; } tr:nth-child(6) td.ticker { color: #fca5a5; }
tr:nth-child(2) td.ticker { color: #a3e635; } tr:nth-child(7) td.ticker { color: #fdba74; }
tr:nth-child(3) td.ticker { color: #67e8f9; } tr:nth-child(8) td.ticker { color: #6ee7b7; }
tr:nth-child(4) td.ticker { color: #fde047; } tr:nth-child(9) td.ticker { color: #93c5fd; }
tr:nth-child(5) td.ticker { color: #fcd34d; } tr:nth-child(10) td.ticker { color: #d1d5db; }
footer { margin-top: 2.5rem; }
.brand-name { font-size: 1.75rem; font-weight: 800; letter-spacing: -1px; }
.brand-subtitle { font-size: 1rem; color: #b0b0b0; }
</style>
</head>
<body>
<div class="image-container">
<header>
<div class="title-block">
<h1>Reddit Mentions</h1>
<h2>r/{{ subreddit_name }}</h2>
</div>
<div class="date">{{ current_date }}</div>
</header>
<table>
<thead>
<tr>
<th class="rank">Rank</th>
<th class="ticker">Ticker</th>
<th class="mentions">Posts</th>
<th class="mentions">Comments</th>
<!-- UPDATED: Added Sentiment column header -->
<th class="sentiment">Sentiment</th>
</tr>
</thead>
<tbody>
{% for ticker in tickers %}
<tr>
<td class="rank">{{ loop.index }}</td>
<td class="ticker">{{ ticker.symbol }}</td>
<td class="mentions">{{ ticker.post_mentions }}</td>
<td class="mentions">{{ ticker.comment_mentions }}</td>
<!-- UPDATED: Added Sentiment data cell -->
<td class="sentiment">
{% if ticker.bullish_mentions > ticker.bearish_mentions %}
<span class="sentiment-bullish">Bullish</span>
{% elif ticker.bearish_mentions > ticker.bullish_mentions %}
<span class="sentiment-bearish">Bearish</span>
{% else %}
<span class="sentiment-neutral">Neutral</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<footer>
<div class="brand-name">r/rstat</div>
<div class="brand-subtitle">visit us for more</div>
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}RSTAT Dashboard{% endblock %}</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="shortcut icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}" />
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
/* This sets the custom font as the default for the page */
body {
font-family: 'Inter', sans-serif;
}
[class*="text-"]>a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease-in-out;
}
[class*="text-"]>a:hover {
color: #ffffff;
}
</style>
</head>
<body class="bg-slate-900 text-slate-200 min-h-screen">
{% if not is_image_mode %}
<header class="p-4 sm:p-6 w-full">
<nav
class="w-full max-w-7xl mx-auto bg-slate-800/50 ring-1 ring-slate-700 rounded-xl p-4 flex flex-col sm:flex-row items-center gap-4">
<div class="flex items-center gap-4">
<!-- Home Link -->
<a href="/"
class="font-bold {% if not subreddit_name %}text-white{% else %}text-slate-400 hover:text-white{% endif %} transition-colors">Home</a>
<!-- Alpine.js Dropdown Component -->
<div x-data="{ isOpen: false }" class="relative">
<!-- The Button that toggles the 'isOpen' state -->
<button @click="isOpen = !isOpen"
class="font-bold flex items-center gap-1 cursor-pointer {% if subreddit_name %}text-white{% else %}text-slate-400 hover:text-white{% endif %} transition-colors">
<span>Subreddits</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
class="transition-transform duration-200" :class="{'rotate-180': isOpen}">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<!-- The Dropdown Menu, controlled by Alpine.js -->
<div x-show="isOpen" @click.outside="isOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute left-0 mt-2 bg-slate-800 ring-1 ring-slate-700 shadow-lg rounded-lg py-1 w-48 z-10"
style="display: none;">
{% for sub in all_subreddits %}
<a href="/subreddit/{{ sub }}"
class="block px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white">{{ sub
}}</a>
{% endfor %}
</div>
</div>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
<a href="?view=daily"
class="px-3 py-1 rounded-md text-sm font-semibold {% if view_type == 'daily' %}bg-sky-500 text-white{% else %}bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-white{% endif %} transition-all">Daily</a>
<a href="?view=weekly"
class="px-3 py-1 rounded-md text-sm font-semibold {% if view_type == 'weekly' %}bg-sky-500 text-white{% else %}bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-white{% endif %} transition-all">Weekly</a>
<a href="/about" title="About this Project"
class="p-2 rounded-md text-slate-400 hover:bg-slate-700 hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</a>
</div>
</nav>
{% endif %}
<main class="w-full p-4 sm:p-6">
{% block content %}{% endblock %}
</main>
{% if not is_image_mode %}
<footer class="mt-8 text-center">
<div class="flex items-center justify-center gap-2">
<img src="{{ url_for('static', filename='dogecoin_logo.png') }}" alt="Doge" class="w-6 h-6">
<!-- text-base makes the text larger -->
<span class="text-base text-slate-400">
Support this service:
<!-- text-sm and p-2 make the code block larger -->
<code
class="text-sm bg-slate-800 p-2 rounded-lg text-slate-300">DRTLo2BsBijY4MrLmNNHzmjZ5tVvpTebFE</code>
</span>
</div>
</footer>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,127 @@
{% extends "dashboard_base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="flex flex-col items-center">
<div
class="w-full max-w-3xl bg-gradient-to-br from-slate-800 to-slate-900 ring-1 ring-slate-700 rounded-2xl p-6 sm:p-8 shadow-2xl">
<header class="flex flex-col sm:flex-row justify-between sm:items-start mb-8">
<div class="text-left">
<h1 class="text-2xl sm:text-4xl font-extrabold tracking-tight text-white">Reddit Ticker Mentions</h1>
<h2 class="text-lg sm:text-xl font-semibold mt-1 text-slate-400">{{ subtitle }}</h2>
</div>
<div class="text-left sm:text-right mt-2 sm:mt-0 flex-shrink-0">
<div class="text-md font-semibold text-slate-400 whitespace-nowrap">{{ date_string }}</div>
{% if not is_image_mode %}
<a href="{{ base_url }}?view={{ view_type }}&image=true" class="inline-block mt-2 sm:float-right"
title="View as Shareable Image">
<svg class="text-slate-400 hover:text-white transition-colors" xmlns="http://www.w3.org/2000/svg"
width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z">
</path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</a>
{% endif %}
</div>
</header>
{% set ticker_colors = {
1: 'text-violet-400', 2: 'text-lime-400', 3: 'text-cyan-400',
4: 'text-yellow-400', 5: 'text-red-400', 6: 'text-orange-400',
7: 'text-emerald-400', 8: 'text-blue-400', 9: 'text-gray-300',
10: 'text-pink-400'
} %}
<!-- Ticker List -->
<div class="flex flex-col">
<!-- 1. The Desktop Header Row (hidden on mobile) -->
<div
class="hidden sm:flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider px-4 py-3 border-b border-slate-700">
<div class="w-1/4 flex items-center gap-4 text-left">
<span class="w-6 text-center">#</span>
<span>Ticker</span>
</div>
<div class="w-3/4 grid grid-cols-4 gap-4 text-right">
<div class="text-center">Mentions</div>
<div class="text-center">Sentiment</div>
<div>Mkt Cap</div>
<div>Close Price</div>
</div>
</div>
<!-- 2. Ticker Rows -->
<div class="divide-y divide-slate-800">
{% for ticker in tickers %}
<!-- THIS IS THE UPDATED LINE -->
<div
class="p-4 flex flex-col sm:flex-row sm:items-center sm:gap-4 hover:bg-slate-800/50 transition-colors duration-150">
<!-- Rank & Ticker Symbol -->
<div class="flex items-center gap-4 w-full sm:w-1/4 text-left mb-4 sm:mb-0">
<span class="text-lg font-bold text-slate-500 w-6 text-center">{{ loop.index }}</span>
<div class="text-xl font-bold">
<span class="{{ ticker_colors.get(loop.index, 'text-slate-200') }}">
{% if is_image_mode %}
{{ ticker.symbol }}
{% else %}
<a href="/deep-dive/{{ ticker.symbol }}">{{ ticker.symbol }}</a>
{% endif %}
</span>
</div>
</div>
<!-- Financial Data Points -->
<div class="w-full grid grid-cols-2 sm:grid-cols-4 gap-4 text-right">
<div class="text-center sm:text-center">
<div class="sm:hidden text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Mentions</div>
<div class="text-lg font-semibold text-white">{{ ticker.total_mentions }}</div>
</div>
<div class="text-center sm:text-center">
<div class="sm:hidden text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Sentiment</div>
<div class="text-lg font-semibold">
{% if ticker.bullish_mentions > ticker.bearish_mentions %}<span
class="text-green-400">Bullish</span>
{% elif ticker.bearish_mentions > ticker.bullish_mentions %}<span
class="text-red-400">Bearish</span>
{% else %}<span class="text-slate-400">Neutral</span>{% endif %}
</div>
</div>
<div>
<div class="sm:hidden text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Mkt
Cap</div>
<div class="text-lg font-semibold text-white">{{ ticker.market_cap | format_mc }}</div>
</div>
<div>
<div class="sm:hidden text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Close
Price</div>
<div class="text-lg font-semibold text-white">
{% if ticker.closing_price %}<a
href="https://finance.yahoo.com/quote/{{ ticker.symbol }}" target="_blank"
class="hover:text-blue-400 transition-colors">${{
"%.2f"|format(ticker.closing_price) }}</a>
{% else %}N/A{% endif %}
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center text-slate-500 p-8">No ticker data found for this period.</div>
{% endfor %}
</div>
</div>
<footer class="mt-8 text-center">
<div class="text-xl font-extrabold tracking-tight text-white">r/rstat</div>
<div class="text-sm text-slate-400">
<a href="https://www.reddit.com/r/rstat/" target="_blank"
class="hover:text-white transition-colors">visit us for more.</a>
</div>
</footer>
</div>
</div>
{% endblock %}

View File

@@ -1,29 +1,54 @@
{% extends "base.html" %}
{% extends "dashboard_base.html" %}
{% block title %}Deep Dive: {{ symbol }}{% endblock %}
{% block content %}
<h1>Deep Dive Analysis for: <strong>{{ symbol }}</strong></h1>
<p>Showing posts that mention {{ symbol }}, sorted by most recent.</p>
<!-- This outer div handles the centering -->
<div class="flex flex-col items-center">
<div class="w-full max-w-3xl bg-slate-800/50 ring-1 ring-slate-700 rounded-2xl p-6 sm:p-10 shadow-2xl">
<!-- --- THIS IS THE KEY CHANGE --- -->
<!-- We wrap all the content in an <article> tag with the 'prose' classes -->
<article class="prose prose-slate prose-invert max-w-none">
<header class="text-center mb-8">
<!-- The h1 and p tags will now be beautifully styled by 'prose' -->
<h1>Deep Dive Analysis: <span class="text-sky-400">{{ symbol }}</span></h1>
<p>Showing posts that mention {{ symbol }}, sorted by most recent.</p>
</header>
<div class="space-y-4 not-prose">
{% for post in posts %}
<div class="post-card">
<h3><a href="{{ post.post_url }}" target="_blank">{{ post.title }}</a></h3>
<div class="post-meta">
<span>r/{{ post.subreddit_name }}</span> |
<span>{{ post.comment_count }} comments analyzed</span> |
<!-- 'not-prose' is used on the container so we can control styling precisely -->
<div class="bg-slate-800/50 ring-1 ring-slate-700/50 rounded-lg p-4 text-left not-prose">
<h3 class="text-lg font-bold text-slate-200 mb-2">
<!-- This link WILL be styled by the parent 'prose' class -->
<a href="{{ post.post_url }}" target="_blank">{{ post.title }}</a>
</h3>
<div class="text-sm text-slate-400 flex flex-col sm:flex-row sm:items-center gap-x-4 gap-y-1">
<span class="font-semibold">r/{{ post.subreddit_name }}</span>
<span class="hidden sm:inline">|</span>
<span>{{ post.comment_count }} comments analyzed</span>
<span class="hidden sm:inline">|</span>
<span>Avg. Sentiment:
{% if post.avg_comment_sentiment > 0.1 %}
<span class="sentiment-bullish">{{ "%.2f"|format(post.avg_comment_sentiment) }}</span>
{% elif post.avg_comment_sentiment < -0.1 %}
<span class="sentiment-bearish">{{ "%.2f"|format(post.avg_comment_sentiment) }}</span>
<span class="font-bold text-green-400">{{ "%.2f"|format(post.avg_comment_sentiment) }}
(Bullish)</span>
{% elif post.avg_comment_sentiment < -0.1 %} <span class="font-bold text-red-400">{{
"%.2f"|format(post.avg_comment_sentiment) }} (Bearish)</span>
{% else %}
<span class="sentiment-neutral">{{ "%.2f"|format(post.avg_comment_sentiment) }}</span>
<span class="font-bold text-slate-500">{{ "%.2f"|format(post.avg_comment_sentiment) }}
(Neutral)</span>
{% endif %}
</span>
</div>
</div>
{% else %}
<p>No analyzed posts found for this ticker. Run the 'rstat' scraper to gather data.</p>
<div class="text-center text-slate-500 p-8 not-prose">No analyzed posts found for this ticker.</div>
{% endfor %}
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -1,48 +0,0 @@
{% extends "base.html" %}
{% block title %}Overall Dashboard{% endblock %}
{% block content %}
<h1>
Top 10 Tickers (All Subreddits)
<!-- ADD THIS LINK -->
<a href="/image/overall" target="_blank" style="font-size: 0.8rem; margin-left: 1rem; font-weight: normal;">(View as Image)</a>
</h1>
<table>
<thead>
<tr>
<th>Ticker</th>
<th>Mentions</th>
<th>Market Cap</th>
<th>Closing Price</th>
<th>Sentiment</th>
</tr>
</thead>
<tbody>
{% for ticker in tickers %}
<tr>
<td><strong><a href="/deep-dive/{{ ticker.symbol }}">{{ ticker.symbol }}</a></strong></td>
<td>{{ ticker.mention_count }}</td>
<td>{{ ticker.market_cap | format_mc }}</td>
<!-- NEW COLUMN FOR CLOSING PRICE -->
<td>
{% if ticker.closing_price %}
${{ "%.2f"|format(ticker.closing_price) }}
{% else %}
N/A
{% endif %}
</td>
<td>
{% if ticker.bullish_mentions > ticker.bearish_mentions %}
<span class="sentiment-bullish">Bullish</span>
{% elif ticker.bearish_mentions > ticker.bullish_mentions %}
<span class="sentiment-bearish">Bearish</span>
{% else %}
<span class="sentiment-neutral">Neutral</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reddit Mentions</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 2rem;
font-family: 'Inter', sans-serif;
background: #1a1a1a;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.image-container {
width: 650px; /* Increased width to accommodate new column */
background: linear-gradient(145deg, #4d302d, #1f2128);
color: #ffffff;
border-radius: 16px;
padding: 2.5rem;
box-shadow: 0 10px B30px rgba(0,0,0,0.5);
text-align: center;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.title-block { text-align: left; }
.title-block h1 { font-size: 2.5rem; font-weight: 800; margin: 0; line-height: 1; }
.title-block h2 { font-size: 1.25rem; font-weight: 600; margin: 0.5rem 0 0; color: #b0b0b0; }
.date { font-size: 1.1rem; font-weight: 600; color: #c0c0c0; letter-spacing: 0.02em; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: 1rem 0.5rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
th { font-weight: 700; text-transform: uppercase; font-size: 0.8rem; color: #a0a0a0; }
td { font-size: 1.1rem; font-weight: 600; }
tr:last-child td { border-bottom: none; }
td.rank { font-weight: 700; color: #d0d0d0; width: 8%; }
td.ticker { width: 30%; }
td.mentions { text-align: center; width: 18%; }
td.sentiment { text-align: center; width: 26%; } /* New width */
/* Sentiment Colors */
.sentiment-bullish { color: #28a745; font-weight: 700; }
.sentiment-bearish { color: #dc3545; font-weight: 700; }
.sentiment-neutral { color: #9e9e9e; font-weight: 600; }
/* Row colors */
tr:nth-child(1) td.ticker { color: #d8b4fe; } tr:nth-child(6) td.ticker { color: #fca5a5; }
tr:nth-child(2) td.ticker { color: #a3e635; } tr:nth-child(7) td.ticker { color: #fdba74; }
tr:nth-child(3) td.ticker { color: #67e8f9; } tr:nth-child(8) td.ticker { color: #6ee7b7; }
tr:nth-child(4) td.ticker { color: #fde047; } tr:nth-child(9) td.ticker { color: #93c5fd; }
tr:nth-child(5) td.ticker { color: #fcd34d; } tr:nth-child(10) td.ticker { color: #d1d5db; }
footer { margin-top: 2.5rem; }
.brand-name { font-size: 1.75rem; font-weight: 800; letter-spacing: -1px; }
.brand-subtitle { font-size: 1rem; color: #b0b0b0; }
</style>
</head>
<body>
<div class="image-container">
<header>
<div class="title-block">
<h1>Reddit Mentions</h1>
<h2>All Subreddits - Top 10</h2>
</div>
<div class="date">{{ current_date }}</div>
</header>
<table>
<thead>
<tr>
<th class="rank">Rank</th>
<th class="ticker">Ticker</th>
<th class="mentions">Posts</th>
<th class="mentions">Comments</th>
<!-- UPDATED: Added Sentiment column header -->
<th class="sentiment">Sentiment</th>
</tr>
</thead>
<tbody>
{% for ticker in tickers %}
<tr>
<td class="rank">{{ loop.index }}</td>
<td class="ticker">{{ ticker.symbol }}</td>
<td class="mentions">{{ ticker.post_mentions }}</td>
<td class="mentions">{{ ticker.comment_mentions }}</td>
<!-- UPDATED: Added Sentiment data cell -->
<td class="sentiment">
{% if ticker.bullish_mentions > ticker.bearish_mentions %}
<span class="sentiment-bullish">Bullish</span>
{% elif ticker.bearish_mentions > ticker.bullish_mentions %}
<span class="sentiment-bearish">Bearish</span>
{% else %}
<span class="sentiment-neutral">Neutral</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<footer>
<div class="brand-name">r/rstat</div>
<div class="brand-subtitle">visit us for more</div>
</footer>
</div>
</body>
</html>

View File

@@ -1,49 +0,0 @@
{% extends "base.html" %}
{% block title %}r/{{ subreddit_name }} Dashboard{% endblock %}
{% block content %}
<h1>
Top 10 Tickers in r/{{ subreddit_name }}
<a href="/image/daily/{{ subreddit_name }}" target="_blank" style="font-size: 0.8rem; margin-left: 1rem; font-weight: normal;">(View Daily Image)</a>
<!-- ADD THIS NEW LINK -->
<a href="/image/weekly/{{ subreddit_name }}" target="_blank" style="font-size: 0.8rem; margin-left: 1rem; font-weight: normal;">(View Weekly Image)</a>
</h1>
<table>
<thead>
<tr>
<th>Ticker</th>
<th>Mentions</th>
<th>Market Cap</th>
<th>Closing Price</th>
<th>Sentiment</th>
</tr>
</thead>
<tbody>
{% for ticker in tickers %}
<tr>
<td><strong><a href="/deep-dive/{{ ticker.symbol }}">{{ ticker.symbol }}</a></strong></td>
<td>{{ ticker.mention_count }}</td>
<td>{{ ticker.market_cap | format_mc }}</td>
<!-- NEW COLUMN FOR CLOSING PRICE -->
<td>
{% if ticker.closing_price %}
${{ "%.2f"|format(ticker.closing_price) }}
{% else %}
N/A
{% endif %}
</td>
<td>
{% if ticker.bullish_mentions > ticker.bearish_mentions %}
<span class="sentiment-bullish">Bullish</span>
{% elif ticker.bearish_mentions > ticker.bullish_mentions %}
<span class="sentiment-bearish">Bearish</span>
{% else %}
<span class="sentiment-neutral">Neutral</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weekly Sentiment: r/{{ subreddit_name }}</title>
<!-- All the <style> and <link> tags from image_view.html go here -->
<!-- You can just copy the entire <head> section from image_view.html -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
/* This entire style block is IDENTICAL to image_view.html */
body { margin: 0; padding: 2rem; font-family: 'Inter', sans-serif; background: #1a1a1a; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.image-container { width: 650px; background: linear-gradient(145deg, #4d302d, #1f2128); color: #ffffff; border-radius: 16px; padding: 2.5rem; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2rem; }
.title-block { text-align: left; }
.title-block h1 { font-size: 2.5rem; font-weight: 800; margin: 0; line-height: 1; }
.title-block h2 { font-size: 1.25rem; font-weight: 600; margin: 0.5rem 0 0; color: #b0b0b0; }
.date { font-size: 1rem; font-weight: 600; color: #c0c0c0; letter-spacing: 0.02em; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: 1rem 0.5rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
th { font-weight: 700; text-transform: uppercase; font-size: 0.8rem; color: #a0a0a0; }
td { font-size: 1.1rem; font-weight: 600; }
tr:last-child td { border-bottom: none; }
td.rank { font-weight: 700; color: #d0d0d0; width: 8%; }
td.ticker { width: 30%; }
td.mentions { text-align: center; width: 18%; }
td.sentiment { text-align: center; width: 26%; }
.sentiment-bullish { color: #28a745; font-weight: 700; }
.sentiment-bearish { color: #dc3545; font-weight: 700; }
.sentiment-neutral { color: #9e9e9e; font-weight: 600; }
tr:nth-child(1) td.ticker { color: #d8b4fe; } tr:nth-child(6) td.ticker { color: #fca5a5; }
tr:nth-child(2) td.ticker { color: #a3e635; } tr:nth-child(7) td.ticker { color: #fdba74; }
tr:nth-child(3) td.ticker { color: #67e8f9; } tr:nth-child(8) td.ticker { color: #6ee7b7; }
tr:nth-child(4) td.ticker { color: #fde047; } tr:nth-child(9) td.ticker { color: #93c5fd; }
tr:nth-child(5) td.ticker { color: #fcd34d; } tr:nth-child(10) td.ticker { color: #d1d5db; }
footer { margin-top: 2.5rem; }
.brand-name { font-size: 1.75rem; font-weight: 800; letter-spacing: -1px; }
.brand-subtitle { font-size: 1rem; color: #b0b0b0; }
</style>
</head>
<body>
<div class="image-container">
<header>
<div class="title-block">
<!-- UPDATED: Title shows it's a weekly report -->
<h1>Weekly Sentiment</h1>
<h2>r/{{ subreddit_name }} - Top 10</h2>
</div>
<!-- UPDATED: Date now shows the range -->
<div class="date">{{ date_range }}</div>
</header>
<!-- The entire table structure is IDENTICAL to image_view.html -->
<table>
<thead>
<tr>
<th class="rank">Rank</th>
<th class="ticker">Ticker</th>
<th class="mentions">Posts</th>
<th class="mentions">Comments</th>
<th class="sentiment">Sentiment</th>
</tr>
</thead>
<tbody>
{% for ticker in tickers %}
<tr>
<td class="rank">{{ loop.index }}</td>
<td class="ticker">{{ ticker.symbol }}</td>
<td class="mentions">{{ ticker.post_mentions }}</td>
<td class="mentions">{{ ticker.comment_mentions }}</td>
<td class="sentiment">
{% if ticker.bullish_mentions > ticker.bearish_mentions %}
<span class="sentiment-bullish">Bullish</span>
{% elif ticker.bearish_mentions > ticker.bullish_mentions %}
<span class="sentiment-bearish">Bearish</span>
{% else %}
<span class="sentiment-neutral">Neutral</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<footer>
<div class="brand-name">r/rstat</div>
<div class="brand-subtitle">visit us for more</div>
</footer>
</div>
</body>
</html>

59
yfinance_test.py Normal file
View File

@@ -0,0 +1,59 @@
# yfinance_test.py
# A standalone script to diagnose the persistent yfinance issue.
import yfinance as yf
import logging
# Set up a simple logger to see detailed error tracebacks
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
# A list of tickers to test. One very common one, and two from your logs.
TICKERS_TO_TEST = ["MSFT", "AEBI", "AEHR"]
print("--- Starting YFINANCE Diagnostic Test ---")
for ticker_symbol in TICKERS_TO_TEST:
print(f"\n--- Testing Ticker: {ticker_symbol} ---")
# --- Test 1: The Ticker().info method ---
try:
logging.info(
f"Attempting to create Ticker object and get .info for {ticker_symbol}..."
)
ticker_obj = yf.Ticker(ticker_symbol)
market_cap = ticker_obj.info.get("marketCap")
if market_cap is not None:
logging.info(f"SUCCESS: Got market cap for {ticker_symbol}: {market_cap}")
else:
logging.warning(
f"PARTIAL SUCCESS: .info call for {ticker_symbol} worked, but no market cap was found."
)
except Exception:
logging.error(
f"FAILURE: An error occurred during the Ticker().info call for {ticker_symbol}.",
exc_info=True,
)
# --- Test 2: The yf.download() method ---
try:
logging.info(f"Attempting yf.download() for {ticker_symbol}...")
data = yf.download(
ticker_symbol, period="2d", progress=False, auto_adjust=False
)
if not data.empty:
logging.info(
f"SUCCESS: yf.download() for {ticker_symbol} returned {len(data)} rows of data."
)
else:
logging.warning(
f"PARTIAL SUCCESS: yf.download() for {ticker_symbol} worked, but returned no data (likely delisted)."
)
except Exception:
logging.error(
f"FAILURE: An error occurred during the yf.download() call for {ticker_symbol}.",
exc_info=True,
)
print("\n--- YFINANCE Diagnostic Test Complete ---")