Skip to content

Commit 53a0e83

Browse files
committed
Add options and prices for crypto
1 parent a76798f commit 53a0e83

File tree

8 files changed

+146
-35
lines changed

8 files changed

+146
-35
lines changed

notebooks/api/options/vol_surface.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ Vol Surface
2929
:member-order: groupwise
3030
:autosummary:
3131
:autosummary-nosignatures:
32+
33+
34+
.. autoclass:: OptionSelection
35+
:members:

notebooks/index.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,25 @@ This documentation is organized into a few major sections.
2424
* **Stochastic Processes** cover all the stochastic models supported and their use
2525
* **Applications** show case the real-world use cases
2626
* **Examples** random examples
27-
* **API Reference** python API reference``
27+
* **API Reference** python API reference
2828

29-
```{code-cell} ipython3
29+
## Installation
3030

31+
To install the library use
3132
```
33+
pip install quantflow
34+
```
35+
36+
37+
## Optional dependencies
38+
39+
Quantflow comes with two optional dependencies:
40+
41+
* `data` for data retrieval, to install it use
42+
```
43+
pip install quantflow[data]
44+
```
45+
* `cli` for command line interface, to install it use
46+
```
47+
pip install quantflow[data,cli]
48+
```

quantflow/cli/app.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import click
77
from prompt_toolkit import PromptSession
88
from prompt_toolkit.completion import NestedCompleter
9+
from prompt_toolkit.formatted_text import HTML
910
from prompt_toolkit.history import FileHistory
1011
from rich.console import Console
1112
from rich.text import Text
@@ -38,6 +39,7 @@ def __call__(self) -> None:
3839
self.prompt_message(),
3940
completer=self.prompt_completer(),
4041
complete_while_typing=True,
42+
bottom_toolbar=self.bottom_toolbar,
4143
)
4244
except KeyboardInterrupt:
4345
break
@@ -82,3 +84,15 @@ def handle_command(self, text: str) -> None:
8284
click.exceptions.UsageError,
8385
) as e:
8486
self.error(e)
87+
88+
def bottom_toolbar(self) -> HTML:
89+
sections = "/".join([str(section.name) for section in self.sections])
90+
back = (
91+
(' <b><style bg="ansired">back</style></b> ' "to exit the current section,")
92+
if len(self.sections) > 1
93+
else ""
94+
)
95+
return HTML(
96+
f"Your are in <strong>{sections}</strong>, type{back} "
97+
'<b><style bg="ansired">exit</style></b> to exit'
98+
)

quantflow/cli/commands/base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from quantflow.cli.app import QfApp
1313

1414

15+
FREQUENCIES = tuple(FMP().historical_frequencies())
16+
17+
1518
class HistoricalPeriod(enum.StrEnum):
1619
day = "1d"
1720
week = "1w"
@@ -122,3 +125,10 @@ class options:
122125
default=-1,
123126
help="maturity index",
124127
)
128+
frequency = click.option(
129+
"-f",
130+
"--frequency",
131+
type=click.Choice(FREQUENCIES),
132+
default="",
133+
help="Frequency of data - if not provided it is daily",
134+
)

quantflow/cli/commands/crypto.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
from quantflow.data.deribit import Deribit
1212
from quantflow.options.surface import VolSurface
13+
from quantflow.utils.numbers import round_to_step
1314

1415
from .base import QuantContext, options, quant_group
16+
from .stocks import get_prices
1517

1618

1719
@quant_group()
@@ -64,15 +66,42 @@ def implied_vol(currency: str, index: int, height: int, chart: bool) -> None:
6466
index_or_none = None if index < 0 else index
6567
vs.bs(index=index_or_none)
6668
df = vs.options_df(index=index_or_none)
67-
df["implied_vol"] = df["implied_vol"] * 100
68-
df = df.round({"ttm": 4, "moneyness": 4, "moneyness_ttm": 4, "implied_vol": 5})
6969
if chart:
70-
data = df["implied_vol"].tolist()
70+
data = (df["implied_vol"] * 100).tolist()
7171
ctx.qf.print(plot(data, {"height": height}))
7272
else:
73+
df[["ttm", "moneyness", "moneyness_ttm"]] = df[
74+
["ttm", "moneyness", "moneyness_ttm"]
75+
].map("{:.4f}".format)
76+
df["implied_vol"] = df["implied_vol"].map("{:.2%}".format)
77+
df["price"] = df["price"].map(lambda p: round_to_step(p, vs.tick_size_options))
78+
df["forward_price"] = df["forward_price"].map(
79+
lambda p: round_to_step(p, vs.tick_size_forwards)
80+
)
7381
ctx.qf.print(df_to_rich(df))
7482

7583

84+
@crypto.command()
85+
@click.argument("symbol")
86+
@options.height
87+
@options.length
88+
@options.chart
89+
@options.frequency
90+
def prices(symbol: str, height: int, length: int, chart: bool, frequency: str) -> None:
91+
"""Fetch OHLC prices for given cryptocurrency"""
92+
ctx = QuantContext.current()
93+
df = asyncio.run(get_prices(ctx, symbol, frequency))
94+
if df.empty:
95+
raise click.UsageError(
96+
f"No data for {symbol} - are you sure the symbol exists?"
97+
)
98+
if chart:
99+
data = list(reversed(df["close"].tolist()[:length]))
100+
ctx.qf.print(plot(data, {"height": height}))
101+
else:
102+
ctx.qf.print(df_to_rich(df[["date", "open", "high", "low", "close", "volume"]]))
103+
104+
76105
async def get_volatility(ctx: QuantContext, currency: str) -> pd.DataFrame:
77106
async with Deribit() as client:
78107
return await client.get_volatility(params=dict(currency=currency))

quantflow/cli/commands/stocks.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,10 @@
1111
from ccy.cli.console import df_to_rich
1212
from ccy.tradingcentres import prevbizday
1313

14-
from quantflow.data.fmp import FMP
1514
from quantflow.utils.dates import utcnow
1615

1716
from .base import HistoricalPeriod, QuantContext, options, quant_group
1817

19-
FREQUENCIES = tuple(FMP().historical_frequencies())
20-
2118

2219
@quant_group()
2320
def stocks() -> None:
@@ -56,13 +53,7 @@ def search(text: str) -> None:
5653
@click.argument("symbol")
5754
@options.height
5855
@options.length
59-
@click.option(
60-
"-f",
61-
"--frequency",
62-
type=click.Choice(FREQUENCIES),
63-
default="",
64-
help="Frequency of data - if not provided it is daily",
65-
)
56+
@options.frequency
6657
def chart(symbol: str, height: int, length: int, frequency: str) -> None:
6758
"""Symbol chart"""
6859
ctx = QuantContext.current()

quantflow/data/deribit.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from datetime import datetime, timezone
2+
from decimal import Decimal
23
from typing import Any, cast
34

45
import pandas as pd
56
from dateutil.parser import parse
67
from fluid.utils.http_client import AioHttpClient, HttpResponse
78

89
from quantflow.options.surface import VolSecurityType, VolSurfaceLoader
9-
from quantflow.utils.numbers import round_to_step
10+
from quantflow.utils.numbers import round_to_step, to_decimal
1011

1112

1213
def parse_maturity(v: str) -> datetime:
@@ -55,12 +56,13 @@ async def volatility_surface_loader(self, currency: str) -> VolSurfaceLoader:
5556
)
5657
instruments = await self.get_instruments(params=dict(currency=currency))
5758
instrument_map = {i["instrument_name"]: i for i in instruments}
58-
59+
min_tick_size = Decimal("inf")
5960
for future in futures:
6061
if (bid_ := future["bid_price"]) and (ask_ := future["ask_price"]):
6162
name = future["instrument_name"]
6263
meta = instrument_map[name]
63-
tick_size = meta["tick_size"]
64+
tick_size = to_decimal(meta["tick_size"])
65+
min_tick_size = min(min_tick_size, tick_size)
6466
bid = round_to_step(bid_, tick_size)
6567
ask = round_to_step(ask_, tick_size)
6668
if meta["settlement_period"] == "perpetual":
@@ -85,12 +87,15 @@ async def volatility_surface_loader(self, currency: str) -> VolSurfaceLoader:
8587
open_interest=int(future["open_interest"]),
8688
volume=int(future["volume_usd"]),
8789
)
90+
loader.tick_size_forwards = min_tick_size
8891

92+
min_tick_size = Decimal("inf")
8993
for option in options:
9094
if (bid_ := option["bid_price"]) and (ask_ := option["ask_price"]):
9195
name = option["instrument_name"]
9296
meta = instrument_map[name]
93-
tick_size = meta["tick_size"]
97+
tick_size = to_decimal(meta["tick_size"])
98+
min_tick_size = min(min_tick_size, tick_size)
9499
loader.add_option(
95100
VolSecurityType.option,
96101
strike=round_to_step(meta["strike"], tick_size),
@@ -103,7 +108,7 @@ async def volatility_surface_loader(self, currency: str) -> VolSurfaceLoader:
103108
bid=round_to_step(bid_, tick_size),
104109
ask=round_to_step(ask_, tick_size),
105110
)
106-
111+
loader.tick_size_options = min_tick_size
107112
return loader
108113

109114
# Internal methods

quantflow/options/surface.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import enum
4+
import warnings
45
from dataclasses import dataclass, field
56
from datetime import datetime, timedelta
67
from decimal import Decimal
@@ -39,14 +40,21 @@ def vol_surface_type(self) -> VolSecurityType: ...
3940

4041

4142
class OptionSelection(enum.Enum):
42-
"""Option selection method"""
43+
"""Option selection method
44+
45+
This enum is used to select which one between calls and puts are used
46+
for calculating implied volatility and other operations
47+
"""
4348

4449
best = enum.auto()
45-
"""Select the best bid/ask options - the ones which are otm"""
50+
"""Select the best bid/ask options.
51+
52+
These are the options which are Out of the Money, where their
53+
intrinsic value is zero"""
4654
call = enum.auto()
47-
"""Select the call options"""
55+
"""Select the call options only"""
4856
put = enum.auto()
49-
"""Select the put options"""
57+
"""Select the put options only"""
5058

5159

5260
@dataclass
@@ -179,6 +187,10 @@ def put_price(self) -> Decimal:
179187
else:
180188
return self.price
181189

190+
@property
191+
def option_type(self) -> str:
192+
return "call" if self.call else "put"
193+
182194
def can_price(self, converged: bool, select: OptionSelection) -> bool:
183195
if self.price_time > ZERO and not np.isnan(self.implied_vol):
184196
if not self.converged and converged is True:
@@ -221,7 +233,7 @@ def _asdict(self) -> dict[str, Any]:
221233
price=float(self.price),
222234
price_bp=float(self.price_bp),
223235
forward_price=float(self.forward_price),
224-
call=self.call,
236+
type=self.option_type,
225237
side=self.side,
226238
)
227239

@@ -396,6 +408,10 @@ class VolSurface(Generic[S]):
396408
"""Sorted tuple of :class:`.VolCrossSection` for different maturities"""
397409
day_counter: DayCounter = default_day_counter
398410
"""Day counter for time to maturity calculations - by default it uses Act/Act"""
411+
tick_size_forwards: Decimal | None = None
412+
"""Tick size for rounding forward and spot prices - optional"""
413+
tick_size_options: Decimal | None = None
414+
"""Tick size for rounding option prices - optional"""
399415

400416
def securities(self) -> Iterator[SpotPrice[S] | FwdPrice[S] | OptionPrices[S]]:
401417
"""Iterator over all securities in the volatility surface"""
@@ -457,17 +473,30 @@ def bs(
457473
initial_vol: float = INITIAL_VOL,
458474
) -> list[OptionPrice]:
459475
"""calculate Black-Scholes implied volatility for all options
460-
in the surface"""
476+
in the surface
477+
478+
:param select: the :class:`.OptionSelection` method
479+
:param index: Index of the cross section to use, if None use all
480+
:param initial_vol: Initial volatility for the root finding algorithm
481+
482+
Some options may not converge, in this case the implied volatility is not
483+
calculated correctly and the option is marked as not converged.
484+
"""
461485
d = self.as_array(
462-
select=select, index=index, initial_vol=initial_vol, converged=False
463-
)
464-
result = implied_black_volatility(
465-
k=d.moneyness,
466-
price=d.price,
467-
ttm=d.ttm,
468-
initial_sigma=d.implied_vol,
469-
call_put=d.call_put,
486+
select=select,
487+
index=index,
488+
initial_vol=initial_vol,
489+
converged=False,
470490
)
491+
with warnings.catch_warnings():
492+
warnings.simplefilter("ignore")
493+
result = implied_black_volatility(
494+
k=d.moneyness,
495+
price=d.price,
496+
ttm=d.ttm,
497+
initial_sigma=d.implied_vol,
498+
call_put=d.call_put,
499+
)
471500
for option, implied_vol, converged in zip(
472501
d.options, result.root, result.converged
473502
):
@@ -495,7 +524,10 @@ def options_df(
495524
) -> pd.DataFrame:
496525
"""Time frame of Black-Scholes call input data"""
497526
data = self.option_prices(
498-
select=select, index=index, initial_vol=initial_vol, converged=converged
527+
select=select,
528+
index=index,
529+
initial_vol=initial_vol,
530+
converged=converged,
499531
)
500532
return pd.DataFrame([d._asdict() for d in data])
501533

@@ -639,8 +671,15 @@ class GenericVolSurfaceLoader(Generic[S]):
639671
"""Helper class to build a volatility surface from a list of securities"""
640672

641673
spot: SpotPrice[S] | None = None
674+
"""Spot price of the underlying asset"""
642675
maturities: dict[datetime, VolCrossSectionLoader[S]] = field(default_factory=dict)
676+
"""Dictionary of maturities and their corresponding cross section loaders"""
643677
day_counter: DayCounter = default_day_counter
678+
"""Day counter for time to maturity calculations - by default it uses Act/Act"""
679+
tick_size_forwards: Decimal | None = None
680+
"""Tick size for rounding forward and spot prices - optional"""
681+
tick_size_options: Decimal | None = None
682+
"""Tick size for rounding option prices - optional"""
644683

645684
def get_or_create_maturity(self, maturity: datetime) -> VolCrossSectionLoader[S]:
646685
if maturity not in self.maturities:
@@ -727,6 +766,8 @@ def surface(self, ref_date: datetime | None = None) -> VolSurface[S]:
727766
spot=self.spot,
728767
maturities=tuple(maturities),
729768
day_counter=self.day_counter,
769+
tick_size_forwards=self.tick_size_forwards,
770+
tick_size_options=self.tick_size_options,
730771
)
731772

732773

0 commit comments

Comments
 (0)