Jan 18, 2024

Chainsight Showcase, Calculate advanced indicator (Implied Volatility / IV) with Lens utilizing multiple data sources

by Megared

In the last issue of our Deep Dive into Showcase series, we covered Realized Volatility (RVOL). It utilized Algorithm Lens to create its own indicators. RVOL's Showcase created an Algorithm Lens for one type of data source. In this article, we will introduce a Showcase that calculates Implied Volatility (IV) as an example of building an Algorithm Lens that is used in multiple pipelines with calculations performed on multiple data sources.

chainsight_deepdive_into_showcase-iv.png

This Implied Volatility (IV) consists of multiple types of Snapshot Indexers, an Algorithm Lens that utilizes them, and a Relayer that propagates each index to an EVM-compatible chain.

https://github.com/horizonx-tech/chainsight-showcase/tree/main/implied_volatility_spx

What is Implied Volatility (IV)?

Used primarily in options trading, it is a forecast of the future rate of change (volatility) in the price of the underlying asset. It can be calculated backwards based on the current "premium" of the option, and is mainly calculated using Black-Scholes models, etc. Numerical analysis is used because the amount of calculation is not constant time. The Black-Scholes model uses Implied Volatility as one of the input parameters, which ultimately yields the theoretical price of the option. In other words, Implied Volatility can be calculated in reverse assuming that the calculated result matches the prevailing price if the option is in the market.

Untitled.png

Source: https://www.businessinsider.com/personal-finance/what-is-volatility

The Algorithm Lens in this Showcase calculates the IV of SP500. The Algorithm Lens in this Showcase calculates the IV of SP500. Two inputs are used to calculate this Index: the option price and the price of the underlying asset of the option. What does the user need to do to use this input in Algorithm Lens? Can be considered in the Manifest for Algorithm Lens.

datasource:
  methods:
  - id: https_spx_for_option
    identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
    func_name_alias: underlying_chart
  - id: https_spx_4500_call
    identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
    func_name_alias: option_chart

If multiple datasource methods are defined in the manifest of Algorithm Lens, code can be generated to use multiple types of data sources. The code generated from this manifest is as follows

pub async fn calculate(targets: Vec<String>, args: CalculateArgs) -> LensValue {
    let _result = get_underlying_chart(targets.get(0usize).unwrap().clone()).await;
    let _result = get_option_chart(targets.get(1usize).unwrap().clone()).await;
    todo!()
}

As you can see, code is generated to retrieve data from the two types of Snapshot Indexers, and we would like to look at how to implement the Algorithm Lens, but first let's take a deeper look at the manifest.

Dig deeper into how Algorithm Lens manifests are written

In Algorithm Lens, it is necessary to set the calling Interface to obtain inputs for the calculation. The easiest way to understand this is to explicitly specify the did file and the function you want to call.

datasource:
  methods:
	- id: underlying
	  identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
	  candid_file_path: src/canisters/https_spx_for_option/https_spx_for_option.did
	- id: option
	  identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
	  candid_file_path: src/canisters/https_spx_4500_call/https_spx_4500_call.did

The candid_file_path specifies the .did to be read, and the identifier identifies the method. The reason candid_file_path is necessary is that the specified identifier contains a SnapshotValue of a type that is not native support. If the identifier contains only the native support type in candid, candid_file_path does not need to be specified. This method is the most proper and explicitly sets what should be set. However, it may be a little cumbersome once you get used to it. Also, users may basically be creating pipelines within their own projects. In that case, we have an easier and simpler way to define it.

If you want to specify a Component from the same project as the data source, you can omit candid_file_path by specifying id as the component name. It interprets the id and automatically loads the .did that should be loaded. This is how simple it can be.

datasource:
  methods:
  - id: https_spx_for_option
    identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
  - id: https_spx_4500_call
    identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'

There may be one point of concern. The code generated in this case is as follows

pub async fn calculate(targets: Vec<String>, args: CalculateArgs) -> LensValue {
    let _result =
        get_get_last_snapshot_value_in_https_spx_for_option(targets.get(0usize).unwrap().clone())
            .await;
    let _result =
        get_get_last_snapshot_value_in_https_spx_4500_call(targets.get(1usize).unwrap().clone())
            .await;
    todo!()
}

This makes it difficult to guess what kind of data can be obtained from the function name. For this purpose, there is a field to specify the function name.

datasource:
  methods:
  - id: https_spx_for_option
    identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
    func_name_alias: underlying_prices
  - id: https_spx_4500_call
    identifier: 'get_last_snapshot_value : () -> (SnapshotValue)'
    func_name_alias: option_prices

Label the field func_name_alias for the function name.

pub async fn calculate(targets: Vec<String>, args: CalculateArgs) -> LensValue {
    let _result = get_underlying_prices(targets.get(0usize).unwrap().clone()).await;
    let _result = get_option_prices(targets.get(1usize).unwrap().clone()).await;
    todo!()
}

This will make it clear in the code that the underlying price is acquired and the option price is acquired. In this way, you can add a twist to the data source acquisition part of Algorithm Lens.

Let's look again at the implementation of Algorithm Lens for IV. We stated earlier that IV can be obtained by numerical analysis by performing an inverse calculation from the theoretical price. The logic is as follows.

// calculator.rs
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SeekIvParam {
    pub s: f64, // spot price
    pub k: f64, // strike price
    pub t: f64, // time to maturity
    pub r: f64, // risk-free rate
    pub is_call: bool, // call or put
    // param to seek
    pub market_price: f64,
    pub initial_sigma: f64,
    pub tolerance: f64,
    pub attempt_count: u64,
}
pub fn seek_implied_volatility(param: SeekIvParam) -> (f64, u64) {
    let SeekIvParam { market_price, initial_sigma, tolerance, attempt_count, s, k, t, r, is_call } = param;
    let mut sigma = initial_sigma;
    let mut attempt = 0;

    for _ in 0..attempt_count {
        attempt += 1;
        let theoretical_price = black_scholes(BlackScholesInput { s, k, t, r, sigma, is_call });
        let vega = vega(VegaInput { s, k, t, r, sigma });
        let diff = theoretical_price - market_price;
        if diff.abs() < tolerance {
            return (sigma, attempt);
        }
        sigma = sigma - diff / vega;
    }
    panic!("Implied volatility not found");
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlackScholesInput {
    pub s: f64, // spot price
    pub k: f64, // strike price
    pub t: f64, // time to maturity
    pub r: f64, // risk-free rate
    pub sigma: f64, // IV
    pub is_call: bool, // call or put
}
fn black_scholes(input: BlackScholesInput) -> f64 {
    let BlackScholesInput { s, k, t, r, sigma, is_call } = input;

    let d1 = d1_of_black_scholes(s, k, t, r, sigma);
    let d2 = d1 - sigma * t.sqrt();

    if is_call {
        s * normal_cdf(d1) - k * (-r * t).exp() * normal_cdf(d2)
    } else {
        k * (-r * t).exp() * normal_cdf(-d2) - s * normal_cdf(-d1)
    }
}
fn d1_of_black_scholes(s: f64, k: f64, t: f64, r: f64, sigma: f64) -> f64 {
    let numerator = (s / k).ln() + (r + sigma.powi(2) / 2.0) * t;
    let denominator = sigma * t.sqrt();
    numerator / denominator
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VegaInput {
    pub s: f64, // spot price
    pub k: f64, // strike price
    pub t: f64, // time to maturity
    pub r: f64, // risk-free rate
    pub sigma: f64, // IV
}
fn vega(input: VegaInput) -> f64 {
    let VegaInput { s, k, t, r, sigma} = input;
    let d1 = d1_of_black_scholes(s, k, t, r, sigma);
    s * normal_cdf(d1) * t.sqrt()
}

I won't go into the details of the calculation flow, but black_scholes implements the calculation of the Black-Scholes model, vega implements the calculation of Greeks' Vega, and seek_implied_volatility, which uses these functions to derive IV numerically, has been implemented. The three functions seek_implied_volatility, which uses them to derive IV numerically, are implemented. If we look at SeekIvParam, the argument of seek_implied_volatility, there are some initial parameters for the search.

    pub initial_sigma: f64, // Used in the initial estimation IV
    pub tolerance: f64,
    pub attempt_count: u64,

These should be set with an awareness of the level or balance between search speed and accuracy. The code should look something like this when used with the Algorithm Lens described above.

pub async fn calculate(targets: Vec<String>) -> LensValue {
    let target_for_underlying = targets.get(0usize).unwrap().to_owned();
    let target_for_option = targets.get(1usize).unwrap().to_owned();
    
    let underlying_current_price = get_underlying_chart(target_for_underlying)
        .await.unwrap_or_else(|msg| panic!("{}", msg))
        .chart.result.get(0).expect("no result in underlying's chart").meta.regularMarketPrice;
    let option_meta = get_option_chart(target_for_option)
        .await.unwrap_or_else(|msg| panic!("{}", msg))
        .chart.result.get(0).expect("no result in option's chart").meta.clone();

    let param = generate_seek_iv_param(underlying_current_price, option_meta);
    let (sigma, _attempt) = calculator::seek_implied_volatility(param);

    sigma
}

fn generate_seek_iv_param(
    underlying_current_price: f64,
    option_meta: Meta,
) -> calculator::SeekIvParam {
    let current_ts_sec = ic_cdk::api::time() / (1000 * 1000000); // nanosec -> sec
    let (k, t, is_call) = param_from_symbol(option_meta.symbol, current_ts_sec as f64);

    calculator::SeekIvParam {
        s: underlying_current_price,
        k,
        t,
        r: 0.0,
        is_call, // NOTE: assuming index options
        market_price: option_meta.regularMarketPrice,
                // Should these be fixed values?
        initial_sigma: 0.2,
        tolerance: 0.00001,
        attempt_count: 100,
    }
}

fn param_from_symbol(s: String, current_sec: f64) -> (f64, f64, bool) { ... }

Is there anything that concerns you about the parameter settings? Are the three search parameters I just gave you correct with fixed values? It may not be bad, just not better. Algorithm Lens is only a vessel for performing some calculations from a predetermined data source, and it is better if the accuracy and speed of the calculations can be injected from the outside. In other words, it would be better if the parameters for this search could be freely set by each user.

Algorithm Lens can accept parameters when invoked for this purpose; by specifying with_args in Manifest, you can generate an Algorithm Lens template that accepts arbitrary parameters.

datasource:
  methods:
		...
with_args: true

This creates CalculateArgs as a structure for mapping parameters. The user can add arbitrary parameters by updating this structure.

#[derive(Clone, Debug, Default, candid :: CandidType, serde :: Deserialize, serde :: Serialize)]
pub struct LensValue {
    pub dummy: u64,
}
#[derive(Clone, Debug, Default, candid :: CandidType, serde :: Deserialize, serde :: Serialize)]
pub struct CalculateArgs {
    pub dummy: u64,
}
pub async fn calculate(targets: Vec<String>, args: CalculateArgs) -> LensValue {

In IV's Showcase, the structure is updated in this way to allow any number of parameters to be set for the search. The first three parameters are for traversal, and the last parameter is for arbitrarily scaling the number of digits in the numerical result of the calculation.

pub struct CalculateArgs {
    pub initial_sigma: f64,
    pub tolerance: f64,
    pub attempt_count: u64,
    pub num_of_digits_to_scale: Option<u64>,
}

This created an Algorithm Lens that computes IV from user-defined arbitrary search parameters. The final code is as follows

// lib.rs
use lens_iv_calculator_accessors::*;
use lens_iv_calculator_bindings::option::Meta;

mod calculator;
mod time;

pub type LensValue = f64;
#[derive(Clone, Debug, Default, candid :: CandidType, serde :: Deserialize, serde :: Serialize)]
pub struct CalculateArgs {
    pub initial_sigma: f64,
    pub tolerance: f64,
    pub attempt_count: u64,
    pub num_of_digits_to_scale: Option<u64>,
}
pub async fn calculate(targets: Vec<String>, args: CalculateArgs) -> LensValue {
    let target_for_underlying = targets.get(0usize).unwrap().to_owned();
    let target_for_option = targets.get(1usize).unwrap().to_owned();
    
    let underlying_current_price = get_underlying_chart(target_for_underlying)
        .await.unwrap_or_else(|msg| panic!("{}", msg))
        .chart.result.get(0).expect("no result in underlying's chart").meta.regularMarketPrice;

    let option_meta = get_option_chart(target_for_option)
        .await.unwrap_or_else(|msg| panic!("{}", msg))
        .chart.result.get(0).expect("no result in option's chart").meta.clone();

    let param = generate_seek_iv_param(underlying_current_price, option_meta, args.clone());
    let (sigma, attempt) = calculator::seek_implied_volatility(param);

    // consider the scale of args
    if let Some(scale) = args.num_of_digits_to_scale {
        let scale = 10u64.pow(scale as u32) as f64;
        return (sigma * scale).round();
    }

    sigma
}

fn generate_seek_iv_param(
    underlying_current_price: f64,
    option_meta: Meta,
    args: CalculateArgs,
) -> calculator::SeekIvParam {
    let CalculateArgs { initial_sigma, tolerance, attempt_count , num_of_digits_to_scale: _} = args;
    
    let current_ts_sec = ic_cdk::api::time() / (1000 * 1000000); // nanosec -> sec
    let (k, t, is_call) = param_from_symbol(option_meta.symbol, current_ts_sec as f64);

    calculator::SeekIvParam {
        s: underlying_current_price,
        k,
        t,
        r: 0.0,
        is_call, // NOTE: assuming index options
        market_price: option_meta.regularMarketPrice,
        initial_sigma,
        tolerance,
        attempt_count,
    }
}

fn param_from_symbol(s: String, current_sec: f64) -> (f64, f64, bool) {
    assert!(s.len() == 18);
    let expiry_yymmdd = &s[3..9];
    let option_type = s.chars().nth(9).unwrap();
    let strike_price = &s[10..];
    ic_cdk::println!("param_from_symbol: expiry_yymmdd={}, option_type={}, strike_price={}", expiry_yymmdd, option_type, strike_price);

    let k = strike_price.parse::<f64>().unwrap() / 1000.0;

    let t = {
        let yyyy = expiry_yymmdd.get(0..2).unwrap().parse::<u32>().unwrap() + 2000;
        let mm = expiry_yymmdd.get(2..4).unwrap().parse::<u32>().unwrap();
        let dd = expiry_yymmdd.get(4..6).unwrap().parse::<u32>().unwrap();
    
        let expiry_ts_sec = time::date_to_epoch(
            yyyy, mm, dd
        ) as f64;

        assert!(expiry_ts_sec >= current_sec);
        (expiry_ts_sec - current_sec) / (365.0 * 24.0 * 60.0 * 60.0)
    };

    let is_call = match option_type {
        'C' => true,
        'P' => false,
        _ => panic!("invalid option type"),
    };

    (k, t, is_call)
}

Thank you for your patience up to this point. The full project is located at

https://github.com/horizonx-tech/chainsight-showcase/tree/main/implied_volatility_spx

In this presentation, we introduced a Showcase based on Algorithm Lens, which performs calculations from multiple data sources and receives parameters from the user.


In this issue, we introduced the algorithmic lens that has enabled IV to accept multiple data sources and unique inputs in its showcase. Stay tuned for the next installment!

Follow us on Twitter and Medium for updates! We look forward to seeing you there!

Written by Megared@Chainsight