Aplikacja webowa do rankingowania ETF-ów według strategii momentum, z przeliczeniem stóp zwrotu na PLN. Uruchamia się jako kontener Docker, dane przechowuje w SQLite.
- Szybki start
- Architektura
- Workflow aplikacji
- Struktura plików
- Konfiguracja
- Formuła Momentum Score
- Grupowanie ETF-ów
- Źródła danych
- Filtr brokerów
- Przepisy na rozszerzenia
git clone <repo>
cd momentum
docker compose up -d --buildAplikacja dostępna pod http://localhost (port 80 → 3000 w kontenerze).
Przy pierwszym uruchomieniu baza jest pusta — aplikacja automatycznie uruchamia pełną aktualizację danych (zajmuje ~5–15 minut).
┌─────────────────────────────────────────────────────┐
│ Docker container: justetf-momentum │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Node.js / │ │ Python 3 │ │
│ │ Express API │ │ scrapers │ │
│ │ server.js │ │ fetch_*.py │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ┌──────▼──────────────────▼───────┐ │
│ │ SQLite (momentum.db) │ │
│ │ etfs │ ranking │ broker_isins │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Cron: 07:00 UTC daily │ │
│ │ Cron: 06:00 UTC 1. dnia mies. │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Stack:
- Backend: Node.js 20, Express, better-sqlite3, js-yaml, node-cron
- Scrapers: Python 3, pandas, playwright/chromium, yfinance, pypdf, beautifulsoup4
- Baza: SQLite (WAL mode)
- Frontend: Vanilla JS, IBM Plex Mono / DM Sans
flowchart TD
A([Trigger: cron / przycisk]) --> B[fetchAndSaveFxRates\nnbp.js]
B -->|Kursy EUR/USD/GBP/CHF\ndo tabeli fx_rates| C[runScraper\nscraper.js]
C --> D[fetch_etfs.py\nJustETF]
C --> E[fetch_pl_etfs.py\nGPW + AtlasETF + yfinance]
D -->|NDJSON stdout| F[upsertEtfsBatch\ndatabase.js]
E -->|NDJSON stdout| F
F --> G[cleanupMissingEtfs\nusuń wycofane ETF-y\nnie dotykaj posiadanych]
G --> H[computeRanking\nranking.js]
H --> I[computeScores\ndla każdego ETF]
I --> J[convertReturnToPLN\nnbp.js]
J --> K[passesFilters\nconfig.yaml]
K --> L[dedup: 1 ETF per grupa\ngroup_key]
L --> M[saveRanking → tabela ranking]
M --> N([UI odświeża się])
flowchart TD
A([Trigger]) --> B[fetch_brokers.py]
B --> C[Pobierz stronę XTB\nxtb.com/pl/specyfikacja-instrumentow]
B --> D[Pobierz stronę BOSSA\nbossa.pl/oferta/rynek-zagraniczny/kid]
B --> E[Pobierz stronę mBank\nmdm.pl/bm/etf]
B --> F[Pobierz stronę PKO\nhttps://www.bm.pkobp.pl/oferta/rynki-zagraniczne]
C --> CC[Znajdź link do PDF\nBS4 + regex fallback]
D --> DD[Znajdź link do PDF\nBS4 + regex fallback]
E --> EE[Znajdź link do PDF\nBS4 + regex fallback]
F --> FF[Znajdź link do PDF\nBS4 + regex fallback]
CC --> G[Pobierz PDF\nextract_text layout mode]
DD --> G
EE --> G
G --> H[Wyciągnij ISINy\nregex ISIN_RE]
H --> I[Dodaj polskie ETF-y\nsource=atlasetf zawsze dostępne]
I --> J[saveBrokerIsins\nDELETE + INSERT\ntabela broker_isins]
J --> K[computeRanking\nz uwzględnieniem defaultBrokers]
sequenceDiagram
participant UI
participant API as server.js
participant DB as SQLite
participant Broker as broker_isins
UI->>API: GET /api/ranking
API->>DB: getRanking()
DB-->>API: rows[]
API->>Broker: getAllBrokerIsins()
Broker-->>API: {xtb: Set, bossa: Set, mbank: Set, pko: Set}
API->>API: enrich() + addBrokers()
API-->>UI: {data: [...], meta: {...}}
UI->>UI: renderTable()
.
├── config.yaml # Główna konfiguracja (wagi, filtry, brokerzy)
├── groups.yaml # Reguły grupowania ETF-ów
├── docker-compose.yml # Definicja usługi Docker
├── Dockerfile # Budowa obrazu (Node + Python + Playwright)
├── package.json # Zależności Node.js
│
├── public/
│ └── index.html # Cały frontend (HTML + CSS + JS w jednym pliku)
│
└── src/
├── server.js # Express API — endpointy REST
├── scheduler.js # Cron + orkiestracja aktualizacji
├── scraper.js # Uruchamianie skryptów Python, zapis do bazy
├── ranking.js # Obliczanie MS, filtrowanie, dedupcja grup
├── database.js # Dostęp do SQLite (wszystkie funkcje DB)
├── nbp.js # Kursy walut z API NBP, konwersja na PLN
│
├── fetch_etfs.py # Scraper JustETF (5000+ ETF-ów)
├── fetch_pl_etfs.py # Scraper polskich ETF-ów (GPW+AtlasETF+yfinance)
├── fetch_brokers.py # Scraper dostępności u brokerów (PDF-y)
│
├── probe_etf.py # Narzędzie diagnostyczne: dane jednego ETF
├── probe_overview.py # Narzędzie diagnostyczne: kolumny JustETF
└── probe_strat.py # Narzędzie diagnostyczne: strategie JustETF
Jedyny plik który regularnie edytujesz. Zmiany działają od razu — plik jest czytany przy każdym żądaniu API, bez restartu kontenera.
Reguły regex do przypisywania ETF-ów do grup dedupcji. Edytowalny bez restartu. Szczegóły w sekcji Grupowanie ETF-ów.
Serwer Express. Definiuje wszystkie endpointy /api/*. Modyfikuj gdy:
- dodajesz nowy endpoint API
- zmieniasz format odpowiedzi
- dodajesz nową zakładkę w UI wymagającą danych z backendu
Zarządza harmonogramem i stanem uruchomień. Modyfikuj gdy:
- zmieniasz częstotliwość aktualizacji (zmień
CRON_SCHEDULEwdocker-compose.yml) - dodajesz nowy rodzaj aktualizacji (analogicznie do
runBrokerUpdate)
Uruchamia skrypty Python jako podprocesy, parsuje NDJSON ze stdout, zapisuje do bazy. Modyfikuj gdy:
- dodajesz nowy scraper Python
- chcesz zmienić timeout skryptu
Serce logiki rankingowej. Modyfikuj gdy:
- zmieniasz wzór na Momentum Score
- dodajesz nowy filtr
- zmieniasz logikę dedupcji grup
Wszystkie operacje SQLite. Modyfikuj gdy:
- dodajesz nową tabelę (dodaj do
initSchema()i migracji wrunMigrations()) - dodajesz nową kolumnę do istniejącej tabeli (dodaj
ALTER TABLEdorunMigrations()) - dodajesz nową funkcję dostępu do danych
Pobiera historyczne kursy walut z api.nbp.pl. Obsługuje EUR, USD, GBP, CHF. Modyfikuj gdy:
- dodajesz nową walutę (dodaj do
CURRENCIES) - zmieniasz horyzont czasowy kursów
Pobiera dane z JustETF przez bibliotekę justetf-scraping. Modyfikuj gdy:
- biblioteka zmieni format danych (sprawdź
probe_overview.py) - chcesz dodać nowe pole z JustETF
Pobiera polskie ETF-y w trzech krokach:
- GPW XLS (lista ISIN/ticker) — własny parser BIFF8 przez
olefile - AtlasETF (metadane) — Playwright/Chromium
- yfinance (dane historyczne, suffix
.WA)
Modyfikuj gdy:
- AtlasETF zmieni układ strony → patrz sekcja Zmiana układu AtlasETF
- GPW zmieni format XLS → patrz parser BIFF8 w funkcji
parse_gpw_xls() - yfinance przestanie działać → zamień
enrich_from_yf()na inne źródło
Pobiera listy ISINów dostępnych u brokerów z ich stron internetowych (PDF). Modyfikuj gdy:
- dodajesz nowego brokera → patrz sekcja Dodanie nowego brokera
- broker zmieni stronę/URL → zaktualizuj stałe
XTB_DOCS_URL/BOSSA_KID_URLi słowa kluczowe
Cały frontend w jednym pliku. Modyfikuj gdy:
- dodajesz nową kolumnę w tabeli → zaktualizuj nagłówek
<th>irenderCols() - zmieniasz wygląd badge'ów
- dodajesz nowy filtr w toolbarze
ranking:
weights:
r1m: 0.10 # Waga zwrotu 1M
r3m: 0.20 # Waga zwrotu 3M
r6m: 0.45 # Waga zwrotu 6M
r12m: 0.00 # Waga zwrotu 12M (zazwyczaj 0 gdy używasz r12m_skip1m)
r12m_skip1m: 0.25 # Zwrot 12M z pominięciem ostatniego miesiąca
mdd12m: 0.00 # Max drawdown (ujemny, naturalnie karze)
filters:
defaultMinVolatility: "" # Min vol roczna (%)
defaultMaxVolatility: "" # Max vol roczna (%)
defaultMinAumMillions: "" # Min AuM (mln EUR)
defaultMaxAumMillions: "" # Max AuM (mln EUR)
defaultMinTer: "" # Min TER (%)
defaultMaxTer: "1.25" # Max TER (%) — domyślnie odcinamy drogie ETF-y
defaultMinMS: "2.25" # Min raw MS (%) — odcina słabe momentum
defaultMaxMS: ""
defaultMinAdjMS: "" # Min MS/Vol (MS znormalizowany)
defaultMaxAdjMS: ""
defaultMinR12M: "" # Min zwrot 12M w PLN (%)
defaultMaxR12M: ""
defaultMinMDD12M: "" # Min MDD 12M (%) — np. -25 odcina MDD < -25%
defaultMaxMDD12M: ""
defaultStrategies: [] # Wyklucz strategie: [short-leveraged]
defaultDividends: [] # Wyklucz politykę: [Dist]
defaultCountries: [] # Wyklucz kraje rejestracji
defaultBrokers: [] # Pokaż tylko dostępne u brokerów: [xtb, bossa, mbank, pko]
excludeIsins: [] # Twarde wykluczenia ISINów
scraper:
maxEtfs: 5000 # Limit ETF-ów z JustETF (pełna baza ~5500)| Zmienna | Domyślna | Opis |
|---|---|---|
CRON_SCHEDULE |
0 7 * * * |
Harmonogram codziennej aktualizacji ETF-ów (UTC) |
CRON_BROKERS |
0 6 1 * * |
Harmonogram aktualizacji brokerów (1. dzień miesiąca) |
PORT |
3000 |
Port Express wewnątrz kontenera |
DATA_DIR |
/app/data |
Katalog z bazą SQLite |
Ważona średnia zwrotów w PLN:
MS_raw = Σ(wᵢ · Rᵢ) / Σwᵢ
gdzie aktywne wagi to te z wartością > 0, a zwroty są przeliczone na PLN:
R_PLN = (1 + R_EUR) × (kurs_EUR_teraz / kurs_EUR_N_miesięcy_temu) - 1
Zwrot 12-miesięczny z pominięciem ostatniego miesiąca (klasyczna technika momentum eliminująca krótkoterminowy reversal):
R(12M-1M) = (1 + R12M) / (1 + R1M) - 1
Jest to wyrażenie nieliniowe — nie równoważne żadnej kombinacji liniowej R12M i R1M.
MS znormalizowany przez zmienność — pozwala porównywać ETF-y o różnej volatility:
MS_adj = MS_raw / Vol_roczna
Ranking domyślnie sortuje po MS_adj.
Plik: src/ranking.js, funkcja computeScores().
Przykład — dodanie nowego okresu (np. 2M):
- Dodaj
perf_2mdo tabelietfswdatabase.js(schema + migracja) - Pobierz dane w scraperze
- Dodaj
r2m = convertReturnToPLN(etf.perf_2m, fx, '2m')wcomputeScores() - Dodaj
{ key: 'r2m', r: r2m, w: weights.r2m ?? 0 }docandidates - Dodaj
r2m: 0.00doconfig.yaml
ETF-y z tej samej kategorii (np. różni emitenci tego samego indeksu) są grupowane — w rankingu pokazuje się tylko najlepszy reprezentant grupy. Reszta jest dostępna po kliknięciu +N.
rules:
- pattern: 'S&P 500' # Regex (case-insensitive) na oczyszczoną nazwę ETF
group: 'equity: s&p500' # Klucz grupy
types: Equity # Opcjonalne: filtruj po asset_class (string lub lista)
sources: justetf # Opcjonalne: filtruj po źródle danych (string lub lista)
- pattern: 'WIG'
group: 'region: poland'
types: [Akcje, Equity]
sources: atlasetfJak działa clean_name(): przed dopasowaniem regex usuwa prefix dostawcy (iShares, Amundi, Xtrackers…) i suffix UCITS ETF. Regex pisze się więc na oczyszczoną nazwę, np. 'S&P 500' zamiast 'iShares Core S&P 500 UCITS ETF USD (Acc)'.
Fallback: jeśli żadna reguła nie pasuje, group_key = asset_class:cleaned_name (każdy ETF tworzy własną grupę).
docker exec justetf-momentum python3 /app/src/probe_etf.py "S&P 500"Biblioteka justetf-scraping pobiera pełny przegląd ETF-ów z justetf.com. Dane obejmują: performance (EUR), vol, TER, AuM, asset class, region, domicile, strategię.
Diagnostyka:
docker exec justetf-momentum python3 /app/src/probe_overview.py # struktura danych
docker exec justetf-momentum python3 /app/src/probe_etf.py ISIN # dane jednego ETFPliki XLS ze strony gpw.pl zawierają ticker, ISIN i walutę polskich ETF/ETC/ETN. Format to stary BIFF8 (OLE2, .xls) — parser własny przez olefile + struct, ponieważ standardowe biblioteki (xlrd, openpyxl) nie obsługują tego formatu prawidłowo.
URL-e: https://www.gpw.pl/etfy?download_xls=1/2/3
Strona renderowana przez React — wymagany Playwright/Chromium. Pobiera: nazwę, TER, AuM (PLN), replikację, dywidendy, strategię, hedging, domicile, asset class, region, kategoria.
Nazwa ETF pobierana z tytułu strony (<title>), format: Nazwa ETF | ISIN.
Historyczne ceny polskich ETF-ów z Yahoo Finance, ticker w formacie ETFBM40TR.WA (suffix .WA). Wymaga odblokowania fc.yahoo.com jeśli używasz pfBlockerNG lub pi-hole.
Obliczane: perf_1m/3m/6m/12m, mdd12m, volatility (w PLN).
api.nbp.pl — historyczne kursy EUR, USD, GBP, CHF z ostatnich 255 dni sesyjnych.
PDF parsowany przez pypdf z extraction_mode="layout" (zapobiega sklejaniu wyrazów).
Gdy defaultBrokers: [xtb] w config.yaml, ranking pokazuje tylko ETF-y dostępne u XTB. Polskie ETF-y (source=atlasetf) są zawsze dostępne u obu brokerów — nie wymagają sprawdzania w PDF.
Dane brokerów są przechowywane w tabeli broker_isins i aktualizowane osobno (przycisk "Brokerzy" lub cron 1. dnia miesiąca).
Funkcja extract_atlas_page() w fetch_pl_etfs.py używa get_labeled(label) — szuka elementu z dokładnym tekstem etykiety, potem bierze następny element rodzeństwo lub ostatnie dziecko rodzica.
Jeśli strona się zmieni:
- Sprawdź nową strukturę HTML w DevTools
- Zaktualizuj etykiety w wywołaniach
get_any('Nowa etykieta', 'Stara etykieta') - Jeśli zmienił się layout (nie sibling, lecz inne relacje), zaktualizuj
get_labeled()
Testowanie:
docker exec justetf-momentum python3 -c "
import sys; sys.path.insert(0,'/app/src')
from fetch_pl_etfs import scrape_atlas_batch
import json
print(json.dumps(scrape_atlas_batch(['PLBETF400025']), indent=2, ensure_ascii=False))
"-
src/fetch_brokers.py— dodaj stałe URL i słowa kluczowe:MÓJBROKER_URL = 'https://mójbroker.pl/strona-z-dokumentami' MÓJBROKER_KEYWORDS = ['Lista ETF', 'instrumenty dostępne'] def fetch_mójbroker_isins() -> set[str]: html = fetch_url(MÓJBROKER_URL) pdf_url = find_pdf_href(html, MÓJBROKER_KEYWORDS, MÓJBROKER_URL) pdf_data = fetch_url(pdf_url, binary=True) return extract_isins_from_pdf(pdf_data)
-
src/fetch_brokers.py— dodaj wywołanie wmain():mójbroker_isins = fetch_mójbroker_isins() | pl_isins for isin in sorted(mójbroker_isins): print(json.dumps({'broker': 'mójbroker', 'isin': isin}))
-
src/scraper.js— dodaj'mójbroker'doexpectedBrokers -
public/index.html— dodaj wBROKER_LABELSi CSS:const BROKER_LABELS = { xtb: 'xtb', bossa: 'bossa', mójbroker: 'MÓJ' };
.broker-mójbroker { background: rgba(0,100,200,.12); color: #0064c8; border: 1px solid rgba(0,100,200,.3) }
-
config.yaml— możesz teraz używaćdefaultBrokers: [mójbroker]
Przykład: dodanie n_holdings (liczba spółek w indeksie) do tabeli rankingu.
-
src/database.js— dodaj doinitSchema()w tabelirankingi dorunMigrations():'ALTER TABLE ranking ADD COLUMN n_holdings INTEGER'Dodaj do
RANKING_DEFAULTS:n_holdings: null -
src/ranking.js— przekaż pole wallScored.push({...}):n_holdings: etf.n_holdings ?? null,
-
src/server.js— dodaj do obiektu zwracanego przez/api/group/:key:n_holdings: etf.n_holdings,
-
public/index.html— dodaj<th>w nagłówkach wszystkich tabel i<td>wrenderCols():<td class="r">${r.n_holdings ?? '<span class="nd">—</span>'}</td>
Przykład: filtr defaultMinNHoldings (min liczba spółek).
-
config.yaml— dodaj parametr:defaultMinNHoldings: ""
-
src/ranking.js— dodaj wpassesFilters():const minNHoldings = num(f.defaultMinNHoldings); if (minNHoldings != null && (etf.n_holdings == null || etf.n_holdings < minNHoldings)) return false;
W docker-compose.yml:
environment:
- CRON_SCHEDULE=0 7 * * * # codziennie o 07:00 UTC
- CRON_BROKERS=0 6 1 * * # 1. dnia miesiąca o 06:00 UTCFormat: min godz dzień miesiąc dzień_tygodnia. Generator: https://crontab.guru
# Pełna aktualizacja (ETF-y + ranking)
curl -X POST http://localhost/api/refresh
# Tylko brokerzy
curl -X POST http://localhost/api/refresh-brokers
# Tylko przelicz ranking (bez pobierania danych)
docker exec justetf-momentum node -e "
const {computeRanking}=require('./src/ranking');
const yaml=require('js-yaml');
const fs=require('fs');
const config=yaml.load(fs.readFileSync('/app/config.yaml','utf8'));
console.log(computeRanking(config),'ETFów');
"# Sprawdź zawartość bazy
docker exec justetf-momentum sqlite3 /app/data/momentum.db \
"SELECT source, count(*) FROM etfs GROUP BY source"
# ETF-y z atlasetf z danymi historycznymi
docker exec justetf-momentum sqlite3 /app/data/momentum.db \
"SELECT isin, name, perf_1m, perf_12m, volatility FROM etfs WHERE source='atlasetf' LIMIT 10"
# Stan brokerów
docker exec justetf-momentum sqlite3 /app/data/momentum.db \
"SELECT broker, isin_count, last_run_at, status FROM broker_updates"
# Ostatnie joby
docker exec justetf-momentum sqlite3 /app/data/momentum.db \
"SELECT * FROM job_log ORDER BY id DESC LIMIT 5"| Metoda | Endpoint | Opis |
|---|---|---|
| GET | /api/ranking |
Pełny ranking z brokerami |
| GET | /api/status |
Status aktualizacji, czas ostatniego runu |
| POST | /api/refresh |
Uruchom pełną aktualizację |
| POST | /api/refresh-brokers |
Aktualizuj dane brokerów |
| GET | /api/brokers |
Status i liczba ISINów per broker |
| GET | /api/group/:key |
Wszystkie ETF-y z grupy (po group_key) |
| GET | /api/owned |
Lista posiadanych ETF-ów |
| POST | /api/owned/:isin |
Oznacz ETF jako posiadany |
| DELETE | /api/owned/:isin |
Usuń z posiadanych |
| GET | /api/ignored |
Lista ignorowanych ETF-ów |
| POST | /api/ignore/:isin |
Ignoruj ETF |
| DELETE | /api/ignore/:isin |
Przywróć z ignorowanych |
| POST | /api/group/:key/rep |
Ustaw reprezentanta grupy |
| DELETE | /api/group/:key/rep |
Zresetuj reprezentanta grupy |
| GET | /api/config |
Aktualna konfiguracja |
| GET | /api/history |
Historia uruchomień (ostatnie 20) |