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 16 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, risk layer, paper trading, live | 7–11 | Wszystko, co odróżnia zabawkę od systemu |
| v4 — poziom pro | Mikrostruktura, market making, offline RL, CVaR, ensembles | 12–16 | Techniki, których używają fundusze — i ich granice |
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 15 (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
Dlaczego RL w tradingu jest fundamentalnie trudniejszy niż w Atari czy szachach?
Gdzie fundusze kwantowe faktycznie stosują RL najskuteczniej?
Jak najlepiej rozumieć zdanie „backtest kłamie domyślnie"?
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.
Co w tradingu oznacza wysokie γ (bliskie 1)?
Które założenie MDP rynek łamie?
Co dokładnie wyraża Q(s, a)?
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: 11 kroków po -0.1, a 12. wchodzi do celu (+10) = +8.9. 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 |
Trzeci wiersz tej tabeli — eksploracja — ma własną oś czasu: ε nie jest stałe, tylko
wygasa w trakcie treningu. Kształt tego wygasania to realny hiperparametr.
Pobaw się nim, zanim spotkasz go ponownie jako exploration_fraction w DQN:
Za szybki zanik = agent zakleszcza się na suboptymalnej polityce (dokładnie jak bandyta z modułu 1 przy ε=0). Za wolny = agent wiecznie hazarduje i nigdy nie „dojrzewa". Na żywym rynku każdy eksploracyjny krok kosztuje realne pieniądze — dlatego eksplorację odbębniamy w symulacji, zanim wpuścimy agenta na produkcję.
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%.
Co wyraża błąd TD: r + γ·max Q(s′,a′) − Q(s,a)?
Q-learning jest off-policy. Dlaczego to praktycznie istotne?
Dlaczego tabela Q nie wystarczy na rynek?
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.
Po pierwsze: wszystko w UTC (nasz skrypt robi utc=True) — mieszanie stref przy łączeniu np. akcji (zamknięcie 16:00 ET) z krypto to przesunięte o godziny „przyszłe" dane. Po drugie: przy agregacji świec (1h → 4h/1d) używaj df.resample("4h", label="right", closed="right"). Domyślna etykieta left sprawia, że świeca podpisana „12:00" zawiera dane do 16:00 — jeśli złączysz ją po indeksie z cechą liczoną „na 12:00", właśnie wstrzyknąłeś 4 godziny przyszłości do stanu.
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())
Kurtoza >> 3 oznacza grube ogony, a grube ogony chodzą parami z klastrami zmienności: spokój skupia się ze spokojem, panika z paniką. To najbardziej „rynkowa" własność danych — i najłatwiejsza do przeoczenia. Sprawdź, czy widzisz ją gołym okiem:
Wykresy cen wyglądają niemal identycznie — obie to błądzenie losowe. Różnica siedzi w ZMIENNOŚCI: model rynkowy (GARCH) skleja okresy spokoju i paniki w klastry, ma grube ogony i kurtozę > 3. To dokładnie ta własność, którą cechy typu vol_regime z modułu 6 mają wyłapać — i powód, dla którego zwykły random walk z modułu 4 to zbyt łagodny test dla strategii.
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.
Dlaczego dane dzielimy chronologicznie, a nie losowo jak w klasycznym ML?
Dlaczego trenujemy na zwrotach logarytmicznych zamiast na surowych cenach?
Dlaczego przy akcjach trzeba używać cen skorygowanych o splity i dywidendy?
Ś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.
Losowe okna startowe: pierwsza obrona przed przeuczeniem
Nasz env v1 ma subtelną wadę: reset() zawsze startuje w tym samym miejscu
i przechodzi całą historię. Dla algorytmu on-policy (PPO) oznacza to, że agent w kółko
ogląda jedną, identyczną trajektorię — idealne warunki do nauczenia się jej na
pamięć, czyli dokładnie tego overfittingu, z którym walczy pół kursu. Standardowa
poprawka: każdy epizod to losowe okno z danych treningowych.
_# Epizod = losowe ~60 dni zamiast całej historii. Dopisz do TradingEnv:
MAX_STEPS = 24 * 60 # długość epizodu w świecach 1h
def reset(self, seed=None, options=None):
super().reset(seed=seed) # inicjalizuje self.np_random
hi = len(self.log_ret) - MAX_STEPS - 1
self.t0 = int(self.np_random.integers(self.window, max(self.window + 1, hi)))
self.t = self.t0 # losowy początek epizodu
self.position, self.equity = 0, 0.0
return self._obs(), {}
_# ...i w step() epizod kończy się po MAX_STEPS krokach:
terminated = (self.t >= self.t0 + MAX_STEPS) or (self.t >= len(self.log_ret))
Bonus: krótsze epizody to częstsze resety i lepsze wykorzystanie wektoryzacji środowisk (moduł 15). Do ewaluacji (walidacja, walk-forward) używaj oczywiście wariantu deterministycznego — od początku do końca okna, żeby wyniki były porównywalne.
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.
Ten sam Q-learning co na gridworldzie dostanie cenę typu „błądzenie losowe" (zero sygnału) i będzie płacić prowizję. Czego się spodziewasz po treningu?
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. - Dołóż do obserwacji niezrealizowany PnL bieżącej pozycji (log-zwrot od ceny wejścia; 0 przy pozycji flat). Bez tego agent nie może nauczyć się polityk typu „zamknij stratną" — decyzja o wyjściu wymaga wiedzy, ile trzymasz. Porównaj na walidacji z wersją bez PnL.
Agent losowy systematycznie ZARABIA w Twoim środowisku. Co to najpewniej znaczy?
Dlaczego decyzję podejmujemy na zamknięciu świecy t, a egzekwujemy po cenie otwarcia t+1?
Dlaczego mówi się, że w RL finansowym środowisko jest ważniejsze niż algorytm?
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.
PPO pod maską: przycinanie i GAE
Skoro PPO jest koniem roboczym kursu, zobaczmy jego dwa mechanizmy naprawdę — bo ich
hiperparametry (clip_range, gae_lambda) będziesz stroić.
PPO porównuje nową politykę ze starą przez iloraz prawdopodobieństw
ρt(θ) = πθ(at|st) / πθ,stara(at|st)
i maksymalizuje przycięty cel:
At to przewaga (advantage): o ile akcja wypadła lepiej, niż
oczekiwał krytyk V(s). Kluczowa asymetria: gdy akcja była dobra (A>0), clip odcina
zachętę do zwiększania jej prawdopodobieństwa ponad (1+ε) — „nie szarżuj po jednym
sukcesie". Gdy była zła (A<0), min zostawia karę nieprzyciętą od dołu —
błędy można korygować mocno. Na rynkowym szumie, gdzie pojedynczy epizod bywa czystym
fartem, ta ostrożność to być albo nie być. Zobacz to na wykresie:
Clip odcina zachętę do dużych zmian polityki — ale tylko w JEDNĄ stronę zależną od znaku przewagi A. Dla A>0 (akcja lepsza niż średnia) cel spłaszcza się powyżej 1+ε: nie opłaca się rozpędzać prawdopodobieństwa w nieskończoność. Dla A<0 spłaszcza się poniżej 1−ε. Ta asymetria to cały sekret stabilności PPO na zaszumionych nagrodach rynkowych — jeden pechowy batch nie wywróci polityki.
Samą przewagę At PPO estymuje przez GAE (Generalized Advantage Estimation) — wykładniczo ważoną sumę błędów TD:
λ to pokrętło bias–wariancja: λ=0 ufa tylko krytykowi (niska wariancja, duży bias — a krytyk na zaszumionych rynkach bywa słaby), λ=1 to surowe zwroty Monte Carlo (bez biasu, ogromna wariancja). Domyślne λ=0.95 działa dobrze; przy bardzo szarpanych krzywych uczenia zejście do 0.9 często stabilizuje trening kosztem tempa. To najczęściej pomijany hiperparametr, który realnie pomaga na danych finansowych.
VecNormalize(norm_reward=True) przeskalowuje nagrody krocząco do jednostkowej wariancji. Wygodne przy gołym log-zwrocie — ale jeśli w module 6 wepniesz nagrodę z ręcznie wyważoną karą (λ·pnl², kara za obrót, DSR), normalizacja zniekształci Twoje proporcje i „czemu moja kara nic nie robi" stanie się zagadką na tydzień. Reguła: własna, starannie wyskalowana nagroda → norm_reward=False; prosty log-zwrot → True.
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()
WINDOW = 32 # jedno źródło prawdy - używane też przy ewaluacji
_# 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, window=WINDOW))])
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[WINDOW]) # nie "magiczne 32" - patrz WINDOW wyżej
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.
Po co w DQN replay buffer?
Po co osobna, zamrożona sieć docelowa (target network) w DQN?
Ten sam kod i dane, inny seed — wynik walidacyjny różni się o kilkadziesiąt p.p. Co z tego wynika?
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.
Zwiększasz złożoność modelu od 1 do 15. Co stanie się z błędem na danych TESTOWYCH?
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.
Nagroda = niezrealizowany PnL, bez żadnej kary za trzymanie stratnej pozycji. Jaki patologiczny efekt?
Dlaczego lepiej zacząć od 10–20 cech niż od 200?
Jak poprawnie porównać dwie różne funkcje nagrody?
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)? ✓
Po co przerwa (embargo) między oknem treningowym a testowym w walk-forward?
Testujesz 100 wariantów strategii na tej samej walidacji i wybierasz najlepszy. Na czym polega problem?
Którą krzywą wolno Ci pokazać jako wynik strategii?
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 deviation (kanonicznie): pierwiastek ŚREDNIEJ kwadratów zwrotów
# poniżej celu 0, uśrednianej po WSZYSTKICH okresach. Popularny skrót
# log_rets[log_rets<0].std() zaniża Sortino i daje NaN przy braku strat.
downside = np.sqrt(np.mean(np.minimum(log_rets, 0.0) ** 2))
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))
Deflated Sharpe: ile jest wart najlepszy wynik z N prób
Obiecaliśmy w module 7, że dowieziemy antidotum na p-hacking — oto ono, w całości. Idea Baileya i Lópeza de Prado: jeśli przetestowałeś N wariantów strategii na tych samych danych, najlepszy Sharpe jest zawyżony przez samo maksimum z N losowań. Deflated Sharpe Ratio odpowiada na pytanie: „jakie jest prawdopodobieństwo, że prawdziwy Sharpe najlepszego wariantu jest większy od zera, biorąc pod uwagę, ile razy próbowałem i jak nienormalne są zwroty?"
"""Deflated Sharpe Ratio (Bailey & López de Prado, 2014).
Wymaga: uv pip install scipy. Wszystkie Sharpe'y PER OKRES (nieanualizowane!)."""
import numpy as np
from scipy.stats import norm, skew, kurtosis
EULER = 0.5772156649 # stała Eulera-Mascheroniego
def deflated_sharpe(rets_best: np.ndarray, sr_all: np.ndarray) -> float:
"""rets_best: zwroty NAJLEPSZEGO wariantu; sr_all: Sharpe'y (per okres)
WSZYSTKICH N testowanych wariantów. Zwraca P(prawdziwy Sharpe > 0)."""
T = len(rets_best)
sr = rets_best.mean() / rets_best.std(ddof=1) # Sharpe per okres
n = len(sr_all)
# 1) Benchmark: oczekiwane MAKSIMUM Sharpe'a z n losowań czystego szumu
# o rozrzucie takim, jak rozrzut Twoich wyników (wzór na E[max] z EVT)
sr0 = np.std(sr_all, ddof=1) * (
(1 - EULER) * norm.ppf(1 - 1/n) + EULER * norm.ppf(1 - 1/(n*np.e)))
# 2) Probabilistic Sharpe Ratio względem tego benchmarku,
# z korektą na skośność i kurtozę zwrotów (grube ogony!)
g3, g4 = skew(rets_best), kurtosis(rets_best, fisher=False)
num = (sr - sr0) * np.sqrt(T - 1)
den = np.sqrt(1 - g3*sr + (g4 - 1)/4 * sr**2)
return float(norm.cdf(num / den))
_# Przykład: 100 wariantów agenta, najlepszy ma anualizowany Sharpe 1.5 na 1 roku 1h
_# dsr = deflated_sharpe(best_rets, np.array(all_sharpes))
_# Interpretacja: dsr > 0.95 - edge prawdopodobnie prawdziwy;
_# dsr ~ 0.5 - najlepszy wariant nieodróżnialny od szczęścia.
N to liczba wszystkich konfiguracji, które oceniłeś na tej samej walidacji: seedy, architektury, funkcje nagrody, zestawy cech, „szybkie sprawdzenia". Dziennik eksperymentów z modułu 6 właśnie po to istnieje. Typowy szok: przy N=100 i rocznych danych 1h oczekiwane maksimum Sharpe'a z czystego szumu przekracza 1 — Twój „świetny" wynik może być dokładnie tym.
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.
Kod wyżej liczy benchmarki na pojedynczym holdoucie — dla uproszczenia. Gdy przejdziesz na walk-forward (moduł 7), benchmarki musisz policzyć na sklejce tych samych okien OOS, na której oceniasz agenta. Porównanie „agent na OOS vs B&H na całej historii" to porównanie dwóch różnych rynków — i kolejny sposób na okłamanie siebie.
Zanim policzysz to na własnych danych, poczuj mechanikę na symulacji — ile edge'u naprawdę trzeba, żeby po kosztach pobić „głupie" benchmarki:
Agent trafia kierunek w 51% kroków (edge 1%), handluje co krok i płaci 5 bps na transakcję. Pobije buy&hold?
Przy edge 0 agent PRZEGRYWA dokładnie o koszty — krzywa osypuje się liniowo w skali log. Pokręć suwakiem edge i sprawdź, ile realnej przewagi trzeba, by po 5 bps kosztu pobić choćby DCA. To jest poprzeczka całego kursu: nie „czy agent zarabia", tylko „czy zarabia więcej niż tępy benchmark PO KOSZTACH".
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 kodem z tego modułu, z uczciwym N (dziennik eksperymentów!). Poniżej 0.95 nie masz podstaw twierdzić, że edge istnieje.
- Sprawdź stabilność między foldami: agent lepszy w 5/6 foldów > agent lepszy średnio dzięki jednemu foldowi.
Agent zarobił 40%, benchmark 30%. Dlaczego to jeszcze nie znaczy, że agent jest lepszy?
Po co liczyć deflated Sharpe zamiast zwykłego Sharpe'a?
Twój agent po kosztach nie bije DCA. Co to mówi?
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
Open interest, imbalance i basis: mikrostruktura jako cechy
Krypto daje za darmo dane, za które na akcjach płaci się krocie. Trzy najtańsze do policzenia, o realnej wartości informacyjnej:
- Open interest (OI) — liczba otwartych kontraktów, czyli ile lewarowanego kapitału siedzi w rynku (
ex.fetch_open_interest_history()). Rosnące OI + skrajny funding = rynek przegrzany i „naładowany" pod kaskadę likwidacji. Jako cecha: log-zmiana OI, znormalizowana jak wszystko w module 6. - Orderbook imbalance (OBI) — (Vbid − Vask) / (Vbid + Vask) na szczycie książki (lub kilku poziomach). Krótkoterminowy predyktor kierunku — im bardziej jednostronna presja, tym bardziej prawdopodobny ruch w jej stronę. Wymaga zbierania snapshotów L2 na bieżąco (nie kupisz historii za darmo) — dobry projekt „po kursie".
- Basis — różnica cen perpetual − spot. Wysoki dodatni basis = euforia lewaru; ujemny = strach. Tani wskaźnik reżimu sentymentu, komplementarny do
vol_regimez modułu 6.
Kaskady likwidacji: anatomia 10 października 2025
Likwidacja to przymusowe zamknięcie pozycji, gdy strata zjada margines. Problem w tym, że likwidacje pchają cenę w stronę kolejnych progów likwidacyjnych — reakcja łańcuchowa. Studium przypadku, które powinieneś znać na pamięć: 10.10.2025, po zapowiedzi 100% ceł USA na Chiny, w ciągu doby zlikwidowano ponad 19 mld USD pozycji na ponad 1.6 mln kont. W kulminacyjnych 40 minutach — prawie 7 mld USD. BTC spadł o ~14.5%, niektóre alty chwilowo o 40%+. Kluczowa obserwacja dla Twoich modeli: głębokość szczytu książki zleceń spadła o ponad 90%, a spready z pojedynczych punktów bazowych urosły do dwucyfrowych procentów.
- Lekcja 1 — model kosztów zależny od reżimu: stały poślizg 0.02% z modułu 4 jest OK w spokoju i absurdalnie optymistyczny w stresie. Minimalny krok: poślizg proporcjonalny do bieżącej zmienności (slippage = k·σt), wpięty w env.
- Lekcja 2 — płynność znika, gdy jej potrzebujesz: stop-loss „rynkowy" w kaskadzie wykonuje się o procenty od ceny wyzwolenia. Risk layer (moduł 10) musi zakładać scenariusz „nie wyjdę po dobrej cenie".
- Lekcja 3 — cross-margin wiąże Cię z najsłabszym aktywem: strukturalnym paliwem 10.10 był unified margin — pod stresem cały portfel jest tak mocny, jak najbardziej lewarowana noga. Preferuj margines izolowany, dopóki nie wiesz dokładnie, co robisz.
Sezonowość dobowa: rynek ma rytm
Pamiętasz cechy hod_sin/hod_cos z modułu 6? To nie dekoracja. Rynek krypto
działa 24/7, ale kapitał nie: badania sezonowości BTC pokazują koncentrację najsilniejszych
zwrotów w oknie 21:00–23:00 UTC (po zamknięciu sesji US) oraz wyraźne
efekty przełomu tygodnia (niedziela ~23:00 UTC, otwarcie Azji w poniedziałek).
Zmienność też ma rytm dobowy: minimum w nocy azjatyckiej, szczyt na otwarciu US.
Wniosek praktyczny: godzina i dzień tygodnia to tanie, legalne (bez lookahead)
cechy — a jeśli Twój agent nauczył się „handlować tylko o 21 UTC", wiedz, że odkrył
znaną anomalię, nie magię.
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ć."""
# PUŁAPKA: fetch_ohlcv zwraca bieżącą, wciąż OTWARTĄ świecę jako ostatnią.
# Budzimy się o :00:05, więc jej close ~ open i ostatni zwrot byłby ~0
# w KAŻDEJ obserwacji - czego w treningu nie było. Bierzemy 41 i tniemy.
ohlcv = ex.fetch_ohlcv(SYMBOL, "1h", limit=41)
close = np.array([c[4] for c in ohlcv], dtype=np.float64)[:-1]
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, kryterium Kelly'ego, vol targeting, kill-switche i matematyka likwidacji dostały własny moduł 10 — Risk management i sizing. To nie jest dodatek do strategii, tylko warstwa, która decyduje, czy przeżyjesz własnego agenta.
Strategia long-only na perpetualach, backtest bez fundingu. Co jest nie tak?
Wynik paper tradingu rozjeżdża się z backtestem na tych samych świecach o więcej niż koszty. Najczęstsza przyczyna?
Krypto ma zmienność 60–100%+ i grubsze ogony niż akcje. Praktyczna konsekwencja dla agenta?
Risk management i sizing: warstwa nad agentem
Cel: zbudować osobną, testowalną warstwę kodu, która decyduje ILE ryzykować i kiedy zablokować agenta — bo o przetrwaniu decyduje sizing, nie kierunek.
Filozofia: agent proponuje, risk layer dysponuje
Do tej pory agent był całym systemem: obserwacja wchodzi, akcja wychodzi, akcja to pozycja. To wystarczało w backteście. W produkcji to przepis na katastrofę, bo model nie ma pojęcia o kapitale, o dzisiejszej stracie ani o tym, że właśnie zwariował. Profesjonalny system rozdziela dwie decyzje, które amator skleja w jedną: w którą stronę (to należy do agenta) i ile (to należy do warstwy ryzyka).
Risk layer to osobny kawałek kodu, który stoi między agentem a giełdą. Dostaje surową akcję modelu i stan portfela, a zwraca akcję przeskalowaną i przyciętą — albo blokadę. Kluczowa własność: jest prosty, deterministyczny i pokryty testami. Agent to czarna skrzynka, której nie ufasz do końca. Warstwa ryzyka to biała skrzynka, którą czytasz linia po linii i której ufasz właśnie dlatego, że nic się w niej nie uczy.
Gdybyś próbował „nauczyć" agenta ryzyka przez funkcję nagrody, dostałbyś ryzyko probabilistyczne — działa średnio, zawodzi w ogonie. A ogon to jedyne, co Cię zabija. Twarde ograniczenia muszą być twarde: egzekwuje je kod, którego model nie kontroluje i nie może obejść.
Volatility targeting: stała ekspozycja na ryzyko, nie na kapitał
Największy błąd początkującego to stała wielkość pozycji („zawsze 1000 USD"). Problem: ta sama pozycja niesie zupełnie inne ryzyko, gdy BTC robi 1% dziennie, a inne, gdy robi 8%. Vol targeting odwraca logikę: ustalasz docelową zmienność portfela i skalujesz ekspozycję tak, by ją utrzymać.
Przykład: celujesz w 10% zmienności rocznej, a rynek realizuje właśnie 15%. Wtedy skala = 10/15 ≈ 0.67 — trzymasz 67% nominalnej pozycji. Gdy rynek się uspokoi do 5%, skala = 10/5 = 2.0 i zwiększasz ekspozycję. Efekt jest kontrintuicyjnie dobry: automatycznie chowasz się w chaosie i odważasz w spokoju, bez żadnej prognozy.
To nie jest folklor — to jeden z niewielu efektów w finansach, który powtarza się na dekadach danych. Na indeksie S&P 500 nałożenie vol-targetingu podniosło Sharpe z 0.43 do 0.61 i obniżyło maksymalne obsunięcie z 52% do 24% — czyli poprawiło zwrot na jednostkę ryzyka i jednocześnie o połowę ścięło najgorszy scenariusz. Powód jest strukturalny: zmienność jest silnie autoskorelowana (skupia się w klastry), więc wczorajsza wysoka zmienność realnie przewiduje jutrzejszą. Kierunku nie umiesz przewidzieć, ale skalę — tak.
Typowe targety to 8–12% rocznie dla akcji. Dla BTC, którego surowa zmienność to 60–100% rocznie, celowanie w 10% oznacza trzymanie ułamka nominalnej pozycji przez większość czasu — i o to chodzi. Nie przenoś targetu z akcji na krypto bezmyślnie: ustaw go świadomie znacznie poniżej nominalnej zmienności instrumentu, bo inaczej vol-targeting nic nie ogranicza.
"""Volatility targeting: skaluj ekspozycję do docelowej zmienności.
Zmienność szacujemy EWMA - reaguje szybciej niż okno kroczące,
bo nowszym zwrotom daje większą wagę."""
import numpy as np
def realized_vol_ewma(log_ret: np.ndarray, halflife: int = 20,
periods_per_year: int = 24 * 365) -> float:
"""Zannualizowana zmienność z EWMA na zwrotach log.
halflife w krokach (dla świec 1h: 20 = ~20h 'pamięci').
periods_per_year: 24*365 dla 1h krypto, 252 dla dziennych akcji."""
if len(log_ret) < 2:
return np.nan
alpha = 1.0 - 0.5 ** (1.0 / halflife) # waga najnowszej obserwacji
weights = (1 - alpha) ** np.arange(len(log_ret))[::-1]
weights /= weights.sum()
mean = np.average(log_ret, weights=weights)
var = np.average((log_ret - mean) ** 2, weights=weights)
return np.sqrt(var * periods_per_year) # sigma per krok -> roczna
def vol_target_scale(log_ret: np.ndarray, sigma_target: float = 0.10,
max_scale: float = 3.0) -> float:
"""Zwraca mnożnik ekspozycji w [0, max_scale].
sigma_target: docelowa zmienność ROCZNA (0.10 = 10%).
max_scale: twardy cap, by w martwym rynku nie wziąć absurdalnej dźwigni."""
sigma_real = realized_vol_ewma(log_ret)
if not np.isfinite(sigma_real) or sigma_real <= 0:
return 0.0 # brak wiarygodnej estymaty = brak pozycji
scale = sigma_target / sigma_real
return float(np.clip(scale, 0.0, max_scale))
if __name__ == "__main__":
_# Symulacja: 500 zwrotów 1h o zmienności ~15% rocznej
rng = np.random.default_rng(0)
sigma_step = 0.15 / np.sqrt(24 * 365) # 15% rocznie -> na krok 1h
r = rng.normal(0, sigma_step, size=500)
print(f"Zmienność realizowana: {realized_vol_ewma(r):.1%} rocznie")
print(f"Skala przy targecie 10%: {vol_target_scale(r, 0.10):.2f}")
_# oczekiwane ~0.67: realized ~15%, target 10% -> 10/15
Kryterium Kelly'ego: teoretyczne optimum, którego nie chcesz grać w całości
Vol targeting mówi, jak skalować pozycję z ryzykiem. Kelly odpowiada na głębsze pytanie: jaki ułamek kapitału maksymalizuje długoterminowe tempo wzrostu? Dla ciągłego przypadku odpowiedź jest zwodniczo prosta:
gdzie μ to oczekiwany zwrot nadwyżkowy, a σ² to jego wariancja. Intuicja: stawiaj tym więcej, im większa przewaga (μ) i im mniejsza niepewność (σ²). Kelly maksymalizuje oczekiwany logarytm kapitału — czyli geometryczne tempo wzrostu — i w długim biegu bije każdą inną strategię sizingu. Brzmi jak święty Graal. Nie jest.
Haczyk siedzi w tym, że μ i σ nie znasz — estymujesz je z hałaśliwych danych. A Kelly jest brutalnie wrażliwy na błąd w μ, bo wchodzi liniowo do licznika. Jeśli przeszacujesz przewagę dwukrotnie (banalnie łatwe na rynku), grasz podwójny Kelly. A krzywa tempa wzrostu w funkcji ułamka Kelly'ego jest odwróconą parabolą: rośnie do f*, po czym spada, i przecina zero dokładnie przy 2× Kelly. Grając pełnym Kelly'm na przeszacowanej przewadze lądujesz przy zerowym wzroście przy potwornej zmienności — a wszystko powyżej to ruina z prawdopodobieństwem rosnącym do 1.
Dlatego praktycy grają ułamkiem Kelly'ego — najczęściej połówką. Matematyka half-Kelly jest wyjątkowo łaskawa: tniesz zmienność portfela o połowę, a oddajesz tylko ~25% tempa wzrostu. To jedna z najlepszych wymian ryzyka na zwrot, jaką znajdziesz — kupujesz gigantyczny spokój za drobny ułamek potencjału. Poniższe demo pozwala poczuć tę parabolę na własnej myszce: przesuwasz ułamek Kelly'ego i patrzysz, jak zmienia się mediana kapitału i ryzyko bankructwa w wielu symulowanych ścieżkach.
Nota dydaktyczna: przesuń „frakcję Kelly'ego" do 50% — połowa zmienności za ok. 3/4 wzrostu (half-Kelly). Powyżej 100% mediana rośnie wolniej, a odsetek „ruiny" gwałtownie skacze; przy ~200% krzywa g(f) schodzi poniżej zera — pełny Kelly na PRZESZACOWANYM edge to matematyczna gwarancja katastrofy.
Na własnej strategii nigdy nie graj więcej niż half-Kelly, a jeśli ufasz swoim estymatom przewagi mniej niż w pełni (a powinieneś) — ćwierć-Kelly. Vol targeting i Kelly nie są konkurencją: vol targeting utrzymuje stałe ryzyko w czasie, ułamek Kelly'ego ustala jego globalny poziom.
Dźwignia i cena likwidacji: arytmetyka, która kończy konta
Na perpetualach dźwignia jest zawsze pod ręką i zawsze kusi. Zanim jej dotkniesz, musisz umieć policzyć w głowie jedną rzecz: jak blisko jest likwidacja. Przy dźwigni L i marginesie izolowanym cena likwidacji leży w przybliżeniu o ruch ~1/L przeciw Twojej pozycji (pomijając prowizje i margin utrzymaniowy, które przybliżają ją jeszcze bardziej).
Przy dźwigni 10× wystarczy ruch 10% przeciw Tobie — na BTC to kwestia jednego nerwowego popołudnia. Przy 20× to 5%, czyli szum wewnątrzdzienny. Dźwignia nie zwiększa Twojej przewagi ani o jotę — mnoży wyłącznie zmienność i przybliża ścianę, o którą się rozbijasz. Demo poniżej pokazuje, jak cena likwidacji zaciska się wokół ceny wejścia wraz z rosnącą dźwignią.
Long BTC z dźwignią 20×, zmienność dzienna 3%. Jakie jest ryzyko likwidacji w ciągu 2 tygodni?
Nota dydaktyczna: przy 20× próg likwidacji to raptem −5%, a dzienna zmienność BTC to 2–4% — likwidacja jest kwestią dni, nie pecha. Zmniejsz dźwignię do 3–5×, żeby zobaczyć, jak odsetek zlikwidowanych ścieżek spada niemal do zera. Kaskady z 10.10.2025 (19 mld USD w dobę) zaczynały się dokładnie tak.
W trybie izolowanym likwidacja jednej pozycji zjada tylko margin do niej przypisany — reszta portfela żyje. W trybie cross cały kapitał konta jest wspólnym zabezpieczeniem, więc jedna pozycja idąca pod wodę może pociągnąć za sobą całe konto. Cross wiąże cały portfel z losem najsłabszego aktywa. Domyślnie handluj izolowanym, a dźwignię traktuj jak narzędzie sizingu (mniejszy margin na tę samą ekspozycję), nie jak sposób na większą ekspozycję.
Kill-switch i twarde limity: ostatnia linia obrony
Vol targeting i Kelly to sizing „na co dzień". Kill-switch to bezpiecznik na dzień, w którym coś pójdzie fundamentalnie nie tak: bug w danych, model trafi na reżim spoza treningu, albo giełda zwróci śmieci. Twarde limity to nie sugestie — to progi, po przekroczeniu których system przestaje handlować, kropka:
- Max daily loss — np. 2% equity. Spadłeś dziś o 2%? Koniec handlu do jutra. To ochrona przed serią, w której model uparcie powtarza ten sam błąd.
- Max drawdown vs historyczny — jeśli bieżące obsunięcie przebija najgorsze z backtestu, to nie pech, to sygnał zmiany reżimu. Zatrzymaj i zbadaj, zanim stracisz więcej.
- Position limits — twardy cap na notional niezależny od tego, co proponuje sizing. Nawet gdy vol targeting policzy skalę 3.0, kod nie przepuści pozycji większej niż ustalony sufit.
- Sanity checks akcji — akcja to NaN albo poza zakresem? Zleceń więcej niż X na godzinę? Zamykasz pozycję, więc zlecenie musi być
reduce-only? Każdy z tych warunków blokuje albo koryguje akcję, zanim dotrze do giełdy.
"""Warstwa ryzyka: stoi między agentem a giełdą.
Bierze surową akcję modelu i stan portfela, zwraca akcję bezpieczną lub BLOCK.
Cała 'inteligencja' jest tu deterministyczna i testowalna - w tym rzecz."""
from dataclasses import dataclass
import numpy as np
BLOCK = None # sygnał: nie wysyłaj żadnego zlecenia
@dataclass
class RiskLimits:
max_daily_loss: float = 0.02 # 2% equity - dzienny stop
max_drawdown: float = 0.20 # 20% - przebicie = zmiana reżimu
max_notional: float = 1.0 # cap ekspozycji jako ułamek equity
max_orders_per_hour: int = 6 # zbyt częste zlecenia = coś się zapętliło
class RiskLayer:
def __init__(self, limits: RiskLimits = RiskLimits()):
self.lim = limits
self.orders_this_hour = 0
def check(self, action, state: dict):
"""action: docelowa pozycja z agenta (np. -1..1 po sizingu).
state: {'equity', 'day_start_equity', 'peak_equity',
'current_pos', 'closing'}.
Zwraca akcję (być może przyciętą) albo BLOCK."""
eq = state["equity"]
_# 1. Sanity check samej akcji - NaN/inf/poza zakresem nie idzie dalej
if action is None or not np.isfinite(action):
return BLOCK
action = float(np.clip(action, -1.0, 1.0))
_# 2. Dzienny stop: strata od otwarcia dnia przebiła limit
daily_pnl = (eq - state["day_start_equity"]) / state["day_start_equity"]
if daily_pnl <= -self.lim.max_daily_loss:
return self._flatten_only(action, state)
_# 3. Drawdown od szczytu equity przebił limit historyczny
drawdown = (state["peak_equity"] - eq) / state["peak_equity"]
if drawdown >= self.lim.max_drawdown:
return self._flatten_only(action, state)
_# 4. Cap na notional - niezależny od sizingu agenta
action = float(np.clip(action, -self.lim.max_notional, self.lim.max_notional))
_# 5. Limit częstotliwości zleceń (chroni przed pętlą wysyłającą w kółko)
if action != state["current_pos"]:
if self.orders_this_hour >= self.lim.max_orders_per_hour:
return BLOCK
self.orders_this_hour += 1
_# 6. reduce-only przy zamykaniu: nie pozwól odwrócić pozycji 'przy okazji'
if state.get("closing") and np.sign(action) == -np.sign(state["current_pos"]):
action = 0.0 # domykamy do zera, nie flipujemy
return action
def _flatten_only(self, action, state):
"""Tryb awaryjny: dozwolone tylko domykanie pozycji do zera."""
if state["current_pos"] == 0:
return BLOCK # brak pozycji - nic nie robimy
return 0.0 # jest pozycja - domknij ją
def reset_hour(self):
self.orders_this_hour = 0
Warstwa ryzyka jest tak ważna, że testujesz ją jak kod lotniczy — każda gałąź ma test. Poniżej minimalny zestaw pytest; w produkcji dopiszesz po jednym teście na każdy limit i każdy tryb awaryjny.
"""Testy warstwy ryzyka. Uruchom: pytest test_risk_layer.py -v"""
import numpy as np
from risk_layer import RiskLayer, RiskLimits, BLOCK
def base_state(**kw):
s = {"equity": 100.0, "day_start_equity": 100.0, "peak_equity": 100.0,
"current_pos": 0.0, "closing": False}
s.update(kw)
return s
def test_nan_action_is_blocked():
rl = RiskLayer()
assert rl.check(np.nan, base_state()) is BLOCK
def test_action_is_clipped_to_notional_cap():
rl = RiskLayer(RiskLimits(max_notional=0.5))
_# agent chce 1.0, cap to 0.5 -> oczekujemy 0.5
assert rl.check(1.0, base_state()) == 0.5
def test_daily_loss_forces_flatten():
rl = RiskLayer(RiskLimits(max_daily_loss=0.02))
_# strata 3% dnia, mamy longa -> wolno tylko domknąć do zera
st = base_state(equity=97.0, current_pos=1.0)
assert rl.check(1.0, st) == 0.0
def test_daily_loss_with_no_position_blocks_new_trades():
rl = RiskLayer(RiskLimits(max_daily_loss=0.02))
st = base_state(equity=97.0, current_pos=0.0)
assert rl.check(1.0, st) is BLOCK # po stopie nie otwieramy nowych
def test_drawdown_breach_forces_flatten():
rl = RiskLayer(RiskLimits(max_drawdown=0.20))
st = base_state(equity=75.0, peak_equity=100.0, current_pos=-1.0)
assert rl.check(-1.0, st) == 0.0
def test_order_frequency_limit():
rl = RiskLayer(RiskLimits(max_orders_per_hour=2))
st = base_state(current_pos=0.0)
assert rl.check(1.0, st) == 1.0 # 1. zlecenie ok
assert rl.check(-1.0, base_state(current_pos=1.0)) == -1.0 # 2. ok
assert rl.check(1.0, base_state(current_pos=-1.0)) is BLOCK # 3. zablokowane
def test_closing_flag_prevents_flip():
rl = RiskLayer()
_# zamykamy longa, agent proponuje shorta -> domknij do zera, nie flipuj
st = base_state(current_pos=1.0, closing=True)
assert rl.check(-1.0, st) == 0.0
Rynek wejdzie w reżim spoza danych treningowych, feed zwróci błędną świecę, albo w wagach zadziała coś, czego nikt nie przewidział — i agent wystawi absurdalną akcję. To pewne jak podatki. Jedyne pytanie brzmi, czy w tym momencie między modelem a Twoimi pieniędzmi stoi warstwa kodu, której model nie kontroluje i nie może obejść. Kill-switch, którego nie napisałeś, zawsze wygląda na niepotrzebny — aż do jednego dnia, w którym jest jedyną rzeczą, która ratuje konto.
Twoja strategia realizuje zmienność 20% rocznie, a Ty celujesz w vol targeting na poziomie 8%. Jaką część nominalnej pozycji powinieneś trzymać?
Dlaczego praktycy grają half-Kelly zamiast pełnego Kelly'ego, mimo że pełny Kelly maksymalizuje tempo wzrostu?
Model wystawił akcję będącą NaN. Gdzie ten problem powinien zostać zatrzymany?
Ostatnia mila: architektura systemu live
Cel: zamienić skrypt z pętlą while True w system, który przeżyje reconnect, timeout i restart bez zgubienia albo zdublowania zlecenia — i wie, kiedy się wyłączyć.
Trzy pętle zamiast jednej
Skrypt paper-tradingu z modułu 9 miał jedną pętlę: pobierz świece, policz akcję, wyślij zlecenie, śpij. To działa na testnecie w dobry dzień. W produkcji ta monolityczna pętla pęka, bo skleja trzy niezależne procesy o zupełnie różnym tempie. Profesjonalny system live rozdziela je na trzy osobne pętle:
- Data feed — słucha WebSocketa giełdy, składa z tików bary i cechy, utrzymuje aktualny obraz rynku. Działa cały czas, niezależnie od decyzji.
- Inference — budzi się raz na zamknięcie bara, bierze gotowe cechy, odpytuje agenta i przekazuje docelową pozycję przez warstwę ryzyka. Dla agenta na świecach 1h to jedno wywołanie na godzinę.
- Order manager — pilnuje cyklu życia zleceń: wysyła, śledzi wypełnienia, obsługuje częściowe fille i anulacje. Działa w swoim rytmie, między inference a giełdą.
Do feedu i zleceń służy CCXT Pro — warstwa WebSocket nad CCXT z metodami
watch_order_book, watch_trades, watch_orders.
Zwróć uwagę na proporcje: dla agenta decydującego raz na godzinę latencja jest
nieistotna — kilkaset milisekund w tę czy tamtą stronę nic nie zmienia. Kluczowa
jest architektura: to, że system poprawnie odtwarza stan po awarii, a nie
to, że robi to o 20 ms szybciej. Odwrotnie niż w HFT, gdzie liczy się każda mikrosekunda,
a architektura jest prosta.
Gdy feed, decyzja i zlecenia siedzą w jednym while True, timeout przy wysyłce zlecenia blokuje odbiór danych, a błąd w budowaniu cech zabija cały system. Rozdzielone pętle upadają niezależnie i restartują niezależnie — a to, jak przeżywają restart, jest treścią całego tego modułu.
Reconciliation: prawdą jest giełda, nie Twój stan lokalny
Najgroźniejszy błąd w tradingu live to rozjazd między tym, co system myśli, że ma, a tym, co faktycznie jest na giełdzie. Powstaje zawsze tak samo: proces padł tuż po wysłaniu zlecenia, ale przed zapisaniem tego faktu; albo reconnect zgubił zdarzenie o wypełnieniu. Po każdym restarcie i po każdym reconnect obowiązuje jedna zasada: nie ufaj pamięci, odtwórz prawdę z giełdy i uzgodnij ją ze stanem lokalnym.
"""Reconciliation: po starcie/reconnect odtwórz prawdę z giełdy.
Rozjazd stanu lokalnego z giełdą = STOP i alarm, nie 'jakoś to będzie'."""
import ccxt, logging
def reconcile(ex, local_state: dict, symbol: str) -> dict:
"""Zwraca prawdziwy stan z giełdy; przy rozjeździe podnosi alarm.
local_state: to, co system myślał, że ma (z pliku/bazy)."""
open_orders = ex.fetch_open_orders(symbol)
positions = ex.fetch_positions([symbol])
balance = ex.fetch_balance()
real_pos = 0.0
for p in positions:
amt = float(p["info"].get("positionAmt", 0) or 0)
if amt:
real_pos = amt
_# Porównaj prawdę z giełdy z tym, co mieliśmy zapisane lokalnie
if abs(real_pos - local_state.get("position", 0.0)) > 1e-8:
logging.error(
f"ROZJAZD POZYCJI: lokalnie {local_state.get('position')} "
f"vs giełda {real_pos} - STOP")
raise SystemExit("Reconciliation failed: rozjazd pozycji")
if open_orders:
logging.warning(f"Znaleziono {len(open_orders)} wiszących zleceń po starcie")
return {"position": real_pos,
"open_orders": open_orders,
"free_usdt": balance["USDT"]["free"]}
Kusi, by po wykryciu rozjazdu „naprawić" pozycję dosyłką zlecenia. Nie rób tego automatycznie. Rozjazd oznacza, że jedno z Twoich założeń o świecie jest fałszywe — a handel na fałszywych założeniach to jak łatanie dziury, której nie widzisz. Zatrzymaj system, podnieś alarm, obejrzyj to ludzkim okiem. Automatyczna korekta na złej diagnozie potrafi podwoić szkodę.
Idempotencja przez clientOrderId: nigdy dwa razy tego samego zlecenia
Wysyłasz zlecenie, mija timeout — i nie wiesz, czy giełda go nie dostała, czy dostała,
ale zgubiła się odpowiedź. Naiwny odruch „wyślę jeszcze raz" to najkrótsza droga do
podwójnej pozycji. Rozwiązanie: nadawaj każdemu zleceniu własny
clientOrderId i po timeoucie najpierw sprawdź, czy takie
zlecenie już istnieje, zanim cokolwiek wyślesz ponownie.
"""Idempotentne wysyłanie zlecenia: na timeout NIE dubluj - sprawdź stan.
clientOrderId nadany przez nas jest kluczem, po którym rozpoznajemy 'to samo'."""
import time, uuid, logging, ccxt
def place_order_idempotent(ex, symbol, side, qty, coid=None,
max_retries=4):
"""Zwraca zlecenie. Bezpieczne do ponowienia: ten sam clientOrderId
nigdy nie utworzy drugiego zlecenia."""
coid = coid or f"bot-{uuid.uuid4().hex[:16]}" # nasz stały identyfikator
for attempt in range(max_retries):
try:
return ex.create_order(
symbol, "market", side, qty,
params={"clientOrderId": coid})
except ccxt.RequestTimeout:
_# Nie wiemy, czy dotarło. Sprawdź, ZANIM wyślesz ponownie.
logging.warning(f"timeout (proba {attempt+1}), sprawdzam clientOrderId={coid}")
try:
existing = ex.fetch_order(coid, symbol,
params={"clientOrderId": coid})
if existing: # jednak dotarło - gotowe
logging.info(f"zlecenie {coid} istnieje - nie dubluję")
return existing
except ccxt.OrderNotFound:
pass # nie dotarło - można ponowić
except ccxt.NetworkError as e:
logging.warning(f"blad sieci: {e}")
time.sleep(2 ** attempt) # exponential backoff: 1,2,4,8 s
raise RuntimeError(f"nie udało się wystawić zlecenia {coid} po {max_retries} próbach")
Typy zleceń: wolny agent może wybrać taniej
Agent decydujący raz na godzinę ma luksus, którego nie ma HFT: czas. Może złożyć zlecenie pasywne i poczekać na wypełnienie, zamiast płacić za natychmiastowość. Na perpetualach Binance różnica jest konkretna: maker 0.02%, taker 0.05%. Przy setkach transakcji rocznie ta różnica jest jedną z największych dźwigni na Twój wynik netto — większą niż niejedna „poprawka modelu".
| Typ zlecenia | Kiedy używać | Koszt / ryzyko |
|---|---|---|
| market | Trzeba wejść/wyjść natychmiast (stop, kill-switch) | Taker 0.05% + poślizg; pewność wykonania |
| limit | Masz cenę graniczną i akceptujesz brak wykonania | Może się nie wypełnić; kontrola ceny |
| post-only | Chcesz gwarancji maker fee — wolny agent może poczekać | Odrzucone, gdyby weszło jako taker; najtaniej |
| reduce-only | Zamykanie/zmniejszanie pozycji na perp — obowiązkowy safety | Nigdy nie odwróci pozycji „przy okazji" |
reduce-only na perpetualach nie jest opcją — to obowiązek. Gwarantuje, że
zlecenie domykające co najwyżej sprowadzi pozycję do zera, ale nigdy jej nie odwróci,
jeśli źle policzyłeś wielkość. To ten sam odruch, co flaga closing w warstwie
ryzyka z modułu 10 — dwie warstwy pilnujące tej samej rzeczy.
Częściowe wypełnienia: zlecenie to nie zdarzenie zero-jedynkowe
Skrypt z modułu 9 zakładał, że zlecenie market albo się wykona w całości, albo nie.
W rzeczywistości giełda zwraca filled i remaining: część
weszła, część czeka. System musi wiedzieć, ile realnie ma pozycji, i świadomie zdecydować
o reszcie:
- Dopłać marketem — gdy zależy na pełnej ekspozycji i akceptujesz koszt taker na resztę.
- Przestaw limit — gdy możesz poczekać i chcesz dokończyć po cenie maker.
- Anuluj resztę — gdy częściowa pozycja jest wystarczająca albo warunki się zmieniły.
Cokolwiek wybierzesz, filled jest jedyną prawdą o Twojej pozycji — nie to,
co zamierzałeś, tylko to, co giełda potwierdziła jako wypełnione.
Poprawiona pętla: świeca, która jeszcze się nie domknęła
Wróćmy do build_obs() z modułu 9 — bo kryje się w nim błąd, który zabija
zgodność trening↔produkcja tak cicho, że łatwo go przeoczyć. CCXT przez
fetch_ohlcv zwraca jako ostatnią świecę bieżącą, jeszcze
otwartą. Jej close to aktualna cena, a nie cena zamknięcia — więc
ostatni policzony zwrot jest zawsze bliski zera i zmienia się w każdej sekundzie. W
treningu takiej świecy nie było: tam wszystkie świece były domknięte.
def build_obs():
"""Ta sama transformacja co w treningu - co do bitu.
POPRAWKA vs modul 9: pobieramy 41 świec i ODCINAMY ostatnią (otwartą),
bo jej 'close' to cena bieżąca, nie zamknięcie - inaczej ostatni zwrot
w obserwacji live jest zawsze ~0 i mamy rozjazd trening/produkcja."""
ohlcv = ex.fetch_ohlcv(SYMBOL, "1h", limit=41) # 41, nie 40
close = np.array([c[4] for c in ohlcv], dtype=np.float64)[:-1] # odetnij otwartą
r = np.diff(np.log(close))[-32:] * 100.0 # dokładnie 32 zwroty z domkniętych świec
pos = get_current_position()
return np.append(r, pos).astype(np.float32)
Rozjazd preprocessingu trening↔produkcja nie rzuca wyjątku i nie pojawia się w logach — po prostu agent w live widzi inny świat niż ten, na którym się uczył, i jego decyzje przestają mieć sens. Efekt: paper trading powoli rozjeżdża się z backtestem, a Ty szukasz błędu w modelu, choć siedzi on w jednej niedomkniętej świecy. Zawsze porównuj obserwację zbudowaną live z tą z backtestu na tym samym timestampie — muszą być identyczne co do bitu.
Monitoring i MLOps: rynek to ciągły dryf
W klasycznym ML rozróżniamy data drift (zmienia się rozkład wejść) i concept drift (zmienia się relacja wejście→wyjście). Na rynku concept drift to nie awaria, to stan domyślny — strategia, która działała w hossie 2021, przestaje działać w bessie 2022, bo zmieniła się sama gra. Dlatego monitoring nie pyta „czy model się zdezaktualizował", tylko „jak bardzo i czy już czas reagować". Konkretne triggery:
- Przebicie historycznego maxDD — bieżące obsunięcie głębsze niż najgorsze z backtestu (jak w warstwie ryzyka z modułu 10).
- Rolling Sharpe live poza przedziałem z backtestu — jeśli krocząca jakość spadła poniżej dolnego progu z ewaluacji, model nie gra już tej gry, na której się uczył.
Zamiast wyłączać model na zgadanie, profesjonalny zespół prowadzi go równolegle:
- Shadow trading — nowy model liczy decyzje na żywo, ale bez wysyłania zleceń. Zbierasz jego „papierowy" wynik na realnych danych, zanim powierzysz mu grosz.
- Champion/challenger — challenger dostaje ułamek kapitału i konkuruje z championem na realnych transakcjach; awansuje dopiero, gdy udowodni przewagę.
- Retraining wyzwalany dryfem, nie kalendarzem — „przetrenuj co miesiąc" to zabobon. Trenuj, gdy monitoring pokaże dryf, bo to on, a nie data w kalendarzu, mówi, że model stracił kontakt z rynkiem.
- Logowanie KAŻDEJ decyzji — stan, akcja, zlecenie, fill, PnL. Bez tego nie odtworzysz, dlaczego bot zrobił to, co zrobił — a ten log jest pierwszą rzeczą, której będziesz szukał po każdym złym dniu.
"""Najprostszy użyteczny monitor dryfu: kroczący Sharpe live vs próg z backtestu.
Gdy jakość spada poniżej dolnej granicy - sygnał do shadow tradingu / retreningu."""
import numpy as np
from collections import deque
class DriftMonitor:
def __init__(self, window: int = 24 * 30, # ~30 dni świec 1h
sharpe_floor: float = 0.0, # dolny próg z backtestu
periods_per_year: int = 24 * 365):
self.returns = deque(maxlen=window)
self.floor = sharpe_floor
self.ppy = periods_per_year
def update(self, step_return: float) -> dict:
"""Dokłada zwrot z ostatniego kroku, zwraca stan monitoringu."""
self.returns.append(step_return)
if len(self.returns) < self.returns.maxlen // 2:
return {"ready": False, "sharpe": None, "alert": False}
r = np.array(self.returns)
sd = r.std()
sharpe = 0.0 if sd == 0 else r.mean() / sd * np.sqrt(self.ppy)
alert = sharpe < self.floor # jakość poniżej progu backtestu
return {"ready": True, "sharpe": float(sharpe), "alert": bool(alert)}
Zyski z krypto rozliczasz w Polsce jako przychód z kapitałów pieniężnych: 19% PIT na formularzu PIT-38. Kluczowa zasada: wymiana krypto↔krypto jest neutralna podatkowo — zdarzenie podatkowe powstaje dopiero przy zamianie na FIAT (albo zapłacie krypto za towar/usługę). Handel BTC↔ETH↔USDT w kółko nie generuje podatku, dopóki nie wyjdziesz do złotówek. Giełdy krypto nie wystawiają PIT-8C — rozliczasz się sam z własnej historii transakcji. Koszty nabycia nieodliczone w danym roku przenosisz na lata kolejne. Od 1 stycznia 2026 giełdy raportują dane o użytkownikach do administracji skarbowej (unijna dyrektywa DAC8) — fiskus i tak zobaczy Twoje ruchy, więc rozliczenie musi się zgadzać.
MiCA / licencja CASP dotyczy firm świadczących usługi krypto (giełd, brokerów, custodianów), a nie osoby fizycznej handlującej na własny rachunek własnym kapitałem. Boty na własne konto Cię nie licencjonują. Osobna, poważniejsza sprawa to ryzyko kontrahenta: FTX, Celsius i Voyager pokazały, że „środki na giełdzie" to nie Twoje środki, tylko roszczenie wobec giełdy. Proof of Reserves nie ratuje — to zdjęcie aktywów w jednym momencie, bez informacji o zobowiązaniach off-chain, więc dowodzi istnienia rezerw, a nie wypłacalności. Praktyczna obrona: self-custody środków, które nie pracują, na giełdzie trzymaj tylko kapitał roboczy, i dywersyfikuj między giełdy, by upadek jednej nie zabrał wszystkiego.
To nie jest porada podatkowa ani prawna — to orientacyjny obraz stanu na 2026 r. Przed realnymi decyzjami skonsultuj się z doradcą podatkowym i sprawdź aktualne przepisy.
Wysłałeś zlecenie market, dostałeś timeout i nie wiesz, czy dotarło. Co robisz?
Po restarcie systemu okazuje się, że stan lokalny mówi „pozycja 0", a giełda pokazuje otwartego longa. Co powinien zrobić system?
W obserwacji live ostatni zwrot logarytmiczny jest prawie zawsze bliski zera, choć w backteście był normalną liczbą. Najbardziej prawdopodobna przyczyna?
Market making i optymalna egzekucja: gdzie RL naprawdę wygrywa
Cel: zrozumieć dwa zastosowania, w których RL ma najmocniejsze umocowanie teoretyczne i empiryczne — kwotowanie market makera i rozłożenie dużego zlecenia w czasie — oraz nauczyć się klasycznych baseline'ów (Avellaneda–Stoikov, Almgren–Chriss), na których RL buduje.
Dlaczego akurat tu RL działa najlepiej
Systematyczny przegląd 167 prac o RL w finansach (2017–2025) policzył coś, co warto zapamiętać na całe życie z tym tematem: „premię RL" — o ile agent RL bije silny baseline klasyczny — dla każdej domeny osobno. Market making zajął pierwsze miejsce z premią 0.488, wyprzedzając egzekucję, zarządzanie portfelem i — na samym końcu — kierunkową spekulację. To nie przypadek.
- Problem jest dobrze zdefiniowany. Market making i egzekucja to zadania sterowania (control), nie prognozowania. Nie zgadujesz, dokąd pójdzie rynek — reagujesz na przepływ zleceń i zarządzasz zapasem/harmonogramem. Sygnał (mikrostruktura książki) jest silniejszy i stabilniejszy niż kierunek ceny.
- Nagroda jest gęsta. Market maker inkasuje spread przy niemal każdej transakcji — feedback przychodzi co sekundy, nie co tygodnie. Gęsta nagroda to marzenie RL; rzadka nagroda (spekulacja) to jego przekleństwo.
- Jest mocny baseline teoretyczny. Avellaneda–Stoikov i Almgren–Chriss dają zamknięte wzory na optymalne kwotowanie i harmonogram. RL nie zaczyna od zera — startuje od rozwiązania analitycznego i uczy się je korygować tam, gdzie założenia klasyki nie trzymają.
YouTube i Medium sprzedają RL jako maszynę do przewidywania ceny BTC. To najtrudniejsze i najsłabiej umocowane zastosowanie. Najłatwiejsze do obronienia — i to, czym faktycznie żyją fundusze — to egzekucja i market making. Jeśli chcesz, żeby Twój projekt RL miał sens naukowy, celuj w problem sterowania z gęstą nagrodą, a nie w wyrocznię kierunku.
Avellaneda–Stoikov: market making na jednej kartce
Model Avellanedy i Stoikova (2008) to punkt odniesienia całego market makingu. Wyobraź sobie, że wystawiasz jednocześnie zlecenie kupna (bid) poniżej ceny i sprzedaży (ask) powyżej. Zarabiasz na spreadzie za każdym razem, gdy ktoś „przejedzie" oba Twoje kwotowania. Ryzyko: gdy rynek idzie w jedną stronę, wypełnia Ci się tylko jedna strona i zostajesz z niechcianą pozycją (inventory), która traci na wartości.
Sedno modelu to cena rezerwacji r — nie środek rynku, lecz środek przesunięty proporcjonalnie do Twojego zapasu. Masz za dużo longa? Cena rezerwacji spada, więc kwotujesz niżej, zachęcając rynek, by odkupił od Ciebie zapas:
Symbol po symbolu: s — bieżąca cena środkowa (mid); q — Twój zapas (dodatni = long, ujemny = short); γ — awersja do ryzyka (jak bardzo boisz się zapasu); σ² — wariancja ceny; (T−t) — czas do końca sesji (im bliżej końca, tym mniej ryzyka niesie otwarty zapas, więc korekta maleje). Wokół tej ceny rezerwacji rozkładasz symetrycznie bid i ask, a łączny optymalny spread wynosi:
Nowy symbol: k — intensywność napływu zleceń rynkowych (jak głęboko w książkę „sięgają" transakcje; wysokie k = płynny, aktywny rynek). Czytaj ten wzór jako dwa napięcia. Pierwszy człon rośnie z ryzykiem (γσ²(T−t)) — im groźniejszy zapas, tym szerzej kwotujesz, żeby dostać większą premię za jego trzymanie. Drugi człon ((2/γ)·ln(1+γ/k)) to kompromis między szansą wypełnienia a marżą: kwotując zbyt szeroko, zarabiasz dużo na transakcję, ale rzadko; zbyt wąsko — często, ale grosze.
Zysk = spread, ryzyko = zapas. Wąski spread → dużo transakcji, ale mikra marża i szybko rosnący zapas. Szeroki spread → tłusta marża, ale rzadkie wypełnienia. Awersja γ i przesunięcie ceny rezerwacji to Twoje dwa pokrętła do zarządzania tym napięciem. Zrozum je na baseline'ie, zanim wpuścisz do gry sieć neuronową.
Ten kompromis najlepiej poczuć rękami. W symulatorze poniżej ustawiasz awersję γ i szerokość kwotowań, a strumień zleceń rynkowych (proces Poissona) wypełnia Twoje bid/ask. Zobacz na własne oczy, jak agresywne, wąskie kwotowanie pęcznieje od zapasu w trendzie i jak przesunięcie ceny rezerwacji „ściąga" pozycję z powrotem do zera — kosztem części zarobionego spreadu.
Nota dydaktyczna: ustaw γ=0 i wąski spread — dużo filli, PnL ze spreadu rośnie, ale inwentarz dryfuje i jeden trend zabiera wszystko przez „PnL z inwentarza". Podnieś γ: kwotowania są spychane przeciw pozycji, zysk mniejszy, lecz inwentarz trzyma się blisko zera. To jest sedno market makingu i powód, dla którego RL uczy się modulować γ zamiast trzymać stałą.
RL nie zastępuje Avellanedy — on ją koryguje
Kluczowe nieporozumienie: ludzie próbują trenować agenta market makera „od zera", z gołą siecią, i dziwią się, że przegrywa z prostym wzorem. Kanoniczny, działający wzorzec jest odwrotny — RL jako warstwa korekcyjna nad Avellanedą–Stoikovem. Klasyka zakłada stałe γ, stałą intensywność k i geometryczny ruch ceny; realny rynek łamie wszystkie trzy założenia. Sieć uczy się modulować parametry kwotowania (najczęściej efektywne γ albo bezpośrednie przesunięcia bid/ask) w zależności od stanu rynku, którego wzór nie widzi: zmienności realizowanej, nierównowagi książki (order-book imbalance), ostatniego przepływu.
Praca w PLOS ONE (2022) pokazała dokładnie ten schemat: agent RL nauczony modulować kwotowania A–S obniżył ryzyko zapasu przy zachowanym zysku ze spreadu — poprawa szła nie z „lepszego przewidywania", lecz z adaptacyjnego zarządzania zapasem. Nowszy front to market making wieloagentowy (arXiv:2510.25929), gdzie kilku agentów konkuruje o ten sam przepływ — naturalne odwzorowanie realnego rynku, na którym nie jesteś jedynym market makerem. To wciąż teren badawczy, ale kierunek jest zdrowy: RL dokłada adaptacyjność tam, gdzie klasyka ma sztywne założenia.
Optymalna egzekucja: problem, który każdy fundusz musi rozwiązać
Masz do sprzedania 100 000 akcji. Wrzucisz wszystko jednym zleceniem rynkowym — zjesz całą płynność po drodze i dostaniesz fatalną średnią cenę (market impact). Rozłożysz na tydzień — zredukujesz impact, ale wystawiasz się na ryzyko, że cena ucieknie, zanim skończysz. To jest cała optymalna egzekucja: trade-off między wpływem na cenę a ryzykiem czasowym.
Model Almgrena–Chrissa (2000) daje na to zamknięte rozwiązanie. Koszt egzekucji dzieli na dwie części. Impact trwały (permanent) — Twoja sprzedaż na stałe obniża cenę dla wszystkich, proporcjonalnie do tempa. Impact tymczasowy (temporary) — chwilowe „wgniecenie" ceny, gdy jesteś zbyt agresywny, które zanika po Tobie. Optymalna trajektoria to kompromis: agent bardziej awersyjny do ryzyka sprzedaje szybciej z przodu (płaci więcej impactu, ale mniej ryzykuje), agent obojętny na ryzyko rozkłada równo (TWAP). Trajektoria ma kształt wykładniczy, a jej „stromość" reguluje właśnie awersja do ryzyka.
Gdzie nk to ilość sprzedana w kroku k, xk to pozostały zapas, γ i η — współczynniki impactu trwałego i tymczasowego, τ — długość kroku. Nie ucz się tego wzoru na pamięć — poniżej zaimplementujemy trajektorię wprost i porównamy ją z naiwnym TWAP (równy podział na kroki).
"""Optymalna trajektoria egzekucji Almgrena-Chrissa vs naiwny TWAP.
Sprzedajemy X akcji w N krokach. Porownujemy oczekiwany koszt i ryzyko."""
import numpy as np
_# --- Parametry problemu ---
X = 100_000 # laczny zapas do sprzedania (akcje)
N = 20 # liczba krokow (np. 20 interwalow sesji)
T = 1.0 # laczny horyzont (znormalizowany)
tau = T / N # dlugosc pojedynczego kroku
sigma = 0.02 # zmiennosc ceny na krok
eta = 2.5e-6 # wspolczynnik impactu TYMCZASOWEGO
gamma = 2.5e-7 # wspolczynnik impactu TRWALEGO
lam = 1e-6 # awersja do ryzyka (lambda): 0 = obojetny -> TWAP
def ac_trajectory(lam: float) -> np.ndarray:
"""Optymalna trajektoria ZAPASU x_k wg Almgrena-Chrissa.
Dla lam=0 wychodzi liniowy spadek (TWAP). Im wieksza lam,
tym bardziej wypukla krzywa = szybsza sprzedaz na poczatku."""
# kappa: parametr stromosci trajektorii wykladniczej
kappa_sq = lam * sigma**2 / eta
kappa = np.arccosh(kappa_sq * tau**2 / 2 + 1) / tau if kappa_sq > 0 else 0.0
k = np.arange(N + 1)
if kappa == 0.0:
return X * (1 - k / N) # TWAP: liniowo do zera
# trajektoria wykladnicza (sinh) - klasyczna postac A-C
return X * np.sinh(kappa * (T - k * tau)) / np.sinh(kappa * T)
def expected_cost(x: np.ndarray) -> tuple[float, float]:
"""Oczekiwany koszt (impact) i wariancja (ryzyko) dla trajektorii zapasu x."""
n = -np.diff(x) # sprzedaz w kazdym kroku
temp = eta * np.sum(n**2) / tau # impact tymczasowy
perm = 0.5 * gamma * np.sum(n**2) # impact trwaly (uproszczony)
var = sigma**2 * np.sum(x[1:]**2) * tau # ryzyko: wariancja kosztu
return temp + perm, var
_# --- Porownanie: TWAP (lam=0) vs agent awersyjny ---
for name, L in [("TWAP (lam=0)", 0.0), ("A-C awersyjny", lam)]:
x = ac_trajectory(L)
cost, var = expected_cost(x)
print(f"{name:16s} | koszt(impact)={cost:10.1f} | ryzyko(std)={np.sqrt(var):10.1f}")
_# Typowy wynik: agent awersyjny placi WIECEJ impactu, ale ma MNIEJSZE ryzyko.
_# To jest cala istota egzekucji - nie ma darmowego obiadu, jest granica efektywna.
Uruchom to i pobaw się parametrem lam. Zobaczysz „granicę efektywną"
egzekucji: każdy punkt na niej to inny kompromis koszt–ryzyko, a wybór na niej to Twoja
decyzja o awersji. Tu wchodzi RL. Almgren–Chriss zakłada stałe, znane
współczynniki impactu i brak sygnału o kierunku. Realny rynek daje sygnały: nierównowagę
książki, chwilową płynność, momentum. Agent RL w symulatorze książki zleceń (LOB)
z zleceniami rynkowymi i limit — uczy się odchylać od harmonogramu A–C, gdy
mikrostruktura mu sprzyja: przyspieszyć, gdy płynność jest tania, wstrzymać się, gdy
książka jest cienka. W symulacjach RL bije VWAP i statyczne heurystyki
(arXiv:2507.06345, arXiv:2411.06389). Anegdotycznie: system LOXM banku JPMorgan to
właśnie produkcyjna egzekucja oparta o uczenie maszynowe — publicznych liczb brak,
ale kierunek branży jest jednoznaczny.
Prawo pierwiastka: dlaczego duże zlecenia bolą nieliniowo
Skąd w ogóle bierze się koszt egzekucji? Z empirycznej regularności, która jest jedną z najsolidniejszych w całej mikrostrukturze rynku — prawo pierwiastka (square-root law). Wpływ zlecenia na cenę rośnie nie liniowo, lecz z pierwiastkiem udziału w wolumenie:
Gdzie q to wielkość Twojego zlecenia, V — dzienny wolumen instrumentu, σ — zmienność, η — stała rzędu jedności. Konsekwencja praktyczna: podwojenie zlecenia nie podwaja kosztu — zwiększa go tylko o √2 ≈ 1,41×. Dlatego opłaca się dzielić zlecenia i dlatego duzi gracze tak bardzo pilnują swojego udziału w wolumenie. Do środowiska treningowego wpinasz to jako realistyczny model kosztu wypełnienia:
_# Realistyczny koszt wykonania: polowa spreadu + impact wg prawa pierwiastka.
_# Wpinasz to w metode step() swojego TradingEnv zamiast stalej prowizji.
import numpy as np
def execution_cost(qty: float, adv: float, sigma: float,
half_spread: float, eta: float = 1.0) -> float:
"""qty - wielkosc zlecenia (w tych samych jednostkach co adv)
adv - sredni dzienny wolumen (Average Daily Volume)
sigma - dzienna zmiennosc instrumentu
half_spread- polowa aktualnego spreadu (koszt 'przejscia' przez ksiazke)
Zwraca koszt jako ulamek wartosci - do odjecia od nagrody."""
participation = abs(qty) / adv # udzial w wolumenie
impact = eta * sigma * np.sqrt(participation) # prawo pierwiastka
return half_spread + impact # spread placisz zawsze, impact rosnie z q
_# Przyklad: to samo zlecenie w normalnym rynku i w stresie (cienka ksiazka)
for regime, hs in [("spokojny", 0.0005), ("stres", 0.0050)]:
c = execution_cost(qty=5_000, adv=1_000_000, sigma=0.02, half_spread=hs)
print(f"rynek {regime:9s}: koszt = {c*100:.3f}% wartosci zlecenia")
Największy błąd w modelowaniu kosztów to traktowanie ich jako stałej prowizji. W stresie rynkowym płynność na szczycie książki (top-of-book depth) potrafi spaść o ponad 90% — obserwowano to m.in. w gwałtownych ruchach z 10 października 2025. Twój model kosztów musi zależeć od reżimu (zmienność, spread, głębokość), bo dokładnie wtedy, gdy najbardziej potrzebujesz wyjść, egzekucja jest najdroższa. Backtest ze stałym kosztem systematycznie przeszacowuje wynik strategii aktywnych.
Uczciwa ramka: to warsztat, nie plan biznesowy
Trzeba to powiedzieć wprost. Jako samodzielny trader z małym kapitałem nie wygrasz z firmami HFT w market makingu na płynnych parach — one mają kolokację, dostęp do surowej książki i latencję w mikrosekundach, a Ty API z opóźnieniem setek milisekund. Nie o to chodzi. Ten moduł uczysz się jako warsztatu inżynierskiego: intuicji zapasu i ryzyka, mikrostruktury, modelowania kosztów, kompromisu impact–ryzyko. To są umiejętności, które podnoszą jakość każdej strategii, którą zbudujesz — także tej wolniejszej, w której masz szansę. Traktuj Avellanedę i Almgrena jako gimnastykę myślenia o egzekucji, nie jako biznesplan na konkurowanie z Citadelem.
W modelu Avellanedy–Stoikova market maker ma duży dodatni zapas (long). Co robi z jego kwotowaniami cena rezerwacji?
Zgodnie z prawem pierwiastka (square-root law), jak zmieni się market impact, jeśli podwoisz wielkość zlecenia?
Dlaczego market making osiągnął najwyższą „premię RL" (0.488) w przeglądzie 167 prac, a kierunkowa spekulacja najniższą?
Zaawansowany warsztat: ryzyko w funkcji celu, offline RL, dane syntetyczne
Cel: poznać techniki z frontu badań, które mają realny, mierzalny sens w tradingu — rozkładowe RL i CVaR w celu, uczenie z zastanych danych, generowanie danych syntetycznych i dyscyplinę anty-overfittingową — bez wpadania w hype.
Zacznij od pokory: algorytm to nie jest to, co Cię uratuje
Ten sam przegląd 167 prac, który w module 12 dał market makingowi koronę, sprawdził też coś, co powinno przewietrzyć Ci głowę: wybór rodziny algorytmu ma statystycznie znikomy wpływ na wynik (p = 0.640). PPO kontra SAC kontra DQN kontra najnowszy błyszczący akronim — to szum. Co decyduje? Jakość i realizm danych, uczciwe modelowanie kosztów i rygor metodologiczny (walk-forward, wiele ziaren, brak wycieku informacji). Zapamiętaj to, zanim zaczniesz gonić za każdą nową architekturą z arXiv.
Mimo to trzy grupy technik z frontu badań naprawdę coś wnoszą — nie dlatego, że są modne, lecz dlatego, że atakują konkretne, mierzalne słabości standardowego RL na rynkach: ślepotę na ryzyko ogonowe, kosztowną eksplorację on-line i głód danych.
Rozkładowe RL i CVaR: ucz się całego rozkładu, nie średniej
Standardowy agent estymuje oczekiwaną wartość akcji — jedną liczbę. Ale w tradingu dwie strategie o tej samej średniej mogą mieć diametralnie różny profil ryzyka: jedna równo zarabia, druga zbiera drobne zyski i raz na jakiś czas obrywa katastrofalnie. Średnia tego nie widzi. Rozkładowe RL (C51, QR-DQN, IQN) uczy się całego rozkładu zwrotu Z(s,a), a nie tylko jego wartości oczekiwanej. Z rozkładu dostajesz za darmo miary ogonowe — w tym CVaR (Conditional Value at Risk), czyli średnią stratę w najgorszych p% przypadków.
_# QR-DQN uczy sie rozkladu zwrotu przez kwantyle. Z kwantyli liczysz CVaR
_# bez zadnego dodatkowego modelu. Dostepny w sb3-contrib.
from sb3_contrib import QRDQN
import numpy as np
model = QRDQN(
"MlpPolicy", env,
n_quantiles = 50, # ile kwantyli rozkladu estymujemy
learning_rate = 1e-4,
buffer_size = 100_000,
device = "cpu",
seed = 42,
)
model.learn(total_timesteps=300_000)
_# --- CVaR wprost z wyuczonych kwantyli akcji ---
def cvar_from_quantiles(quantiles: np.ndarray, alpha: float = 0.05) -> float:
"""Srednia z najgorszych alpha (np. 5%) kwantyli = CVaR_5%.
Bardziej ujemna wartosc = grubszy lewy ogon = wieksze ryzyko."""
q_sorted = np.sort(quantiles)
cutoff = max(1, int(alpha * len(q_sorted)))
return float(q_sorted[:cutoff].mean())
_# W ewaluacji: dla kazdego stanu porownaj akcje nie po sredniej,
_# lecz po CVaR - wybieraj te o akceptowalnym ogonie, nie tylko wysokiej sredniej.
Idziemy krok dalej: skoro potrafimy zmierzyć ogon, wpiszmy go do celu uczenia. CVaR-PPO (CPPO) dokłada do funkcji celu PPO karę za lewy ogon strat. Agent nie maksymalizuje już samego zwrotu — maksymalizuje zwrot pomniejszony o ryzyko ogonowe. W praktyce (m.in. w FinRL-DeepSeek) daje to mierzalnie płytszy drawdown przy niewielkim koszcie średniego zwrotu. To jeden z najlepszych „kursów wymiany" w całym RL na rynkach: oddajesz odrobinę zysku, kupujesz realną kontrolę nad katastrofą.
Klasyczny RL optymalizuje zwrot, a ryzykiem zajmuje się „gdzieś obok" (limity, stop-lossy). Rozkładowe RL i CVaR-PPO wnoszą ryzyko do środka optymalizowanej funkcji. Dla tradingu, gdzie przetrwanie drawdownu decyduje o wszystkim, to filozoficznie właściwe miejsce. Jeśli masz wybrać jedną „zaawansowaną" technikę z tego modułu do wdrożenia — zacznij od kary CVaR w celu.
Offline RL: ucz się z historii, nie pal pieniędzy na eksplorację
Standardowy RL uczy się przez interakcję — próbuje, dostaje nagrodę, poprawia się. Na rynku „próba" to prawdziwe zlecenie za prawdziwe pieniądze, a eksploracja on-line to dosłownie palenie kapitału na sprawdzanie złych akcji. Offline RL (CQL, IQL) rozwiązuje to inaczej: uczy się wyłącznie z zastanego zbioru trajektorii — Twoich historycznych transakcji, logów paper tradingu, danych z backtestów — bez ani jednej nowej interakcji z rynkiem. Kluczowa sztuczka to konserwatyzm: algorytm celowo zaniża wartość akcji, których nie ma w danych, żeby agent nie „fantazjował" o zyskownych ruchach, których nikt nigdy nie zaobserwował.
"""Offline RL (CQL) na trajektoriach z wlasnego TradingEnv, biblioteka d3rlpy.
Nie ma tu treningu on-line - uczymy sie WYLACZNIE z zebranych przejsc."""
import numpy as np
from d3rlpy.algos import DiscreteCQLConfig
from d3rlpy.dataset import MDPDataset
_# --- 1. Zbierz trajektorie z istniejacej polityki (np. z paper tradingu) ---
_# Kazde przejscie to (obserwacja, akcja, nagroda, koniec_epizodu).
_# Tutaj laduje sie je z wczesniej zapisanych tablic NumPy.
obs = np.load("data/offline_obs.npy") # (T, dim_stanu)
actions = np.load("data/offline_actions.npy") # (T,) dyskretne akcje
rewards = np.load("data/offline_rewards.npy") # (T,)
terminals = np.load("data/offline_terminals.npy") # (T,) 1 na koncu epizodu
_# --- 2. Konwersja do standardowego formatu MDPDataset ---
dataset = MDPDataset(
observations = obs,
actions = actions,
rewards = rewards,
terminals = terminals,
)
_# --- 3. Trening konserwatywnego agenta (CQL) - zero interakcji z rynkiem ---
cql = DiscreteCQLConfig(
alpha = 1.0, # sila konserwatyzmu: wyzej = mocniej karze akcje spoza danych
).create(device="cpu")
cql.fit(dataset, n_steps=100_000)
cql.save_model("models/cql_offline.pt")
_# Standard datasetow offline RL: Minari (Farama) - jesli chcesz dzielic sie
_# danymi lub korzystac z gotowych zbiorow, konwertuj do formatu Minari.
Wariant, o którym warto wiedzieć, bo pasuje do profilu danych rynkowych: Decision Transformer (DT). Zamiast uczyć wartości, traktuje RL jak modelowanie sekwencji — podajesz mu ciąg (zwrot-do-uzyskania, stan, akcja, zwrot-do-uzyskania, stan, akcja, …) i uczysz transformer przewidywać kolejną akcję warunkowaną pożądanym zwrotem. Badanie z ICLR 2024 pokazało ważny kompromis: DT potrzebuje więcej danych niż CQL, ale jest odporniejszy przy rzadkiej nagrodzie i słabej jakości danych — a to dokładnie profil danych rynkowych. W tradingu testowano warianty typu GPT-2 z dostrajaniem LoRA jako DT (arXiv:2411.17900).
Offline RL i Decision Transformery mają mocne teoretyczne uzasadnienie i świetne wyniki w benchmarkach sterowania (D4RL, robotyka). Ale nie ma publicznych, przekonujących wyników out-of-sample na realnym rynku. Traktuj je jako „obiecujące i dobrze umocowane", nie jako „udowodnione, że zarabiają". To najlepszy stan wiedzy na 2026 — nie pozwól, by ładna architektura zastąpiła Ci dowód.
Dane syntetyczne: więcej rynków, niż dała Ci historia
Fundamentalny problem RL na rynkach: masz jedną historię. Jeden ciąg cen BTC, jedną hossę 2021, jeden krach 2022. Agent, który widział jedną trajektorię, uczy się jej na pamięć. Odpowiedź to generowanie dodatkowych, wiarygodnych przebiegów. Trzy poziomy, od najtańszego do najbardziej badawczego:
(a) Bootstrap blokowy / stacjonarny — najtańszy i zaskakująco skuteczny. Zamiast losować pojedyncze zwroty (co niszczy autokorelację), losujesz bloki kolejnych zwrotów, zachowując lokalną strukturę czasową. To standardowy sposób na sprawdzenie, czy Twoja strategia jest odporna, czy tylko dopasowała się do jednej ścieżki historii:
"""Stationary bootstrap (Politis-Romano): resampluj zwroty blokami o losowej
dlugosci, zachowujac autokorelacje. Rozklad wynikow na wielu resamplach
mowi Ci, czy strategia jest odporna, czy to fuks jednej sciezki."""
import numpy as np
def stationary_bootstrap(returns: np.ndarray, p: float = 0.1,
rng=None) -> np.ndarray:
"""p - prawdopodobienstwo zakonczenia biezacego bloku (1/p = srednia dlugosc).
Zwraca nowy szereg zwrotow tej samej dlugosci co oryginal."""
rng = rng or np.random.default_rng()
n = len(returns)
out = np.empty(n)
i = rng.integers(n) # losowy start pierwszego bloku
for t in range(n):
out[t] = returns[i]
if rng.random() < p:
i = rng.integers(n) # zakoncz blok: skok w nowe miejsce
else:
i = (i + 1) % n # kontynuuj blok: nastepny zwrot
return out
_# --- Rozklad wyniku backtestu na 500 resamplach ---
real_returns = np.load("data/returns.npy")
rng = np.random.default_rng(42)
finals = []
for _ in range(500):
sim = stationary_bootstrap(real_returns, p=0.1, rng=rng)
finals.append(np.exp(np.cumsum(sim))[-1] - 1) # koncowy zwrot strategii B&H na resamplu
finals = np.array(finals)
print(f"Zwrot: mediana={np.median(finals):+.2%} | "
f"5-95 pct=[{np.percentile(finals,5):+.2%}, {np.percentile(finals,95):+.2%}]")
_# Szeroki, silnie ujemny 5. percentyl = strategia krucha. Patrz na ROZKLAD, nie na jeden wynik.
(b) Modele dyfuzyjne — nowszy front. Te same modele, które generują obrazy, generują też realistyczne szeregi finansowe i biją GAN-y: mają mniejszy mode collapse i lepiej odtwarzają grube ogony oraz skupianie zmienności (arXiv:2410.18897). Sensowne do augmentacji zbioru treningowego RL. (c) World models — agent uczy się wewnętrznego modelu dynamiki rynku i trenuje „we śnie", w wyobrażonym środowisku. W finansach to wciąż czysto badawczy teren, bez dowodów rynkowych — wzmiankuję dla kompletności mapy, nie jako narzędzie na dziś.
Lab anty-overfittingowy: przeuczenie jako test hipotezy
Cały kurs powtarza: RL na rynkach domyślnie się przeucza. Jest na to dyscyplina metodologiczna mocniejsza niż „patrz na walidację". Praca arXiv:2209.05559 proponuje sformułować wykrywanie przeuczenia jako formalny test hipotezy: trenujesz N agentów (różne ziarna), estymujesz rozkład ich wyników walidacyjnych i odrzucasz tych, którzy wyglądają na przeuczonych względem tego rozkładu — zanim w ogóle dopuścisz ich do danych testowych.
"""Szkic procedury anty-overfittingowej: trenuj komitet, odrzuc przeuczonych.
Idea: agent, ktory swietnie radzi sobie na treningu, ale slabo na walidacji,
jest statystycznie odrzucany PRZED dopuszczeniem do testu."""
import numpy as np
def filter_overfit(train_scores: np.ndarray, val_scores: np.ndarray,
z_thresh: float = 1.0) -> np.ndarray:
"""train_scores, val_scores - wyniki N agentow na treningu i walidacji.
Zwraca maske agentow NIEprzeuczonych.
Kryterium: luka train-val nie moze byc razaco wieksza od typowej."""
gap = train_scores - val_scores # miara przeuczenia kazdego agenta
# standaryzujemy luke wzgledem rozkladu w komitecie
z = (gap - gap.mean()) / (gap.std() + 1e-9)
keep = z < z_thresh # odrzuc agentow z rzucajaca sie w oczy luka
# dodatkowy warunek: wynik walidacyjny musi byc dodatni
keep &= val_scores > 0
return keep
_# --- Uzycie: N agentow z rozna kombinacja seed/hiperparametry ---
train_scores = np.load("runs/train_scores.npy") # (N,)
val_scores = np.load("runs/val_scores.npy") # (N,)
keep = filter_overfit(train_scores, val_scores)
print(f"Zachowano {keep.sum()}/{len(keep)} agentow (odrzucono przeuczonych)")
_# Dopiero z tego przefiltrowanego komitetu budujesz ensemble (modul 10) i idziesz na TEST.
W badaniu na 10 kryptowalutach, przeprowadzonym przez krach 2022, agenci mniej przeuczeni (przefiltrowani tą procedurą) pobili zarówno agentów przeuczonych, jak i portfel równoważony (equal-weight) oraz benchmark. To rzadki przypadek, gdy „mniej dopasowany" empirycznie wygrywa — bo test przeszedł przez reżim, którego trening nie widział. Dokładnie o to chodzi w niestacjonarnym rynku.
Hybrydy i LLM: rynek badawczy już zagłosował
Najsilniejszy sygnał z całego pola: udział podejść hybrydowych (RL jako warstwa decyzyjna nad klasycznymi sygnałami quant, a nie surowe end-to-end) wzrósł z 15% (2020) do 42% (2025), a czyste end-to-end DRL spadło z 85% do 58%. To nie moda — to zbiorowa odpowiedź badaczy na niestacjonarność: dawaj sieci najlepsze cechy, jakie umiesz policzyć, zamiast każać jej odkrywać wszystko od zera na zaszumionych danych. Dokładnie ta sama lekcja, co w module 6, potwierdzona statystyką całej dziedziny.
Drugi kierunek: LLM jako źródło cech (nie jako trader). FinRL-DeepSeek (arXiv:2502.07393) wyciąga z newsów sygnały ryzyka i sentymentu modelem językowym (DeepSeek V3) i dokłada je do stanu agenta CPPO handlującego Nasdaq-100. Uczciwy bilans: sygnały z LLM potrafią podnieść zwrot, ale kontrola drawdownu pozostaje problemem, a trwałej przewagi out-of-sample nikt nie udowodnił.
Pomysł, że wrzucisz GPT wykresy i newsy, a on będzie tradował — to marketing, nie metoda. Nie ma na to żadnych wiarygodnych wyników. Natomiast używanie LLM do ekstrakcji cech (sentyment, ryzyko, zdarzenia z tekstu), które potem trafiają jako liczby do sprawdzonego agenta RL — to badawczo umocowany, testowalny kierunek. Różnica jest fundamentalna: w pierwszym LLM „decyduje", w drugim tylko „czyta". Buduj to drugie.
Na koniec dwa kierunki warte świadomości, ale nie fundamentu. Hierarchical RL rozdziela decyzję na piętra — agent wysokiego poziomu wybiera aktywa, niskiego zajmuje się egzekucją (arXiv:2410.14927). Multi-agent RL jest naturalny wszędzie tam, gdzie modelujesz rynek jako grę wielu graczy — market making i symulatory książki zleceń. Oba to obecnie proof-of-concept: ciekawe, ale nie buduj na nich systemu produkcyjnego.
Przegląd 167 prac wykazał, że wybór rodziny algorytmu (PPO vs SAC vs DQN) ma na wynik wpływ statystycznie znikomy (p = 0.640). Jaki jest praktyczny wniosek?
Czym różni się offline RL (CQL) od standardowego RL na rynku i dlaczego to ważne?
Po co stosować CVaR-PPO (karę za lewy ogon strat w funkcji celu) zamiast zwykłego PPO?
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.
Market making i optymalna egzekucja — jedyne obszary, gdzie RL ma naprawdę twarde dowody przewagi — dostały własny moduł 12: od wzorów Avellanedy–Stoikova i Almgrena–Chrissa po RL jako korektę klasyki.
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, ryzyko w funkcji celu, dane syntetyczne
Trzy techniki z frontu badań, które mają już realne, mierzalne zastosowania — offline RL (uczenie z zastanych trajektorii bez kosztownej eksploracji), distributional RL z CVaR (ryzyko ogonowe wprost w funkcji celu) i syntetyczne dane rynkowe — rozwijamy z kodem w module 13. Tutaj wystarczy wiedzieć, że istnieją i kiedy po nie sięgnąć: offline RL, gdy masz dużo danych historycznych i zero ochoty płacić za eksplorację; CVaR, gdy drawdown boli bardziej niż cieszy zwrot; syntetyki, gdy walk-forward daje za mało niezależnych okien.
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.
Skoro wariancja między ziarnami jest ogromna, jaka jest profesjonalna odpowiedź?
Które zastosowanie RL w finansach jest najbardziej udane i dlaczego?
Po co detekcja reżimów (trend/flauta, niska/wysoka zmienność)?
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 |
Dlaczego mały MLP w RL bywa szybszy na CPU niż na GPU (MPS) na M3?
Co realnie daje 128 GB unified memory w tym kursie?
Uruchamiasz 6 foldów walk-forward równolegle przez multiprocessing. Dlaczego ustawić OMP_NUM_THREADS na małą wartość PRZED importem numpy/torch?
Roadmapa 16 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, deflated Sharpe, 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.
Risk layer
Moduł 10. Vol targeting, ułamkowy Kelly, kill-switch i limity jako osobna, testowana jednostkowo warstwa nad agentem.
Zaliczenie: RiskLayer z testami pytest; symulacja, w której risk layer ratuje kapitał przed celowo zepsutym agentem.
Paper trading i architektura live
Moduły 9 i 11. Pętla na testnecie Binance z reconciliation i idempotencją zleceń, logowanie każdej decyzji, monitoring dryfu.
Zaliczenie: 2+ tygodnie nieprzerwanej pracy pętli (z co najmniej jednym restartem, po którym stan uzgodnił się sam); rozjazd paper↔backtest wyjaśniony co do przyczyny.
Mikrostruktura i realistyczne koszty
Moduły 9 i 12. Funding w środowisku, poślizg zależny od zmienności (√-prawo), OI/basis jako cechy.
Zaliczenie: ten sam agent przetestowany w env v1 (koszty stałe) i v2 (koszty reżimowe) — raport różnic. Zwykle boli.
Zaawansowany warsztat
Moduł 13. Offline RL (CQL na trajektoriach z Twojego env) albo CVaR-PPO — jedno z dwóch, porządnie. Lab anty-overfittingowy na własnych agentach.
Zaliczenie: porównanie z bazowym PPO na tych samych oknach OOS, z deflated Sharpe i uczciwym N.
Iteracja bez końca
Moduł 14: ensembles, detekcja reżimów. 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, Decision Transformer) z prostym API; do tego Minari (Farama) jako standard datasetów offline. Warsztat do modułu 13.
- 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.
Kluczowe prace (frontier 2022–2026)
- „A Systematic Review of RL in Financial Decision Making" (2025, arXiv:2512.10913) — przegląd 167 prac; wniosek-kręgosłup: rodzina algorytmu prawie nie ma znaczenia, metodologia i dane — tak.
- Bailey & López de Prado, „The Deflated Sharpe Ratio" (2014) — matematyka z modułu 8: ile wart jest najlepszy wynik z N prób.
- „DRL for Cryptocurrency Trading: Addressing Backtest Overfitting" (2022, arXiv:2209.05559) — wykrywanie przeuczenia jako test hipotezy; podstawa labu w module 13.
- „RL for Trade Execution with Market and Limit Orders" (2025, arXiv:2507.06345) i RL + Avellaneda–Stoikov (PLOS ONE 2022) — dowody z modułu 12.
- „When should we prefer Decision Transformers for Offline RL?" (ICLR 2024) — kiedy DT bije CQL (rzadka nagroda, słabe dane) i odwrotnie.
- FinRL Contests (arXiv:2504.02281) i FinRL-DeepSeek (arXiv:2502.07393) — żywe benchmarki i LLM-cechy z modułu 13.
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.