ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 파이썬을 활용한 ELS 가치 평가하기: ELS Valuation3
    금융퀀트/자산평가&프로그램매매 2024. 1. 1. 16:34
    반응형

    ELS 평가하기

    파이썬을 활용한 주가 경로 구하기: ELS Valuation2 를 통해서 주가 경로를 구할 수 있게 되었다면 몬테카를로 시뮬레이션을 통한 ELS 가치평가가 가능해진다. 각 주가 경로 시뮬레이션 별로 ELS 상품에 제시된 계약 조건에 따라서 내 투자금이 얼마로 늘어나는지 혹은 줄어드는지 파악하면 되는 것이다. 그리고 각 시뮬레이션별로 나오는 미래 가치를 현재 가치로 환산하고 그 금액들의 평균을 구하면 ELS 계약의 가치를 알 수 있다.

     ELS 계약조건

    다양한 ELS 상품이 있겠지만 이번에 평가해 볼 ELS는 낙인, 낙아웃 조건 없는 가장 기본적인 아래 그림 1의 상품이다.

    그림1: 평가 대상 ELS

    아래 그림 2의 평가 일정에 따라서 평가일에 계약시점 대비 기초자산인 S&P500 지수가 "조기/만기 상환조건"보다 크면 조기에 상품이 종료된다. 조기 상환 전까지는 위 그림 1에서 보이는 연 5.01% 의 수익률(월 0.4175%)을 매월 지급받게 된다.(실제 수익은 상환지급일에 받게 된다.)

    그림2: 조기상환 스케줄

    만기에 S&P500 지수가 계약시점 대비 65% 이상이면 상환조건에 충족되어서 원금에 계약기간인 3 년간 연 5.01%의 수익률을 얻을 수 있지만 65% 이하로 떨어지면 3 년간 연 5.01%의 수익률은 받을 수 있으나 계약시점 대비 지수가 하락한 비율만큼 원금 손실이 발생하게 된다. 이 원금 손실 조건이 아래 그림 3에 나타난다.

    그림3: 수익률 및 상환 조건

    계약 조건별 함수 구성

    조기상환 일자 구하기: Early_PaymentDate 함수 만들기

    ELS의 수익률 및 상환 조건들을 부분으로 쪼개서 각각 함수로 표현할 수 있는데, 먼저 위 그림 2에 나온 상환스케줄 함수부터 구현해보자. 각 상환 스케줄의 사이의 구간을 step이라고 하고, 조기상환일자가 도래할 때마다 step 이 0 -> 1 -> 2 -> 3...으로 점점 늘어나는 구조로 생각한다면 아래 그림 4와 같은 구조를 생각해 볼 수 있다.

    그림4: Early_PaymentDate 함수 구조도

    이 구조를 함수로 나타내면 아래 코드로 나타낼 수 있다. 

    def Early_PaymentDate(self, datelist: list, step: int, curdate: datetime):
        # step 이 0 일 때 현재일자가 평가일자 리스트의 첫번째 날짜보다 같거나 크면 step 1 반환
        if step == 0 and curdate >= datetime.strptime(datelist[0], "%Y-%m-%d"):
            step = 1
            return step
        # step 이 1 일 때 현재일자가 평가일자 리스트의 두번째 날짜보다 같거나 크면 step 2 반환
        elif step == 1 and curdate >= datetime.strptime(datelist[1], "%Y-%m-%d"):
            step = 2
            return step
        # step 이 2 일 때 현재일자가 평가일자 리스트의 세번째 날짜보다 같거나 크면 step 3 반환
        elif step == 2 and curdate >= datetime.strptime(datelist[2], "%Y-%m-%d"):
            step = 3
            return step
        # step 이 3 일 때 현재일자가 평가일자 리스트의 네번째 날짜보다 같거나 크면 step 4 반환
        elif step == 3 and curdate >= datetime.strptime(datelist[3], "%Y-%m-%d"):
            step = 4
            return step
        # step 이 4 일 때 현재일자가 평가일자 리스트의 다섯번째 날짜보다 같거나 크면 step 5 반환
        elif step == 4 and curdate >= datetime.strptime(datelist[4], "%Y-%m-%d"):
            step = 5
            return step
        # step 이 5 일 때 현재일자가 평가일자 리스트의 여섯번째 날짜보다 같거나 크면 step 6 반환
        elif step == 5 and curdate >= datetime.strptime(datelist[5], "%Y-%m-%d"):
            step = 6
            return step
        else:
                return step

     

    조기상환 가격 조건 구하기: Early_PaymentPrice 함수 만들기

    조기상환 기준 가격도 각 상환 스케줄의 사이의 구간을 step이라고 한다면 step 1로 변할 때 초기가격 * 0.95,  step 2로 변할 때 초기가격 * 0.9... 와 같은 구조로 생각해 볼 수 있다. 이를 함수로 나타내면 아래 코드로 나타낼 수 있다. 

    def Early_PaymentPrice(self, basevalue: float, step):
    	# step 1 일 때 입수된 basevalue 에 0.95 로 조기상환 기준가 생성
        if step == 1:
            price = basevalue * 0.95
        # step 2 일 때 입수된 basevalue 에 0.90 로 조기상환 기준가 생성
        elif step == 2:
            price = basevalue * 0.90
        # step 3 일 때 입수된 basevalue 에 0.85 로 조기상환 기준가 생성
        elif step == 3:
            price = basevalue * 0.85
        # step 4 일 때 입수된 basevalue 에 0.80 로 조기상환 기준가 생성
        elif step == 4:
            price = basevalue * 0.80
        # step 5 일 때 입수된 basevalue 에 0.75 로 조기상환 기준가 생성
        elif step == 5:
            price = basevalue * 0.75
        # step 6 일 때 입수된 basevalue 에 0.65 로 조기상환 기준가 생성
        elif step == 6:
            price = basevalue * 0.65
        return price

     

    상환 시점의 가치 현재가치화 하기: Get_CurrentValue 함수 만들기

    주가 시나리오별로 step 1에서 조기상환 조건을 만족해서 6개월의 수익을 얻은 시나리오도 있을 것이고, 만기까지 간 뒤에 기준가가 65% 밑으로 떨어져서 3년 동안 월별 이자는 받았지만 원금의 손실은 발생한 시나리오도 있을 것이다. 시나리오별 미래 가치를 현재 가치화해서 평균을 내면 상품의 현재가치가 된다. 현재 가치화하기 위한 금리, 투자기간(연 환산을 위해서 365로 나눔), 미래가치를 변수로 받는 함수를 아래와 같이 만들 수 있다. 

    def Get_CurrentValue(self, discountrate: int, duration: int, futurevalue: int):
        # 미래가치 * exp(-할인율 * 투자기간(일수)/365 ) 
        currentvalue = futurevalue * np.exp(-discountrate*duration/365)
        # 현재가치 반환
        return currentvalue

     

    ELS 가치 평가 모형: Get_StochasticReturn 함수 만들기

    Get_StochasticReturn으로 시나리오 구성

    지금까지 만든 함수를 활용해서 주가 시나리오 별로 ELS 가입에 따른 최종 수익 및 자산의 현재가치를 만들어주는 함수를 만들 수 있다. 처음에(datelist[0]) 최초 포트폴리오 가치 100을 설정해 주고, 다음 주가 경로를 GeometricBrownianMotion 함수를 이용해서 만들어 준다. 그 과정에서 재평가일자에 해당되는 경우 조기상환 조건을 충족하는지 확인한다. 조기상환 조건을 충족하면 계약기간 동안 연 5.01%의 수익률로 월별 수익을 지급하고, 원금을 지급한다. 특히 만기인 마지막 재평가일자에는 기초자산가치가 초기 가격보다 65% 이하로 떨어졌을 경우 원금에 기초자산이 하락한 만큼의 비율을 감안해서 원금 손실을 부여해 주는 것을 감안한다. 이를 그림으로 나타내면 아래 그림 5와 같다.

    그림 5 ELS 평가 함수 구조

    그림 5에 나타낸 구조를 코드로 구성하면 아래와 같다. 

    # Get_StochasticReturn 함수는 maxno로 시뮬레이션 횟수, rfr로 현재가치화 하는 금리, assetvol 로 
    # 기초자산 변동성, timeunit으로 시간단위, elsreturn으로 els 수익률, datelist로 영업일 list
    # contractdates로 계약 시작일, 종료일 리스트, terminationdates로 중도청산일자 list 등을 변수로 받는다.
    def Get_StochasticReturn(self, maxno: int, rfr: int, assetvol: int, timeunit: int, elsreturn: float, \
        datelist, contractdates: list, terminationdates: list):
        # 시나리오별 주가경로를 저장할 빈 dataframe을 만든다.
        totdf = pd.DataFrame()
        # 시나리오별 수익률, 종료일, 미래가치, 현재가치 등을 저장할 빈 dataframe를 만든다.
        asset_fin_result = []
        # 계약시작일자를 날짜형식으로 변수화
        contstartdate = datetime.strptime(contractdates[0], "%Y-%m-%d")
        for simulationno in range(1, maxno + 1):
            columns = ["DATE", f"SCENARIO{simulationno}"]
            columns_return = ["SCENARIO", "TERMINATIONDATE", "RETURN", "TOTVALUE", "CURRENTVALUE", "DURATION"]
            subdf = pd.DataFrame(columns=columns)
            # 하나의 시나리오 결과를 results list에 저장
            results = []
            # step 0부터 시작
            step = 0
            maxreturn = elsreturn
            for date in datelist:
            	# 첫번째 평가일의 경우 자산가치 = 최초가치
                if date == datelist[0]:
                    curvalue = basevalue
                    result = [date, curvalue]
                    results.append(result)
                # 그 외의 경우
                else:
                	# GeometricBrownianMotion함수로 다음날의 기초자산 가치 계산
                    nextvalue = self.GeometricBrownianMotion(curvalue, rfr, assetvol, timeunit)
                    curvalue = nextvalue
                    # 투자기간 구하기
                    dur = (date - contstartdate).days
                    # 재평가일자에 해당되고, 마지막 이전의 재평가일자일 경우
                    if step!=5 and step != self.Early_PaymentDate(terminationdates, step, date):
                        step = self.Early_PaymentDate(terminationdates, step, date)
                        # 조기상환 조건충종할 경우
                        if curvalue >= self.Early_PaymentPrice(basevalue, step):
                            fv = maxreturn/12*step*6*basevalue+basevalue
                            pv = self.Get_CurrentValue(rfr, dur, fv)
                            # 원금 + 연 5.01% 수익 받고 해당 시뮬레이션 종료
                            asset_result = [f"SCENARIO{simulationno}", date, maxreturn, fv, pv, dur/365]
                            break
                    # 재평가일자에 해당되고 마지막 재평가일자일 경우
                    elif step==5 and step != self.Early_PaymentDate(terminationdates, step, date):
                        # 기초자산가치가 "초기가치 * 65%"보다 큰 경우
                        if curvalue >= self.Early_PaymentPrice(basevalue, step):
                            fv = maxreturn/12*step*6*basevalue+basevalue
                            pv = self.Get_CurrentValue(rfr, dur, fv)
                            # 원금 + 연 5.01% 수익 받고 해당 시뮬레이션 종료
                            asset_result = [f"SCENARIO{simulationno}", date, maxreturn, fv, pv, dur/365]
                            break
                        # 그 외의 경우(기초자산가치가 65% 보다 더 적은 경우)
                        else:
                            fv = maxreturn/12*step*6*basevalue + basevalue * (curvalue/basevalue)
                            pv = self.Get_CurrentValue(rfr, dur, fv)
                            # 원금손실 + 연 5.01% 수익 받고 해당 시뮬레이션 종료
                            asset_result = [f"SCENARIO{simulationno}", date, (fv/basevalue-1)*365/dur, fv, pv, dur/365]
                            break
                    # 재평가일자에 해당되지 않을 경우 시나리오list(results)에 주가경로 결과만 추가
                    else:
                        result = [date, curvalue]
                        results.append(result)
            # datelist를 거치면서 만들어진 results list를 dataframe 형식으로 변환
            subdf = pd.DataFrame(data=results, columns=columns)
            # 시뮬레이션 결과를 "DATE"컬럼을 기준으로 오른쪽컬럼에 붙이기
            if simulationno == 1:
                totdf = subdf
            else:
                totdf = pd.merge(totdf, subdf, on="DATE")
            # asset_fin_result에 시뮬레이션 결과 요약을 추가하기(row 추가) 
            asset_fin_result.append(asset_result)
        return_df = pd.DataFrame(data=asset_fin_result, columns=columns_return)
        return totdf, return_df

     

    모든 시나리오의 평균 수익률, 평균 가치, 평균 현재가치, 평균 투자기간 산출

    계약기간은 2023-11-27 ~ 2026-12-04 이고, 파이썬을 활용한 주가 경로 구하기: ELS Valuation2 에서 작업한 내용과 동일하게 무위험 금리는 SOFR 금리인 0.0532%, 변동성 산출 기간은 2007-06-01 ~ 2009-06-30으로 설정한다. 그리고 중도청산일자인 2024-06-05, 2024-12-04, 2025-06-04, 2025-12-04, 2026-06-05, 2026-12-04를 리스트로 변수화하고, 시뮬레이션 횟수를 100번으로 해서 계약의 현재가치를 계산하는 코드는 아래와 같다.

    import yfinance as yf
    import numpy as np
    import pandas_market_calendars as mcal
    from datetime import datetime
    import pandas as pd
    
    class SNP500ELS001():
        def __init__(self, ticker: str, calendar: str):
            self.ticker = ticker
            self.cal = calendar
            
        def Get_Variance(self, startdate: str, enddate: str):
            asset_price = yf.download(self.ticker, start=startdate, end=enddate)
            asset_returns = asset_price["Adj Close"].pct_change().dropna()
            asset_vol = np.std(asset_returns)
            return asset_vol
    
        def Get_TimeUnit(self, startdate: str, enddate: str):
            calname = mcal.get_calendar(self.cal)
            stdate = datetime.strptime(startdate, "%Y-%m-%d")
            eddate = datetime.strptime(enddate, "%Y-%m-%d")
            schedule = calname.valid_days(start_date=stdate, end_date=eddate)
            datestrlist = [date.strftime('%Y-%m-%d') for date in schedule]
            datelist = [datetime.strptime(date, '%Y-%m-%d') for date in datestrlist]
            return datelist
    
        def GeometricBrownianMotion(self, curvalue: float, rfr: float, asset_vol: float, timeunit: float):
            z = np.random.randn()
            delta_t = 1/timeunit
            nextvalue = curvalue * np.exp((rfr - 0.5*(asset_vol**2))*delta_t \
                        + asset_vol*np.sqrt(delta_t)*z)
            return nextvalue
    
        def Early_PaymentDate(self, datelist: list, step: int, curdate: datetime):
            if step == 0 and curdate >= datetime.strptime(datelist[0], "%Y-%m-%d"):
                step = 1
                return step
            elif step == 1 and curdate >= datetime.strptime(datelist[1], "%Y-%m-%d"):
                step = 2
                return step
            elif step == 2 and curdate >= datetime.strptime(datelist[2], "%Y-%m-%d"):
                step = 3
                return step
            elif step == 3 and curdate >= datetime.strptime(datelist[3], "%Y-%m-%d"):
                step = 4
                return step    
            elif step == 4 and curdate >= datetime.strptime(datelist[4], "%Y-%m-%d"):
                step = 5
                return step
            elif step == 5 and curdate >= datetime.strptime(datelist[5], "%Y-%m-%d"):
                step = 6
                return step
            else:
                return step
    
        def Early_PaymentPrice(self, basevalue: float, step):
            if step == 1:
                price = basevalue * 0.95
            elif step == 2:
                price = basevalue * 0.90
            elif step == 3:
                price = basevalue * 0.85
            elif step == 4:
                price = basevalue * 0.80
            elif step == 5:
                price = basevalue * 0.75
            elif step == 6:
                price = basevalue * 0.65
            return price
    
        def Get_CurrentValue(self, discountrate: int, duration: int, futurevalue: int):
            currentvalue = futurevalue * np.exp(-discountrate*duration/365)
            return currentvalue
    
        def Get_StochasticStockProcess(self, maxno: int, rfr: int, assetvol: int, timeunit: int, datelist):
            totdf = pd.DataFrame()
            for simulationno in range(1, maxno+1):
                columns = ["DATE", f"SCENARIO{simulationno}"]
                subdf = pd.DataFrame(columns=columns)
                results = []
                for date in datelist:
                    if date == datelist[0]:
                        curvalue = basevalue
                        result = [date, curvalue]
                        results.append(result)
                    else:
                        nextvalue = self.GeometricBrownianMotion(curvalue, rfr, assetvol, timeunit)
                        curvalue = nextvalue
                        result = [date, curvalue]
                        results.append(result)
                subdf = pd.DataFrame(data=results, columns=columns)
                if simulationno == 1:
                    totdf = subdf
                else:
                    totdf = pd.merge(totdf, subdf, on="DATE")
            return totdf
    
        def Get_StochasticReturn(self, maxno: int, rfr: int, assetvol: int, timeunit: int, elsreturn: float, \
            datelist, contractdates: list, terminationdates: list):
            totdf = pd.DataFrame()
            asset_fin_result = []
            contstartdate = datetime.strptime(contractdates[0], "%Y-%m-%d")
            for simulationno in range(1, maxno + 1):
                columns = ["DATE", f"SCENARIO{simulationno}"]
                columns_return = ["SCENARIO", "TERMINATIONDATE", "RETURN", "TOTVALUE", "CURRENTVALUE", "DURATION"]
                subdf = pd.DataFrame(columns=columns)
                results = []
                step = 0
                maxreturn = elsreturn
                for date in datelist:
                    if date == datelist[0]:
                        curvalue = basevalue
                        result = [date, curvalue]
                        results.append(result)
                    else:
                        nextvalue = self.GeometricBrownianMotion(curvalue, rfr, assetvol, timeunit)
                        curvalue = nextvalue
                        dur = (date - contstartdate).days
                        if step!=5 and step != self.Early_PaymentDate(terminationdates, step, date):
                            step = self.Early_PaymentDate(terminationdates, step, date)
                            if curvalue >= self.Early_PaymentPrice(basevalue, step):
                                fv = maxreturn/12*step*6*basevalue+basevalue
                                pv = self.Get_CurrentValue(rfr, dur, fv)
                                asset_result = [f"SCENARIO{simulationno}", date, maxreturn, fv, pv, dur/365]
                                break
                        elif step==5 and step != self.Early_PaymentDate(terminationdates, step, date):
                            if curvalue >= self.Early_PaymentPrice(basevalue, step):
                                fv = maxreturn/12*step*6*basevalue+basevalue
                                pv = self.Get_CurrentValue(rfr, dur, fv)
                                asset_result = [f"SCENARIO{simulationno}", date, maxreturn, fv, pv, dur/365]
                                break
                            else:
                                fv = maxreturn/12*step*6*basevalue + basevalue * (curvalue/basevalue)
                                pv = self.Get_CurrentValue(rfr, dur, fv)
                                asset_result = [f"SCENARIO{simulationno}", date, (fv/basevalue-1)*365/dur, fv, pv, dur/365]
                                break
                        else:
                            result = [date, curvalue]
                            results.append(result)
                subdf = pd.DataFrame(data=results, columns=columns)
                if simulationno == 1:
                    totdf = subdf
                else:
                    totdf = pd.merge(totdf, subdf, on="DATE")
                asset_fin_result.append(asset_result)
            return_df = pd.DataFrame(data=asset_fin_result, columns=columns_return)
            return totdf, return_df
                
    if __name__ =="__main__":
        els = SNP500ELS001("^GSPC", "XNYS")
        contractdates = ["2023-11-27", "2026-12-04"]
        datadates = ["2007-06-01", "2009-06-30"]
        assetvol = els.Get_Variance(datadates[0], datadates[1])
        datelist = els.Get_TimeUnit(contractdates[0], contractdates[1])
        timeunit = len(datelist)
        basevalue = 100
        rfr = 0.0532
        elsreturn = 0.0501
        terminationdates = ["2024-06-05", "2024-12-04", "2025-06-04", "2025-12-04"\
                        , "2026-06-05", "2026-12-04"]
        trialno = 100
        
        totdf, return_df = els.Get_StochasticReturn(trialno, rfr, assetvol, timeunit, elsreturn, \
            datelist, contractdates, terminationdates)
        meandf = pd.DataFrame(columns=["RETURN", "TOTVALUE", "CURRENTVALUE", "DURATION"])
        for column in meandf.columns:
            meandf.loc[0, column] = return_df[column].mean()
        print(meandf)

     

    StochasticProcess이기 때문에 실행때마다 결과가 다를 것이지만 해당 코드를 실행하면 아래 그림 6과 같이 계약의 현재가치는 99.690728 이 나오고, 평균 투자기간은 반년 정도 나온다.

    그림6: 시뮬레이션 결과 예시

    그림 6에 따르면 해당 ELS는 100만큼 투자했을 때 투자금의 현재가치가 99.69728로 100보다 못한 가치를 지닌다. 투자의 무위험수익률인 SOFR가 5.32%인데, 위험성이 들어갔는데도 불구하고 ELS 수익으로 고작 5.01%를 지급하니 당연히 현재가치는 최초 투자액을 100으로 잡았을 때보다 더 낮은 것이다. 그리고 해당 ELS의 기초자산은 S&P500으로 변동성이 매우 낮다. 따라서 보통 6개월 뒤에도 큰 변화가 없을 확률이 매우 높다. 그 결과 최초 조기상환일인 2024-06-05 일에 대부분 조기상환 조건을 충족하여 투자기간도 6개월 정도로(1년 기준으로 0.5) 시뮬레이션 결과가 나오는 것이다. 시뮬레이션 결과 그렇게 좋은 상품 같지는 않다.

    반응형
Designed by Tistory.