242 lines
6.9 KiB
Go
242 lines
6.9 KiB
Go
package mujica
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// OptionLegKind is what a leg holds. The zero value is LegCall.
|
|
type OptionLegKind int
|
|
|
|
const (
|
|
LegCall OptionLegKind = iota
|
|
LegPut
|
|
// LegStock is the underlying, bought or sold at Premium.
|
|
LegStock
|
|
)
|
|
|
|
// OptionLeg is one position of a strategy; Quantity is negative for short.
|
|
type OptionLeg struct {
|
|
Kind OptionLegKind
|
|
Strike float64
|
|
Premium float64
|
|
Quantity float64
|
|
}
|
|
|
|
// PayoffAt is the strategy's profit at expiry with the underlying at price.
|
|
func PayoffAt(legs []OptionLeg, price float64) float64 {
|
|
sum := 0.0
|
|
for _, l := range legs {
|
|
switch l.Kind {
|
|
case LegCall:
|
|
sum += l.Quantity * (max(price-l.Strike, 0) - l.Premium)
|
|
case LegPut:
|
|
sum += l.Quantity * (max(l.Strike-price, 0) - l.Premium)
|
|
case LegStock:
|
|
sum += l.Quantity * (price - l.Premium)
|
|
default:
|
|
panic("mujica: OptionLeg Kind out of range")
|
|
}
|
|
}
|
|
return sum
|
|
}
|
|
|
|
// Breakevens are the prices in lo..hi where the strategy's payoff crosses zero, ascending.
|
|
func Breakevens(legs []OptionLeg, lo, hi float64) []float64 {
|
|
kinks := []float64{lo, hi}
|
|
for _, l := range legs {
|
|
if l.Kind != LegStock && l.Strike > lo && l.Strike < hi {
|
|
kinks = append(kinks, l.Strike)
|
|
}
|
|
}
|
|
slices.Sort(kinks)
|
|
var out []float64
|
|
for i := 1; i < len(kinks); i++ {
|
|
a, b := kinks[i-1], kinks[i]
|
|
fa, fb := PayoffAt(legs, a), PayoffAt(legs, b)
|
|
if fa == 0 && (len(out) == 0 || out[len(out)-1] != a) {
|
|
out = append(out, a)
|
|
}
|
|
if fa*fb < 0 {
|
|
out = append(out, a+(b-a)*fa/(fa-fb))
|
|
}
|
|
}
|
|
if PayoffAt(legs, hi) == 0 && (len(out) == 0 || out[len(out)-1] != hi) {
|
|
out = append(out, hi)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PayoffDiagramOptions configure a PayoffDiagram.
|
|
type PayoffDiagramOptions struct {
|
|
Frame ChartFrame
|
|
// Spot is the current price, marked on the chart; zero hides it.
|
|
Spot float64
|
|
// Low and High bound the price axis; both zero span the strikes ±30%.
|
|
Low, High float64
|
|
// Points is how many prices the keys step through; zero means 101.
|
|
Points int
|
|
Format func(v float64) string
|
|
}
|
|
|
|
// PayoffDiagram plots a strategy's profit at expiry with breakevens and the current price.
|
|
func PayoffDiagram(c *ui.Context, legs []OptionLeg, opts PayoffDiagramOptions) ChartResult {
|
|
lo, hi := payoffRange(legs, opts)
|
|
n := opts.Points
|
|
if n == 0 {
|
|
n = 101
|
|
}
|
|
if n < 2 {
|
|
panic("mujica: PayoffDiagram needs at least 2 Points")
|
|
}
|
|
ch := newChart(c, opts.Frame)
|
|
k := Tokens(c)
|
|
size := FontSize(c, theme.CaptionSize)
|
|
type data struct {
|
|
px, pl, be []float64
|
|
ylo, yhi float64
|
|
}
|
|
d := chartMemo(ch, "payoff", fmt.Sprint(legs, lo, hi, n), func() data {
|
|
var v data
|
|
v.be = Breakevens(legs, lo, hi)
|
|
v.ylo, v.yhi = 0, 0
|
|
for i := range n {
|
|
x := lo + (hi-lo)*float64(i)/float64(n-1)
|
|
v.px, v.pl = append(v.px, x), append(v.pl, PayoffAt(legs, x))
|
|
v.ylo, v.yhi = min(v.ylo, v.pl[i]), max(v.yhi, v.pl[i])
|
|
}
|
|
return v
|
|
})
|
|
if len(legs) == 0 {
|
|
n = 0
|
|
}
|
|
format := func(v float64) string { return chartValue(c, v, opts.Format) }
|
|
ylo, yhi, step := internal.Nice(d.ylo, d.yhi, 4)
|
|
ticks := internal.Ticks(ylo, yhi, step)
|
|
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
|
plot := chartInset(r, 56, 26)
|
|
return plot, internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
|
internal.Linear{D0: ylo, D1: yhi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
|
}
|
|
up, down := trendColor(c, 1), trendColor(c, -1)
|
|
spec := chartSpec{n: n}
|
|
spec.readout = func(i int) string {
|
|
return fmt.Sprintf(msg(c, "payoff_diagram.readout"), format(d.px[i]), trendWord(c, trendSign(d.pl[i])), format(d.pl[i]))
|
|
}
|
|
spec.pick = func(r ui.Rect, x, y float32) int {
|
|
plot, xs, _ := geom(r)
|
|
if !plot.Contains(x, y) {
|
|
return -1
|
|
}
|
|
return max(0, min(n-1, int(math.Round((xs.Invert(x)-lo)/(hi-lo)*float64(n-1)))))
|
|
}
|
|
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
|
plot, xs, ys := geom(r)
|
|
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
|
lx, ll, lstep := internal.Nice(lo, hi, max(2, int(plot.W/90)))
|
|
var tx []float32
|
|
var tl []string
|
|
for _, t := range internal.Ticks(lx, ll, lstep) {
|
|
if t >= lo && t <= hi {
|
|
tx, tl = append(tx, xs.Map(t)), append(tl, internal.FormatTick(t, lstep))
|
|
}
|
|
}
|
|
internal.AxisX(p, plot, tx, tl, size, k)
|
|
zero := internal.Snap(p, ys.Map(0))
|
|
pt := func(x, v float64) (float32, float32) { return internal.Snap(p, xs.Map(x)), internal.Snap(p, ys.Map(v)) }
|
|
// Exact corners: the grid plus strikes and breakevens.
|
|
xsAll := slices.Clone(d.px)
|
|
for _, l := range legs {
|
|
if l.Kind != LegStock && l.Strike > lo && l.Strike < hi {
|
|
xsAll = append(xsAll, l.Strike)
|
|
}
|
|
}
|
|
xsAll = append(xsAll, d.be...)
|
|
slices.Sort(xsAll)
|
|
for i := 1; i < len(xsAll); i++ {
|
|
a, b := xsAll[i-1], xsAll[i]
|
|
fa, fb := PayoffAt(legs, a), PayoffAt(legs, b)
|
|
col := up
|
|
if fa+fb < 0 {
|
|
col = down
|
|
}
|
|
var area ui.Path
|
|
ax, ay := pt(a, fa)
|
|
bx, by := pt(b, fb)
|
|
area.MoveTo(ax, zero).LineTo(ax, ay).LineTo(bx, by).LineTo(bx, zero).Close()
|
|
p.FillPath(&area, col.Alpha(0.16))
|
|
}
|
|
p.Line(plot.X, zero, plot.X+plot.W, zero, 1, k.ControlBorder)
|
|
var line ui.Path
|
|
for i, x := range xsAll {
|
|
if i == 0 {
|
|
line.MoveTo(pt(x, PayoffAt(legs, x)))
|
|
} else {
|
|
line.LineTo(pt(x, PayoffAt(legs, x)))
|
|
}
|
|
}
|
|
p.StrokePath(&line, 2, k.AccentText)
|
|
for _, b := range d.be {
|
|
bx, _ := pt(b, 0)
|
|
p.FillPath(internal.Diamond(bx, zero, 9), k.Text)
|
|
chartText(p, format(b), bx, zero-6, size, k.Text, 0.5, 1)
|
|
}
|
|
if opts.Spot > lo && opts.Spot < hi {
|
|
sx, _ := pt(opts.Spot, 0)
|
|
for y := plot.Y; y < plot.Y+plot.H; y += 6 {
|
|
p.Line(sx, y, sx, min(y+3, plot.Y+plot.H), 1, k.Info)
|
|
}
|
|
chartText(p, msg(c, "payoff_diagram.spot")+" "+format(opts.Spot), sx+4, plot.Y, size, k.Info, 0, 0)
|
|
}
|
|
if active >= 0 {
|
|
x, y := pt(d.px[active], d.pl[active])
|
|
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.TextMuted)
|
|
internal.Marker(p, 0, x, y, 13, k.Surface)
|
|
internal.Marker(p, 0, x, y, 9, k.AccentText)
|
|
}
|
|
}
|
|
res, _ := ch.build(spec)
|
|
if len(d.be) > 0 && n > 0 {
|
|
parts := make([]string, len(d.be))
|
|
for i, b := range d.be {
|
|
parts[i] = format(b)
|
|
}
|
|
res.Element.Children(func() {
|
|
ui.Text(c, msg(c, "payoff_diagram.breakevens")+" "+strings.Join(parts, ", ")).FontSize(size).TextColor(k.TextMuted)
|
|
})
|
|
}
|
|
return res
|
|
}
|
|
|
|
// payoffRange is the price axis: the options' range, or the strikes ±30%.
|
|
func payoffRange(legs []OptionLeg, opts PayoffDiagramOptions) (lo, hi float64) {
|
|
if opts.Low != 0 || opts.High != 0 {
|
|
if !(opts.High > opts.Low) || opts.Low < 0 {
|
|
panic("mujica: PayoffDiagram needs 0 ≤ Low < High")
|
|
}
|
|
return opts.Low, opts.High
|
|
}
|
|
lo, hi = math.Inf(1), math.Inf(-1)
|
|
for _, l := range legs {
|
|
ref := l.Strike
|
|
if l.Kind == LegStock {
|
|
ref = l.Premium
|
|
}
|
|
lo, hi = min(lo, ref), max(hi, ref)
|
|
}
|
|
if opts.Spot > 0 {
|
|
lo, hi = min(lo, opts.Spot), max(hi, opts.Spot)
|
|
}
|
|
if math.IsInf(lo, 1) || hi <= 0 {
|
|
return 0, 100
|
|
}
|
|
return lo * 0.7, hi * 1.3
|
|
}
|