본문 바로가기
경제 금융 포털사이트 경제 금융 포털사이트

LSTM 실제 구현 및 백테스트

금알남 읽는 시간 약 24분
ai thumbnail infographic LSTM 실제 구현 및 백테스트 1789199039

LSTM 모델을 만드는 것보다 중요한 것은 미래 데이터를 보지 않고 제대로 검증하는 것인데, 예측 정확도가 높아 보여도 실제 백테스트 결과가 달라질 수 있는 이유를 코드로 확인해봅니다. 데이터 수집부터 LSTM 학습, 매매 신호 생성, 거래비용을 반영한 백테스트까지 하나의 코드로 연결해보는 것이 중요함

주가 예측에 인공지능을 활용하는 방법을 검색하다 보면 빠지지 않고 등장하는 모델이 있습니다.

바로 LSTM입니다.

과거 가격 데이터를 학습시키고 다음 날 가격을 예측하는 그래프를 보면 처음에는 상당히 그럴듯해 보입니다.

하지만 여기서 중요한 질문이 하나 있습니다.

“예측 그래프가 비슷하게 나왔다는 것과 실제 투자 전략으로 작동한다는 것은 같은 의미일까?”

결론부터 말하면 그렇지 않습니다.

머신러닝 모델은 학습 방법에 따라 미래 데이터를 간접적으로 미리 보는 데이터 누수가 발생할 수 있고, 예측 오차가 작더라도 실제 매매 방향을 제대로 맞히지 못할 수도 있습니다.

따라서 LSTM을 금융 데이터에 적용한다면 모델 생성에서 끝낼 것이 아니라 별도로 남겨둔 테스트 데이터에서 실제 매매 규칙을 적용해보는 백테스트 과정까지 확인해야 합니다.

이번 글에서는 Python과 TensorFlow/Keras를 이용해 LSTM을 직접 만들고, 다음 거래일 수익률을 예측한 뒤 간단한 매매전략으로 연결해 백테스트해보겠습니다.

특정 종목의 상승이나 하락을 전망하기 위한 글이 아니라 머신러닝 시계열 분석 과정을 이해하기 위한 교육용 실습입니다.

LSTM은 무엇을 학습하는 모델일까?

LSTM은 Long Short-Term Memory의 약자입니다.

일반적인 신경망과 달리 시간 순서가 있는 데이터의 이전 정보를 내부 상태를 통해 처리할 수 있도록 설계된 순환신경망 계열의 모델입니다.

현재 Keras의 LSTM 레이어는 입력값을 기본적으로 (batch, timesteps, feature) 형태의 3차원 텐서로 받습니다. 즉 주가 데이터에 적용한다면 여러 거래일의 데이터를 하나의 연속된 구간으로 만들어 모델에 전달해야 합니다.

예를 들어 최근 60거래일의 수익률을 이용해 다음 거래일의 수익률을 예측한다고 가정해보겠습니다.

구조는 다음과 같습니다.

1일~60일 수익률 → 61일 수익률 예측
2일~61일 수익률 → 62일 수익률 예측
3일~62일 수익률 → 63일 수익률 예측

이런 형태를 슬라이딩 윈도우라고 생각하면 이해하기 쉽습니다.

이번 실습에서는 가격 그 자체보다 일별 수익률을 사용합니다.

가격은 장기간 우상향하거나 추세를 가질 수 있기 때문에 단순 가격 예측 그래프만 보면 모델의 성능을 실제보다 좋게 착각하기 쉽기 때문입니다.

먼저 필요한 라이브러리를 설치합니다.

pip install tensorflow yfinance pandas numpy scikit-learn matplotlib

이후 필요한 라이브러리를 불러옵니다.

import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt

from sklearn.preprocessing import MinMaxScaler

from tensorflow import keras
from tensorflow.keras import layers

예제에서는 데이터 분석 방법을 보여주기 위해 SPY 데이터를 사용하겠습니다.

yfinance.download()는 날짜 범위와 일별·주별·월별 등의 interval을 지정해 데이터를 가져올 수 있습니다. 현재 문서 기준으로 auto_adjust=True를 지정하면 가격 조정 기능을 사용할 수 있습니다.

df = yf.download(
    "SPY",
    start="2015-01-01",
    auto_adjust=True,
    progress=False,
    multi_level_index=False
)

df = df[["Close"]].dropna()

df["return"] = df["Close"].pct_change()

df = df.dropna()

print(df.head())
print(df.tail())

여기까지 실행하면 날짜별 종가와 일별 수익률을 확보할 수 있습니다.

다만 지금부터가 더 중요합니다.

데이터를 곧바로 전체 구간에서 정규화하면 안 됩니다.

image 14

학습 데이터와 테스트 데이터를 먼저 분리해야 하는 이유

머신러닝 금융 분석에서 가장 주의해야 할 것 중 하나가 데이터 누수입니다.

예를 들어 2015년부터 2026년까지의 데이터가 있다고 가정해보겠습니다.

전체 데이터의 최솟값과 최댓값을 이용해 먼저 정규화한 다음 2015~2023년 데이터를 학습한다면 어떻게 될까요?

모델 자체는 미래 가격을 직접 보지 않았더라도 데이터 전처리 과정에 미래 구간의 정보가 들어갈 수 있습니다.

그래서 시간 순서를 먼저 구분합니다.

split_idx = int(len(df) * 0.8)

train_df = df.iloc[:split_idx]
test_df = df.iloc[split_idx:]

print("Train:", train_df.index[0], "~", train_df.index[-1])
print("Test :", test_df.index[0], "~", test_df.index[-1])

앞쪽 80%를 학습 데이터로 사용하고 뒤쪽 20%는 테스트 데이터로 남겨둡니다.

일반적인 무작위 train_test_split처럼 데이터를 섞지 않는 것이 중요합니다.

시계열 데이터에서는 과거를 이용해 미래를 평가해야 하기 때문입니다. scikit-learn 역시 시계열 검증에서는 일반적인 무작위 분할이 부적절할 수 있어 시간 순서를 유지하는 TimeSeriesSplit을 별도로 제공합니다.

이제 스케일러도 학습 데이터에만 맞춥니다.

scaler = MinMaxScaler(
    feature_range=(-1, 1)
)

scaler.fit(
    train_df[["return"]]
)

scaled_returns = scaler.transform(
    df[["return"]]
)

MinMaxScaler는 데이터 값을 지정한 범위로 변환하는 전처리 방법입니다. 중요한 점은 테스트 구간을 포함한 전체 데이터가 아니라 학습 구간을 이용해 fit()했다는 것입니다.

이제 최근 60거래일을 입력 데이터로 만들겠습니다.

LOOKBACK = 60

X = []
y = []
target_index = []

for i in range(
    LOOKBACK,
    len(scaled_returns)
):
    X.append(
        scaled_returns[
            i - LOOKBACK:i
        ]
    )

    y.append(
        scaled_returns[i]
    )

    target_index.append(i)

X = np.array(X)
y = np.array(y)
target_index = np.array(target_index)

print(X.shape)
print(y.shape)

LSTM 입력 데이터는 다음과 같은 구조가 됩니다.

샘플 개수 × 60거래일 × 1개 변수

이제 목표 날짜를 기준으로 학습과 테스트 샘플을 다시 나눕니다.

train_mask = target_index < split_idx
test_mask = target_index >= split_idx

X_train = X[train_mask]
y_train = y[train_mask]

X_test = X[test_mask]
y_test = y[test_mask]

test_target_index = target_index[
    test_mask
]

여기까지가 실제 LSTM보다 더 중요한 과정이라고 볼 수도 있습니다.

모델이 아무리 복잡해도 검증 구조가 잘못되어 있다면 백테스트 결과를 신뢰하기 어렵기 때문입니다.

TensorFlow Keras로 LSTM 실제 구현하기

이제 모델을 만들어보겠습니다.

학습 데이터 중 마지막 일부는 validation 데이터로 남겨두겠습니다.

val_size = int(
    len(X_train) * 0.2
)

X_fit = X_train[:-val_size]
y_fit = y_train[:-val_size]

X_val = X_train[-val_size:]
y_val = y_train[-val_size:]

다음은 LSTM 모델입니다.

model = keras.Sequential([
    keras.Input(
        shape=(LOOKBACK, 1)
    ),

    layers.LSTM(
        32
    ),

    layers.Dropout(
        0.2
    ),

    layers.Dense(
        1
    )
])

model.compile(
    optimizer="adam",
    loss="mse"
)

model.summary()

이번 예제에서는 복잡한 네트워크를 만들기보다 LSTM 하나와 Dropout, Dense 레이어만 사용했습니다.

LSTM의 유닛 수를 늘리거나 여러 층을 쌓는다고 해서 금융 데이터 예측력이 자동으로 좋아지는 것은 아닙니다.

오히려 데이터에 비해 모델이 지나치게 복잡하면 과적합 위험이 커질 수 있습니다.

학습이 불필요하게 오래 진행되는 것을 줄이기 위해 EarlyStopping도 사용해보겠습니다.

early_stop = keras.callbacks.EarlyStopping(
    monitor="val_loss",
    patience=10,
    restore_best_weights=True
)

Keras의 EarlyStopping은 지정한 지표가 일정 기간 개선되지 않을 때 학습을 종료하는 기능이며, restore_best_weights=True를 사용하면 가장 좋은 시점의 가중치로 되돌릴 수 있습니다.

이제 모델을 학습합니다.

history = model.fit(
    X_fit,
    y_fit,

    validation_data=(
        X_val,
        y_val
    ),

    epochs=100,
    batch_size=32,

    shuffle=False,

    callbacks=[
        early_stop
    ],

    verbose=1
)

여기서 shuffle=False도 중요한 부분입니다.

시간 순서가 중요한 시계열 데이터를 무작위로 뒤섞지 않기 위해서입니다.

학습 손실과 검증 손실을 그래프로 확인할 수도 있습니다.

plt.figure(
    figsize=(10, 5)
)

plt.plot(
    history.history["loss"],
    label="Train Loss"
)

plt.plot(
    history.history["val_loss"],
    label="Validation Loss"
)

plt.legend()
plt.grid(True)
plt.show()

두 그래프의 차이가 지나치게 커진다면 과적합 가능성을 의심해볼 수 있습니다.

이제 학습 과정에서 사용하지 않은 테스트 구간을 예측합니다.

pred_scaled = model.predict(
    X_test,
    verbose=0
)

pred_return = scaler.inverse_transform(
    pred_scaled
).flatten()

actual_return = df["return"].iloc[
    test_target_index
].values

예측 수익률과 실제 수익률을 데이터프레임으로 정리합니다.

result = pd.DataFrame(
    {
        "actual": actual_return,
        "predicted": pred_return
    },
    index=df.index[
        test_target_index
    ]
)

print(result.head())

여기까지 실행하면 LSTM이 다음 거래일 수익률을 어느 방향으로 예측했는지 확인할 수 있습니다.

하지만 아직 모델이 투자 전략으로 의미가 있는지는 알 수 없습니다.

백테스트가 필요한 이유입니다.

예측값을 실제 백테스트로 연결하기

가장 단순한 규칙을 사용하겠습니다.

LSTM이 다음 거래일 수익률을 플러스로 예상하면 보유하고, 마이너스로 예상하면 현금 상태를 유지하는 전략입니다.

result["signal"] = (
    result["predicted"] > 0
).astype(int)

그리고 실제 수익률에 신호를 적용합니다.

result["strategy_return"] = (
    result["signal"]
    * result["actual"]
)

그런데 이렇게 끝내면 현실적인 백테스트라고 보기 어렵습니다.

포지션을 변경할 때 거래비용이 발생하기 때문입니다.

예제에서는 거래비용을 임의로 0.05%, 즉 5bp로 가정해보겠습니다.

실제 거래비용, 세금, 스프레드 등은 시장과 거래환경에 따라 달라질 수 있습니다.

COST = 0.0005

result["turnover"] = (
    result["signal"]
    .diff()
    .abs()
    .fillna(
        result["signal"].iloc[0]
    )
)

result["strategy_net"] = (
    result["strategy_return"]
    - result["turnover"] * COST
)

누적수익률을 계산합니다.

result["buy_hold"] = (
    1 + result["actual"]
).cumprod()

result["strategy_equity"] = (
    1 + result["strategy_net"]
).cumprod()

그리고 그래프로 비교합니다.

plt.figure(
    figsize=(12, 6)
)

plt.plot(
    result.index,
    result["buy_hold"],
    label="Buy & Hold"
)

plt.plot(
    result.index,
    result["strategy_equity"],
    label="LSTM Strategy"
)

plt.legend()
plt.grid(True)
plt.show()

이제 단순히 “예측 그래프가 비슷하다”가 아니라 실제 매매 규칙을 적용했을 때 자산곡선이 어떻게 달라지는지 확인할 수 있습니다.

성과지표도 만들어보겠습니다.

def performance_metrics(
    returns
):
    returns = np.asarray(
        returns
    )

    equity = np.cumprod(
        1 + returns
    )

    annual_return = (
        equity[-1]
        ** (252 / len(returns))
        - 1
    )

    annual_vol = (
        np.std(
            returns,
            ddof=1
        )
        * np.sqrt(252)
    )

    if np.std(
        returns,
        ddof=1
    ) == 0:
        sharpe = np.nan
    else:
        sharpe = (
            np.mean(returns)
            / np.std(
                returns,
                ddof=1
            )
            * np.sqrt(252)
        )

    peak = np.maximum.accumulate(
        equity
    )

    drawdown = (
        equity / peak - 1
    )

    max_drawdown = (
        drawdown.min()
    )

    return {
        "Annual Return": annual_return,
        "Annual Volatility": annual_vol,
        "Sharpe": sharpe,
        "MDD": max_drawdown
    }

전략과 단순 보유 전략을 비교합니다.

strategy_metrics = performance_metrics(
    result["strategy_net"]
)

benchmark_metrics = performance_metrics(
    result["actual"]
)

print(
    "LSTM Strategy"
)

for key, value in strategy_metrics.items():
    print(
        key,
        round(value, 4)
    )

print(
    "\nBuy & Hold"
)

for key, value in benchmark_metrics.items():
    print(
        key,
        round(value, 4)
    )

방향 예측 정확도도 확인할 수 있습니다.

direction_accuracy = np.mean(
    np.sign(
        result["predicted"]
    )
    ==
    np.sign(
        result["actual"]
    )
)

print(
    "Direction Accuracy:",
    round(
        direction_accuracy,
        4
    )
)

여기서 주의해야 합니다.

정확도가 높다고 수익률이 반드시 높은 것은 아닙니다.

작은 움직임을 여러 번 맞히고 큰 하락을 한 번 틀릴 수도 있기 때문입니다.

반대로 방향 적중률이 절반 부근이어도 손익 구조에 따라 결과가 달라질 수 있습니다.

그래서 머신러닝 투자 모델은 MSE나 정확도 하나만으로 평가하기보다 누적수익률, 변동성, 최대낙폭, 거래 횟수, 비용 민감도 등을 함께 살펴볼 필요가 있습니다.

LSTM 백테스트에서 가장 많이 놓치는 부분

LSTM 모델을 한 번 실행했다고 해서 “AI가 주가를 예측한다”는 결론을 내리기는 어렵습니다.

오히려 실제 분석에서는 다음 항목을 더 중요하게 봐야 합니다.

  1. 데이터 누수가 없는가?

테스트 데이터를 이용해 정규화하거나 하이퍼파라미터를 계속 수정하면 사실상 테스트 구간의 정보를 모델 개발에 사용하게 됩니다.

  1. 시간 순서를 지켰는가?

과거 데이터로 미래를 예측해야 합니다.

시계열 데이터에서 무작위로 학습 데이터와 테스트 데이터를 섞으면 실전에서는 얻을 수 없는 정보를 활용하게 될 가능성이 있습니다.

  1. 거래비용을 반영했는가?

매매 빈도가 높은 전략은 작은 비용 차이만으로도 결과가 크게 달라질 수 있습니다.

  1. 단순 전략보다 실제로 나은가?

복잡한 LSTM을 만들었다는 사실 자체에는 투자 가치가 없습니다.

동일 기간의 단순 보유 전략이나 매우 단순한 규칙과 비교해야 합니다.

  1. 다른 기간에서도 작동하는가?

특정 상승장이나 하락장 한 구간에서만 잘 작동한 모델이라면 시장 환경이 바뀌었을 때 성능이 크게 달라질 수 있습니다.

따라서 한 번의 80:20 분할보다 향후에는 Walk-Forward 방식이나 TimeSeriesSplit을 이용한 반복 검증으로 확장하는 것이 좋습니다.

예를 들어 학습과 테스트 구간을 다음과 같이 이동시키는 방식입니다.

과거 A → 미래 A 검증
과거 A+B → 미래 B 검증
과거 A+B+C → 미래 C 검증

이런 방식을 사용하면 특정 테스트 기간 하나에 결과가 좌우되는 문제를 어느 정도 줄일 수 있습니다.

LSTM을 배우면서 처음 목표로 삼아야 할 것은 높은 수익률 숫자를 만드는 것이 아닙니다.

첫 번째 목표는 데이터 수집부터 다음 과정을 정확하게 연결하는 것입니다.

  1. 데이터를 시간 순서대로 준비한다.
  2. 학습·검증·테스트 데이터를 분리한다.
  3. 학습 데이터에만 전처리 기준을 맞춘다.
  4. 과거 데이터로 LSTM을 학습한다.
  5. 사용하지 않은 미래 데이터에서 예측한다.
  6. 예측값을 명확한 매매 규칙으로 변환한다.
  7. 거래비용을 포함해 백테스트한다.
  8. 단순 벤치마크와 결과를 비교한다.

이 과정이 제대로 만들어져야 이후 FRED 금리, 환율, 거래량, 변동성, 기술적 지표와 같은 변수를 추가하는 것도 의미가 있습니다.

특히 앞서 FRED에서 미국채 10년물 DGS10 데이터를 수집했다면 다음 단계에서는 주가 데이터와 미국 10년물 금리를 하나의 데이터프레임으로 합쳐 다변량 LSTM 모델을 만들어볼 수도 있습니다.

하지만 변수를 많이 추가한다고 성능이 자동으로 좋아지는 것은 아닙니다.

금융시장은 경제지표, 기업 실적, 정책, 예상하지 못한 사건 등 여러 요인의 영향을 받습니다. 과거 데이터에서 발견한 패턴이 미래에도 동일하게 반복된다는 보장도 없습니다.

LSTM 실습의 진짜 의미는 “AI로 미래 가격을 맞히는 공식”을 찾는 것이 아니라, 시계열 데이터를 어떻게 학습시키고 결과를 어떻게 객관적으로 검증해야 하는지를 배우는 데 있습니다.

처음에는 복잡한 모델보다 오늘 작성한 기본 구조를 직접 실행해보는 것이 좋습니다.

그리고 결과가 좋든 나쁘든 숫자를 그대로 받아들이지 말고 데이터 누수는 없는지, 비용은 반영했는지, 다른 기간에서도 같은 결과가 나오는지를 다시 확인해보세요.

그 과정부터가 제대로 된 백테스트의 시작입니다.

참고 자료

  • Keras LSTM 공식 문서: LSTM 레이어의 입력 구조, 주요 파라미터와 사용법을 확인할 수 있습니다.
  • Keras EarlyStopping 공식 문서: validation 지표를 기준으로 학습을 조기에 종료하는 방법을 확인할 수 있습니다.
  • scikit-learn TimeSeriesSplit 공식 문서: 시간 순서를 유지한 시계열 검증 방식에 대한 설명을 확인할 수 있습니다.
  • yfinance 공식 문서: download()를 이용한 가격 데이터 조회 옵션을 확인할 수 있습니다.

투자 및 데이터 이용 유의사항

본 글은 Python, 딥러닝 및 시계열 데이터 분석 방법을 설명하기 위한 교육·정보 제공 목적의 콘텐츠입니다.

본문에 사용한 종목과 매매 규칙은 코드 구현 방법을 설명하기 위한 예시이며 특정 금융상품의 매수·매도 또는 보유를 권유하는 의미가 아닙니다.

백테스트 결과는 데이터 기간, 모델 구조, 학습 조건, 거래비용, 시장 환경 등에 따라 크게 달라질 수 있으며 과거의 분석 결과가 미래의 투자 성과를 보장하지 않습니다.

실제 투자 판단은 개인의 투자 목적과 위험 감수 수준을 고려해 충분한 검토 후 결정해야 합니다.

금알남

금알남
함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.