diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ed8ba5 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +1.Model for data ingestion + +The project has been given an extensive set of test data. Each file is a snapshot of data for a particular options series. This task requires building an object model and deserialization logic for this data from JSON. + +2.Design and implement metrics for assessing input data quality + +The provided test data is quite diverse, covering different markets, and within each market, many underlying assets. Underlying assets and markets differ greatly in options liquidity, ranging from a super-dense strike grid with tight spreads, to underlyings with just one expiration, ten strikes, and near-zero liquidity. + +The quality (in the general sense) of options data strongly affects how well naive volatility-curve-building algorithms perform. On good data (the happy path), even the simplest implementation works well. But on sparse, illiquid data, simple algorithms will produce results just as inadequate as the input data itself. + +This task is to design and implement metrics that let us assess the quality of the input data. Based on the metric values, we can then branch the algorithms accordingly. + +3.Enumerate all combinations of options properties + +The project comes with a large test dataset covering several markets, each with many underlying assets. It includes European and American options on stocks, ETFs, indices, and futures, dividend-paying stocks and non-dividend-paying ones. We need to write logic that goes through all the test data and collects statistics on combinations of options parameters. The combination determines which pricing model to choose. + +It's important to go through every single file, not just one file per underlying. A single underlying can have both American- and European-style options; this is common for options on futures. + +4.Implement all the pricing models we need(later) + +The task of enumerating all possible combinations of options parameters is addressed elsewhere. Once that's done, it will become clear exactly which models we need for pricing the test data we have on hand. + +These models need to be implemented. This task can be split into subtasks. + +5.Implement logic for computing the implied dividend rate + +Implement logic for computing the implied dividend rate for those assets expected to pay dividends. + +6.Write visualization logic for the source data + +Debugging such a heavily mathematical project is impossible without good visualization at every stage of the algorithm. This task lays a solid foundation for that visualization. Specifically, it proposes visualizing the source data, some functionality, for example a CLI command, where you point it at a file and get an image (at minimum a PNG) as output. + +7.Implement logic for approximating the raw option price(later) + +The idea is that with poor data, when there are few option quotes and the ones available have wide spreads, it will be nearly impossible to approximate them directly in volatility space. The proposal is to first approximate the source data in price space, and only afterward convert that approximated price into volatility space. diff --git a/Tasks2and7.py b/Tasks2and7.py new file mode 100644 index 0000000..8edb25f --- /dev/null +++ b/Tasks2and7.py @@ -0,0 +1,278 @@ +import numpy as np +import pandas as pd +import cvxpy as cp + +""" +when we will see final results of team 1, we will probably rename some columns, +but as for now I assume that our data is represented by pandas dataframe +We call marketData["time"] here as "snapshot_time"; + +Moreover we assume that df has columns "strike", "ask_price", "bid_price" etc and they mean what they should + +""" + +def format_time(df): + """ + this function formats the time columns to UTC time + """ + df = df.copy() + + df["quote_time"] = pd.to_datetime( + df["quote_time"], + utc=True, + errors="coerce", + ) + + df["snapshot_time"] = pd.to_datetime( + df["snapshot_time"], + utc=True, + errors="coerce", + ) + + return df + + +def add_quality(df): + """ + function that takes dataframe as input and add some extra columns to it which represent the metric values + """ + df = df.copy() + df = format_time(df) + + is_bid_ok = ( + df["bid_price"].notna() + & (df["bid_price"] > 0) + & (df["bid_size"] > 0) + ) + + is_ask_ok = ( + df["ask_price"].notna() + & (df["ask_price"] > 0) + & (df["ask_size"] > 0) + ) + + broken = ( + (df["strike"] <= 0) + | (df["bid_price"] < 0) + | (df["ask_price"] < 0) + | ( + is_bid_ok + & is_ask_ok + & (df["ask_price"] < df["bid_price"]) + ) + ) + + df["quote_status"] = np.select( + [ + broken, + is_bid_ok & is_ask_ok, + is_ask_ok, + is_bid_ok, + ], + [ + "broken", + "two_sided", + "ask_only", + "bid_only", + ], + default="missing_both", + ) + + two_sided = df["quote_status"] == "two_sided" + + df["mid_price"] = np.where( + two_sided, + (df["bid_price"] + df["ask_price"]) / 2, + np.nan, + ) + + df["spread"] = np.where( + two_sided, + df["ask_price"] - df["bid_price"], + np.nan, + ) + + df["spread_pct"] = np.where( + two_sided, + df["spread"] / df["mid_price"] * 100, + np.nan, + ) + + df["lower_bound"] = np.where( + is_bid_ok, + df["bid_price"], + np.nan, + ) + + df["upper_bound"] = np.where( + is_ask_ok, + df["ask_price"], + np.nan, + ) + + df["quote_age_seconds"] = ( + df["snapshot_time"] - df["quote_time"] + ).dt.total_seconds() + + return df + + +def slice_quality(df, group_col="chain_id"): + # Return one row of quality metrics per slice + df = df.copy() + + df["is_two_sided"] = df["quote_status"] == "two_sided" + df["is_one_sided"] = df["quote_status"].isin(["ask_only", "bid_only"]) + # copy the strike only for usable quotes, NaN otherwise, so that + # nunique below counts strikes that actually have a two-sided price + df["usable_strike"] = df["strike"].where(df["is_two_sided"]) + + metrics = df.groupby(group_col, sort=False).agg( + n_quotes=("quote_status", "size"), + n_strikes=("strike", "nunique"), + n_usable_strikes=("usable_strike", "nunique"), + frac_two_sided=("is_two_sided", "mean"), + frac_one_sided=("is_one_sided", "mean"), + min_strike=("strike", "min"), + max_strike=("strike", "max"), + # spread_pct is NaN for every non-two-sided quote so median and + # max here describe only two-sided usable quotes + median_spread_pct=("spread_pct", "median"), + max_spread_pct=("spread_pct", "max"), # look for outliers + median_quote_age_s=("quote_age_seconds", "median"), + ) + return metrics.reset_index() + + +def classify_slices( + metrics, + min_usable_strikes, #input thresholds + max_median_spread_pct, + max_one_sided_frac, + max_median_age_s, +): + """ + send a slice to the thin branch if any of the signals is hit, else to + the good branch. + """ + + m = metrics + thin = ( + (m["n_usable_strikes"] < min_usable_strikes) + (m["median_spread_pct"] > max_median_spread_pct) + (m["frac_one_sided"] > max_one_sided_frac) + (m["median_quote_age_s"] > max_median_age_s) + ) + out = metrics.copy() + out["branch"] = np.where(thin, "thin", "naive") + return out + +def extract_price_curve(slice_df, option_type): + # Pull the usable price points for one option type out of one slice and keeps only two-sided quotes sorted by strike + rows = slice_df[ + (slice_df["option_type"] == option_type) + & (slice_df["quote_status"] == "two_sided") + ] + out = pd.DataFrame( + { + "strike": rows["strike"].to_numpy(), + "price": rows["mid_price"].to_numpy(), + "spread": rows["spread"].to_numpy(), + } + ).sort_values("strike").reset_index(drop=True) + return out + + + +def fit_convex(strikes, prices, option_type, weights=None): + """ + + Stays as close as possible to the real prices while keeping no arb + rules: for calls price only falls as strike rises, and the curve is convex, + for puts price only rises as strike rises, and the curve is convex + + The weights let tighter and better quotes count for more + """ + + strikes = np.asarray(strikes, dtype=float) + prices = np.asarray(prices, dtype=float) + + # sort by strike so the shape rules line up + order = np.argsort(strikes) + strikes = strikes[order] + prices = prices[order] + n = len(strikes) + + if weights is None: + weights = np.ones(n) + else: + weights = np.asarray(weights) + weights = weights[order] + + + f = cp.Variable(n) + + # weighted least squares + cost = cp.sum(cp.multiply(weights, cp.square(f - prices))) + + # collect the rules the fitted prices must obey + rules = [] + rules.append(f >= 0) # a price can never be negative + + # need at least 2 points before we can talk about the curve's direction + if n >= 2: + strike_gaps = np.diff(strikes) # distance between neighbouring strikes + price_steps = cp.diff(f) # change in fitted price between neighbours + slopes = price_steps / strike_gaps # slope of each segment + + if option_type == "call": + rules.append(slopes <= 0) # a call only falls as strike rises + else: + rules.append(slopes >= 0) # a put only rises as strike rises + + # need at least 3 points before looking at curvature of line + if n >= 3: + # convex = the slopes only increase from left to right + rules.append(cp.diff(slopes) >= 0) + + problem = cp.Problem(cp.Minimize(cost), rules) + problem.solve(solver=cp.CLARABEL) + + # f.value is the answer as plain numbers + fitted_prices = np.asarray(f.value).ravel() + return strikes, fitted_prices + + +def price_at(strikes, fitted_prices, query_strikes): + """ + Read the fitted curve at strikes between the fitted ones, by drawing + straight lines between the fitted points. Do not read outside the + range of fitted strikes. + """ + query_strikes = np.asarray(query_strikes, dtype=float) + return np.interp(query_strikes, strikes, fitted_prices) + + +def fit_slice(slice_df, option_type, smoothness=0.0, use_spread_weights=True): + """ + Pull one option type out of a slice and fit it. + Returns the strikes, the raw prices, and the fitted prices. + """ + # step 1: get the usable prices out of the slice + strikes, prices, spreads = extract_price_curve(slice_df, option_type) + + # step 2: turn spreads into weights, if asked. tighter spread means a + # more trustworthy price, so it gets a bigger weight. the max() keeps a + # zero spread from dividing by zero. + if use_spread_weights: + safe_spreads = np.maximum(spreads, 1e-6) + weights = 1.0 / safe_spreads + else: + weights = None + + # step 3: fit the curve + strikes, fitted = fit_convex(strikes, prices, option_type, + weights=weights, smoothness=smoothness) + return strikes, prices, fitted + +