sobota, 3 maja 2025

Modele w zbiorze ufności w rugarch (język R)

Jeśli powiem, że każda prognoza gospodarcza czy giełdowa jest okraszona niepewnością, to będzie to banał. Ale jeśli powiem, że celem prognozy powinno być nie tyle odgadnięcie przyszłej wartości, co minimalizacja jej niepewności, to już banałem nie jest. Dla uproszczenia założymy, że niepewność to przedział ufności dla prognozy naszego modelu. Im więcej informacji zawiera model, tym przedział ufności staje się mniejszy, zbliżając się tym samym do prognozy punktowej. Widzimy więc, że idea nie jest wcale banalna: należy zebrać jak najwięcej istotnych informacji, aby zawężać przedział prognozy, zmniejszając tym samym ryzyko.

Analogicznie do przedziału ufności, Hansen et al.* zaproponowali zbiór ufności dla wielu modeli: z całej puli testowanych modeli, poszukiwane są te najlepsze, które wpadają do zbioru ufności. Im więcej posiadamy informacji, tym mniejszy będzie ten zbiór, zbliżając się tym samym do jednego najlepszego modelu.

Standardową metodą wyboru najlepszego modelu jest użycie kryterium informacyjnego, np. AIC, BIC czy HQIC. Niestety nie uwzględniają one niepewności czy przypadkowości parametrów. Z tym się ciągle zmagamy. Przykładowo, mam zawsze wątpliwości co do używania modeli parametrycznych, ponieważ wydają się one sztuczne, realizując uśrednianie na siłę. Dlatego tak ważne są testy stabilności parametrów. Ale stabilność to dopiero początek, bo: 
(1) jeśli spośród wielu modeli różnica w kryterium informacyjnym jest niewielka, to może ona wynikać po prostu z przypadku,
(2) dodanie tylko jednej obserwacji bardzo często zmienia wybór modelu.

W rugarch jest funkcja mcsTest (Model Confidence Set - MCS), która testuje, czy dany model należy zachować w zbiorze ufności czy go odrzucić. Ma ona następujące argumenty:

- losses: macierz lub ramka danych z wartościami strat dla każdego modelu (każda kolumna odpowiada jednemu modelowi). To w jaki sposób definiujemy straty, zależy od nas. W przypadku rozkładów symetrycznych, najczęściej stosuje się kwadraty odchyleń od prognozy lub wartości absolutne tych odchyleń. Dla rozkładów niesymetrycznych stosować można semi-odchylenia albo miary oparte na wartości zagrożonej.  

- alpha: poziom istotności (domyślnie 0.05).

- nboot: liczba replikacji bootstrapowych (domyślnie 100). Bootstrap to procedura wielokrotnego losowania próbki ze zwracaniem. nboot to właśnie liczba tych losowań.

- nblock: długość bloku dla bootstrappu blokowego (domyślnie 1). Jeżeli obserwacje mogą być zależne od siebie, to bootstrap musi losować blok występujących po sobie x obserwacji. Jeżeli uważamy, że każda obserwacja jest niezależna od innej, to x = 1. Im rząd autokorelacji większy, tym blok powinien być naturalnie też większy.

- boot: rodzaj bootstrapu ("stationary" lub "block"). Chociaż w obydwu przypadkach mamy do czynienia z blokami, to metoda blokowa, "block", oznacza, że całą próbę dzielimy na stałą liczbę przedziałów, tak że każdy przedział składa się bloku obserwacji o długości wyznaczonej dokładnie przez nblock. Potem dopiero losujemy blok, powtarzamy to losowanie dokładnie nboot razy. Natomiast  metoda stacjonarna, "stationary", tworzy bloki o losowej długości ze średnią równą nblock. Jeśli występuje autokorelacja w szeregu czasowym, to blok nie może być za krótki. Najczęściej "stationary" jest lepszy od "block".


Co właściwie robi ten test? W skrócie, kiedy mamy macierz strat dla każdego modelu, liczymy różnice między każdym modelem (prognozą każdego modelu) i są one odpowiednio uśredniane. Tworzymy bloki bootstrapowe i dla każdego bloku obliczamy średnie różnice między średnią stratą w bloku, średnią stratą dla całego modelu i średnią stratą ze wszystkich modeli. Dla każdego modelu będą różne wartości tworzące rozkład różnic. Wartości zbyt odchylone na plus (tzn. prawostronne ogony rozkładu) zostają odrzucone i tak uzyskujemy zbiór ufności. 

Oczywiście najpierw sami budujemy funkcję strat. Ja użyłem definicji najbardziej dla mnie intuicyjnej: jeżeli znak prognozy różni się od znaku wartości rzeczywistej, to bierzemy moduł z różnicy między nimi. Jeżeli znak jest ten sam, to strata wynosi zero. 

Przetestujmy tę funkcję. Będziemy używać mcsTest na zbiorze uczącym. Reguły używania poniższych funkcji w rugarch omówiłem szczegółowo tutaj
Stwierdziłem wcześniej, że próba między 300 a 400 tygodniowych danych giełdowych dla GARCH wydaje się już w miarę odpowiednia. Zrobię symulację EGARCH o próbie równej 330. Powiedzmy, że prawdziwy proces to EGARCH(1, 1)-ARMA(3, 1). Obok niego mamy też fałszywe - wszystkie EGARCH: ARMA(0, 0), ARMA(1, 0), ARMA(2, 0), ARMA(3, 0). Zobaczmy które z nich zostaną wykluczone przez mcsTest, a które przyjęte do zbioru ufności. Zrobimy test w dwóch wariantach: blokowy i stacjonarny. Ustawienia: nblock = 20 oraz nboot = 1000. 

library("rugarch")
library("xts")

# 1. Definicje stałych parametrów
stale_3_1 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  ar2    = 0.5,
  ar3    = 0.7,
  ma1    = 0.4,
  gamma1 = 0.05
)

stale_0_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  gamma1 = 0.05
)

stale_1_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  gamma1 = 0.05
)

stale_2_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  ar2    = 0.5,
  gamma1 = 0.05
)

stale_3_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  ar2    = 0.5,
  ar3    = 0.7,
  gamma1 = 0.05
)

stale_0_1 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ma1    = 0.4,
  gamma1 = 0.05
)

# 2. Specyfikacje modeli EGARCH(1,1) z różnymi specyfikacjami średniej:
spec_3_1 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(3,1), include.mean = TRUE),
  distribution.model = "norm",
  fixed.pars = stale_3_1
)

spec_0_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(0,0), include.mean = TRUE),
  distribution.model = "norm",
  fixed.pars = stale_0_0
)

spec_1_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(1,0), include.mean = TRUE),
  distribution.model = "norm",
  fixed.pars = stale_1_0
)

spec_2_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(2,0), include.mean = TRUE),
  distribution.model = "norm",
  fixed.pars = stale_2_0
)

spec_3_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(3,0), include.mean = TRUE),
  distribution.model = "norm",
  fixed.pars = stale_3_0
)

spec_0_1 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(0,1), include.mean = TRUE),
  distribution.model = "norm",
  fixed.pars = stale_0_1
)

# 3. Symulacja 330 obserwacji według poprawnego modelu (ARMA(3,1))
set.seed(765)
symulacja <- ugarchpath(spec_3_1, n.sim = 330)
dane_symulowane <- as.numeric(fitted(symulacja))

# 4. Dopasowanie modeli do całego szeregu (in-sample)
dopasowanie_3_1 <- ugarchfit(spec = spec_3_1, data = dane_symulowane)
dopasowanie_0_0 <- ugarchfit(spec = spec_0_0, data = dane_symulowane)
dopasowanie_1_0 <- ugarchfit(spec = spec_1_0, data = dane_symulowane)
dopasowanie_2_0 <- ugarchfit(spec = spec_2_0, data = dane_symulowane)
dopasowanie_3_0 <- ugarchfit(spec = spec_3_0, data = dane_symulowane)
dopasowanie_0_1 <- ugarchfit(spec = spec_0_1, data = dane_symulowane)

# 5. Zapisanie wyników dopasowania w liście (dla przejrzystości)
dopasowania <- list(
  model_3_1 = dopasowanie_3_1,
  model_0_0 = dopasowanie_0_0,
  model_1_0 = dopasowanie_1_0,
  model_2_0 = dopasowanie_2_0,
  model_3_0 = dopasowanie_3_0,
  model_3_0 = dopasowanie_0_1
)
print("Dopasowane modele:")
print(names(dopasowania))

# 6. Definicja funkcji strat:
Funkcja_strat <- function(rzeczywiste, prognoza) {
  roznica <- rzeczywiste - prognoza
  strata <- ifelse(sign(rzeczywiste) != sign(prognoza), abs(roznica), 0)
  return(strata)
}

# 7. Obliczenie funkcji strat dla każdego modelu (in-sample)
strata_3_1 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_3_1))
strata_0_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_0_0))
strata_1_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_1_0))
strata_2_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_2_0))
strata_3_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_3_0))
strata_0_1 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_0_1))

# Łączenie strat w macierz – wiersze odpowiadają obserwacjom, kolumny modelom
straty <- cbind(
  model_3_1 = strata_3_1,
  model_0_0 = strata_0_0,
  model_1_0 = strata_1_0,
  model_2_0 = strata_2_0,
  model_3_0 = strata_3_0,
  model_3_0 = strata_0_1
)

# 8. Usunięcie wierszy składających się tylko z zer
straty <- straty[rowSums(straty != 0) > 0, ]
straty <- coredata(straty)

# 9. Test MCS
# 9.1. Wariant 1: stacjonarny
mcs_stationary <- mcsTest(straty, alpha = 0.05, nboot = 1000, nblock = 20, boot = "stationary")

# 9.2. Wariant 1: blokowy
mcs_block <- mcsTest(straty, alpha = 0.05, nboot = 1000, nblock = 20, boot = "block")

# 10. Wyświetlenie wyników:
cat("\nWyniki mcsTest (ustawienie nboot = 1000, nblock = 20, boot = stationary):\n")
print(mcs_stationary)

Wyniki mcsTest (ustawienie nboot = 1000, nblock = 20, boot = stationary):

$includedR
[1] 1 5

$pvalsR
      [,1]
[1,] 0.000
[2,] 0.000
[3,] 0.000
[4,] 0.004
[5,] 0.697
[6,] 1.000

$excludedR
[1] 6 2 4 3

$includedSQ
[1] 1 5

$pvalsSQ
      [,1]
[1,] 0.000
[2,] 0.000
[3,] 0.000
[4,] 0.003
[5,] 0.697
[6,] 1.000

$excludedSQ
[1] 6 2 4 3


Należy zauważyć, że mamy tu 2 typy testów: statystyka R (Range statistic) oraz SQ (Semi-Quadratic statistic). Pierwszy z nich skupia się na różnicach w maksymalnych stratach między modelami, a drugi na różnicach w kwadratach strat. Najlepiej, jeśli obydwa wskazują ten sam zbiór, bo wtedy dostajemy potwierdzenie, że zbiór ufności zawiera odpowiednie modele.

Jak widać obydwa testy rzeczywiście wskazują to samo: included 1 oraz 5, czyli zbiór ufności zawiera tylko dwa modele - pierwszy oraz piąty. Pozostałe modele zostają odrzucone (excluded), zarówno przez R jak SQ. Możemy powiedzieć, że test zadziałał poprawnie, mimo przyjęcia błędnego modelu ARMA(3, 0). Model ten jest podobny do prawidłowego ARMA(3, 1), a co więcej poprawny model ma największe p-value = 1 (piąty 0.697). O tym może jeszcze powiem. Otóż wydrukowana lista może być trochę myląca, bo kolejność nie pokazuje p-value w kolejności od modelu 1 do 6, tylko w kolejności od najbardziej istotnego do najmniej istotnego wyniku dotyczącego odrzucenia modelu ze zbioru ufności. Im mniejsze p-value, tym większe prawdopodobieństwo, że dany model należy wyrzucić ze zbioru. 

Żeby zawęzić zbiór ufności, należy zwiększyć próbę. Po zwiększeniu jej do 1600, dostałem:

Wyniki mcsTest (ustawienie nboot = 1000, nblock = 20, boot = stationary):

$includedR
[1] 1

$pvalsR
      [,1]
[1,] 0.000
[2,] 0.000
[3,] 0.000
[4,] 0.000
[5,] 0.047
[6,] 1.000

$excludedR
[1] 6 2 4 3 5

$includedSQ
[1] 1

$pvalsSQ
      [,1]
[1,] 0.000
[2,] 0.000
[3,] 0.000
[4,] 0.000
[5,] 0.047
[6,] 1.000

$excludedSQ
[1] 6 2 4 3 5


Przy tak dużej próbie test nie ma już wątpliwości, że jedyny prawidłowy model to ten pierwszy.

Bardzo podobne wyniki dostałem dla boot = "block" i nie ma sensu pokazywać ich. Podobnie jak dla "stationary" musiałem ustawić próbę  liczną 1600, aby uzyskać wykluczenie wszystkich fałszywych modeli. Co jednak zauważyłem, to to, że w przypadku "block" wykluczenie modelu 5 było ledwo, ledwo, na styk, bo p-value wyniosło 0.049 (dla stationary też, ale niższe, bo 0.047).  Stacjonarny test generalnei jest lepszą alternatywą.

Powyższe przykłady zakładały rozkład normalny reszt modelu. Dla rynków może być lepszy inny, np. skośny rozkład t-Studenta. W takiej sytuacji potrzebne są dwa dodatkowe parametry do stałych: skew oraz shape. Zamiast "norm" będzie "sstd". Przykładowo kod będzie taki:

# 1. Definicje stałych parametrów
stale_3_1 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  ar2    = 0.5,
  ar3    = 0.7,
  ma1    = 0.4,
  gamma1 = 0.05,
  skew = 1.5,
  shape = 5
)

stale_0_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  gamma1 = 0.05,
  skew = 1.5,
  shape = 5
)

stale_1_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  gamma1 = 0.05,
  skew = 1.5,
  shape = 5
)

stale_2_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  ar2    = 0.5,
  gamma1 = 0.05,
  skew = 1.5,
  shape = 5
)

stale_3_0 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ar1    = -1,
  ar2    = 0.5,
  ar3    = 0.7,
  gamma1 = 0.05,
  skew = 1.5,
  shape = 5
)

stale_0_1 <- list(
  omega  = 1,
  alpha1 = 0.3,
  beta1  = 0.2,
  mu     = 0.0001,
  ma1    = 0.4,
  gamma1 = 0.05,
  skew = 1.5,
  shape = 5
)

# 2. Specyfikacje modeli EGARCH(1,1) z różnymi specyfikacjami średniej:
spec_3_1 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(3,1), include.mean = TRUE),
  distribution.model = "sstd",
  fixed.pars = stale_3_1
)

spec_0_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(0,0), include.mean = TRUE),
  distribution.model = "sstd",
  fixed.pars = stale_0_0
)

spec_1_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(1,0), include.mean = TRUE),
  distribution.model = "sstd",
  fixed.pars = stale_1_0
)

spec_2_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(2,0), include.mean = TRUE),
  distribution.model = "sstd",
  fixed.pars = stale_2_0
)

spec_3_0 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(3,0), include.mean = TRUE),
  distribution.model = "sstd",
  fixed.pars = stale_3_0
)

spec_0_1 <- ugarchspec(
  variance.model = list(model = "eGARCH", garchOrder = c(1,1)),
  mean.model     = list(armaOrder = c(0,1), include.mean = TRUE),
  distribution.model = "sstd",
  fixed.pars = stale_0_1
)

# 3. Symulacja 330 obserwacji według poprawnego modelu (ARMA(3,1))
set.seed(55)
symulacja <- ugarchpath(spec_3_1, n.sim = 330)
dane_symulowane <- as.numeric(fitted(symulacja))

# 4. Dopasowanie modeli do całego szeregu (in-sample)
dopasowanie_3_1 <- ugarchfit(spec = spec_3_1, data = dane_symulowane)
dopasowanie_0_0 <- ugarchfit(spec = spec_0_0, data = dane_symulowane)
dopasowanie_1_0 <- ugarchfit(spec = spec_1_0, data = dane_symulowane)
dopasowanie_2_0 <- ugarchfit(spec = spec_2_0, data = dane_symulowane)
dopasowanie_3_0 <- ugarchfit(spec = spec_3_0, data = dane_symulowane)
dopasowanie_0_1 <- ugarchfit(spec = spec_0_1, data = dane_symulowane)

# 5. Zapisanie wyników dopasowania w liście (dla przejrzystości)
dopasowania <- list(
  model_3_1 = dopasowanie_3_1,
  model_0_0 = dopasowanie_0_0,
  model_1_0 = dopasowanie_1_0,
  model_2_0 = dopasowanie_2_0,
  model_3_0 = dopasowanie_3_0,
  model_3_0 = dopasowanie_0_1
)
print("Dopasowane modele:")
print(names(dopasowania))

# 6. Definicja funkcji strat:
Funkcja_strat <- function(rzeczywiste, prognoza) {
  roznica <- rzeczywiste - prognoza
  strata <- ifelse(sign(rzeczywiste) != sign(prognoza), abs(roznica), 0)
  return(strata)
}

# 7. Obliczenie funkcji strat dla każdego modelu (in-sample)
strata_3_1 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_3_1))
strata_0_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_0_0))
strata_1_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_1_0))
strata_2_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_2_0))
strata_3_0 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_3_0))
strata_0_1 <- Funkcja_strat(dane_symulowane, fitted(dopasowanie_0_1))

# Łączenie strat w macierz – wiersze odpowiadają obserwacjom, kolumny modelom
straty <- cbind(
  model_3_1 = strata_3_1,
  model_0_0 = strata_0_0,
  model_1_0 = strata_1_0,
  model_2_0 = strata_2_0,
  model_3_0 = strata_3_0,
  model_3_0 = strata_0_1
)

# 8. Usunięcie wierszy składających się tylko z zer
straty <- straty[rowSums(straty != 0) > 0, ]
straty <- coredata(straty)

# 9.1. Wariant stacjonarny
mcs_stationary <- mcsTest(straty, alpha = 0.05, nboot = 1000, nblock = 20, boot = "stationary")

# 10. Wyświetlenie wyników:
cat("\nWyniki mcsTest (ustawienie nboot = 1000, nblock = 20, boot = stationary):\n")
print(mcs_stationary)

Wyniki mcsTest (ustawienie nboot = 1000, nblock = 20, boot = stationary):

$includedR
[1] 5 1

$pvalsR
      [,1]
[1,] 0.000
[2,] 0.000
[3,] 0.000
[4,] 0.000
[5,] 0.071
[6,] 1.000

$excludedR
[1] 6 2 4 3

$includedSQ
[1] 5 1

$pvalsSQ
      [,1]
[1,] 0.000
[2,] 0.000
[3,] 0.000
[4,] 0.000
[5,] 0.071
[6,] 1.000

$excludedSQ
[1] 6 2 4 3


Jak widać, ponownie można by podwyższyć p-value do 10%, żeby dostać tylko model 1. Tak czy inaczej test działa bardzo dobrze także dla innych rozkładów.

Oczywiście to bardzo wzorcowe przykłady i w praktyce będzie to trudniejsze, ale to jest dobry punkt wyjścia.

* Hansen, P., A. Lunde, and J. Nason (2011). The model confidence set. Econometrica 79, 453–497.

piątek, 25 kwietnia 2025

Krzyż śmierci na S&P500 - czy faktycznie ma znaczenie?

 Niektórzy zwrócili niedawno uwagę, że na S&P 500 występuje tzw. krzyż śmierci, czyli 50-dniowa średnia krocząca przecinająca od góry 200-dniową średnią kroczącą:


Na ten sygnał zwrócił m.in. portal xyz.pl , dodając jednak notkę, że analitycy firmy LPL Financial zaobserwowali, że historycznie w ciągu kolejnego miesiąca indeks spadał średnio niecały 1%, a w ciągu 3 miesięcy nawet rósł. Mowa jednak o medianie, a więc średnia i dominanta mogły się sporo różnić. Podanie samej mediany jest niewystarczające.

Sprawdźmy jak to rzeczywiście wyglądało dotychczas. Zaczniemy tak jak ta firma od 1950 r. Trzeba precyzyjnie określić warunki. Nie wystarczy, że SMA50 przetnie SMA200 z góry w dół, bo za chwilę może jeszcze zawrócić i średnie mogą się znowu dotknąć albo w ogóle SMA50 może przebić z dołu w górę. Jeśli taka sytuacja dzieje się w np. w ciągu tygodnia, to sygnału nie będzie. Tak więc określamy minimalne okno tego zdarzenia. Przyjmę cały tydzień, co oznacza 3 kroki do tyłu i 3 kroki do przodu od momentu przecięcia. Taki układ warunków jest już wystarczający, chociaż może nie być przekonujący, bo nawet po przecięciu się obie średnie mogą rosnąć. Tak na intuicję wydaje się, że  powinny spadać. Z drugiej strony już sama nazwa "krzyż" sugeruje dowolne przecięcie, np. rosnącej SM200 i spadkowej SMA50. Z powodu tej różnicy w interpretacji krzyża śmierci, zrobimy 2 sprawdziany:

1) Są warunki: (a) SMA50 <= SMA200, (b) SMA50[t-3] > SMA200[t-3], (c) SMA50[t+3] < SMA200[t+3] ,

2) To samo co p. (1) i dodatkowo warunek, że obie średnie spadają.

Ostatnia sprawa to sam okres przyszłej stopy zwrotu. Sprawdzimy 1-3 miesięczne zwroty do przodu. Za 1 miesiąc przyjmiemy 21 dni, czyli 21 sesji.

Ad 1) Kod w R: 

# 1. Pobieramy dane S&P500, ładujemy pakiety, np. xts.

# 2. Przekształcamy ceny na logarytmy

logCena <- log(sp500)

# 3. Obliczamy 50-dniową i 200-dniową średnią kroczącą

sma50  <- SMA(logCena, n = 50)

sma200 <- SMA(logCena, n = 200)

# 4. Ustalamy wartość przesunięcia (krok) dla warunków – tu krok = 3

krok <- 3

# Warunki dla sygnału

war1 <- sma50 <= sma200

war2 <- lag(sma50, k = krok) > lag(sma200, k = krok)           # SMA50[t-krok] > SMA200[t-krok]

war3 <- lag(sma50, k = -krok) < lag(sma200, k = -krok)                # SMA50[t+krok] < SMA200[t+krok]

war4 <- TRUE

# 5. Tworzymy wektor sygnału: 1, gdy wszystkie warunki są spełnione, NA w przeciwnym przypadku

sygnal <- ifelse(war1 & war2 & war3 & war4, 1, 0)

sygnal <- na.omit(sygnal)

datySygnalow <- index(sygnal)

# Przypisujemy daty wszystkich sygnałów

print("Daty sygnałów (pełny szereg):")

print(datySygnalow)

# 6. Teraz dodajemy warunek, aby wyłapać jedynie moment przejścia z 0 na 1.

# To pozwala zachować tylko pierwszy dzień pojawienia się sygnału.

sygnal_krok1 <- lag(sygnal, k = 1)  # przesunięcie danych o jeden dzień do tyłu (opóźnione)

sygnal_krok1[is.na(sygnal_krok1)] <- 0  # traktujemy pierwszy dzień jako 0

unikalneDatySygnalow <- which(sygnal_krok1 == 0 & sygnal == 1)

unikalneDatySygnalow <- datySygnalow[unikalneDatySygnalow] 

print("Unikalne daty sygnału (przejście z 0 na 1):")

print(unikalneDatySygnalow)

# 7. Obliczamy dzienne logarytmiczne stopy zwrotu

logZwrot <- na.omit(diff(logCena))

# 8. Używamy rollapply do sumowania logarytmicznych stóp zwrotu na horyzoncie 21 sesji,

# ale dnia następnego po sygnale 

oknoPrzyszlosci <- 21

logZwrot_suma <- lag(rollapply(logZwrot,

                           width = oknoPrzyszlosci,

                           FUN = sum,

                           align = "left"), -1)

# 9. Przekształcamy sumy logarytmicznych stóp zwrotu na zwykłe stopy zwrotu: exp(sum) - 1

miesZwrot <- exp(logZwrot_suma) - 1

# 10. Dopasowujemy daty unikalnych sygnałów do obliczonych miesięcznych stóp zwrotu

wyniki <- data.frame(Date = unikalneDatySygnalow,

                     MonthlyReturn = as.numeric(miesZwrot[unikalneDatySygnalow]))

wyniki <- na.omit(wyniki)

print("Wyniki (data unikalnego sygnału + miesięczna stopa zwrotu):")

print(wyniki)

mean(wyniki$MonthlyReturn)

# 11. Rysujemy histogram miesięcznych stóp zwrotu dla wyłapanych sygnałów

h <- hist(wyniki$MonthlyReturn,

     breaks = 7,

     col = "lightblue",

     border = "black",

     main = "Histogram miesięcznych stóp zwrotu",

     xlab = "Stopa zwrotu",

     ylab = "Liczba sygnałów",

     freq = NULL)

# 12. Przekształcamy liczebności na prawdopodobieństwa

czestosc <- h$counts / sum(h$counts)

# 13. Ustawiamy układ dwóch wykresów oraz marginesy

par(mfrow = c(2, 1), mar = c(2, 5, 2.2, 3))

slupkiZwrotow <- 100 * round(h$mids, 3)

# 14. Rysujemy wykres słupkowy prawdopodobieństwa 1-miesięcznych stóp zwrotu

b <- barplot(czestosc,

             names.arg = slupkiZwrotow,

             col       = "lightblue",

             border    = "black",

             main      = "Prawdopodobieństwo 1-miesięcznych stóp zwrotu",

             xlab      = "",

             ylab      = "Prawdopodobieństwo",

             yaxt      = "n",

             space     = 0,

             cex.main  = 0.9)

# 15. Dodajemy oś Y z wartościami prawdopodobieństwa

axis(2, at = round(czestosc, 2), las = 2, cex = 0.7)

# 16. Zaznaczamy dominantę prawdopodobieństwa linią pionową

abline(v  = b[which.max(czestosc)],

       col  = "red",

       lty  = 2,

       lwd  = 2)

# 17. Rysujemy wykres słupkowy skumulowanego prawdopodobieństwa

par(mar = c(4, 5, 1.5, 3))

barplot(cumsum(czestosc),

        names.arg = slupkiZwrotow,

        col       = "lightblue",

        border    = "black",

        main      = "Skumulowane prawdopodobieństwo 1-miesięcznych stóp zwrotu",

        xlab      = "",

        ylab      = "Prawdopodobieństwo",

        yaxt      = "n",

        space     = 0,

        cex.main  = 0.9)

# 18. Dodajemy oś Y z wartościami skumulowanego prawdopodobieństwa

axis(2, at = round(cumsum(czestosc), 2), las = 2, cex = 0.7)

# 19. Dodajemy opis osi X

mtext(text = "Stopa zwrotu, 1-miesięczna (%)", side = 1, padj = 3.5)

# 20. Zaznaczamy dominantę skumulowanego prawdopodobieństwa linią poziomą

abline(h    = cumsum(czestosc)[which.max(czestosc)],

       col  = "red",

       lty  = 2,

       lwd  = 2)

# 21. Zaznaczamy dominantę pojedynczego prawdopodobieństwa linią pionową

abline(v    = b[which.max(czestosc)],

       col  = "red",

       lty  = 2,

       lwd  = 2)



Liczba przypadków = 35.
Otrzymane statystyki:



Okazuje się, że dominuje ujemna stopa zwrotu z medianą -2,5%, ale jej częstość to niecałe 0,4, a  skumulowane prawdopodobieństwo empiryczne 0,57. Oznacza to, że na prawie 60% będzie spadać w kolejnym miesiącu.  Skośność jednak powoduje, że sporo też jest dodatnich stóp zwrotu wokół +2,5%.

Sprawdzamy 2-miesięczne zwroty, tj. oknoPrzyszlosci <- 42.




 W kolejnych dwóch miesiącach po krzyżu śmierci dominowały wzrosty z medianą 2,5%, ale znów skośność powoduje duże ryzyko spadków.

3-miesięczna stopa zwrotu (oknoPrzyszlosci <- 63):




Po 3 miesiącach dostajemy w zasadzie czystą przypadkowość - możliwe są zarówno wzrosty jak i spadki.


Ad 2) Cały kod będzie ten sam, jedynie war4 przyjmuje teraz postać
war4 <- lag(diff(sma50, lag = krok*2 + 1), k = -krok) < 0 & lag(diff(sma200, lag = krok*2 + 1), k = -krok) < 0  # oba spadają


Dostałem 20 przypadków.

a) miesięczne zwroty:


Dostajemy tu zbliżone wyniki do pierwszej definicji krzyża śmierci, z tym, że w tym przypadku prawdopodobieństwa spadków są wyższe niż poprzednio, bo aż 0,75.

b) 2-miesięczne zwroty:


Dominanta praktycznie nie istnieje. Można tylko powiedzieć, że zwroty przechylają się minimalnie na plus.

c) 3-miesięczne zwroty


Prawdopodobieństwo 3-miesięcznych wzrostów jest w tym wypadku obiektywnie większe niż spadków (chociaż okolice do -0% też nie jest takie małe). 

Porównując otrzymane statystyki ze wspomnianym na początku badaniem mogę powiedzieć, że się one pokrywają. Oni dostali średniomiesięcznie (medianę) -1%, ja dostałem dominantę -2,5%, a przy skumulowanym prawdopodobieństwie 0,57, a 0,57*2,5 = 1,43. Podobnie jak oni dostałem także dodatnią 3-miesięczną średnią. 


Naturalne staje się na koniec pytanie, czy bieżący krzyż śmierci spełnia w ogóle określone definicje? Jeśli spełnia drugą, to oczywiście spełnia też pierwszą (bo druga jest mocniejsza), więc sprawdzamy najpierw drugą. Wszystkie sygnały są zapisane w zmiennej unikalneDatySygnalow:

> unikalneDatySygnalow
 [1] "1953-05-11" "1957-09-26" "1960-02-15" "1962-05-08" "1965-07-22" "1968-02-27" "1969-06-23"
 [8] "1977-03-03" "1980-04-22" "1984-02-06" "1987-11-04" "1990-09-07" "1994-04-19" "2000-10-30"
[15] "2010-07-02" "2011-08-12" "2015-08-28" "2016-01-11" "2018-12-07" "2020-03-27" "2025-04-14"

Ostatnia pozycja to właśnie ostatni sygnał z 14 kwietnia. Czyli druga definicja zostaje spełniona. Stąd wnioskujemy, że kolejne 21 sesji będzie spadkowych, z "prawdopodobieństwem" 0,75. To znaczy, że do połowy maja należy spodziewać się generalnie spadków. Kolejne 2 miesiące są niejednoznaczne, z lekkim przechyleniem na plus, a 3 miesiące "powinny" być dodatnie. Trudno mi uwierzyć w to ostatnie, ale wszystkiego trzeba się spodziewać przy takim rozedrganiu, z jakim mamy do czynienia.