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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +139 -0
  4. data/.yard/hooks/before_generate.rb +7 -0
  5. data/.yardopts +11 -0
  6. data/Gemfile +26 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +180 -0
  9. data/Rakefile +44 -0
  10. data/docs/OptionLab/BinomialTree.html +1271 -0
  11. data/docs/OptionLab/BjerksundStensland.html +2022 -0
  12. data/docs/OptionLab/BlackScholes.html +2388 -0
  13. data/docs/OptionLab/Engine.html +1716 -0
  14. data/docs/OptionLab/Models/AmericanModelInputs.html +937 -0
  15. data/docs/OptionLab/Models/ArrayInputs.html +463 -0
  16. data/docs/OptionLab/Models/BaseModel.html +223 -0
  17. data/docs/OptionLab/Models/BinomialModelInputs.html +1161 -0
  18. data/docs/OptionLab/Models/BlackScholesInfo.html +967 -0
  19. data/docs/OptionLab/Models/BlackScholesModelInputs.html +851 -0
  20. data/docs/OptionLab/Models/ClosedPosition.html +445 -0
  21. data/docs/OptionLab/Models/EngineData.html +2523 -0
  22. data/docs/OptionLab/Models/EngineDataResults.html +435 -0
  23. data/docs/OptionLab/Models/Inputs.html +2241 -0
  24. data/docs/OptionLab/Models/LaplaceInputs.html +777 -0
  25. data/docs/OptionLab/Models/Option.html +736 -0
  26. data/docs/OptionLab/Models/Outputs.html +1753 -0
  27. data/docs/OptionLab/Models/PoPOutputs.html +645 -0
  28. data/docs/OptionLab/Models/PricingResult.html +848 -0
  29. data/docs/OptionLab/Models/Stock.html +583 -0
  30. data/docs/OptionLab/Models/TreeVisualization.html +688 -0
  31. data/docs/OptionLab/Models.html +251 -0
  32. data/docs/OptionLab/Plotting.html +548 -0
  33. data/docs/OptionLab/Support.html +2884 -0
  34. data/docs/OptionLab/Utils.html +619 -0
  35. data/docs/OptionLab.html +133 -0
  36. data/docs/_index.html +376 -0
  37. data/docs/class_list.html +54 -0
  38. data/docs/css/common.css +1 -0
  39. data/docs/css/full_list.css +58 -0
  40. data/docs/css/style.css +503 -0
  41. data/docs/file.LICENSE.html +70 -0
  42. data/docs/file.README.html +263 -0
  43. data/docs/file_list.html +64 -0
  44. data/docs/frames.html +22 -0
  45. data/docs/index.html +263 -0
  46. data/docs/js/app.js +344 -0
  47. data/docs/js/full_list.js +242 -0
  48. data/docs/js/jquery.js +4 -0
  49. data/docs/method_list.html +1974 -0
  50. data/docs/top-level-namespace.html +110 -0
  51. data/examples/american_options.rb +163 -0
  52. data/examples/covered_call.rb +76 -0
  53. data/lib/option_lab/binomial_tree.rb +238 -0
  54. data/lib/option_lab/bjerksund_stensland.rb +276 -0
  55. data/lib/option_lab/black_scholes.rb +323 -0
  56. data/lib/option_lab/engine.rb +492 -0
  57. data/lib/option_lab/models.rb +768 -0
  58. data/lib/option_lab/plotting.rb +182 -0
  59. data/lib/option_lab/support.rb +471 -0
  60. data/lib/option_lab/utils.rb +107 -0
  61. data/lib/option_lab/version.rb +5 -0
  62. data/lib/option_lab.rb +134 -0
  63. data/option_lab.gemspec +43 -0
  64. 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