Uczenie ze wzmocnieniem na giełdzie i w krypto — kompletny kurs
Od pierwszego agenta ε-zachłannego w czystym Pythonie, przez własne środowisko Gymnasium i PPO na Twoim Macu M3, po walk-forward, koszty transakcyjne i szkielet systemu produkcyjnego. Każdy moduł ma teorię, działający kod z komentarzami, komendy do terminala i interaktywne symulacje.
Zanim zaczniesz: czym to jest, a czym nie jest
Cel: uczciwie skalibrować oczekiwania, zrozumieć dlaczego RL w tradingu jest trudny, i zobaczyć mapę całego kursu.
Uczciwa rozmowa na start
Reinforcement learning (RL) to gałąź uczenia maszynowego, w której agent uczy się podejmować sekwencyjne decyzje przez interakcję ze środowiskiem, maksymalizując skumulowaną nagrodę. Trading wygląda jak podręcznikowy przypadek: stan (rynek), akcje (kup/sprzedaj/czekaj), nagroda (zysk). I rzeczywiście — fundusze kwantowe używają RL, głównie do egzekucji zleceń i market makingu.
Ale zanim zainwestujesz 12 tygodni, musisz znać trzy fakty:
- Rynki są niestacjonarne. Gra, której nauczył się agent, zmienia zasady w trakcie. Model wytrenowany na hossie 2021 był bezużyteczny w 2022. To fundamentalna różnica względem Atari czy szachów, gdzie reguły są stałe.
- Sygnał jest słaby, szum ogromny. Stosunek sygnału do szumu w zwrotach dziennych jest bliski zera. RL, który potrzebuje milionów prób, bardzo łatwo „uczy się" szumu — czyli overfittuje.
- Backtest kłamie domyślnie. Większość spektakularnych wyników RL w tradingu z internetu to artefakty: lookahead bias, brak kosztów transakcyjnych, dobór okresu. Połowa tego kursu to obrona przed samooszukiwaniem się.
Traktuj ten kurs jako naukę inżynierii i metodologii badawczej, a nie przepis na pieniądze. Handluj wyłącznie na paper tradingu / testnecie, dopóki nie przejdziesz modułów 7–8 i nie zrozumiesz, dlaczego Twój backtest prawie na pewno przeszacowuje wynik. Nigdy nie ryzykuj pieniędzy, których utrata by Cię zabolała.
Dlaczego mimo to warto
- Nauczysz się całego stosu quanta: dane → cechy → model → backtest → ewaluacja → egzekucja. To umiejętności przenośne do każdej pracy z ML na szeregach czasowych.
- RL wymusza myślenie o decyzjach, nie prognozach — uwzględnia koszty, pozycję i ryzyko w samej funkcji celu, czego klasyczna predykcja nie robi.
- Masz idealny sprzęt: M3 ze 128 GB unified memory spokojnie wystarcza na wszystko w tym kursie — RL w finansach to małe sieci i dużo iteracji, nie LLM-y.
Mapa kursu — jak iterujemy
Kurs jest zbudowany wokół czterech coraz dojrzalszych wersji tego samego projektu:
| Iteracja | Co budujesz | Moduły | Czego się uczysz |
|---|---|---|---|
| v0 — zabawka | Bandyta i Q-learning na gridworldzie, czysty Python + NumPy | 1–2 | Mechanika RL od zera: wartości, eksploracja, Bellman |
| v1 — prototyp | Własny env Gymnasium na prawdziwych danych, tabularny agent | 3–4 | Dane, struktura środowiska, pierwszy „trading" agent |
| v2 — deep RL | DQN/PPO ze Stable-Baselines3, sensowne cechy i nagroda | 5–6 | Sieci neuronowe jako polityki, projekt nagrody |
| v3 — rzetelny system | Walk-forward, koszty, metryki, paper trading, monitoring | 7–12 | Wszystko, co odróżnia zabawkę od systemu |
Wymagania wstępne
- Python na poziomie: funkcje, klasy, NumPy. Pandas poznasz w trakcie.
- Matematyka: średnia, wariancja, pochodna — intuicyjnie. Wszystko poza tym wyprowadzimy.
- Terminal: podstawy zsh/bash. Każdą komendę podaję jawnie.
Jeśli chcesz od razu przygotować maszynę, skocz do modułu 11 (setup Mac M3) — możesz go zrobić w dowolnym momencie. Minimalny start na teraz:
_# Menedżer pakietów Homebrew (jeśli nie masz) + uv: szybki menedżer środowisk Pythona /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" brew install uv _# Katalog projektu i środowisko z Pythonem 3.11 (najlepsza kompatybilność z bibliotekami RL) mkdir -p ~/dev/rl-trading && cd ~/dev/rl-trading uv venv --python 3.11 source .venv/bin/activate _# Pakiety na moduły 1-2: tylko NumPy i matplotlib - fundamenty budujemy bez frameworków uv pip install numpy matplotlib
Fundamenty RL: agent, środowisko, nagroda
Cel: zrozumieć formalizm MDP i dylemat eksploracja–eksploatacja tak dobrze, że sam napiszesz agenta ε-zachłannego z pamięci.
Pętla, na której stoi wszystko
Każdy system RL — od bandyty po AlphaGo i agenta tradingowego — to ta sama pętla. W kroku t agent obserwuje stan st, wybiera akcję at według swojej polityki π(a|s), a środowisko zwraca nagrodę rt+1 i nowy stan st+1. Cel: maksymalizować sumę zdyskontowanych nagród, zwaną zwrotem (return):
Współczynnik dyskonta γ mówi, jak bardzo agent dba o przyszłość. γ=0 to agent-jętka (liczy się tylko następna nagroda), γ→1 to agent-strateg. W tradingu γ ma piękną interpretację: to Twój horyzont inwestycyjny. Scalper ma niskie efektywne γ, inwestor pozycyjny wysokie.
W języku tradingu:
| Pojęcie RL | Symbol | W tradingu |
|---|---|---|
| Stan | st | Okno cen/zwrotów, wskaźniki, aktualna pozycja, niezrealizowany PnL |
| Akcja | at | Dyskretna: long/flat/short. Ciągła: docelowa wielkość pozycji ∈ [−1, 1] |
| Nagroda | rt | Zmiana wartości portfela minus koszty (to tylko punkt wyjścia — moduł 6) |
| Polityka | π(a|s) | Twoja strategia: mapowanie sytuacji rynkowej na decyzję |
| Epizod | — | Przebieg przez okno historyczne, np. rok danych godzinowych |
MDP: założenie, które rynek łamie
Formalnie pętla to proces decyzyjny Markowa (MDP): przyszłość zależy tylko od bieżącego stanu i akcji, nie od całej historii. Rynek tego założenia nie spełnia — cena zamknięcia nie zawiera pełnej informacji o świecie. Praktyczna odpowiedź: budujemy stan tak, by był „wystarczająco markowski" — dokładamy do niego okno historii, wskaźniki, zmienność, pozycję. To jest właśnie inżynieria cech (moduł 6). Zapamiętaj: jakość agenta ogranicza jakość stanu, który mu pokazujesz.
Wartość: ile „jest warta" sytuacja
Kluczowa idea RL to funkcja wartości. Q(s, a) odpowiada na pytanie: „jaki łączny zwrot mogę oczekiwać, jeśli w stanie s wykonam akcję a i dalej będę grał optymalnie?". Jeśli znasz prawdziwe Q, optymalna polityka jest trywialna: wybieraj akcję o największym Q. Cała trudność RL to estymacja Q z doświadczenia. Spina to równanie Bellmana:
Czytaj: wartość akcji = oczekiwana natychmiastowa nagroda + zdyskontowana wartość najlepszej akcji w następnym stanie. To definicja rekurencyjna — i prawie każdy algorytm w tym kursie (Q-learning, DQN) to jakiś sposób iteracyjnego „dokręcania" estymat do tej równości.
Eksploracja kontra eksploatacja: wielorękI bandyta
Zanim dojdzie do stanów i przejść, wyizolujmy najczystszy dylemat RL. Masz 4 „automaty" (strategie) o nieznanych średnich wypłatach. Każda próba kosztuje. Grasz tym, który wydaje się najlepszy (eksploatacja), czy sprawdzasz inne (eksploracja)? Klasyczne rozwiązanie: ε-zachłanność — z prawdopodobieństwem ε wybierz losowo, w przeciwnym razie najlepszy według bieżących estymat. Poniżej możesz to poczuć na własnej myszce.
Żal (regret) = ile straciliśmy względem grania od początku najlepszym ramieniem. Zauważ: przy ε=0 agent często „zakleszcza się" na złym ramieniu — krzywa żalu rośnie liniowo. Przy dużym ε żal też rośnie liniowo, bo agent wiecznie hazarduje. Słodki punkt jest pośrodku (albo: ε malejące w czasie).
Ten sam bandyta w kodzie
To dokładnie ta logika, którą właśnie oglądałeś — 40 linii czystego Pythona. Przepisz ją ręcznie (serio, nie kopiuj) — to najlepszy test zrozumienia.
import numpy as np
rng = np.random.default_rng(seed=42) # generator z ziarnem = powtarzalne wyniki
_# --- Środowisko: 4 ramiona o nieznanych agentowi średnich wypłatach ---
true_means = rng.normal(0.0, 1.0, size=4) # "prawda", której agent nie widzi
def pull(arm: int) -> float:
"""Pociągnięcie ramienia: wypłata = prawdziwa średnia + szum N(0,1).
W tradingu odpowiednik: wynik jednej transakcji danej strategii."""
return true_means[arm] + rng.normal(0.0, 1.0)
_# --- Agent epsilon-zachłanny ---
n_arms = 4
Q = np.zeros(n_arms) # bieżące estymaty wartości każdego ramienia
N = np.zeros(n_arms) # licznik pociągnięć każdego ramienia
epsilon = 0.1 # 10% kroków to eksploracja
n_steps = 2000
for t in range(n_steps):
if rng.random() < epsilon:
arm = rng.integers(n_arms) # EKSPLORACJA: losowe ramię
else:
arm = int(np.argmax(Q)) # EKSPLOATACJA: najlepsze wg estymat
reward = pull(arm)
N[arm] += 1
# Przyrostowa średnia: Q += (1/N) * (cel - Q).
# To szkielet KAŻDEJ reguły uczenia w tym kursie:
# nowa_estymata = stara + krok * (obserwacja - stara)
Q[arm] += (reward - Q[arm]) / N[arm]
print("Prawdziwe średnie:", np.round(true_means, 2))
print("Estymaty Q: ", np.round(Q, 2))
print("Pociągnięcia: ", N.astype(int)) # najlepsze ramię ~85-90% prób
_# Uruchomienie (w aktywowanym środowisku z modułu 0) python 01_bandit.py
estymata += krok · (cel − estymata). W bandycie cel = nagroda. W Q-learningu cel = r + γ·max Q(s′). W DQN cel liczy sieć docelowa. Gdy zrozumiesz tę jedną linijkę, zrozumiesz 80% RL.
Co musisz umieć po tym module
- Narysować pętlę agent–środowisko i podpisać s, a, r, π, γ.
- Wyjaśnić równanie Bellmana własnymi słowami.
- Napisać z pamięci bandytę ε-zachłannego i wyjaśnić, czemu żal przy ε=0 bywa liniowy.
- Powiedzieć, które założenie MDP rynek łamie i co z tym robimy.
Q-learning w praktyce: od tabeli do polityki
Cel: zaimplementować pełny Q-learning, zobaczyć jak polityka „wyrasta" z tabeli wartości, i poznać hiperparametry, które później wrócą w deep RL.
Od bandyty do sekwencji decyzji
Bandyta nie ma stanów — każda decyzja jest niezależna. Trading (i życie) tak nie działa: dzisiejsza decyzja zmienia jutrzejszą sytuację. Q-learning rozszerza naszą regułę przyrostową na sekwencje. Po każdym przejściu (s, a, r, s′) aktualizujemy:
Wyraz w nawiasie to błąd TD (temporal difference): różnica między tym, co się właśnie okazało (r + zdyskontowana wartość następnego stanu), a tym, co sądziliśmy. α to szybkość uczenia. Q-learning jest off-policy: uczy się polityki optymalnej, nawet gdy zbiera dane polityką eksplorującą — ta własność wróci przy DQN i replay bufferze.
Gridworld: obejrzyj, jak rodzi się polityka
Zanim dotkniemy rynku, potrenujmy na świecie, w którym znamy optymalne rozwiązanie — inaczej nie odróżnisz „algorytm działa" od „mam szczęście". Agent (▲) startuje z lewego dolnego rogu, cel daje +10, wpadnięcie w „strefę strat" −10, każdy krok kosztuje −0.1 (jak prowizja!). Strzałki pokazują najlepszą akcję według bieżącej tabeli Q, intensywność tła — wartość stanu.
Obserwuj kolejność uczenia: wartości „rozlewają się" od celu wstecz — to równanie Bellmana w akcji. ε maleje wykładniczo: wczesne epizody to chaos (eksploracja), późne to niemal deterministyczna trasa. Kara za krok sprawia, że agent uczy się drogi najkrótszej, nie tylko bezpiecznej.
Pełna implementacja z komentarzami
import numpy as np
rng = np.random.default_rng(0)
_# --- Środowisko: siatka 8x6, cel i pułapka ---
W, H = 8, 6
START = (0, 5) # (kolumna, wiersz) - lewy dolny róg
GOAL = (7, 0) # +10 i koniec epizodu
TRAP = (4, 2) # -10 i koniec epizodu
STEP_COST = -0.1 # kara za każdy krok (analog prowizji)
ACTIONS = [(0,-1), (0,1), (-1,0), (1,0)] # góra, dół, lewo, prawo
def step(state, action):
"""Wykonaj akcję; wyjście poza planszę = zostań w miejscu."""
x = min(max(state[0] + ACTIONS[action][0], 0), W - 1)
y = min(max(state[1] + ACTIONS[action][1], 0), H - 1)
s2 = (x, y)
if s2 == GOAL: return s2, 10.0, True
if s2 == TRAP: return s2, -10.0, True
return s2, STEP_COST, False
_# --- Hiperparametry (te same nazwy zobaczysz w SB3 przy PPO/DQN) ---
ALPHA = 0.1 # szybkość uczenia (learning_rate)
GAMMA = 0.97 # dyskonto: 0.97^30 ~ 0.4, więc horyzont ~30 kroków
EPS_START = 1.0 # zaczynamy w 100% losowo...
EPS_END = 0.05 # ...kończymy prawie zachłannie
EPS_DECAY = 0.995 # mnożnik po każdym epizodzie
N_EPISODES = 1500
Q = np.zeros((W, H, len(ACTIONS))) # tabela: stan (x,y) -> wartości 4 akcji
eps, returns = EPS_START, []
for ep in range(N_EPISODES):
s, done, ep_return = START, False, 0.0
while not done:
# wybór akcji: epsilon-zachłannie względem bieżącej tabeli
if rng.random() < eps:
a = rng.integers(len(ACTIONS))
else:
a = int(np.argmax(Q[s[0], s[1]]))
s2, r, done = step(s, a)
ep_return += r
# cel TD: nagroda + zdyskontowane maksimum w nowym stanie;
# dla stanu końcowego przyszłość = 0 (nie ma "dalej")
target = r if done else r + GAMMA * np.max(Q[s2[0], s2[1]])
Q[s[0], s[1], a] += ALPHA * (target - Q[s[0], s[1], a])
s = s2
returns.append(ep_return)
eps = max(EPS_END, eps * EPS_DECAY) # wygaszanie eksploracji
print(f"Śr. zwrot, ostatnie 100 epizodów: {np.mean(returns[-100:]):.2f}")
_# Optimum: 12 kroków * (-0.1) + 10 = +8.8. Jeśli masz ~8.5+, działa.
Hiperparametry — intuicje, które przenoszą się na deep RL
| Parametr | Za mały | Za duży | W tradingu |
|---|---|---|---|
| α (learning rate) | Uczenie trwa wieki | Estymaty skaczą, nie zbiegają | Rynkowy szum wymaga małego α — pojedyncza transakcja to prawie zero informacji |
| γ (dyskonto) | Agent myopiczny, ignoruje trendy | Wariancja celu rośnie, uczenie niestabilne | Dobierz do horyzontu: intraday ~0.9, swing ~0.99 |
| ε / entropia | Zakleszczenie na złej strategii | Agent nigdy nie „dojrzewa" | Eksploracja na rynku = realne straty; dlatego trenujemy na symulacji, nie live |
Tabela Q wymaga skończonej, małej liczby stanów. Stan rynkowy to wektor liczb rzeczywistych — dyskretyzacja grubą siatką gubi informację, a drobną eksploduje wykładniczo (przekleństwo wymiarowości). Rozwiązanie: zastąpić tabelę siecią neuronową aproksymującą Q(s,a) — to jest dokładnie DQN, moduł 5. Ale najpierw: dane i środowisko.
Ćwiczenia (zrób zanim pójdziesz dalej)
- Zmień
STEP_COSTna 0 i na −1. Jak zmienia się trasa? To pierwsza lekcja projektowania nagrody. - Ustaw
GAMMA=0.5. Dlaczego agent przestaje znajdować cel z odległych pól? - Dodaj „wiatr": z prawdopodobieństwem 20% akcja przesuwa agenta losowo. Zobacz, że Q-learning nadal zbiega — radzi sobie ze stochastycznością. Rynek to wiatr 95%.
Dane rynkowe: skąd brać, jak czyścić, jak przechowywać
Cel: zbudować własny, powtarzalny pipeline danych — bo model jest wart dokładnie tyle, ile dane, na których go trenujesz.
Skąd brać dane — mapa źródeł
| Źródło | Rynek | Koszt | Granularność | Uwagi |
|---|---|---|---|---|
Binance API / Vision (przez ccxt) | krypto | darmowe | 1m OHLCV, trades, order book | Najlepszy darmowy zbiór do nauki: pełna historia od 2017, bez limitów przy pobieraniu paczek z data.binance.vision |
| yfinance | akcje, ETF, FX, indeksy | darmowe | 1d pełna historia; 1m tylko ~30 dni | Nieoficjalne API Yahoo — dobre do prototypów, nie do produkcji (zmienia się bez ostrzeżenia) |
| Stooq | akcje US/PL, indeksy | darmowe | 1d | Prosty CSV przez URL, dobre dane GPW |
| Alpaca | akcje US, krypto | darmowe + płatne | 1m | Darmowe dane + darmowy paper trading — najprostsza ścieżka do live na akcjach US |
| Polygon.io / Databento / Finnhub | akcje US | free tier + płatne | tick / 1m | Jakość produkcyjna; free tiery ograniczone (Polygon: akcje ~1 rok wstecz), do nauki wystarczą |
| Kraken / Bybit / OKX API | krypto | darmowe | 1m OHLCV, funding | Drugie źródło do weryfikacji krzyżowej danych z Binance |
| CryptoDataDownload, Kaggle | krypto, akcje | darmowe | różna | Gotowe CSV — wygodne, ale zawsze weryfikuj próbkę względem giełdy |
Ucz się na krypto z Binance przez ccxt: dane minutowe za darmo, rynek 24/7 (brak luk nocnych i dywidend do obsługi), te same API do paper- i live-tradingu. Akcje przez yfinance traktuj jako drugi zbiór do sprawdzenia, czy strategia nie działa „tylko na BTC".
Pobieranie: kompletny skrypt do OHLCV
_# Pakiety do warstwy danych uv pip install ccxt pandas pyarrow yfinance
"""Pobiera pełną historię świec z Binance i zapisuje do Parquet.
Uruchamiaj wielokrotnie - dociąga tylko nowe dane (idempotentny)."""
import ccxt, pandas as pd, time
from pathlib import Path
SYMBOL = "BTC/USDT"
TIMEFRAME = "1h" # zacznij od 1h: 8 lat = ~70k świec, ideał do nauki
OUT = Path(f"data/{SYMBOL.replace('/','')}_{TIMEFRAME}.parquet")
OUT.parent.mkdir(exist_ok=True)
ex = ccxt.binance() # publiczne endpointy - klucz API niepotrzebny
_# Start: od ostatniej zapisanej świecy albo od początku historii
if OUT.exists():
since = int(pd.read_parquet(OUT).index[-1].timestamp() * 1000) + 1
else:
since = ex.parse8601("2017-09-01T00:00:00Z")
rows = []
while True:
# Binance zwraca max 1000 świec na zapytanie - stronicujemy
batch = ex.fetch_ohlcv(SYMBOL, TIMEFRAME, since=since, limit=1000)
if not batch:
break
rows += batch
since = batch[-1][0] + 1 # następna strona: po ostatnim timestampie
time.sleep(ex.rateLimit / 1000) # szanuj limity API (ccxt zna właściwe opóźnienie)
print(f"\r{len(rows):>8} świec, do {pd.to_datetime(batch[-1][0], unit='ms')}", end="")
if rows:
df = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close", "volume"])
df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
df = df.set_index("ts")
if OUT.exists(): # dołącz do istniejącego pliku, bez duplikatów
df = pd.concat([pd.read_parquet(OUT), df])
df = df[~df.index.duplicated(keep="last")].sort_index()
df.to_parquet(OUT) # Parquet: 10x mniejszy i szybszy niż CSV
print(f"\nZapisano {len(df)} świec do {OUT}")
python 03_download.py
_# 70000+ świec, do 2026-07-06 ...
_# Zapisano 77000 świec do data/BTCUSDT_1h.parquet
_# Akcje dla porównania - jedna linijka:
python -c "import yfinance; yfinance.download('SPY', start='2000-01-01').to_parquet('data/SPY_1d.parquet')"
Walidacja: nie ufaj żadnym danym
Zanim cokolwiek wytrenujesz, przepuść dane przez kontrolę jakości. Typowe wady: luki czasowe, świece zerowe, splity/dywidendy (akcje), flash-crashe, duplikaty.
import pandas as pd
df = pd.read_parquet("data/BTCUSDT_1h.parquet")
_# 1. Luki: różnice między kolejnymi timestampami powinny być stałe (1h)
gaps = df.index.to_series().diff().value_counts()
print("Rozkład odstępów:\n", gaps.head()) # wszystko != 1h to luka (awaria giełdy?)
_# 2. Sanity check OHLC: high >= max(open,close), low <= min(open,close)
bad = ((df.high < df[["open","close"]].max(axis=1)) |
(df.low > df[["open","close"]].min(axis=1))).sum()
print("Świec z niespójnym OHLC:", bad) # powinno być 0
_# 3. Ekstremalne zwroty: >20% w 1h to prawie na pewno artefakt (albo krach - sprawdź datę!)
ret = df.close.pct_change()
print("Zwroty |r| > 20%:\n", ret[ret.abs() > 0.20])
_# 4. Zerowy wolumen = martwy rynek albo błąd danych
print("Świec z volume==0:", (df.volume == 0).sum())
Przy akcjach zawsze używaj cen skorygowanych o splity i dywidendy (yfinance: auto_adjust=True, domyślne). Trening na cenach nieskorygowanych nauczy agenta „grać pod splity", które w danych wyglądają jak −90% krachy. To klasyczny, cichy błąd.
Ceny → zwroty: dlaczego i jak
Sieci neuronowe (i tabele Q) źle znoszą wejścia niestacjonarne. Cena BTC to 3 000 USD w 2018 i 100 000 USD w 2025 — model uczony na surowych cenach nie generalizuje, bo „nowe" ceny leżą poza rozkładem treningowym. Standard: zwroty logarytmiczne:
- Addytywność: zwrot za tydzień = suma zwrotów dziennych (procenty się nie sumują, logi tak) — wygodne przy nagrodach.
- Quasi-stacjonarność: rozkład zwrotów jest znacznie stabilniejszy w czasie niż poziom cen (choć zmienność też dryfuje — stąd normalizacja zmiennością w module 6).
- Symetria: +10% i −10% logarytmicznie mają tę samą wielkość, procentowo nie.
import numpy as np, pandas as pd
df = pd.read_parquet("data/BTCUSDT_1h.parquet")
df["log_ret"] = np.log(df.close / df.close.shift(1))
print(df.log_ret.describe()) # mean ~1e-5, std ~0.008 dla BTC 1h
_# Kurtoza >> 3: grube ogony. Rynek to NIE rozkład normalny - zapamiętaj do modułu 8.
print("kurtoza:", df.log_ret.kurtosis())
Struktura projektu i podział czasowy
Od teraz trzymamy żelazną dyscyplinę podziału danych. Podział musi być chronologiczny — losowy podział (jak w klasycznym ML) przecieka informację z przyszłości przez autokorelację zmienności.
import pandas as pd
df = pd.read_parquet("data/BTCUSDT_1h.parquet")
_# Chronologicznie: trening -> walidacja (dobór hiperparametrów) -> test (dotykany RAZ)
train = df.loc[:"2023-12-31"] # ~6 lat: nauka
val = df.loc["2024-01-01":"2024-12-31"] # 1 rok: porównywanie modeli i strojenie
test = df.loc["2025-01-01":] # holdout: finalna ocena, JEDEN raz na końcu
for name, part in [("train", train), ("val", val), ("test", test)]:
part.to_parquet(f"data/{name}.parquet")
print(f"{name:5}: {len(part):>6} świec {part.index[0].date()} - {part.index[-1].date()}")
Każde spojrzenie na wynik z testu i „poprawka" modelu po nim zamienia test w drugą walidację — wynik przestaje być wiarygodny. Profesjonalna praktyka: test otwierasz raz, na samym końcu projektu, i raportujesz co wyszło — nawet jeśli wyszło źle. W module 7 zastąpimy ten prosty podział walk-forwardem.
Co musisz mieć po tym module
- Katalog
data/z BTC/USDT 1h (pełna historia) i SPY 1d w Parquet. - Skrypt walidacji przechodzący bez czerwonych flag (albo z flagami, które rozumiesz).
- Podział train/val/test i obietnicę, że testu nie dotkniesz do modułu 8.
Środowisko tradingowe: własny Gymnasium env
Cel: zbudować od zera środowisko zgodne z API Gymnasium — serce całego projektu, w którym zaszyta jest cała „fizyka" rynku: pozycje, koszty, poślizg.
Dlaczego środowisko jest ważniejsze niż algorytm
W RL finansowym 90% błędów siedzi w środowisku, nie w algorytmie. Jeśli env pozwala agentowi kupić po cenie, którą zna z przyszłości, albo nie nalicza prowizji — agent znajdzie tę dziurę i ją wyeksploatuje, bo od tego jest. Wynik: piękna krzywa w backteście, strata na żywo. Dlatego budujemy env sami, linia po linii, zamiast brać czarną skrzynkę.
Standard branżowy to API Gymnasium (następca OpenAI Gym): środowisko implementuje reset() i step(action), dzięki czemu każdy algorytm (Twój własny, Stable-Baselines3, cokolwiek) może z nim rozmawiać.
uv pip install gymnasium
Decyzje projektowe (v1)
- Obserwacja: okno ostatnich 32 zwrotów logarytmicznych + bieżąca pozycja. Celowo minimalistycznie — cechy dołożymy w module 6.
- Akcje: dyskretne {0: flat, 1: long, 2: short}. Docelowa pozycja, nie „kup/sprzedaj" — prostsza księgowość, brak dźwigni.
- Egzekucja: decyzja na zamknięciu świecy t, wykonanie po cenie otwarcia t+1. To jedna linijka, która eliminuje najczęstszy lookahead bias.
- Koszty: prowizja + poślizg naliczane od każdej zmiany pozycji.
- Nagroda: zwrot logarytmiczny pozycji netto po kosztach (punkt wyjścia — alternatywy w module 6).
"""Minimalne, ale UCZCIWE środowisko tradingowe (Gymnasium API).
Uczciwe = decyzja w t, egzekucja w t+1, koszty od zmiany pozycji."""
import numpy as np
import gymnasium as gym
from gymnasium import spaces
class TradingEnv(gym.Env):
def __init__(self, prices: np.ndarray, window: int = 32,
fee: float = 0.0004, slippage: float = 0.0002):
"""prices: wektor cen close; fee: 0.04% (taker Binance z BNB);
slippage: 0.02% - konserwatywny dodatek za poślizg na płynnym rynku."""
super().__init__()
self.prices = prices.astype(np.float64)
self.log_ret = np.diff(np.log(self.prices)) # log_ret[t] = zwrot od t do t+1
self.window = window
self.cost = fee + slippage # łączny koszt zmiany pozycji o 1
# Przestrzeń akcji: docelowa pozycja {flat, long, short}
self.action_space = spaces.Discrete(3)
# Obserwacja: `window` ostatnich zwrotów + aktualna pozycja = window+1 liczb
self.observation_space = spaces.Box(-np.inf, np.inf,
shape=(window + 1,), dtype=np.float32)
def _obs(self):
"""Stan: zwroty z okna [t-window, t) oraz pozycja. Skalowanie x100:
zwroty 1h to ~0.008 - bez skalowania sieć widzi 'prawie zera'."""
r = self.log_ret[self.t - self.window : self.t] * 100.0
return np.append(r, self.position).astype(np.float32)
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self.t = self.window # start po nagromadzeniu pełnego okna
self.position = 0 # -1 short, 0 flat, +1 long
self.equity = 0.0 # skumulowany log-zwrot strategii
return self._obs(), {}
def step(self, action: int):
target = {0: 0, 1: 1, 2: -1}[int(action)]
# KOLEJNOŚĆ MA ZNACZENIE (anty-lookahead):
# 1) płacimy koszt zmiany pozycji (wykonanej na otwarciu t+1),
# 2) NOWA pozycja zarabia zwrot świecy t -> t+1.
trade_cost = abs(target - self.position) * self.cost
self.position = target
reward = self.position * self.log_ret[self.t] - trade_cost
self.equity += reward
self.t += 1
terminated = self.t >= len(self.log_ret) # koniec danych
info = {"equity": self.equity, "position": self.position}
return (self._obs() if not terminated else
np.zeros(self.observation_space.shape, np.float32),
float(reward), terminated, False, info)
Test środowiska — zawsze, zanim cokolwiek wytrenujesz
Środowisko testujemy jak każdy inny kod. Trzy obowiązkowe sanity checki:
import numpy as np, pandas as pd
from trading_env import TradingEnv
prices = pd.read_parquet("data/train.parquet").close.to_numpy()
env = TradingEnv(prices)
_# 1. Zgodność z API Gymnasium (kształty, typy, zakresy)
from gymnasium.utils.env_checker import check_env
check_env(env) # brak wyjątku = OK
_# 2. Agent losowy MUSI tracić ~koszty transakcyjne (nie zarabiać!)
_# Jeśli losowe akcje zarabiają, środowisko przecieka przyszłość.
obs, _ = env.reset()
total, done = 0.0, False
while not done:
obs, r, done, _, info = env.step(env.action_space.sample())
total += r
print(f"Agent losowy, log-equity: {total:.3f}") # oczekiwane: wyraźnie ujemne
_# 3. Buy&hold w env == zwrot z danych (księgowość się zgadza)
obs, _ = env.reset()
total, done = 0.0, False
while not done:
obs, r, done, _, _ = env.step(1) # zawsze long
total += r
expected = np.log(prices[-1] / prices[env.window]) - env.cost
print(f"B&H w env: {total:.4f} vs z cen: {expected:.4f}") # różnica < 1e-9
Zapamiętaj tę heurystykę na zawsze: jeśli losowa polityka systematycznie zarabia w Twoim środowisku, środowisko jest zepsute. Losowość nie ma przewagi informacyjnej — może co najwyżej płacić koszty.
Zobacz problem na własne oczy
Poniższa symulacja pokazuje, czemu trading jest trudniejszy niż gridworld. Ten sam Q-learning co w module 2, stan = znak ostatnich zwrotów (prymitywna dyskretyzacja). Na cenie z trendem agent szybko odkrywa sensowną politykę. Na błądzeniu losowym — nie ma czego się nauczyć, a koszty robią swoje.
Przełącz reżim na „błądzenie losowe" i zauważ: krzywa agenta dryfuje w dół mimo treningu — to koszty transakcyjne zjadają szum, który agent bierze za sygnał. Lekcja: RL nie wyczaruje sygnału, którego nie ma w danych.
Ćwiczenia
- Dodaj do
infolicznik transakcji i wypisuj go po epizodzie — będzie potrzebny w module 8. - Zepsuj celowo środowisko: użyj
self.log_ret[self.t]w obserwacji (przeciek przyszłego zwrotu!). Zobacz, że agent losowy dalej traci, ale zachłanny względem obserwacji nagle „zarabia". Tak wygląda lookahead od środka. - Zaimplementuj wariant z akcją ciągłą (
spaces.Box(-1, 1)): pozycja ułamkowa. Przyda się przy SAC w module 5.
Deep RL: DQN, PPO i SAC na Twoim M3
Cel: zrozumieć, jak sieci neuronowe zastępują tabelę Q, poznać trzy rodziny algorytmów i wytrenować pierwszego agenta PPO na prawdziwych danych.
Od tabeli do sieci: DQN
Pomysł jest prosty: zamiast tabeli Q(s,a) — sieć neuronowa Qθ(s), która dla stanu zwraca wektor wartości wszystkich akcji. Uczymy ją minimalizując kwadrat błędu TD. Ale naiwne połączenie „sieć + Q-learning" jest niestabilne; DQN (DeepMind, 2015) dodał dwa stabilizatory, które musisz rozumieć:
- Replay buffer: przejścia (s, a, r, s′) trafiają do bufora, a uczymy na losowych minibatchach z niego. Łamie to korelację kolejnych próbek (sąsiednie świece są silnie skorelowane!) i wielokrotnie używa drogich danych. Możliwe dzięki temu, że Q-learning jest off-policy.
- Sieć docelowa (target network): cel TD liczymy zamrożoną kopią sieci, aktualizowaną co N kroków. Bez tego cel „ucieka" razem z siecią i uczenie się rozbiega.
Trzy rodziny — którą wybrać
| Algorytm | Rodzina | Akcje | Mocne strony | W tradingu |
|---|---|---|---|---|
| DQN (+Double, Dueling) | value-based, off-policy | dyskretne | Efektywność próbkowa (replay), prostota pojęciowa | Dobre wejście: long/flat/short; wrażliwy na szum i hiperparametry |
| PPO | policy gradient (actor-critic), on-policy | dyskretne i ciągłe | Stabilny, odporny, mało strojenia — koń roboczy całej branży | Domyślny wybór tego kursu; entropia = wbudowana eksploracja |
| SAC | actor-critic, off-policy | ciągłe | Maks. entropii = mocna eksploracja, efektywny próbkowo | Najlepszy do ciągłego sizingu pozycji [−1, 1] |
Intuicja różnicy: value-based (DQN) uczy się „ile warta jest każda akcja" i wybiera najlepszą; policy gradient (PPO) bezpośrednio poprawia rozkład π(a|s), zwiększając prawdopodobieństwo akcji, które wypadły lepiej niż oczekiwał krytyk (funkcja przewagi A(s,a)). PPO dodatkowo przycina (clip) wielkość zmiany polityki w jednym kroku — stąd „Proximal" — co czyni go wyjątkowo stabilnym na zaszumionych nagrodach. Dokładnie dlatego jest domyślnym wyborem w finansach.
Setup: Stable-Baselines3 na Apple Silicon
_# PyTorch (z natywnym wsparciem Metal/MPS) + Stable-Baselines3 + TensorBoard
uv pip install torch stable-baselines3 tensorboard
_# Szybki test, czy PyTorch widzi GPU Twojego M3:
python -c "import torch; print('MPS dostępne:', torch.backends.mps.is_available())"
_# MPS dostępne: True
Dla małych sieci MLP (a takich używamy) trening RL bywa szybszy na CPU niż na GPU — wąskim gardłem jest pętla środowiska i tysiące małych forward-passów, a narzut transferu do GPU przewyższa zysk. Reguła praktyczna: MLP do ~3 warstw × 256 neuronów → device="cpu"; sieci CNN/LSTM/transformer albo duże batche → device="mps". Zmierz oba — na M3 masz dużo szybkich rdzeni CPU.
Pierwszy agent PPO na prawdziwych danych
"""Trening PPO na BTC/USDT 1h. Walidacja na osobnym roku danych."""
import numpy as np, pandas as pd
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
from stable_baselines3.common.monitor import Monitor
from trading_env import TradingEnv
train_p = pd.read_parquet("data/train.parquet").close.to_numpy()
val_p = pd.read_parquet("data/val.parquet").close.to_numpy()
_# VecNormalize: krocząca standaryzacja obserwacji i nagród.
_# Dla RL na rynkach to często różnica między "uczy się" a "nie uczy się".
env = DummyVecEnv([lambda: Monitor(TradingEnv(train_p))])
env = VecNormalize(env, norm_obs=True, norm_reward=True, clip_obs=10.0)
model = PPO(
"MlpPolicy", env,
learning_rate = 3e-4, # standard; przy niestabilności zejdź do 1e-4
n_steps = 2048, # długość rolloutu przed każdą aktualizacją
batch_size = 256,
gamma = 0.99, # horyzont ~100 świec = ~4 dni na danych 1h
ent_coef = 0.01, # premia za entropię: nie pozwala polityce
# zapaść się przedwcześnie w jedną akcję
policy_kwargs = dict(net_arch=[128, 128]), # mały MLP wystarczy
device = "cpu", # patrz ramka: MLP -> CPU szybsze na M3
tensorboard_log = "runs/",
seed = 42,
verbose = 1,
)
model.learn(total_timesteps=500_000) # ~10-20 min na M3
model.save("models/ppo_btc_v1")
env.save("models/vecnorm_v1.pkl") # statystyki normalizacji - KONIECZNE do ewaluacji
_# --- Ewaluacja na walidacji (dane, których model nie widział) ---
def run(model, prices, vecnorm):
env = DummyVecEnv([lambda: TradingEnv(prices)])
env = VecNormalize.load(vecnorm, env)
env.training, env.norm_reward = False, False # zamrażamy statystyki!
obs, done, eq = env.reset(), False, []
while not done:
act, _ = model.predict(obs, deterministic=True)
obs, r, dones, infos = env.step(act)
done = dones[0]; eq.append(infos[0]["equity"])
return np.array(eq)
eq = run(model, val_p, "models/vecnorm_v1.pkl")
bh = np.log(val_p[-1] / val_p[32])
print(f"Agent (walidacja): {eq[-1]:+.3f} log-ret | B&H: {bh:+.3f}")
python 06_train_ppo.py _# W drugim terminalu - podgląd krzywych uczenia na żywo: tensorboard --logdir runs/ _# otwórz http://localhost:6006 i patrz na rollout/ep_rew_mean
Jak czytać krzywe uczenia RL
rollout/ep_rew_mean— średnia nagroda epizodu. Powinna rosnąć, ale na danych rynkowych będzie bardzo szarpana. Patrz na trend z 50+ punktów.train/entropy_loss— entropia polityki. Zbyt szybki spadek do zera = przedwczesna pewność siebie (podnieśent_coef). Brak spadku = agent nie zbiega.train/approx_kl— wielkość zmian polityki. Skoki = niestabilność (zmniejsz learning rate).- Najważniejsze: krzywa treningowa w górę przy płaskiej/spadającej walidacji = overfitting. W RL na rynkach to stan domyślny, nie wyjątek.
Ten sam kod, te same dane, inne seed — i wynik potrafi się różnić o kilkadziesiąt punktów procentowych. Nigdy nie wyciągaj wniosków z jednego przebiegu. Standard: trenuj min. 5 ziaren i raportuj medianę + rozrzut. To nie pedanteria — pojedynczy „udany" seed to najczęstszy sposób, w jaki ludzie oszukują samych siebie (i czytelników na GitHubie).
SAC dla ciągłego sizingu — szkic
_# Wymaga wariantu env z akcją ciągłą (ćwiczenie z modułu 4):
_# action_space = spaces.Box(-1.0, 1.0, shape=(1,)) # docelowa pozycja ułamkowa
from stable_baselines3 import SAC
model = SAC(
"MlpPolicy", env,
learning_rate = 3e-4,
buffer_size = 200_000, # replay buffer (off-policy!)
batch_size = 256,
tau = 0.005, # miękka aktualizacja sieci docelowej
ent_coef = "auto", # SAC sam stroi entropię - jego supermoc
device = "cpu",
seed = 42,
)
model.learn(total_timesteps=300_000)
_# Uwaga: SAC z akcją ciągłą + koszty transakcyjne = agent uczy się też
_# NIE handlować zbyt często, bo drobne korekty pozycji kosztują. To pożądane.
Ćwiczenia
- Wytrenuj PPO na 5 ziarnach (pętla po
seed). Zapisz wyniki walidacyjne wszystkich — zobacz rozrzut na własne oczy. - Porównaj
device="cpu"vs"mps"(czas 100k kroków). Zanotuj wynik — to Twoja lokalna prawda o M3. - Zamień PPO na DQN (
from stable_baselines3 import DQN). Porównaj stabilność krzywych uczenia z PPO.
Inżynieria cech i projektowanie nagrody
Cel: nauczyć się dwóch dźwigni, które realnie decydują o wyniku — co agent widzi (stan) i za co jest chwalony (nagroda).
Stan: co agent powinien widzieć
Deep RL teoretycznie „sam wyciągnie cechy" — na rynkach to mit. Przy stosunku sygnału do szumu bliskim zera dobre cechy to połowa sukcesu. Zasady:
- Stacjonarność przede wszystkim: nigdy surowe ceny; zwroty, relacje (cena/średnia − 1), rangi percentylowe.
- Normalizacja zmiennością: zwrot ÷ krocząca zmienność. Ten sam ruch 1% znaczy co innego przy VIX 12 i 40.
- Wielo-skalowość: cechy z okien 24h, 7d, 30d — rynek ma wiele rytmów.
- Pozycja i PnL w stanie: agent musi wiedzieć, co trzyma — inaczej decyzja o wyjściu jest niemożliwa do nauczenia.
- Mniej znaczy więcej: każda cecha to wymiar do przeszukania i okazja do overfittingu. Zaczynaj od 10–20, nie 200.
"""Zestaw cech v2 - stacjonarnych i znormalizowanych zmiennością.
Wszystkie liczone WYŁĄCZNIE z danych dostępnych w chwili t (rolling, nigdy center)."""
import numpy as np, pandas as pd
def make_features(df: pd.DataFrame) -> pd.DataFrame:
f = pd.DataFrame(index=df.index)
ret = np.log(df.close / df.close.shift(1))
# Zmienność krocząca (EWMA ~1 tydzień na danych 1h) - mianownik normalizacji
vol = ret.ewm(span=168).std()
# 1) Zwroty znormalizowane zmiennością, kilka horyzontów
for h in (1, 6, 24, 72):
f[f"zret_{h}"] = (np.log(df.close / df.close.shift(h))
/ (vol * np.sqrt(h))).clip(-5, 5)
# 2) Pozycja ceny względem średnich (trend) - relacja, nie poziom
for w in (24, 168):
f[f"trend_{w}"] = (df.close / df.close.rolling(w).mean() - 1) / (vol * np.sqrt(w))
# 3) Reżim zmienności: bieżąca vs długoterminowa (log-stosunek)
f["vol_regime"] = np.log(vol / vol.rolling(720).mean())
# 4) RSI przeskalowane do [-1, 1] (klasyk, ale znormalizowany)
delta = df.close.diff()
up = delta.clip(lower=0).ewm(span=14).mean()
dn = (-delta.clip(upper=0)).ewm(span=14).mean()
f["rsi"] = (up / (up + dn + 1e-12) - 0.5) * 2
# 5) Względny wolumen (aktywność rynku)
f["rvol"] = np.log(df.volume / df.volume.rolling(168).mean() + 1e-12).clip(-3, 3)
# 6) Rytm dobowy - krypto ma wyraźną sezonowość śróddzienną
hour = df.index.hour
f["hod_sin"] = np.sin(2 * np.pi * hour / 24)
f["hod_cos"] = np.cos(2 * np.pi * hour / 24)
return f.dropna() # początek serii nie ma pełnych okien - odcinamy
Każda cecha musi być policzalna o godz. t z danych ≤ t. Zakazane: rolling(..., center=True), normalizacja statystykami z całego zbioru (fit scalera na train+test!), wskaźniki „repaintujące" (ZigZag, niektóre pivoty), łączenie danych o różnym opóźnieniu publikacji. Scaler fitujesz na train, aplikujesz na resztę — albo używasz statystyk kroczących jak wyżej.
Nagroda: najważniejsza decyzja projektowa w całym systemie
Agent maksymalizuje dokładnie to, co mu każesz — łącznie z lukami w Twojej specyfikacji. Ten efekt (reward hacking) w tradingu objawia się np. tak: nagroda za niezrealizowany PnL → agent trzyma stratne pozycje w nieskończoność; brak kary za obrót → agent handluje co świecę.
| Funkcja nagrody | Definicja | Zalety | Ryzyka |
|---|---|---|---|
| Log-zwrot netto (nasza v1) | pozycja · rt − koszty | Prosta, gęsta, addytywna | Ignoruje ryzyko: agent może lubić wariancję |
| Zwrot − λ·ryzyko | j.w. − λ·(pozycja·rt)² | Kara za zmienność wyników; λ to Twój apetyt na ryzyko | Dobór λ wymaga eksperymentów (start: sweep 0.05–0.5) |
| Differential Sharpe Ratio | przyrostowa aktualizacja Sharpe'a (Moody & Saffell, 1998) | Bezpośrednio optymalizuje risk-adjusted return | Bardziej złożona; wrażliwa na parametr zapominania |
| Kara za drawdown | zwrot − β·max(0, przyrost DD) | Agent unika głębokich obsunięć | Rzadki, opóźniony sygnał — trudniejsze uczenie |
_# Wariant A: kara za wariancję (risk-aversion) - jedna linijka w step(): pnl = self.position * self.log_ret[self.t] reward = pnl - trade_cost - self.risk_lambda * pnl**2 _# Wariant B: dodatkowa kara za obrót (uczy cierpliwości): reward = pnl - trade_cost - self.turnover_penalty * abs(target - prev_position) _# Wariant C: differential Sharpe ratio (Moody & Saffell) - stan A,B w env: _# A_t = A + eta*(r - A); B_t = B + eta*(r^2 - B) _# DSR ~ (B*(r - A) - 0.5*A*(r^2 - B)) / (B - A^2)**1.5 eta = 0.01 dA, dB = r - self.A, r**2 - self.B denom = max((self.B - self.A**2) ** 1.5, 1e-9) reward = (self.B * dA - 0.5 * self.A * dB) / denom self.A += eta * dA; self.B += eta * dB
Zmieniaj jedną rzecz naraz i porównuj na walidacji tą samą metryką zewnętrzną (np. Sharpe po kosztach z modułu 8) — nie wartością nagrody, bo różne nagrody mają różne skale. Prowadź dziennik eksperymentów: data, commit, config, seed’y, wynik. Bez tego po dwóch tygodniach nie będziesz wiedział, co już próbowałeś.
Overfitting: zobacz go, zanim Cię ograbi
Poniżej najważniejsza symulacja kursu. Dopasowujemy model o rosnącej złożoności do zaszumionej serii „zwrotów". Błąd na treningu spada monotonicznie — na teście spada, a potem rośnie. RL z milionami kroków na tych samych danych to maszyna do przejeżdżania tego minimum.
Przesuwaj złożoność. Zielona linia to prawdziwy (prosty!) sygnał, punkty to sygnał + szum. Model o dużej złożoności „wyjaśnia" szum treningowy i przegrywa na świeżych danych. W RL odpowiednikami suwaka są: rozmiar sieci, liczba cech, liczba kroków treningu i… liczba pomysłów, które przetestowałeś na tej samej walidacji.
Ćwiczenia
- Wepnij
features.pydo środowiska (obserwacja = wektor cech + pozycja) i porównaj z gołym oknem zwrotów na walidacji, 5 ziaren. - Przetestuj λ ∈ {0, 0.1, 0.3} w nagrodzie z karą za wariancję. Jak zmienia się liczba transakcji i drawdown?
- W symulacji overfittingu ustaw złożoność 15 i klikaj „Nowa próbka szumu" — zobacz, jak wynik testowy skacze. Tak samo skaczą backtesty przeuczonych agentów.
Backtesting: jak nie okłamać samego siebie
Cel: poznać wszystkie kanały, którymi przyszłość przecieka do backtestu, i zbudować walk-forward — jedyny uczciwy protokół oceny na szeregach czasowych.
Katalog przecieków i błędów
| Pułapka | Mechanizm | Obrona |
|---|---|---|
| Lookahead bias | Decyzja korzysta z danych z przyszłości: egzekucja po close świecy, którą dopiero „widzimy", wskaźnik z center=True, scaler fitowany na całości | Egzekucja na open t+1; audyt każdej cechy; test agenta losowego (moduł 4) |
| Survivorship bias | Trening tylko na spółkach/coinach, które przetrwały — LUNA, FTT i tysiące akcji znikły z indeksów | Dane point-in-time; w krypto trenuj też na okresach delistingów |
| Ignorowanie kosztów | Prowizja + spread + poślizg + (krypto) funding. Strategia 1h robiąca 500 transakcji/rok: 0.06% kosztu na trade = −3 p.p. rocznie | Koszty w środowisku od pierwszego dnia; testuj wrażliwość na 2× koszty |
| Data snooping / p-hacking | Testujesz 100 wariantów na tej samej walidacji — najlepszy jest najlepszy przypadkiem | Licz wykonane testy; deflated Sharpe (moduł 8); świeży holdout na koniec |
| Dobór okresu | Backtest 2020–2021 na BTC „udowodni" każdą strategię long-only | Testuj przez pełny cykl: hossa, krach, flauta (dla BTC: min. 2017–2025) |
| Przeżycie jednego seeda | Raportujesz najlepszy z 20 przebiegów RL | Mediana + IQR z ≥5 ziaren, zawsze |
Walk-forward: standard branżowy
Pojedynczy podział train/val/test ma wadę: oceniasz model na jednym okresie, a rynek ma reżimy. Walidacja walk-forward naśladuje rzeczywistą eksploatację: trenuj na oknie przeszłości, testuj na kolejnym kawałku przyszłości, przesuń okno, powtórz. Wynik = sklejka wyników wyłącznie z okien testowych. Dodatkowo między treningiem a testem zostawiamy przerwę (embargo) — sąsiednie świece są skorelowane (zmienność!), więc bez przerwy informacja przecieka przez samą bliskość czasową. To samo rozumowanie prowadzi do purged k-fold CV z książki Lópeza de Prado, a w wersji najmocniejszej — do CPCV (Combinatorial Purged Cross-Validation): zamiast jednego przebiegu przez historię testujesz model na wielu kombinacjach bloków czasowych, więc każdy fragment danych trafia do testu wielokrotnie. Sam López de Prado przestrzega, że pojedynczy walk-forward też da się przeoptymalizować (to wciąż jedna ścieżka przez historię) — dlatego traktuj go jako minimum, a CPCV jako poziom docelowy.
Każdy pasek to jeden fold: model trenowany od zera (albo dostrajany) na niebieskim, oceniany na zielonym. Sklejone zielone segmenty tworzą krzywą out-of-sample — jedyną, którą wolno Ci pokazać komukolwiek. Rozjazd wyników między foldami mówi więcej niż średnia: to miara wrażliwości na reżim.
Implementacja walk-forward dla agenta RL
"""Walk-forward dla PPO: trening od zera w każdym foldzie, wynik = sklejka OOS."""
import numpy as np, pandas as pd
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
from trading_env import TradingEnv
df = pd.read_parquet("data/BTCUSDT_1h.parquet")
prices = df.close.to_numpy()
N = len(prices)
TEST_LEN = 24 * 90 # okno testowe: 90 dni świec 1h
EMBARGO = 24 * 7 # 7 dni przerwy między train a test
MIN_TRAIN = 24 * 365 * 2 # minimum 2 lata treningu (okno rosnące)
SEEDS = [0, 1, 2, 3, 4]
oos_curves = [] # krzywe out-of-sample z każdego folda
fold, start_test = 0, MIN_TRAIN + EMBARGO
while start_test + TEST_LEN <= N:
tr = prices[: start_test - EMBARGO] # trening: wszystko do embarga
te = prices[start_test : start_test + TEST_LEN]
fold_eq = []
for seed in SEEDS: # >=5 ziaren - patrz moduł 5
env = DummyVecEnv([lambda: TradingEnv(tr)])
env = VecNormalize(env, clip_obs=10.0)
m = PPO("MlpPolicy", env, seed=seed, device="cpu", verbose=0,
policy_kwargs=dict(net_arch=[128, 128]))
m.learn(total_timesteps=300_000)
# ewaluacja na oknie testowym z zamrożoną normalizacją
tenv = DummyVecEnv([lambda: TradingEnv(te)])
tenv = VecNormalize(tenv, training=False, norm_reward=False)
tenv.obs_rms = env.obs_rms # statystyki WYŁĄCZNIE z treningu
obs, done, eq = tenv.reset(), False, 0.0
while not done:
a, _ = m.predict(obs, deterministic=True)
obs, r, d, infos = tenv.step(a)
done = d[0]; eq = infos[0]["equity"]
fold_eq.append(eq)
med = float(np.median(fold_eq))
print(f"fold {fold}: mediana OOS = {med:+.4f} (rozrzut {np.ptp(fold_eq):.4f})")
oos_curves.append(med)
start_test += TEST_LEN # przesuwamy okno o długość testu
fold += 1
print(f"\nŁączny OOS log-return: {sum(oos_curves):+.4f} w {fold} foldach")
print("Foldy dodatnie:", sum(c > 0 for c in oos_curves), "/", fold)
6 foldów × 5 ziaren × 300k kroków = 9M kroków treningu. Na M3 to rząd wielkości kilku–kilkunastu godzin na CPU. Praktyki: puszczaj na noc (caffeinate -i python 08_walkforward.py — nie pozwala Macowi zasnąć), zrównolegl foldy przez multiprocessing (M3 ma dużo rdzeni, a 128 GB RAM pomieści wiele środowisk naraz), zapisuj wyniki do CSV po każdym foldzie.
Test wrażliwości: strategia musi przeżyć stress
_# Uczciwa strategia nie umiera od drobnej zmiany założeń. Przetestuj macierz:
for fee_mult in (1.0, 1.5, 2.0): # a jeśli koszty są 2x wyższe?
for delay in (0, 1): # a jeśli egzekucja spóźnia się o świecę?
for slip in (0.0002, 0.0005): # a przy gorszym poślizgu?
run_backtest(fee=BASE_FEE * fee_mult, exec_delay=delay, slippage=slip)
_# Jeśli zysk znika przy fee_mult=1.5 - nie masz strategii, masz artefakt kosztowy.
Checklist przed uwierzeniem we własny backtest
- Agent losowy w env traci ~koszty? ✓
- Egzekucja po open t+1, cechy bez przyszłości, scaler z train? ✓
- Koszty: prowizja + poślizg (+ funding dla perpetuali)? ✓
- Walk-forward min. 5 foldów, w tym co najmniej jeden kryzysowy okres? ✓
- ≥5 ziaren, raportujesz medianę? ✓
- Wynik przeżywa 2× koszty i 1 świecę opóźnienia? ✓
- Policzyłeś, ile konfiguracji przetestowałeś (do deflated Sharpe)? ✓
Ewaluacja: metryki, benchmarki i DCA
Cel: mierzyć strategię jak profesjonalista — ryzykiem, nie tylko zwrotem — i zawsze porównywać z tanimi benchmarkami, które trzeba najpierw pokonać.
Metryki, które musisz liczyć
| Metryka | Co mierzy | Interpretacja praktyczna |
|---|---|---|
| CAGR | Średnioroczny zwrot składany | Bez kontekstu ryzyka — nigdy solo |
| Sharpe | Zwrot na jednostkę zmienności | <0.5 słabo · 0.5–1 OK · 1–2 dobrze · >2 w backteście = podejrzane |
| Sortino | Jak Sharpe, ale karze tylko zmienność w dół | Lepszy przy asymetrycznych strategiach |
| Max drawdown | Największe obsunięcie od szczytu | Czy przeżyjesz psychicznie i kapitałowo? −50% wymaga +100%, by wyjść |
| Calmar | CAGR / |MaxDD| | Zwrot na jednostkę bólu; >1 to solidnie |
| Obrót (turnover) | Suma |zmian pozycji| na rok | Główny mnożnik kosztów; porównuj strategie przy tym samym obrocie |
| Deflated Sharpe (Bailey & López de Prado) | Sharpe po korekcie na liczbę prób i nienormalność zwrotów | Antidotum na p-hacking: przy 100 testowanych wariantach najlepszy Sharpe 1.5 może być statystycznie niczym |
"""Komplet metryk z prostych log-zwrotów strategii. K = okresów w roku."""
import numpy as np
def metrics(log_rets: np.ndarray, K: int = 8760) -> dict:
eq = np.exp(np.cumsum(log_rets)) # krzywa kapitału (start = 1.0)
years = len(log_rets) / K
cagr = eq[-1] ** (1 / years) - 1
mu, sd = log_rets.mean(), log_rets.std(ddof=1)
sharpe = mu / (sd + 1e-12) * np.sqrt(K)
downside = log_rets[log_rets < 0].std(ddof=1) # odchylenie tylko strat
sortino = mu / (downside + 1e-12) * np.sqrt(K)
peak = np.maximum.accumulate(eq) # dotychczasowy szczyt
dd = eq / peak - 1 # krzywa "podwodna"
mdd = dd.min()
return dict(CAGR=cagr, Sharpe=sharpe, Sortino=sortino,
MaxDD=mdd, Calmar=cagr / abs(mdd + 1e-12),
n_periods=len(log_rets))
Drawdown: poznaj swojego wroga
Drawdown to metryka, którą czujesz. Strategia z CAGR 30% i drawdownem 60% jest w praktyce nieużywalna — porzucisz ją (albo zbankrutujesz na dźwigni) na dnie obsunięcia, tuż przed odbiciem. Poeksploruj krzywą kapitału poniżej: najedź myszką, zobacz głębokość i długość obsunięć (czas pod wodą bywa gorszy niż głębokość).
Suwak zmienności pokazuje brutalną prawdę: przy tym samym średnim zwrocie wyższa zmienność = głębsze doły i niższy kapitał końcowy (koszt wariancji: exp(μ−σ²/2)). To dlatego profesjonaliści optymalizują Sharpe'a, nie zwrot.
Benchmarki: tani przeciwnik, którego musisz pobić
Wynik agenta RL bez punktu odniesienia jest bez znaczenia. Zanim powiesz „agent zarobił 40%", odpowiedz: a ile zarobiłby idiota? Trzy obowiązkowe benchmarki:
- Buy & hold — kup pierwszego dnia, nic nie rób. Zero kosztów, zero pracy. Na rynkach z długim dryfem w górę (akcje US, BTC historycznie) to zaskakująco mocny przeciwnik.
- DCA (dollar-cost averaging) — kupuj za stałą kwotę co interwał (np. co tydzień), niezależnie od ceny. Uśrednia cenę wejścia, redukuje ryzyko złego timingu i wariancję wyniku względem wejścia jednorazowego. To benchmark „rozsądnego pasywnego inwestora" — i strategia, którą realnie warto stosować prywatnie, kiedy Twoje modele jeszcze nie są gotowe.
- Momentum SMA — long, gdy cena nad średnią 200-okresową, flat pod nią. Reprezentuje „najprostszą aktywną strategię z sensem". Jeśli RL z tygodniami treningu nie bije 3 linijek kodu — po co Ci RL?
"""Trzy benchmarki na tych samych danych i kosztach co agent. Zawsze."""
import numpy as np, pandas as pd
from metrics import metrics
df = pd.read_parquet("data/test.parquet") # dopiero teraz, RAZ (moduł 3!)
ret = np.log(df.close / df.close.shift(1)).dropna().to_numpy()
COST = 0.0006 # te same założenia co w env
_# 1) Buy & hold: pełna ekspozycja od pierwszej świecy
bh = ret.copy(); bh[0] -= COST
_# 2) DCA: co tydzień (168h) dokupujemy 1/N docelowej pozycji.
_# Ekspozycja rośnie schodkowo 0 -> 1, więc wczesne zwroty ważą mniej.
N_TRANCHES = 52
expo = np.minimum(np.arange(len(ret)) // 168 + 1, N_TRANCHES) / N_TRANCHES
dca = expo * ret
dca[::168][:N_TRANCHES] -= COST / N_TRANCHES # koszt każdej transzy
_# 3) Momentum: long nad SMA200 (liczoną z danych DO t, pozycja działa od t+1)
sma = pd.Series(df.close).rolling(200).mean().to_numpy()
signal = (df.close.to_numpy() > sma).astype(float)[:-1] # sygnał w t...
signal = np.nan_to_num(signal)
mom = signal * ret[len(ret)-len(signal):] # ...zwrot t->t+1
mom -= np.abs(np.diff(signal, prepend=0.0)) * COST # koszt zmian pozycji
for name, r in [("Buy&Hold", bh), ("DCA 52 transze", dca), ("SMA200 momentum", mom)]:
m = metrics(r)
print(f"{name:16} CAGR {m['CAGR']:+7.1%} Sharpe {m['Sharpe']:5.2f} "
f"MaxDD {m['MaxDD']:6.1%} Calmar {m['Calmar']:5.2f}")
DCA nie zwiększa oczekiwanego zwrotu (matematycznie na rynku z dodatnim dryfem wejście od razu ma wyższą średnią) — DCA redukuje wariancję żalu: nigdy nie wchodzisz całością na szczycie. Dla agenta RL to świetny benchmark, bo reprezentuje wynik osiągalny bez żadnej informacji o rynku. Twój agent ma dostęp do informacji — jeśli nie bije DCA po kosztach, informacji nie wykorzystuje.
Werdykt: jak porównać agenta z benchmarkami
- Te same dane, te same koszty, ten sam okres OOS (sklejka walk-forward).
- Porównuj Sharpe i Calmar, nie sam zwrot — agent może „wygrywać" zwrotem, biorąc 3× większe ryzyko.
- Policz deflated Sharpe: uwzględnij, ile wariantów agenta przetestowałeś. Wzór i kod znajdziesz w książce Lópeza de Prado (rozdz. 14) — implementacja to ~20 linii.
- Sprawdź stabilność między foldami: agent lepszy w 5/6 foldów > agent lepszy średnio dzięki jednemu foldowi.
Specyfika krypto: perpetuale, funding, testnet
Cel: zrozumieć mikrostrukturę rynku krypto — i przenieść agenta z backtestu na paper trading na testnecie Binance.
Czym krypto różni się od akcji (dla modelu)
| Cecha | Akcje | Krypto | Konsekwencja dla RL |
|---|---|---|---|
| Godziny handlu | Sesje + luki nocne/weekendowe | 24/7/365 | Brak luk = czystsze dane; ale reżimy azjatycki/US w cyklu dobowym (cechy hod_sin/cos!) |
| Short | Pożyczka akcji, koszty, ograniczenia | Perpetual futures — natywnie | Akcja short jest realistyczna; ale dochodzi funding |
| Koszty | Prowizje niskie, spread wąski (large cap) | Taker 0.04–0.1%, maker bywa ujemny; spread zależny od coina | Model kosztów MUSI być per-giełda i per-instrument |
| Zmienność | ~15–25% rocznie (indeksy) | 60–100%+ (BTC), więcej na altach | Grubsze ogony; sizing pozycji ważniejszy niż kierunek |
| Manipulacje | Regulowane | Pump&dump, wash trading na małych coinach | Trzymaj się top płynności: BTC, ETH; wolumen altów bywa fikcją |
Funding rate: koszt, o którym zapominają wszyscy
Perpetual futures nie wygasają — ich cena jest wiązana ze spotem przez funding: co 8h (Binance) posiadacze pozycji po „przeciążonej" stronie płacą drugiej stronie. Historycznie funding BTC jest przeważnie dodatni (longi płacą shortom) i wynosi ~0.01%/8h — to ~11% rocznie kosztu trzymania longa w spokojnych warunkach, a w euforii potrafi być wielokrotnie wyższy. Strategia long-only na perpetualach bez uwzględnienia fundingu to systematyczne zawyżanie backtestu.
"""Pobierz historię funding rate i dolicz do środowiska."""
import ccxt, pandas as pd
ex = ccxt.binanceusdm() # rynek USD-M futures
rows, since = [], ex.parse8601("2020-01-01T00:00:00Z")
while True:
batch = ex.fetch_funding_rate_history("BTC/USDT:USDT", since=since, limit=1000)
if not batch: break
rows += batch
since = batch[-1]["timestamp"] + 1
f = pd.DataFrame([{"ts": r["timestamp"], "rate": r["fundingRate"]} for r in rows])
f["ts"] = pd.to_datetime(f.ts, unit="ms", utc=True)
f.set_index("ts").to_parquet("data/funding_btc.parquet")
print(f"Średni funding/8h: {f.rate.mean():.5%} => rocznie ~{f.rate.mean()*3*365:.1%}")
_# W środowisku (step, co 8. świeca 1h):
_# reward -= self.position * funding_rate_t # long płaci przy dodatnim fundingu
Paper trading na testnecie Binance
Zanim jakiekolwiek prawdziwe środki: testnet — pełne API Binance Futures
na wirtualnych środkach. Załóż konto na testnet.binancefuture.com, wygeneruj
klucze API i wpnij agenta w pętlę na żywo:
_# Klucze trzymaj w zmiennych środowiskowych, NIGDY w kodzie / repo export BINANCE_TESTNET_KEY="twoj_klucz" export BINANCE_TESTNET_SECRET="twoj_sekret"
"""Pętla paper-tradingu: co godzinę obserwacja -> decyzja agenta -> zlecenie na testnet.
Uruchamiaj pod launchd/cron albo w tmux; loguj WSZYSTKO."""
import os, time, logging, ccxt, numpy as np, pandas as pd
from stable_baselines3 import PPO
logging.basicConfig(filename="paper.log", level=logging.INFO,
format="%(asctime)s %(message)s")
ex = ccxt.binanceusdm({
"apiKey": os.environ["BINANCE_TESTNET_KEY"],
"secret": os.environ["BINANCE_TESTNET_SECRET"],
})
ex.set_sandbox_mode(True) # <- przełącznik na testnet
model = PPO.load("models/ppo_btc_v1")
SYMBOL, POS_USD = "BTC/USDT:USDT", 1000 # stała wielkość pozycji w USD
def build_obs():
"""Ta sama transformacja co w treningu - co do bitu.
Rozjazd trening/produkcja w preprocessing to klasyczna cicha śmierć."""
ohlcv = ex.fetch_ohlcv(SYMBOL, "1h", limit=40)
close = np.array([c[4] for c in ohlcv], dtype=np.float64)
r = np.diff(np.log(close))[-32:] * 100.0
pos = get_current_position()
return np.append(r, pos).astype(np.float32)
def get_current_position() -> int:
for p in ex.fetch_positions([SYMBOL]):
amt = float(p["info"].get("positionAmt", 0))
if amt: return int(np.sign(amt))
return 0
def set_position(target: int, price: float):
current = get_current_position()
if target == current: return # nic do zrobienia
qty = round(POS_USD / price, 3)
if current != 0: # najpierw zamknij obecną
side = "sell" if current > 0 else "buy"
ex.create_order(SYMBOL, "market", side, qty, params={"reduceOnly": True})
if target != 0: # potem otwórz docelową
ex.create_order(SYMBOL, "market", "buy" if target > 0 else "sell", qty)
logging.info(f"pozycja {current} -> {target} @ {price}")
while True:
try:
obs = build_obs()
action, _ = model.predict(obs, deterministic=True)
price = ex.fetch_ticker(SYMBOL)["last"]
target = {0: 0, 1: 1, 2: -1}[int(action)]
set_position(target, price)
except Exception as e: # sieć/API padnie - to pewne
logging.exception(f"błąd pętli: {e}")
# śpimy do pełnej godziny + 5 s (świeca musi się domknąć)
time.sleep(3600 - time.time() % 3600 + 5)
Porównuj co tydzień: wynik paper vs wynik z backtestu na tych samych świecach. Rozjazd > kosztów transakcyjnych = masz błąd w pipeline (najczęściej: inna normalizacja obserwacji, inna cena egzekucji, strefa czasowa). Dopiero zgodność paper↔backtest daje moralne prawo myśleć o realnych środkach — małych.
Sizing i ryzyko: zanim pomyślisz o dźwigni
- Vol targeting: wielkość pozycji = cel_zmienności / zmienność_bieżąca. Automatycznie zmniejsza ekspozycję w chaosie — najprostszy risk management, który realnie działa.
- Kryterium Kelly'ego: teoretyczne optimum wielkości zakładu f* = μ/σ². W praktyce estymaty μ są tak niepewne, że gra się ułamkiem Kelly (¼–½). Pełny Kelly na błędnej estymacie = bankructwo.
- Twarde limity poza modelem: max pozycja, max strata dzienna, kill-switch. Model może zwariować (i kiedyś zwariuje) — limity egzekwuje osobna warstwa kodu, nie agent.
Profesjonalny stack: co robią fundusze, czego nie robi internet
Cel: poznać techniki poziomu produkcyjnego — ensembles, detekcję reżimów, offline RL — i architekturę kompletnego systemu.
Gdzie RL naprawdę działa w finansach
Warto wiedzieć, gdzie profesjonaliści faktycznie stosują RL — bo rzadko jest to „przewidywanie kierunku BTC":
- Optymalna egzekucja: jak rozłożyć duże zlecenie w czasie, by zminimalizować wpływ na cenę (następca algorytmów typu Almgren–Chriss). Sygnał: mikrostruktura książki zleceń — silniejszy i stabilniejszy niż kierunek rynku. To najbardziej udane zastosowanie RL w finansach.
- Market making: ustawianie kwotowań bid/ask; nagroda = spread minus koszty inwentarza. Środowisko dobrze zdefiniowane, feedback szybki.
- Zarządzanie portfelem: RL jako warstwa alokacji nad sygnałami z innych modeli — akcja to wagi portfela, nie „kup/sprzedaj".
- Hedging opcji: deep hedging — RL uczy się replikować wypłaty z kosztami transakcyjnymi, gdzie klasyczna teoria (Black–Scholes) zakłada ich brak.
Ensembles: mediana zamiast bohatera
Skoro wariancja między ziarnami jest ogromna (moduł 5), profesjonalna odpowiedź brzmi: nie wybieraj najlepszego — uśrednij komitet. Trenuj 5–10 agentów (różne ziarna, można też różne architektury/nagrody) i zagreguj decyzje:
_# Głosowanie komitetu: pozycja = mediana pozycji docelowych agentów.
_# Redukuje wariancję pojedynczego seeda i wygładza obrót.
import numpy as np
from stable_baselines3 import PPO
models = [PPO.load(f"models/ppo_seed{s}") for s in range(5)]
def ensemble_action(obs) -> int:
# {0: flat, 1: long, 2: short} -> pozycje {-1, 0, +1}
pos = [{0: 0, 1: 1, 2: -1}[int(m.predict(obs, deterministic=True)[0])]
for m in models]
return int(np.sign(np.median(pos))) # mediana głosów komitetu
_# Wariant pro (FinRL): co okres walk-forwardu wybieraj do komitetu tylko
_# agentów z dodatnim wynikiem na OSTATNIM oknie walidacyjnym.
Detekcja reżimów: nie jedna polityka, lecz mapa polityk
Rynek przełącza się między reżimami (trend/flauta, niska/wysoka zmienność), a polityka optymalna w jednym bywa fatalna w innym. Dwa podejścia:
- Jawnie: klasyfikator reżimu (np. HMM na zwrotach i zmienności, biblioteka
hmmlearn) + osobny agent per reżim, albo reżim jako cecha w stanie. - Niejawnie: sieć rekurencyjna (
RecurrentPPOz pakietusb3-contrib, polityka LSTM) — agent sam utrzymuje wewnętrzną „pamięć reżimu". Kosztuje więcej treningu, ale nie wymaga ręcznej definicji reżimów.
Offline RL i decision transformers — frontier
Klasyczny RL uczy się przez interakcję; na rynku „interakcja" to strata pieniędzy.
Offline RL (CQL, IQL) uczy się z zastanego zbioru trajektorii
bez dalszej eksploracji — konserwatywnie zaniżając wartość akcji, których nie ma w danych.
Decision Transformer idzie dalej: traktuje RL jak modelowanie sekwencji
(stan, akcja, zwrot-do-uzyskania) i uczy się transformera warunkowanego pożądanym wynikiem.
W finansach to obiecujący, ale wciąż badawczy teren — traktuj jako moduł „po kursie",
nie fundament. Biblioteka do eksperymentów: d3rlpy (CQL/IQL, czyste API).
W literaturze i praktyce wyraźnie rośnie udział podejść hybrydowych (RL jako warstwa decyzyjna nad klasycznymi cechami i sygnałami quant) kosztem czystego end-to-end RL — hybrydy dają stabilniejsze wyniki na niestacjonarnych rynkach. Drugi kierunek: LLM jako źródło cech — np. FinRL-DeepSeek dokłada do stanu agenta sygnały ryzyka/sentymentu wyciągnięte z newsów przez model językowy. Dla Ciebie praktyczny wniosek jest ten sam, co w module 6: nie każ sieci odkrywać wszystkiego — dawaj jej najlepsze cechy, jakie umiesz policzyć.
Architektura kompletnego systemu
Tak wygląda minimalny system produkcyjny — każdy prostokąt to osobny, testowalny moduł:
| Warstwa | Odpowiedzialność | Narzędzia z tego kursu |
|---|---|---|
| Dane | Pobieranie, walidacja, magazyn point-in-time | ccxt + Parquet + skrypt walidacji (moduł 3) |
| Cechy | Jedna implementacja dla treningu i produkcji | features.py (moduł 6) — importowana przez env i pętlę live |
| Badania | Trening, walk-forward, rejestr eksperymentów | SB3 + TensorBoard + CSV/MLflow (moduły 5, 7) |
| Egzekucja | Pętla live, zlecenia, stan pozycji | ccxt + testnet (moduł 9) |
| Ryzyko | Limity twarde, kill-switch — NAD modelem | Osobna klasa, testowana jednostkowo |
| Monitoring | Logi, alerty, porównanie live vs backtest | logging + prosty dashboard / powiadomienia |
Wszystko, co dotyka pieniędzy, licz dwiema niezależnymi drogami: PnL według Twojego systemu vs PnL według giełdy; pozycja według stanu wewnętrznego vs fetch_positions(). Rozjazd → stop i alarm. Fundusze mają całe zespoły rekoncyliacji; Ty potrzebujesz przynajmniej jednego asserta.
Czego ten kurs świadomie nie pokrył (dalsze horyzonty)
- Dane alternatywne: sentyment (X/Reddit), dane on-chain (Glassnode), przepływy giełdowe — potencjalne cechy o realnej wartości informacyjnej.
- Symulacja książki zleceń (LOB): dla strategii HFT/market-making środowisko musi symulować kolejkę zleceń — rząd wielkości więcej pracy (ABIDES, własny symulator).
- Multi-asset: portfel wielu instrumentów — akcja to wektor wag; zaczynając, ogranicz się do 2–5 płynnych aktywów.
- Meta-labeling (López de Prado): model pierwotny daje kierunek, ML decyduje tylko „grać czy nie grać" — często lepsze niż end-to-end RL.
Setup: Mac M3 ze 128 GB jako stacja badawcza
Cel: kompletna, powtarzalna konfiguracja maszyny — od Homebrew po nocne treningi — z realistycznymi oczekiwaniami co do wydajności.
Co ten sprzęt daje w RL
- 128 GB unified memory to luksus: cały zbiór minutowy BTC (lata) mieści się w RAM wielokrotnie; możesz trzymać dziesiątki równoległych środowisk i ogromne replay buffery bez dotykania dysku.
- Rdzenie CPU M3 są bardzo szybkie w pojedynczym wątku — a pętla RL (env → mały MLP → env) jest właśnie single-thread-bound. To dlatego CPU wygrywa z MPS dla małych sieci.
- GPU (MPS) włączasz dla CNN/LSTM/transformerów i dużych batchy. MLX (framework ML Apple) jest świetny do LLM, ale ekosystem RL stoi na PyTorchu — zostań przy PyTorch + MPS.
- Czego nie zrobisz lokalnie: masywne sweepy tysięcy konfiguracji (to domena klastrów). Ale 5 ziaren × 6 foldów × PPO? Spokojnie, przez noc.
Instalacja od zera — kompletny skrypt
_# 1. Narzędzia bazowe
xcode-select --install # kompilatory (wymagane przez część pakietów)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install uv git htop
_# 2. Projekt i środowisko (Python 3.11 - najszersza kompatybilność RL)
mkdir -p ~/dev/rl-trading && cd ~/dev/rl-trading && git init
uv venv --python 3.11 && source .venv/bin/activate
_# 3. Pełny stack kursu
uv pip install numpy pandas pyarrow matplotlib \
ccxt yfinance \
gymnasium stable-baselines3 sb3-contrib tensorboard \
torch \
hmmlearn d3rlpy \
pytest ruff
_# 4. Weryfikacja instalacji
python - <<'EOF'
import torch, stable_baselines3, gymnasium, ccxt
print("PyTorch:", torch.__version__, "| MPS:", torch.backends.mps.is_available())
print("SB3:", stable_baselines3.__version__, "| Gymnasium:", gymnasium.__version__)
EOF
_# 5. Higiena repo: nie commituj danych, modeli ani sekretów
printf "data/\nmodels/\nruns/\n.venv/\n*.log\n.env\n" > .gitignore
git add -A && git commit -m "init: środowisko RL trading"
Nocne treningi i równoległość
_# caffeinate: Mac nie zaśnie w trakcie treningu (klapa może być zamknięta przy zasilaniu) caffeinate -i python 08_walkforward.py _# Trening w tle z logiem i możliwością wylogowania: nohup caffeinate -i python 08_walkforward.py > wf.log 2>&1 & tail -f wf.log # podgląd postępu _# Monitorowanie zasobów podczas treningu: htop # obciążenie rdzeni CPU sudo powermetrics --samplers gpu_power -i 2000 # czy GPU faktycznie pracuje (dla MPS)
"""Walk-forward z foldami w osobnych procesach. M3 + 128 GB RAM: 6 foldów
naraz to nic. UWAGA: ogranicz wątki BLAS per proces, inaczej procesy
będą się dusić nawzajem o rdzenie."""
import os
os.environ["OMP_NUM_THREADS"] = "2" # PRZED importem numpy/torch!
from multiprocessing import Pool
from my_walkforward import run_fold # funkcja: (fold_id) -> wynik
if __name__ == "__main__":
with Pool(processes=6) as pool: # 6 foldów jednocześnie
results = pool.map(run_fold, range(6))
print(results)
Wektoryzacja środowisk — darmowe przyspieszenie
_# SubprocVecEnv: N kopii środowiska w osobnych procesach; PPO zbiera
_# doświadczenie N razy szybciej. Na M3 sweet spot to zwykle 8-12 kopii.
from stable_baselines3.common.vec_env import SubprocVecEnv
def make_env(seed_offset):
def _f():
env = TradingEnv(train_prices)
env.reset(seed=seed_offset)
return env
return _f
if __name__ == "__main__":
env = SubprocVecEnv([make_env(i) for i in range(8)])
model = PPO("MlpPolicy", env, n_steps=256, device="cpu") # 256*8=2048 kroków/rollout
model.learn(2_000_000)
Orientacyjne czasy na M3 (rząd wielkości)
| Zadanie | Czas | Uwagi |
|---|---|---|
| Pobranie pełnej historii BTC 1h | ~2 min | limit API, nie łącze |
| PPO 500k kroków, MLP 2×128, 1 env | 10–20 min | CPU, single-thread bound |
| PPO 2M kroków, 8 env równolegle | ~30–60 min | SubprocVecEnv |
| Walk-forward 6 foldów × 5 ziaren | noc | zrównoleglone po foldach |
| Sweep 30 konfiguracji hiperparametrów | weekend | tu kończy się laptop, zaczyna chmura |
Roadmapa 12 tygodni i biblioteka zasobów
Cel: zamienić ten kurs w plan tygodniowy z jasnymi kryteriami „zaliczenia" każdego etapu.
Plan tygodniowy
Fundamenty RL
Moduły 1–2. Bandyta i Q-learning z pamięci, ćwiczenia z gridworlda.
Zaliczenie: Twój Q-learning osiąga śr. zwrot ≥ 8.5 na gridworldzie; umiesz wyjaśnić Bellmana bliskiej osobie.
Pipeline danych
Moduł 3. Pobieranie, walidacja, podział chronologiczny, BTC 1h + SPY 1d.
Zaliczenie: 03_download.py działa idempotentnie; raport jakości danych bez niezrozumiałych anomalii.
Środowisko
Moduł 4. TradingEnv + testy (API check, agent losowy, zgodność B&H).
Zaliczenie: wszystkie trzy sanity checki przechodzą; rozumiesz każdą linię step().
Deep RL
Moduł 5. PPO na train, ewaluacja na val, 5 ziaren, TensorBoard, benchmark CPU vs MPS.
Zaliczenie: tabela wyników 5 ziaren z medianą; potrafisz wskazać overfitting na krzywych.
Cechy i nagroda
Moduł 6. Wektor cech v2, eksperymenty z λ ryzyka, dziennik eksperymentów.
Zaliczenie: porównanie „okno zwrotów vs cechy v2" na 5 ziarnach, wnioski na piśmie.
Walk-forward i ewaluacja
Moduły 7–8. Pełny walk-forward z embargiem, metryki, benchmarki B&H / DCA / SMA, stress test kosztów.
Zaliczenie: raport OOS: Sharpe, Calmar, MaxDD agenta vs 3 benchmarki, wrażliwość na 2× koszty. Uwaga: wynik „agent przegrywa z DCA" to też zaliczenie — nauczyłeś się prawdy.
Paper trading
Moduł 9. Pętla na testnecie Binance, logowanie, cotygodniowa rekoncyliacja live vs backtest.
Zaliczenie: 2+ tygodnie nieprzerwanej pracy pętli; rozjazd paper↔backtest wyjaśniony co do przyczyny.
Iteracja bez końca
Moduł 10: ensembles, reżimy, offline RL. Więcej instrumentów, lepsze cechy, dłuższy paper trading. Realne środki — najwcześniej po miesiącach zgodnego paper tradingu i wyłącznie kwotą, której utratę akceptujesz z góry.
Biblioteka
Książki — kanon
- Sutton & Barto, „Reinforcement Learning: An Introduction" (2. wyd.) — biblia RL, darmowy PDF na stronie autorów. Rozdziały 1–6 pokrywają moduły 1–2.
- Marcos López de Prado, „Advances in Financial Machine Learning" — obowiązkowa: purged CV / CPCV, deflated Sharpe, meta-labeling, fractional differentiation. Krótsza kontynuacja: „Machine Learning for Asset Managers" (2020). Przeczytaj też jego esej „10 Reasons Most ML Funds Fail".
- Stefan Jansen, „Machine Learning for Algorithmic Trading" (2. wyd.) — szeroki podręcznik praktyczny z rozdziałem o deep RL; kod na GitHubie (stefan-jansen/machine-learning-for-trading).
- Ernest Chan, „Algorithmic Trading" — klasyka o strategiach i ich psuciu się; zdrowy sceptycyzm.
Narzędzia i repozytoria
- Stable-Baselines3 (DLR-RM/stable-baselines3) — implementacje referencyjne PPO/DQN/SAC; czytaj ich dokumentację „RL Tips and Tricks" — złoto.
- sb3-contrib — RecurrentPPO (LSTM), QR-DQN i inne rozszerzenia.
- FinRL (AI4Finance-Foundation/FinRL) — framework RL-dla-finansów: gotowe środowiska (akcje, krypto, portfele), tutoriale i coroczny FinRL Contest z aktualnymi benchmarkami. Świetny do czytania i inspiracji; jakość kodu bywa nierówna i API się zmienia, więc traktuj jako baseline, nie fundament — do nauki i tak zbuduj własny env (wiesz już czemu).
- gym-trading-env — lekkie, dobrze udokumentowane środowisko tradingowe Gymnasium; dobre drugie spojrzenie po Twoim własnym.
- vectorbt — błyskawiczne wektorowe backtesty (NumPy+Numba; miliony zleceń w ułamku sekundy). Idealny do triage: zanim wydasz noc GPU na RL, sprawdź w minutę, czy w sygnale w ogóle jest alpha.
- NautilusTrader — profesjonalna platforma event-driven (rdzeń w Rust): realistyczna egzekucja (poślizg, częściowe fille) i ten sam kod w backteście i live. Wyższy próg wejścia; naturalny następca Twojej własnej pętli z modułu 9. Rekomendowany workflow: vectorbt (przesiew) → Nautilus (realistyczna weryfikacja i live).
- backtrader / zipline-reloaded — starsze silniki event-driven; backtrader od 2023 tylko w trybie utrzymania (bez nowych funkcji), zipline-reloaded to żywy fork do badań na akcjach. TensorTrade omijaj — projekt w stagnacji.
- d3rlpy — offline RL (CQL, IQL) z prostym API.
- ccxt — jednolite API do 100+ giełd krypto; Twoja warstwa danych i egzekucji.
Kursy i materiały online
- David Silver, „RL Course" (UCL/DeepMind, YouTube) — najlepszy wykład teorii RL.
- OpenAI „Spinning Up in Deep RL" — zwięzłe, precyzyjne wprowadzenie do deep RL z kodem.
- Hugging Face „Deep RL Course" — darmowy, praktyczny, na Gymnasium i SB3.
- QuantStart, Quantopian lectures (archiwum) — metodologia badań kwantowych.
Słowo na koniec
Jeżeli po 12 tygodniach Twój agent przegrywa z DCA — jesteś w większości, która mówi prawdę. Prawdziwa wygrana tego kursu to umiejętności: budowa środowisk, dyscyplina walidacyjna, sceptycyzm wobec własnych wyników. To one są rzadkie i cenne — na rynku pracy bardziej niż niejeden „działający" backtest. A jeśli kiedyś znajdziesz przewagę: będziesz miał warsztat, żeby ją zweryfikować, zanim jej zaufasz.