
MQL5의 정량적 분석: 유망한 알고리즘 구현하기
금융 시장에서 정량적 분석이란 무엇인가요?
금융 시장에서 정량적 분석이란 무엇인가요? 정량적 분석은 머신 러닝의 일종의 선구자로 등장했으며 사실 통계적 학습의 하위 섹션입니다. 컴퓨터가 막 등장하여 방 하나를 통째로 차지하고 펀치카드로 작업하던 시절에 진보적인 사람들은 빅 데이터와 통계 분석에 컴퓨터를 적용하려고 노력했습니다. 당시에는 가격을 실행할 수 있는 통계 연산과 함수 집합이 극히 적었고 함수 자체도 매우 단순했으며 발견되는 패턴도 특별히 복잡하지 않았습니다.
이러한 연구는 데이터에서 특정 관계를 파악하기 위한 간단한 계산들이었고 대부분 선형적이었습니다.
금융 시장에서 가장 간단하고 쉽게 배울 수 있는 정량적 분석 방법은 관련 자산 간의 스프레드 분석입니다. 예를 들어 상관관계가 있는 두 자산 간의 스프레드를 표시하고 정량적 분석을 사용하여 이 스프레드의 평균, 최대, 중앙값 편차를 구할 수 있습니다. 우리가 데이터에 대한 정량적 설명을 알게 되면 한 자산이 다른 자산에서 얼마나 벗어났는지를 파악할 수 있으며 두 자산의 불일치가 제거되면(자산이 서로를 향해 움직일 때) 두 자산이 확실히 반환하는 곳인 균형 상태를 대략적으로 이해할 수 있습니다. 일반적으로 페어(pair) 트레이딩에서 정량적 분석의 사용은 매우 흥미로운 주제이므로 향후의 기사에서 이 점을 확실히 다룰 것입니다.
헤지펀드들이 정량적 분석을 이용하는 방법
정량적 분석을 사용한 최초의 시도는 1970년대에 주식과 해당 주식의 워런트 사이의 스프레드를 분석하여 자산이 워런트에 비해 얼마나 고평가 또는 저평가되었는지 계산하는 방법을 배운 에드워드 오 토프(Edward O. Thorp)의 작업이었습니다. 당시 토프의 컴퓨터는 방 하나를 통째로 차지할 정도로 컸고 펀치 카드로도 작동했습니다. 일반적으로 컴퓨터 정량적 분석을 금융 시장에 최초로 적용한 사람은 에드워드 오 토프입니다. 그것은 전 세계가 인정한 당시의 획기적인 기술이었습니다. 토프는 세계 최초의 '정략적(quantitative)' 헤지펀드를 만들었습니다.
아시다시피 주식 시장에서 가장 먼저 떠오르는 정략적 분석의 예는 페어 트레이딩 또는 바스켓 트레이딩에 적용하는 예입니다. 우리는 그러한 옵션을 고려하겠지만 오늘의 정량적 분석 알고리즘은 다른 원칙에 기반할 것입니다.
주요 시장 참여자들은 또 어떻게 정량적 분석을 활용할까요?
이들은 통계적 차익거래(Statistical Arbitrage)를 통해 다른 시장 또는 다른 시점의 금융상품 가격 차이를 감지할 수 있습니다. 이를 통해 펀드는 다양한 시장에서 수익성 있는 거래 기회를 파악하고 활용할 수 있습니다. 또한 정략적 모델은 헤지펀드가 통계 데이터를 기반으로 미래 시장 움직임을 예측하여 정보에 입각한 트레이딩 결정을 내리는 데 도움이 됩니다.
위험 관리는 정량적 분석의 또 다른 매우 중요한 응용 분야입니다. 헤지 펀드는 모델을 사용하여 포트폴리오의 리스크를 평가하고 관리합니다. 이들은 리스크에 따라 자산 구조를 최적화하여 잠재적 손실을 최소화합니다. 그 예로는 마코위츠 포트폴리오 이론에 따른 포트폴리오 최적화(포트폴리오의 편차가 잠재 수익을 초과하지 않도록 리스크를 기반으로 함), VaR 시스템에 따른 리스크 관리 등 다양한 예가 있습니다. 후자는 99%의 확률로 초과히지 않는 드로다운을 계산할 수 있는 고유한 모델입니다.
물론 실제 시장은 수학으로 설명하기 어려운 경우가 많기 때문에 부정적인 사례도 존재합니다. 1998년 LTCM 헤지펀드는 포지션이 큰 손실을 가져오지 않을 것이라고 계산하고 정량적 분석을 기반으로 미국 장기채와 단기채의 스프레드를 노리는 차익거래 전략으로 시장에 진입했습니다. 그러나 러시아가 디폴트하고 아시아가 위기를 겪으면서 나비 효과를 통해 미국 국채 시장이 패닉에 빠졌습니다. LTCM 펀드는 스프레드가 비정상적으로 높고 가격이 반드시 반대 방향으로 "롤백"될 것이며 펀드의 포지션이 반드시 이익으로 청산될 것이라는 모델을 사용했습니다.
그 결과 펀드는 평균화를 적용하고 매우 공격적으로 큰 레버리지를 얻었으며 직원인 노벨상 수상자들은 그러한 결과가 불가능하다고 말했지만 자산으로 부채를 늘리고 파산했습니다. 이는 하나의 정량적 분석 모델인 VaR이 미국 시장 전체를 거의 파괴할 뻔했던 사례입니다. 앨런 그린스펀 연준 의장은 미국 최대 은행장들에게 긴급히 전화를 걸어 펀드의 한계 포지션을 매입하도록 요청해야 했습니다. 그러지 않고 막대한 자산을 '시장에' 내놓았다면 것은 미국 주식 시장을 즉각적으로 리셋하고 대공황보다 더 큰 공황을 일으켰을 것입니다.
따라서 정량적 분석과 지표의 평균화를 적용할 때는 정규 확률 분포의 꼬리를 기억하는 것이 중요합니다. 종 모양의 확률 곡선은 금융 시장의 경우 '블랙 스완'이라고도 하는 상당한 편차를 반영하는 '뚱뚱한 꼬리'를 가지고 있습니다. 한편으로는 통계적으로 가능성이 극히 낮지만, 다른 한편으로는 이러한 이벤트의 규모와 위력은 투자자의 포트폴리오와 헤지펀드 포트폴리오가 파괴시키고 한계 포지션이 사라지게 하고 시장을 파괴시키고 새로운 사이클마다 마켓을 바뀌게 하기도 합니다. 1998년, 2008년, 2020년, 2022년에도 이러한 현상이 나타났습니다. 앞으로도 이런 일은 더 자주 발생할 것입니다.
정량적 분석은 헤지펀드에 많은 도움을 주며 일상 업무에서 지속적으로 사용됩니다. 그러나 수백만 명의 결정, 공황 상태, 특정 사건에 대한 반응을 계산할 수 있는 기능은 없다는 점을 기억하는 것이 중요합니다. 공격적인 트레이딩 전략을 사용할 때 예치금을 날려 버릴수 있는 정규 분포의 꼬리에 대해 기억하는 것도 중요합니다.
알고리즘 기반: 움직임 파 계산
트레이더인 아르템 즈베즈딘은 자산의 고평가 또는 저평가 정도를 파악하기 위해 가격 변동 파동의 크기를 계산하는 트레이더이며 이 아이디어의 기초를 처음으로 제시했습니다. 예를 들어 지난 500-5000바 동안의 강세와 약세의 파동을 계산하여 각 작은 주기에서 가격이 얼마나 멀리 움직였는지 파악합니다. 가격 움직임의 각각의 사이클은 누군가의 포지션, 누군가의 자금, 매수 또는 매도의 결정을 반영합니다. 새로운 사이클마다 시장의 새로운 탄생과 죽음이 반복됩니다. 우리는 롤백 없이 가격 변동을 위에서 아래로 분석하는 아이디어를 사용할 것입니다. 이는 거의 동일하게 행동하는 별도의 참가자들의 집합입니다. 그러므로 우리는 주기의 길이가 항상 거의 동일할 것이라는 가설을 세웁니다. 우리는 MetaTrader 5 터미널 패키지에 포함된 지그재그(ZigZag) 지표를 사용하여 평균 가격 변동을 계산합니다.
이 글에 있는 제가 만든 Expert Advisor를 살펴보겠습니다. 먼저 EA의 헤더 부분을 살펴보세요. 설정은 매우 간단합니다. 표준 거래 라이브러리를 사용합니다. 랏 설정의 경우 고정 랏을 거래하거나 잔액 값을 기준으로 랏을 계산하도록 지정할 수 있습니다. 수익이 0보다 크면 EA는 총 수익에 따라 거래를 청산합니다. 손절매와 테이크프로핏은 ATR 값, 즉 상품의 현재 변동성에 따라 계산됩니다. EA의 계산에 쓰이는 지그재그 설정은 일반적으로 표준이므로 여기서는 자세히 설명하지 않겠습니다. 또한 EA 템플릿은 다중 통화로 다양한 자산에서 사용할 수 있다는 점에 유의하세요. 향후 버전의 Expert Advisor에서 관련 자산 바스켓을 거래하여 전반적인 위험을 줄이려면 이 기능이 필요합니다. 현재 버전 0.90은 하나의 심볼에서만 작동합니다.
//+------------------------------------------------------------------+ //| QuantAnalysisSample.mq5 | //| Copyright 2023 | //| Evgeniy Koshtenko | //+------------------------------------------------------------------+ #property copyright "Copyright 2023, Evgeniy Koshtenko" #property link "https://www.mql5.com" #property version "0.90" #property strict #include <Trade\Trade.mqh> #include <Graphics\Graphic.mqh> #include <Math\Stat\Normal.mqh> #include <Math\Stat\Math.mqh> CTrade trade; //--- Inputs input double Lots = 0.1; // lot input double Risk = 0.1; // risk input double Profit = 0; // profit input int StopLoss = 0; // ATR stop loss input int TakeProfit = 0; // ATR take profit input string Symbol1 = "EURUSD"; input int Magic = 777; // magic number //--- Indicator inputs input uint InpDepth = 120; // ZigZag Depth input uint InpDeviation = 50; // ZigZag Deviation input uint InpBackstep = 30; // ZigZag Backstep input uchar InpPivotPoint = 1; // ZigZag pivot point datetime t=0; double last=0; double countMovements; double currentMovement; // Global variable for storing the indicator descriptor int zigzagHandle;
이제 EA의 나머지 함수에 대해 살펴보겠습니다. 초기화 및 초기화 해제 함수는 일반적으로 간단하고 이해하기 쉽습니다. EA가 자신의 주문을 다른 주문과 구별할 수 있는 고유 식별자인 EA의 매직넘버를 설정합니다. 동시에 OnInit을 통해 직접 다중 통화 핸들을 로드하면 EA에서 오류가 발생합니다. 그러므로 추가로 우리가 직접 작성한 함수에서 핸들을 설정합니다. 그렇기 때문에 우리는 매우 간단하고 쉬운 솔루션을 사용합니다.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { trade.SetExpertMagicNumber(Magic); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert initialization function custom | //+------------------------------------------------------------------+ int OnIniti(string symb) {// Loading the ZigZag indicator zigzagHandle = iCustom(symb, _Period, "ZigZag", InpDepth, InpDeviation, InpBackstep, InpPivotPoint); if (zigzagHandle == INVALID_HANDLE) { Print("Error loading the ZigZag indicator: ", GetLastError()); return(INIT_FAILED); } return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { Comment(""); }
Expert Advisor의 다른 함수도 살펴보는건 어떨까요? 모든 포지션의 총 수익을 계산하는 함수와 모든 주문을 완전히 청산하는 함수가 있습니다:
//+------------------------------------------------------------------+ //| Position Profit | //+------------------------------------------------------------------+ double AllProfit(int type=-1) { double p=0; for(int i=PositionsTotal()-1; i>=0; i--) { if(PositionSelectByTicket(PositionGetTicket(i))) { if(PositionGetInteger(POSITION_MAGIC)==Magic) { if(PositionGetInteger(POSITION_TYPE)==type || type==-1) p+=PositionGetDouble(POSITION_PROFIT); } } } return(p); } //+------------------------------------------------------------------+ //| CloseAll | //+------------------------------------------------------------------+ void CloseAll(int type=-1) { for(int i=PositionsTotal()-1; i>=0; i--) { if(PositionSelectByTicket(PositionGetTicket(i))) { if(PositionGetInteger(POSITION_MAGIC)==Magic) { if(PositionGetInteger(POSITION_TYPE)==type || type==-1) trade.PositionClose(PositionGetTicket(i)); } } } }
다음으로 랏 계산 함수와 오픈 포지션의 수를 계산하는 함수가 있습니다:
//+------------------------------------------------------------------+ //| CountTrades | //+------------------------------------------------------------------+ int CountTrades(string symb) { int count=0; for(int i=PositionsTotal()-1; i>=0; i--) { if(PositionSelectByTicket(PositionGetTicket(i))) { if(PositionGetString(POSITION_SYMBOL)==symb) { count++; } } } return(count); } //+------------------------------------------------------------------+ //| Lot | //+------------------------------------------------------------------+ double Lot() { double lot=Lots; if(Risk>0) lot=AccountInfoDouble(ACCOUNT_BALANCE)*Risk/100000; return(NormalizeDouble(lot,2)); }
또한 매수 및 매도의 마지막 거래 가격을 계산하는 함수와 포지션의 방향을 결정하는 함수도 있습니다(나중에 사용하겠습니다).
//+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double FindLastBuyPrice(string symb) { double pr=0; for(int i=PositionsTotal()-1; i>=0; i--) { if(PositionSelectByTicket(PositionGetTicket(i)) && PositionGetInteger(POSITION_TYPE)==0) { if(PositionGetString(POSITION_SYMBOL)==symb) { pr=PositionGetDouble(POSITION_PRICE_OPEN); break; } } } return(pr); } //+------------------------------------------------------------------+ //| | //+------------------------------------------------------------------+ double FindLastSellPrice(string symb) { double pr=0; for(int i=PositionsTotal()-1; i>=0; i--) { if(PositionSelectByTicket(PositionGetTicket(i)) && PositionGetInteger(POSITION_TYPE)==1) { if(PositionGetString(POSITION_SYMBOL)==symb) { pr=PositionGetDouble(POSITION_PRICE_OPEN); break; } } } return(pr); } //+------------------------------------------------------------------+ //| PositionType | //+------------------------------------------------------------------+ int PositionType(string symb) { int type=8; for(int i=PositionsTotal()-1; i>=0; i--) { if(PositionSelectByTicket(PositionGetTicket(i))) { if(PositionGetString(POSITION_SYMBOL)==symb) { type=(int)PositionGetInteger(POSITION_TYPE); break; } } } return(type); }
물론 가장 중요한 함수는 평균 및 현재 움직임을 계산하는 함수로 편의를 위해 포인트가 아닌 가격 단위의 이동 금액으로 계산됩니다. "사용자 지정 초기화"라고 부르고 버퍼를 복사한 다음 for 루프에서 지그재그의 상단에서 마지막 극한까지 가격 변동의 크기를 계산합니다. 이 함수는 가격 변동 단위와 평균 변동 단위로 현재의 움직임을 출력합니다.
//+------------------------------------------------------------------+ //| CalculateAverageMovement | //+------------------------------------------------------------------+ void CalculateAverageMovement(string symb, double &averageMovement, double ¤tMovement) { const int lookback = 500; // Number of bars for analysis double sumMovements = 0.0; int countMovements = 0; double lastExtremePrice = 0.0; double zigzagArray[500]; // Array to store ZigZag values OnIniti(symb); // Copy ZigZag values to array if (CopyBuffer(zigzagHandle, 0, 0, lookback, zigzagArray) <= 0) { Print("Error copying indicator data"); averageMovement = -1; currentMovement = -1; return; } // Copy ZigZag values to array if (CopyBuffer(zigzagHandle, 0, 0, lookback, zigzagArray) <= 0) { Print("Error copying indicator data"); averageMovement = -1; currentMovement = -1; return; } for (int i = 0; i < lookback; i++) { if (zigzagArray[i] != 0 && zigzagArray[i] != lastExtremePrice) { if (lastExtremePrice != 0) { // Determine the movement direction double movement = zigzagArray[i] - lastExtremePrice; sumMovements += movement; countMovements++; } lastExtremePrice = zigzagArray[i]; } } // Calculate the current movement double lastMovement = iClose(symb, _Period, 0) - lastExtremePrice; currentMovement = lastMovement; // Calculate the average movement averageMovement = countMovements > 0 ? sumMovements / countMovements : 0.0; // Print the result Print("Average movement: ", averageMovement); Print("Current movement: ", currentMovement); // Release resources IndicatorRelease(zigzagHandle); }
또 다른 주요 함수 중 하나는 현재 가격 변동이 평균값을 초과한다는 신호를 기반으로 하는 다중 통화 거래 함수입니다. 이익실현과 손절매는 ATR을 기준으로 설정됩니다. 또한 ATR은 그리드 단계(평균화)에도 사용됩니다. 새로운 바에서 거래가 시작됩니다. 이는 우리에게 중요한 일입니다. 그런 다음 이 함수는 OnTick에서 호출되어 하나 또는 여러 개의 심볼에서 작동합니다. 이미 말씀드렸듯이 저는 아직 여러 심볼에서 EA를 성공적으로 실행할 수 없었기 때문에 EA가 실행되는 심볼은 하나만 사용할 것입니다. 이 심볼은 EA 설정에서 지정해야 합니다.
//+------------------------------------------------------------------+ //| Expert Trade unction | //+------------------------------------------------------------------+ void Trade(string symb) { double averageMovement = 0; double currentMovement = 0; double pr=0,sl=0,tp=0,hi=0,lo=0; // Call function for calculation CalculateAverageMovement(symb, averageMovement, currentMovement); // Use results double Ask = SymbolInfoDouble(symb, SYMBOL_ASK); double Bid = SymbolInfoDouble(symb, SYMBOL_BID); int dg=(int)SymbolInfoInteger(symb,SYMBOL_DIGITS); double pp=SymbolInfoDouble(symb,SYMBOL_POINT); double atr = iATR(symb, PERIOD_CURRENT, 3); // Here define your logic for buying and selling bool sell = currentMovement > -averageMovement; // Buy condition bool buy = -currentMovement > averageMovement; // Sell condition if(AllProfit()>Profit && Profit>0) CloseAll(); if(t!=iTime(symb,PERIOD_CURRENT,0)) { if(buy && CountTrades(symb)<1) { if(StopLoss>0) sl=NormalizeDouble(Bid-(atr*StopLoss)*Point(),_Digits); if(TakeProfit>0) tp=NormalizeDouble(Bid+(atr*TakeProfit)*Point(),_Digits); pr=NormalizeDouble(Bid,dg); trade.Buy(Lot(),symb,pr,sl,tp,""); last=pr; } if(sell && CountTrades(symb)<1) { if(StopLoss>0) sl=NormalizeDouble(Ask+(atr*StopLoss)*Point(),_Digits); if(TakeProfit>0) tp=NormalizeDouble(Ask-(atr*TakeProfit)*Point(),_Digits); pr=NormalizeDouble(Ask,dg); trade.Sell(Lot(),symb,Ask,sl,tp,""); last=pr; } if(CountTrades(symb)>0) { if(PositionType(symb)==0 && (FindLastBuyPrice(symb)-Ask)/pp>=atr*30) { if(StopLoss>0) sl=NormalizeDouble(Bid-(atr*StopLoss)*Point(),_Digits); if(TakeProfit>0) tp=NormalizeDouble(Bid+(atr*TakeProfit)*Point(),_Digits); trade.Buy(Lot(),symb,Ask,sl,tp); } if(PositionType(symb)==1 && (Bid-FindLastSellPrice(symb))/pp>=atr*30) { if(StopLoss>0) sl=NormalizeDouble(Ask+(atr*StopLoss)*Point(),_Digits); if(TakeProfit>0) tp=NormalizeDouble(Ask-(atr*TakeProfit)*Point(),_Digits); trade.Sell(Lot(),symb,Bid,sl,tp); } } t=iTime(symb,0,0); } }
모델 테스트
이제 재미있는 부분입니다. 실제 시장에서 모델을 테스트할 차례입니다. 루프 기반의 계산은 프로세서를 많이 사용하므로 EA를 시가에 대해서만 실행하는 것이 더 합리적이라는 점에 유의하세요. 2020년 1월 1일부터 2023년 12월 6일까지의 EURUSD, 시가, 상반기 기간에 대해 단일 테스트를 실행해 보겠습니다:
단일 테스트는 수익성이 높지만 손실이 큽니다. 거래할 때 추가적인 위험을 감수하고 싶은 사람은 아무도 없습니다. 수익 기반의 청산도 있다는 점을 기억하세요. 네팅 계정에서 테스트를 실행할 수 있습니다.
수익 기반의 종가로 테스트를 실행하려면 0 이상의 수익으로 종가를 설정하세요. 테스트해 보겠습니다. 아마도 안정적인 테스트를 받을 수 있을 것입니다. 동일한 자산에 대해 EA를 시가로 실행합니다. 계좌 유형은 헤징입니다. 그리고 이것이 우리가 보는 것입니다:
EA는 평균화 때문에 매우 위험한 것으로 판명되었습니다. 이제 네팅 계정에서 동일한 테스트를 실행해 보겠습니다.
다시 한 번 큰 손실이 발생했습니다. 수익은 위험을 감수할 가치가 전혀 없습니다. 코드를 수정해 보겠습니다. 이번에는 신호에 의한 청산을 구현할 예정입니다(강세 신호가 약세 신호로 바뀌면 이전 포지션이 청산됩니다). 다음 코드를 사용하여 수익별 종가를 추가합니다:
if (CloseSig) { if (buy) CloseAll(1); if (sell) CloseAll(0); }
그리고 이 설정을 추가합니다:
input bool CloseSig = 1; // close by signal
테스트를 반복합니다. 결과는 또 좋지 않습니다:
일반적으로 테스트는 이상적이라고 할 수 없습니다. 네팅 계정과 헤징 계정 모두에서 큰 폭의 하락이 발생했습니다. 또한 신호에 기반한 청산은 긍정적인 결과를 생성하지 않으며 일반적으로 수익성이 없습니다. 상당히 당황스러운 일입니다.
결론
MQL5에서 기본적이고 간단한 정량적 분석 알고리즘을 만드는 간단한 예제를 살펴보았습니다. 우리는 가격 변동 파동을 세고 평균값과 비교하고 이 데이터를 바탕으로 매수 또는 매도를 결정했습니다. 안타깝게도 아이디어의 기본은 꽤 좋았지만 결과적으로 손해를 보는 알고리즘이 되었습니다. 다음 글에서 정량적 분석에 대한 탐구를 이어가겠습니다.
MetaQuotes 소프트웨어 사를 통해 러시아어가 번역됨.
원본 기고글: https://www.mql5.com/ru/articles/13835



