option_lab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +139 -0
- data/.yard/hooks/before_generate.rb +7 -0
- data/.yardopts +11 -0
- data/Gemfile +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +180 -0
- data/Rakefile +44 -0
- data/docs/OptionLab/BinomialTree.html +1271 -0
- data/docs/OptionLab/BjerksundStensland.html +2022 -0
- data/docs/OptionLab/BlackScholes.html +2388 -0
- data/docs/OptionLab/Engine.html +1716 -0
- data/docs/OptionLab/Models/AmericanModelInputs.html +937 -0
- data/docs/OptionLab/Models/ArrayInputs.html +463 -0
- data/docs/OptionLab/Models/BaseModel.html +223 -0
- data/docs/OptionLab/Models/BinomialModelInputs.html +1161 -0
- data/docs/OptionLab/Models/BlackScholesInfo.html +967 -0
- data/docs/OptionLab/Models/BlackScholesModelInputs.html +851 -0
- data/docs/OptionLab/Models/ClosedPosition.html +445 -0
- data/docs/OptionLab/Models/EngineData.html +2523 -0
- data/docs/OptionLab/Models/EngineDataResults.html +435 -0
- data/docs/OptionLab/Models/Inputs.html +2241 -0
- data/docs/OptionLab/Models/LaplaceInputs.html +777 -0
- data/docs/OptionLab/Models/Option.html +736 -0
- data/docs/OptionLab/Models/Outputs.html +1753 -0
- data/docs/OptionLab/Models/PoPOutputs.html +645 -0
- data/docs/OptionLab/Models/PricingResult.html +848 -0
- data/docs/OptionLab/Models/Stock.html +583 -0
- data/docs/OptionLab/Models/TreeVisualization.html +688 -0
- data/docs/OptionLab/Models.html +251 -0
- data/docs/OptionLab/Plotting.html +548 -0
- data/docs/OptionLab/Support.html +2884 -0
- data/docs/OptionLab/Utils.html +619 -0
- data/docs/OptionLab.html +133 -0
- data/docs/_index.html +376 -0
- data/docs/class_list.html +54 -0
- data/docs/css/common.css +1 -0
- data/docs/css/full_list.css +58 -0
- data/docs/css/style.css +503 -0
- data/docs/file.LICENSE.html +70 -0
- data/docs/file.README.html +263 -0
- data/docs/file_list.html +64 -0
- data/docs/frames.html +22 -0
- data/docs/index.html +263 -0
- data/docs/js/app.js +344 -0
- data/docs/js/full_list.js +242 -0
- data/docs/js/jquery.js +4 -0
- data/docs/method_list.html +1974 -0
- data/docs/top-level-namespace.html +110 -0
- data/examples/american_options.rb +163 -0
- data/examples/covered_call.rb +76 -0
- data/lib/option_lab/binomial_tree.rb +238 -0
- data/lib/option_lab/bjerksund_stensland.rb +276 -0
- data/lib/option_lab/black_scholes.rb +323 -0
- data/lib/option_lab/engine.rb +492 -0
- data/lib/option_lab/models.rb +768 -0
- data/lib/option_lab/plotting.rb +182 -0
- data/lib/option_lab/support.rb +471 -0
- data/lib/option_lab/utils.rb +107 -0
- data/lib/option_lab/version.rb +5 -0
- data/lib/option_lab.rb +134 -0
- data/option_lab.gemspec +43 -0
- metadata +207 -0
@@ -0,0 +1,182 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'unicode_plot'
|
4
|
+
|
5
|
+
module OptionLab
|
6
|
+
module Plotting
|
7
|
+
class << self
|
8
|
+
# Plot profit/loss diagram
|
9
|
+
# @param outputs [Models::Outputs] Strategy outputs
|
10
|
+
# @return [void]
|
11
|
+
def plot_pl(outputs)
|
12
|
+
st = outputs.data
|
13
|
+
inputs = outputs.inputs
|
14
|
+
|
15
|
+
if st.strategy_profit.empty?
|
16
|
+
raise RuntimeError, 'Before plotting the profit/loss profile diagram, you must run a calculation!'
|
17
|
+
end
|
18
|
+
|
19
|
+
# Extract data
|
20
|
+
stock_prices = st.stock_price_array
|
21
|
+
strategy_profit = st.strategy_profit
|
22
|
+
|
23
|
+
# Print explanation for the plot
|
24
|
+
comment = "Profit/Loss diagram:\n--------------------\n"
|
25
|
+
comment += "The vertical line (|) corresponds to the stock's current price (#{inputs.stock_price}).\n"
|
26
|
+
comment += "Break-even points are where the line crosses zero.\n"
|
27
|
+
|
28
|
+
# Process strikes and add to comment
|
29
|
+
call_buy_strikes = []
|
30
|
+
put_buy_strikes = []
|
31
|
+
call_sell_strikes = []
|
32
|
+
put_sell_strikes = []
|
33
|
+
|
34
|
+
st.strike.each_with_index do |strike, i|
|
35
|
+
next if strike == 0.0
|
36
|
+
|
37
|
+
case st.type[i]
|
38
|
+
when 'call'
|
39
|
+
if st.action[i] == 'buy'
|
40
|
+
call_buy_strikes << strike
|
41
|
+
elsif st.action[i] == 'sell'
|
42
|
+
call_sell_strikes << strike
|
43
|
+
end
|
44
|
+
when 'put'
|
45
|
+
if st.action[i] == 'buy'
|
46
|
+
put_buy_strikes << strike
|
47
|
+
elsif st.action[i] == 'sell'
|
48
|
+
put_sell_strikes << strike
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
if call_buy_strikes.any?
|
54
|
+
comment += "Long Call Strikes: #{call_buy_strikes.join(', ')}\n"
|
55
|
+
end
|
56
|
+
|
57
|
+
if call_sell_strikes.any?
|
58
|
+
comment += "Short Call Strikes: #{call_sell_strikes.join(', ')}\n"
|
59
|
+
end
|
60
|
+
|
61
|
+
if put_buy_strikes.any?
|
62
|
+
comment += "Long Put Strikes: #{put_buy_strikes.join(', ')}\n"
|
63
|
+
end
|
64
|
+
|
65
|
+
if put_sell_strikes.any?
|
66
|
+
comment += "Short Put Strikes: #{put_sell_strikes.join(', ')}\n"
|
67
|
+
end
|
68
|
+
|
69
|
+
# Handle profit target and loss limit
|
70
|
+
if inputs.profit_target
|
71
|
+
comment += "Profit Target: $#{inputs.profit_target}\n"
|
72
|
+
end
|
73
|
+
|
74
|
+
if inputs.loss_limit
|
75
|
+
comment += "Loss Limit: $#{inputs.loss_limit}\n"
|
76
|
+
end
|
77
|
+
|
78
|
+
# Print comment
|
79
|
+
puts comment
|
80
|
+
|
81
|
+
# Create LineChart with stock prices and strategy profit
|
82
|
+
plot = UnicodePlot.lineplot(
|
83
|
+
stock_prices.to_a,
|
84
|
+
strategy_profit.to_a,
|
85
|
+
title: 'Options Strategy Profit/Loss',
|
86
|
+
xlabel: 'Stock Price',
|
87
|
+
ylabel: 'Profit/Loss'
|
88
|
+
)
|
89
|
+
|
90
|
+
# Add horizontal zero line for break-even
|
91
|
+
zero_line = Array.new(stock_prices.size, 0)
|
92
|
+
plot = UnicodePlot.lineplot!(
|
93
|
+
plot,
|
94
|
+
stock_prices.to_a,
|
95
|
+
zero_line,
|
96
|
+
name: 'Break-even',
|
97
|
+
color: :magenta
|
98
|
+
)
|
99
|
+
|
100
|
+
# Add vertical line at current stock price
|
101
|
+
# Find index closest to current stock price
|
102
|
+
current_price_idx = stock_prices.to_a.index { |p| p >= inputs.stock_price } || (stock_prices.size / 2)
|
103
|
+
current_x = [stock_prices[current_price_idx], stock_prices[current_price_idx]]
|
104
|
+
current_y = [strategy_profit.min, strategy_profit.max]
|
105
|
+
|
106
|
+
plot = UnicodePlot.lineplot!(
|
107
|
+
plot,
|
108
|
+
current_x,
|
109
|
+
current_y,
|
110
|
+
name: 'Current Price',
|
111
|
+
color: :green
|
112
|
+
)
|
113
|
+
|
114
|
+
# Add profit target line if specified
|
115
|
+
if inputs.profit_target
|
116
|
+
target_line = Array.new(stock_prices.size, inputs.profit_target)
|
117
|
+
plot = UnicodePlot.lineplot!(
|
118
|
+
plot,
|
119
|
+
stock_prices.to_a,
|
120
|
+
target_line,
|
121
|
+
name: 'Profit Target',
|
122
|
+
color: :blue
|
123
|
+
)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Add loss limit line if specified
|
127
|
+
if inputs.loss_limit
|
128
|
+
loss_line = Array.new(stock_prices.size, inputs.loss_limit)
|
129
|
+
plot = UnicodePlot.lineplot!(
|
130
|
+
plot,
|
131
|
+
stock_prices.to_a,
|
132
|
+
loss_line,
|
133
|
+
name: 'Loss Limit',
|
134
|
+
color: :red
|
135
|
+
)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Display the plot
|
139
|
+
puts plot
|
140
|
+
|
141
|
+
# Print break-even points
|
142
|
+
break_even_points = find_break_even_points(stock_prices.to_a, strategy_profit.to_a)
|
143
|
+
if break_even_points.any?
|
144
|
+
puts "\nBreak-even prices: #{break_even_points.map { |p| sprintf('$%.2f', p) }.join(', ')}"
|
145
|
+
else
|
146
|
+
puts "\nNo break-even points found in the analyzed price range."
|
147
|
+
end
|
148
|
+
|
149
|
+
# Print max profit/loss in range
|
150
|
+
puts "Maximum profit in range: $#{strategy_profit.max.round(2)}"
|
151
|
+
puts "Maximum loss in range: $#{strategy_profit.min.round(2)}"
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
# Find approximate break-even points where profit/loss crosses zero
|
157
|
+
# @param prices [Array<Float>] Array of stock prices
|
158
|
+
# @param profits [Array<Float>] Array of profit/loss values
|
159
|
+
# @return [Array<Float>] Approximate break-even points
|
160
|
+
def find_break_even_points(prices, profits)
|
161
|
+
break_even_points = []
|
162
|
+
|
163
|
+
# Find where profit crosses zero (sign changes)
|
164
|
+
(0...profits.size - 1).each do |i|
|
165
|
+
if (profits[i] <= 0 && profits[i + 1] > 0) || (profits[i] >= 0 && profits[i + 1] < 0)
|
166
|
+
# Linear interpolation to find more accurate break-even point
|
167
|
+
if profits[i] != profits[i + 1] # Avoid division by zero
|
168
|
+
ratio = profits[i].abs / (profits[i].abs + profits[i + 1].abs)
|
169
|
+
break_even = prices[i] + ratio * (prices[i + 1] - prices[i])
|
170
|
+
break_even_points << break_even.round(2)
|
171
|
+
else
|
172
|
+
# If same profit (unlikely but possible), use midpoint
|
173
|
+
break_even_points << ((prices[i] + prices[i + 1]) / 2).round(2)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
break_even_points
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,471 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'numo/narray'
|
4
|
+
require 'distribution'
|
5
|
+
|
6
|
+
module OptionLab
|
7
|
+
module Support
|
8
|
+
# Cache for create_price_seq method
|
9
|
+
@price_seq_cache = {}
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Get profit/loss profile and cost of an options trade at expiration
|
13
|
+
# @param option_type [String] 'call' or 'put'
|
14
|
+
# @param action [String] 'buy' or 'sell'
|
15
|
+
# @param x [Float] Strike price
|
16
|
+
# @param val [Float] Option price
|
17
|
+
# @param n [Integer] Number of options
|
18
|
+
# @param s [Numo::DFloat] Array of stock prices
|
19
|
+
# @param commission [Float] Brokerage commission
|
20
|
+
# @return [Array<Numo::DFloat, Float>] P/L profile and cost
|
21
|
+
def get_pl_profile(option_type, action, x, val, n, s, commission = 0.0)
|
22
|
+
if action == 'buy'
|
23
|
+
cost = -val
|
24
|
+
elsif action == 'sell'
|
25
|
+
cost = val
|
26
|
+
else
|
27
|
+
raise ArgumentError, "Action must be either 'buy' or 'sell'!"
|
28
|
+
end
|
29
|
+
|
30
|
+
if Models::OPTION_TYPES.include?(option_type)
|
31
|
+
[
|
32
|
+
n * _get_pl_option(option_type, val, action, s, x) - commission,
|
33
|
+
n * cost - commission
|
34
|
+
]
|
35
|
+
else
|
36
|
+
raise ArgumentError, "Option type must be either 'call' or 'put'!"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Get profit/loss profile and cost of a stock position
|
41
|
+
# @param s0 [Float] Initial stock price
|
42
|
+
# @param action [String] 'buy' or 'sell'
|
43
|
+
# @param n [Integer] Number of shares
|
44
|
+
# @param s [Numo::DFloat] Array of stock prices
|
45
|
+
# @param commission [Float] Brokerage commission
|
46
|
+
# @return [Array<Numo::DFloat, Float>] P/L profile and cost
|
47
|
+
def get_pl_profile_stock(s0, action, n, s, commission = 0.0)
|
48
|
+
if action == 'buy'
|
49
|
+
cost = -s0
|
50
|
+
elsif action == 'sell'
|
51
|
+
cost = s0
|
52
|
+
else
|
53
|
+
raise ArgumentError, "Action must be either 'buy' or 'sell'!"
|
54
|
+
end
|
55
|
+
|
56
|
+
[
|
57
|
+
n * _get_pl_stock(s0, action, s) - commission,
|
58
|
+
n * cost - commission
|
59
|
+
]
|
60
|
+
end
|
61
|
+
|
62
|
+
# Get profit/loss profile and cost of an options trade before expiration using Black-Scholes
|
63
|
+
# @param option_type [String] 'call' or 'put'
|
64
|
+
# @param action [String] 'buy' or 'sell'
|
65
|
+
# @param x [Float] Strike price
|
66
|
+
# @param val [Float] Option price
|
67
|
+
# @param r [Float] Risk-free interest rate
|
68
|
+
# @param target_to_maturity_years [Float] Time remaining to maturity from target date
|
69
|
+
# @param volatility [Float] Volatility
|
70
|
+
# @param n [Integer] Number of options
|
71
|
+
# @param s [Numo::DFloat] Array of stock prices
|
72
|
+
# @param y [Float] Dividend yield
|
73
|
+
# @param commission [Float] Brokerage commission
|
74
|
+
# @return [Array<Numo::DFloat, Float>] P/L profile and cost
|
75
|
+
def get_pl_profile_bs(option_type, action, x, val, r, target_to_maturity_years, volatility, n, s, y = 0.0, commission = 0.0)
|
76
|
+
if action == 'buy'
|
77
|
+
cost = -val
|
78
|
+
factor = 1
|
79
|
+
elsif action == 'sell'
|
80
|
+
cost = val
|
81
|
+
factor = -1
|
82
|
+
else
|
83
|
+
raise ArgumentError, "Action must be either 'buy' or 'sell'!"
|
84
|
+
end
|
85
|
+
|
86
|
+
# Calculate prices using Black-Scholes
|
87
|
+
d1 = BlackScholes.get_d1(s, x, r, volatility, target_to_maturity_years, y)
|
88
|
+
d2 = BlackScholes.get_d2(s, x, r, volatility, target_to_maturity_years, y)
|
89
|
+
calc_price = BlackScholes.get_option_price(option_type, s, x, r, target_to_maturity_years, d1, d2, y)
|
90
|
+
|
91
|
+
profile = factor * n * (calc_price - val) - commission
|
92
|
+
|
93
|
+
[profile, n * cost - commission]
|
94
|
+
end
|
95
|
+
|
96
|
+
# Generate a sequence of stock prices from min to max with $0.01 increment
|
97
|
+
# @param min_price [Float] Minimum stock price
|
98
|
+
# @param max_price [Float] Maximum stock price
|
99
|
+
# @return [Numo::DFloat] Array of sequential stock prices
|
100
|
+
def create_price_seq(min_price, max_price)
|
101
|
+
cache_key = "#{min_price}-#{max_price}"
|
102
|
+
|
103
|
+
# Return cached result if available
|
104
|
+
return @price_seq_cache[cache_key] if @price_seq_cache.key?(cache_key)
|
105
|
+
|
106
|
+
if max_price > min_price
|
107
|
+
# Create array with increment 0.01
|
108
|
+
steps = ((max_price - min_price) * 100 + 1).to_i
|
109
|
+
arr = Numo::DFloat.new(steps).seq(min_price, 0.01)
|
110
|
+
|
111
|
+
# Round to 2 decimal places (Numo::DFloat doesn't support arguments to round)
|
112
|
+
arr = arr.round
|
113
|
+
|
114
|
+
# Cache the result
|
115
|
+
@price_seq_cache[cache_key] = arr
|
116
|
+
|
117
|
+
arr
|
118
|
+
else
|
119
|
+
raise ArgumentError, "Maximum price cannot be less than minimum price!"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Estimate probability of profit
|
124
|
+
# @param s [Numo::DFloat] Array of stock prices
|
125
|
+
# @param profit [Numo::DFloat] Array of profits
|
126
|
+
# @param inputs_data [Models::BlackScholesModelInputs, Models::ArrayInputs] Model inputs
|
127
|
+
# @param target [Float] Return target
|
128
|
+
# @return [Models::PoPOutputs] Probability of profit outputs
|
129
|
+
def get_pop(s, profit, inputs_data, target = 0.01)
|
130
|
+
# Initialize variables
|
131
|
+
probability_of_reaching_target = 0.0
|
132
|
+
probability_of_missing_target = 0.0
|
133
|
+
expected_return_above_target = nil
|
134
|
+
expected_return_below_target = nil
|
135
|
+
|
136
|
+
# Get profit ranges
|
137
|
+
t_ranges = _get_profit_range(s, profit, target)
|
138
|
+
|
139
|
+
reaching_target_range = t_ranges[0] == [[0.0, 0.0]] ? [] : t_ranges[0]
|
140
|
+
missing_target_range = t_ranges[1] == [[0.0, 0.0]] ? [] : t_ranges[1]
|
141
|
+
|
142
|
+
# Calculate PoP based on inputs model
|
143
|
+
if inputs_data.is_a?(Models::BlackScholesModelInputs)
|
144
|
+
probability_of_reaching_target, expected_return_above_target,
|
145
|
+
probability_of_missing_target, expected_return_below_target =
|
146
|
+
_get_pop_bs(s, profit, inputs_data, t_ranges)
|
147
|
+
elsif inputs_data.is_a?(Models::ArrayInputs)
|
148
|
+
probability_of_reaching_target, expected_return_above_target,
|
149
|
+
probability_of_missing_target, expected_return_below_target =
|
150
|
+
_get_pop_array(inputs_data, target)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Return outputs
|
154
|
+
Models::PoPOutputs.new(
|
155
|
+
probability_of_reaching_target: probability_of_reaching_target,
|
156
|
+
probability_of_missing_target: probability_of_missing_target,
|
157
|
+
reaching_target_range: reaching_target_range,
|
158
|
+
missing_target_range: missing_target_range,
|
159
|
+
expected_return_above_target: expected_return_above_target,
|
160
|
+
expected_return_below_target: expected_return_below_target
|
161
|
+
)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Create price array for simulations
|
165
|
+
# @param inputs_data [Hash, Models::BlackScholesModelInputs, Models::LaplaceInputs] Model inputs
|
166
|
+
# @param n [Integer] Number of prices to generate
|
167
|
+
# @param seed [Integer, nil] Random seed
|
168
|
+
# @return [Numo::DFloat] Array of prices
|
169
|
+
def create_price_array(inputs_data, n: 100_000, seed: nil)
|
170
|
+
# Set random seed if provided
|
171
|
+
Kernel.srand(seed) if seed
|
172
|
+
|
173
|
+
# Convert hash to appropriate model if needed
|
174
|
+
inputs = if inputs_data.is_a?(Hash)
|
175
|
+
if %w[black-scholes normal].include?(inputs_data[:model] || inputs_data['model'])
|
176
|
+
Models::BlackScholesModelInputs.new(inputs_data)
|
177
|
+
elsif (inputs_data[:model] || inputs_data['model']) == 'laplace'
|
178
|
+
Models::LaplaceInputs.new(inputs_data)
|
179
|
+
else
|
180
|
+
raise ArgumentError, "Invalid model type!"
|
181
|
+
end
|
182
|
+
else
|
183
|
+
inputs_data
|
184
|
+
end
|
185
|
+
|
186
|
+
# Generate array based on model
|
187
|
+
arr = if inputs.is_a?(Models::BlackScholesModelInputs)
|
188
|
+
_get_array_price_from_BS(inputs, n)
|
189
|
+
elsif inputs.is_a?(Models::LaplaceInputs)
|
190
|
+
_get_array_price_from_laplace(inputs, n)
|
191
|
+
else
|
192
|
+
raise ArgumentError, "Invalid inputs type!"
|
193
|
+
end
|
194
|
+
|
195
|
+
# Reset random seed
|
196
|
+
Kernel.srand if seed
|
197
|
+
|
198
|
+
arr
|
199
|
+
end
|
200
|
+
|
201
|
+
private
|
202
|
+
|
203
|
+
# Calculate P/L of an option at expiration
|
204
|
+
# @param option_type [String] 'call' or 'put'
|
205
|
+
# @param opvalue [Float] Option price
|
206
|
+
# @param action [String] 'buy' or 'sell'
|
207
|
+
# @param s [Numo::DFloat] Array of stock prices
|
208
|
+
# @param x [Float] Strike price
|
209
|
+
# @return [Numo::DFloat] P/L profile
|
210
|
+
def _get_pl_option(option_type, opvalue, action, s, x)
|
211
|
+
if action == 'sell'
|
212
|
+
opvalue - _get_payoff(option_type, s, x)
|
213
|
+
elsif action == 'buy'
|
214
|
+
_get_payoff(option_type, s, x) - opvalue
|
215
|
+
else
|
216
|
+
raise ArgumentError, "Action must be either 'sell' or 'buy'!"
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Calculate option payoff at expiration
|
221
|
+
# @param option_type [String] 'call' or 'put'
|
222
|
+
# @param s [Numo::DFloat] Array of stock prices
|
223
|
+
# @param x [Float] Strike price
|
224
|
+
# @return [Numo::DFloat] Option payoff
|
225
|
+
def _get_payoff(option_type, s, x)
|
226
|
+
if option_type == 'call'
|
227
|
+
diff = s - x
|
228
|
+
(diff + diff.abs) / 2.0
|
229
|
+
elsif option_type == 'put'
|
230
|
+
diff = x - s
|
231
|
+
(diff + diff.abs) / 2.0
|
232
|
+
else
|
233
|
+
raise ArgumentError, "Option type must be either 'call' or 'put'!"
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Calculate P/L of a stock position
|
238
|
+
# @param s0 [Float] Spot price
|
239
|
+
# @param action [String] 'buy' or 'sell'
|
240
|
+
# @param s [Numo::DFloat] Array of stock prices
|
241
|
+
# @return [Numo::DFloat] P/L profile
|
242
|
+
def _get_pl_stock(s0, action, s)
|
243
|
+
if action == 'sell'
|
244
|
+
s0 - s
|
245
|
+
elsif action == 'buy'
|
246
|
+
s - s0
|
247
|
+
else
|
248
|
+
raise ArgumentError, "Action must be either 'sell' or 'buy'!"
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# Calculate PoP using Black-Scholes model
|
253
|
+
# @param s [Numo::DFloat] Array of stock prices
|
254
|
+
# @param profit [Numo::DFloat] Array of profits
|
255
|
+
# @param inputs [Models::BlackScholesModelInputs] Model inputs
|
256
|
+
# @param profit_range [Array<Array<Array<Float>>>] Profit and loss ranges
|
257
|
+
# @return [Array<Float, Float, Float, Float>] PoP calculation results
|
258
|
+
def _get_pop_bs(s, profit, inputs, profit_range)
|
259
|
+
# Initialize variables
|
260
|
+
expected_return_above_target = nil
|
261
|
+
expected_return_below_target = nil
|
262
|
+
probability_of_reaching_target = 0.0
|
263
|
+
probability_of_missing_target = 0.0
|
264
|
+
|
265
|
+
# Calculate sigma
|
266
|
+
sigma = inputs.volatility > 0.0 ?
|
267
|
+
inputs.volatility * Math.sqrt(inputs.years_to_target_date) : 1e-10
|
268
|
+
|
269
|
+
# Calculate PoP for each range
|
270
|
+
profit_range.each_with_index do |t, i|
|
271
|
+
prob = 0.0
|
272
|
+
|
273
|
+
if t != [[0.0, 0.0]]
|
274
|
+
t.each do |p_range|
|
275
|
+
# Calculate log values
|
276
|
+
lval = p_range[0] > 0.0 ? Math.log(p_range[0]) : -Float::INFINITY
|
277
|
+
hval = Math.log(p_range[1])
|
278
|
+
|
279
|
+
# Calculate drift and mean
|
280
|
+
drift = (
|
281
|
+
inputs.interest_rate -
|
282
|
+
inputs.dividend_yield -
|
283
|
+
0.5 * inputs.volatility * inputs.volatility
|
284
|
+
) * inputs.years_to_target_date
|
285
|
+
|
286
|
+
m = Math.log(inputs.stock_price) + drift
|
287
|
+
|
288
|
+
# Calculate probability
|
289
|
+
prob += Distribution::Normal.cdf((hval - m) / sigma) - Distribution::Normal.cdf((lval - m) / sigma)
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
if i == 0
|
294
|
+
probability_of_reaching_target = prob
|
295
|
+
else
|
296
|
+
probability_of_missing_target = prob
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
[
|
301
|
+
probability_of_reaching_target,
|
302
|
+
expected_return_above_target,
|
303
|
+
probability_of_missing_target,
|
304
|
+
expected_return_below_target
|
305
|
+
]
|
306
|
+
end
|
307
|
+
|
308
|
+
# Calculate PoP using array of terminal prices
|
309
|
+
# @param inputs [Models::ArrayInputs] Array inputs
|
310
|
+
# @param target [Float] Return target
|
311
|
+
# @return [Array<Float, Float, Float, Float>] PoP calculation results
|
312
|
+
def _get_pop_array(inputs, target)
|
313
|
+
if inputs.array.size == 0
|
314
|
+
raise ArgumentError, "The array is empty!"
|
315
|
+
end
|
316
|
+
|
317
|
+
# Split array by target
|
318
|
+
above_target = inputs.array[inputs.array >= target]
|
319
|
+
below_target = inputs.array[inputs.array < target]
|
320
|
+
|
321
|
+
# Calculate probabilities
|
322
|
+
probability_of_reaching_target = above_target.size.to_f / inputs.array.size
|
323
|
+
probability_of_missing_target = 1.0 - probability_of_reaching_target
|
324
|
+
|
325
|
+
# Calculate expected returns
|
326
|
+
expected_return_above_target = above_target.size > 0 ? above_target.mean.round(2) : nil
|
327
|
+
expected_return_below_target = below_target.size > 0 ? below_target.mean.round(2) : nil
|
328
|
+
|
329
|
+
[
|
330
|
+
probability_of_reaching_target,
|
331
|
+
expected_return_above_target,
|
332
|
+
probability_of_missing_target,
|
333
|
+
expected_return_below_target
|
334
|
+
]
|
335
|
+
end
|
336
|
+
|
337
|
+
# Find profit/loss ranges
|
338
|
+
# @param s [Numo::DFloat] Array of stock prices
|
339
|
+
# @param profit [Numo::DFloat] Array of profits
|
340
|
+
# @param target [Float] Profit target
|
341
|
+
# @return [Array<Array<Array<Float>>>] Profit and loss ranges
|
342
|
+
def _get_profit_range(s, profit, target = 0.01)
|
343
|
+
profit_range = []
|
344
|
+
loss_range = []
|
345
|
+
|
346
|
+
# Find where profit crosses target
|
347
|
+
crossings = _get_sign_changes(profit, target)
|
348
|
+
n_crossings = crossings.size
|
349
|
+
|
350
|
+
# Handle case with no crossings
|
351
|
+
if n_crossings == 0
|
352
|
+
if profit[0] >= target
|
353
|
+
return [[[0.0, Float::INFINITY]], [[0.0, 0.0]]]
|
354
|
+
else
|
355
|
+
return [[[0.0, 0.0]], [[0.0, Float::INFINITY]]]
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
# Find profit and loss ranges
|
360
|
+
lb_profit = hb_profit = nil
|
361
|
+
lb_loss = hb_loss = nil
|
362
|
+
|
363
|
+
crossings.each_with_index do |index, i|
|
364
|
+
if i == 0
|
365
|
+
if profit[index] < profit[index - 1]
|
366
|
+
lb_profit = 0.0
|
367
|
+
hb_profit = s[index - 1]
|
368
|
+
lb_loss = s[index]
|
369
|
+
|
370
|
+
hb_loss = Float::INFINITY if n_crossings == 1
|
371
|
+
else
|
372
|
+
lb_profit = s[index]
|
373
|
+
lb_loss = 0.0
|
374
|
+
hb_loss = s[index - 1]
|
375
|
+
|
376
|
+
hb_profit = Float::INFINITY if n_crossings == 1
|
377
|
+
end
|
378
|
+
elsif i == n_crossings - 1
|
379
|
+
if profit[index] > profit[index - 1]
|
380
|
+
lb_profit = s[index]
|
381
|
+
hb_profit = Float::INFINITY
|
382
|
+
hb_loss = s[index - 1]
|
383
|
+
else
|
384
|
+
hb_profit = s[index - 1]
|
385
|
+
lb_loss = s[index]
|
386
|
+
hb_loss = Float::INFINITY
|
387
|
+
end
|
388
|
+
else
|
389
|
+
if profit[index] > profit[index - 1]
|
390
|
+
lb_profit = s[index]
|
391
|
+
hb_loss = s[index - 1]
|
392
|
+
else
|
393
|
+
hb_profit = s[index - 1]
|
394
|
+
lb_loss = s[index]
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
if lb_profit && hb_profit
|
399
|
+
profit_range << [lb_profit, hb_profit]
|
400
|
+
lb_profit = hb_profit = nil
|
401
|
+
end
|
402
|
+
|
403
|
+
if lb_loss && hb_loss
|
404
|
+
loss_range << [lb_loss, hb_loss]
|
405
|
+
lb_loss = hb_loss = nil
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
[profit_range, loss_range]
|
410
|
+
end
|
411
|
+
|
412
|
+
# Find indices where profit crosses target
|
413
|
+
# @param profit [Numo::DFloat] Array of profits
|
414
|
+
# @param target [Float] Profit target
|
415
|
+
# @return [Array<Integer>] Array of indices
|
416
|
+
def _get_sign_changes(profit, target)
|
417
|
+
# Subtract target and add small epsilon
|
418
|
+
p_temp = profit - target + 1e-10
|
419
|
+
|
420
|
+
# Get signs (convert to array first since Numo::DFloat doesn't have collect)
|
421
|
+
signs_1 = p_temp[0...-1].to_a.map { |v| v > 0 ? 1 : -1 }
|
422
|
+
signs_2 = p_temp[1..-1].to_a.map { |v| v > 0 ? 1 : -1 }
|
423
|
+
|
424
|
+
# Find sign changes
|
425
|
+
changes = []
|
426
|
+
signs_1.each_with_index do |s1, i|
|
427
|
+
changes << i + 1 if s1 * signs_2[i] < 0
|
428
|
+
end
|
429
|
+
|
430
|
+
changes
|
431
|
+
end
|
432
|
+
|
433
|
+
# Generate array of prices using Black-Scholes model
|
434
|
+
# @param inputs [Models::BlackScholesModelInputs] Black-Scholes inputs
|
435
|
+
# @param n [Integer] Number of prices to generate
|
436
|
+
# @return [Numo::DFloat] Array of prices
|
437
|
+
def _get_array_price_from_BS(inputs, n)
|
438
|
+
# Calculate mean and std
|
439
|
+
mean = Math.log(inputs.stock_price) +
|
440
|
+
(inputs.interest_rate - inputs.dividend_yield - 0.5 * inputs.volatility**2) *
|
441
|
+
inputs.years_to_target_date
|
442
|
+
std = inputs.volatility * Math.sqrt(inputs.years_to_target_date)
|
443
|
+
|
444
|
+
# Generate random values
|
445
|
+
random_values = Numo::DFloat.new(n).rand_norm(0, 1)
|
446
|
+
|
447
|
+
# Apply formula
|
448
|
+
Numo::NMath.exp(mean + std * random_values)
|
449
|
+
end
|
450
|
+
|
451
|
+
# Generate array of prices using Laplace distribution
|
452
|
+
# @param inputs [Models::LaplaceInputs] Laplace inputs
|
453
|
+
# @param n [Integer] Number of prices to generate
|
454
|
+
# @return [Numo::DFloat] Array of prices
|
455
|
+
def _get_array_price_from_laplace(inputs, n)
|
456
|
+
# Calculate location and scale
|
457
|
+
location = Math.log(inputs.stock_price) + inputs.mu * inputs.years_to_target_date
|
458
|
+
scale = (inputs.volatility * Math.sqrt(inputs.years_to_target_date)) / Math.sqrt(2.0)
|
459
|
+
|
460
|
+
# Generate random values from uniform distribution
|
461
|
+
u = Numo::DFloat.new(n).rand - 0.5
|
462
|
+
|
463
|
+
# Convert to Laplace distribution
|
464
|
+
laplace_values = location - scale * u.abs.map { |v| v < 0 ? -1 : 1 } * Numo::NMath.log(1 - 2 * u.abs)
|
465
|
+
|
466
|
+
# Apply formula
|
467
|
+
Numo::NMath.exp(laplace_values)
|
468
|
+
end
|
469
|
+
end
|
470
|
+
end
|
471
|
+
end
|