This project is a lightweight public stock tracker for an Avanza portfolio.
The important security decision is architectural:
avanza-apiis only used locally in an export script.- The public site is static and only reads
site/portfolio.json. - No Avanza credentials are ever needed in the browser or on the public host.
- The public output contains percentages and labels only, never monetary values.
Because Avanza credentials are inherently trading-capable, this is the safest practical way to share portfolio data publicly. A server that stores live Avanza credentials can never guarantee "no possibility" of trading if that server is compromised.
scripts/export_portfolio.py: logs in to Avanza and exports a sanitized JSON snapshot.scripts/notify_new_purchase.py: sends web push notifications when the latest order changes.scripts/generate_vapid_keys.py: generates VAPID keys for browser push.site/index.html: static public page.site/app.js: renders the snapshot.site/styles.css: page styling.site/portfolio.json: generated data file that the page reads.supabase/schema.sql: storage schema for push subscriptions and notification state.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtCopy .env.example to .env and fill in your values:
cp .env.example .envRequired:
AVANZA_USERNAMEAVANZA_PASSWORDAVANZA_TOTP_SECRET
Optional:
PORTFOLIO_TITLEPORTFOLIO_OWNERAVANZA_ACCOUNT_IDSAVANZA_TRANSACTION_MAX_ELEMENTSPUBLIC_NOTE
AVANZA_ACCOUNT_IDS should be a comma-separated list if you only want to publish specific accounts. If omitted, the exporter tries to include all accounts returned by the positions endpoint.
AVANZA_TRANSACTION_MAX_ELEMENTS controls how many buy/sell transactions the exporter asks Avanza for in each transaction request.
It defaults to 10000.
Run:
set -a
source .env
set +a
python3 scripts/export_portfolio.pyThis updates site/portfolio.json with:
- portfolio allocation percentages
- holding-level percentage performance when available
- the 10 latest buy or sell transactions for the included accounts
- spot price at the time for each listed order when Avanza returns it
- for sells, percentage gain/loss versus matched earlier buys when enough history is available
If a recent sell cannot be matched from the broad transaction response, the exporter makes an additional transaction request filtered by that instrument's ISIN across all accounts. It then recalculates the recent order matches. Instrument transfers move FIFO lots between accounts when the corresponding transfer out and transfer in can be paired.
The static site can offer browser push subscriptions, but sending notifications still requires a trusted server-side secret. This repo uses:
- GitHub Pages for the public static site
- Supabase to store push subscriptions
- a GitHub Actions workflow plus
scripts/notify_new_purchase.pyto send alerts
Run:
python3 scripts/generate_vapid_keys.pyPut the generated values into .env:
WEB_PUSH_VAPID_PUBLIC_KEYWEB_PUSH_VAPID_PRIVATE_KEYWEB_PUSH_SUBJECT
Create a Supabase project, then run the SQL in:
supabase/schema.sql
This creates:
push_subscriptionsnotification_state
The schema also grants the browser anon role permission to insert and update push_subscriptions.
If you created the tables before this change, re-run supabase/schema.sql so those grants are applied.
Edit site/config.js and fill in:
supabaseUrlsupabaseAnonKeywebPushPublicKeypublicSiteUrl
These are public values and are safe to ship in the static site.
Use the project base URL for supabaseUrl, for example https://your-project.supabase.co, not the /rest/v1 endpoint.
Add these to .env for local testing:
SUPABASE_URLSUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYWEB_PUSH_VAPID_PRIVATE_KEYWEB_PUSH_SUBJECTPUBLIC_SITE_URL
Add the same private values as GitHub repository secrets for the notification workflow:
SUPABASE_URLSUPABASE_SERVICE_ROLE_KEYWEB_PUSH_VAPID_PRIVATE_KEYWEB_PUSH_SUBJECTPUBLIC_SITE_URL
After exporting the portfolio, run:
python3 scripts/notify_new_purchase.pyBehavior:
- first run initializes state and sends nothing
- later runs send notifications only when the top
recentOrders[0]entry changes - expired subscriptions are removed automatically when the push service returns
404or410
python3 -m http.server 8000 --directory siteThen open:
http://localhost:8000
Publish the contents of site/ to any static host:
- GitHub Pages
- Netlify
- Cloudflare Pages
- Vercel static output
Only the generated JSON and static assets need to be public.
This repo includes a GitHub Pages workflow at .github/workflows/deploy-pages.yml that publishes the site/ directory.
It also includes .github/workflows/notify-purchases.yml to send push alerts when site/portfolio.json changes on main.
To enable it:
- Push the repository to GitHub.
- In GitHub, open
Settings->Pages. - Under
Build and deployment, setSourcetoGitHub Actions. - Push to the
mainbranch to deploy.
The static site includes two anti-indexing measures:
site/robots.txtdisallows all crawlerssite/index.htmlincludesnoindex, nofollowmeta tags
This reduces search indexing, but does not make the site private. Anyone with the URL can still access it.
The exporter intentionally strips monetary values before writing the public JSON. It keeps:
- holding names
- tickers when available
- portfolio allocation percentage
- performance percentages when Avanza returns them
- timestamps and labels
The Avanza package used here exposes both read and write operations in its API docs:
get_accounts_positionsfor read-only portfolio dataplace_orderand other write methods for trading
This project only uses the read path in the local exporter and never exposes the client itself publicly.
For a safer automated setup, keep Avanza credentials on a Raspberry Pi you control and let the Pi publish only the generated site/portfolio.json.
This repo includes:
scripts/update_and_publish.sh: exports the snapshot, commitssite/portfolio.jsonif it changed, and pushes itsystemd/avanza-tracker-update.service: oneshot service for the update jobsystemd/avanza-tracker-update.timer: weekday schedule at 11:00, 13:00, 16:00, 19:00
Clone the repo into your home directory so the default systemd unit paths work:
cd ~
git clone git@github.com:fluzzywuzzy/portfolio-tracker.git avanza_tracker
cd avanza_tracker
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .envFill in .env with your Avanza credentials and any optional settings.
The Pi also needs Git push access to the repository, typically via an SSH key that is added to GitHub.
Run:
./scripts/update_and_publish.shBehavior:
- exits safely if another run is already active
- refuses to overwrite
site/portfolio.jsonif that file has local uncommitted edits - pushes only when the generated snapshot actually changed
Copy the units into your user systemd directory:
mkdir -p ~/.config/systemd/user
cp systemd/avanza-tracker-update.service ~/.config/systemd/user/
cp systemd/avanza-tracker-update.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now avanza-tracker-update.timerCheck the schedule:
systemctl --user list-timers avanza-tracker-update.timerCheck logs after a run:
journalctl --user -u avanza-tracker-update.service -n 100 --no-pagerIf this should keep running after reboots even when you are not logged in:
sudo loginctl enable-linger "$USER"systemduses the Pi's local timezone. Set the Pi timezone correctly toEurope/Stockholmif you want those wall-clock times.- A successful push to
mainwill trigger the existing GitHub Pages deployment and purchase notification workflow. - If you later move the repo to a different path, update
WorkingDirectoryandExecStartinsystemd/avanza-tracker-update.service.
Sources:
- Avanza package docs: https://qluxzz.github.io/avanza/avanza.html
- API method docs: https://qluxzz.github.io/avanza/avanza/avanza.html