I was in Chicago this week. I was given the opportunity to attend a conference this fall, and I picked RubyConf'24. Since taking a new job in July, I am back developing code part of my time. My entire stint with NextEra Energy (over five years), I did not actively develop code that was destine for a production environment. That was one of the Faustian Bargains I had to make when accepting that job in 2019: I would not be writing software, I would be dedicating my time to managing people and projects. Prior to NextEra, I was at the Institute for Social Research and Data Innovation, and it was a Ruby shop. I was hired there in 2012 as a senior Ruby developer. And that's what I primarily did: write Ruby code. NextEra Analytics was a Kotlin/NodeJS/Python shop; there was no place for Ruby.

But, I am back, actively looking through and developing software, in Ruby, no less. So, on Wednesday of this week, I found myself sitting in the audience at Hilton Chicago, listening and watching the creator of the Ruby language, Yukihiro Matsumoto give the keynote. It was a broad based overview of Ruby, where it came from, highlights over the years and a bit of a vision on where Mats sees it going. I had forgotten how much I like writing Ruby. It is a real joy.

So, I took a little time to write some Ruby. I settled on a topic that I wrote extensively about earlier in the year: pricing stock options. Most of that work was done in Rust and I investigated different numerical methods for pricing options. I did not go that far with my Ruby, in fact, I settled on implementing Black-Scholes, which, in its basic form, is only useful for pricing European-style options. Black-Scholes does not account for the early exercise American-style options.

# frozen_string_literal: true

module BlackScholes
  module_function

  OptionType = Struct.new(:name)
  CALL = OptionType.new(:call)
  PUT = OptionType.new(:put)

  def calculate(type:, stock_price:, strike_price:, time_to_expiry:, risk_free_rate:, volatility:)
    params = {
      type: type,
      stock_price: stock_price,
      strike_price: strike_price,
      time_to_expiry: time_to_expiry,
      risk_free_rate: risk_free_rate,
      volatility: volatility
    }
    validate_params(params)

    d1, d2 = calculate_d1_d2(stock_price, strike_price, time_to_expiry, risk_free_rate, volatility)
    discount_factor = Math.exp(-risk_free_rate * time_to_expiry)

    option_price = case type
    when CALL
      stock_price * norm_cdf(d1) - strike_price * discount_factor * norm_cdf(d2)
    when PUT
      strike_price * discount_factor * norm_cdf(-d2) - stock_price * norm_cdf(-d1)
    else
      raise ArgumentError, "Invalid option type: #{type}"
    end

    option_price.round(6)
  end

  def option_calculator_for(type)
    ->(params) { calculate(type: type, **params) }
  end

  def calculate_d1_d2(s, k, t, r, sigma)
    components = [
      -> { Math.log(s / k) },
      -> { r * t },
      -> { (sigma**2 / 2) * t },
      -> { sigma * Math.sqrt(t) }
    ]

    d1 = components.take(3).sum(&:call) / components.last.call
    d2 = d1 - components.last.call

    [d1, d2]
  end

  def norm_cdf(x)
    (1 + Math.erf(x / Math.sqrt(2))) / 2
  end

  def validate_params(params)
    required_params = %i[type stock_price strike_price time_to_expiry risk_free_rate volatility]
    numeric_params = required_params - [:type]

    missing_params = required_params - params.keys
    raise ArgumentError, "Missing parameters: #{missing_params.join(', ')}" unless missing_params.empty?

    params.each do |key, value|
      case [key, value]
      in [:type, OptionType]
        next
      in [param, Numeric => n] if numeric_params.include?(param)
        raise ArgumentError, "#{param} must be positive" unless n.positive?
      in [param, _] if numeric_params.include?(param)
        raise ArgumentError, "#{param} must be a positive number"
      else
        raise ArgumentError, "Invalid parameter: #{key}"
      end
    end
  end

end

# Usage example
option_params = {
  stock_price: 100,
  strike_price: 100,
  time_to_expiry: 1,
  risk_free_rate: 0.05,
  volatility: 0.2
}

[BlackScholes::CALL, BlackScholes::PUT].each do |option_type|
  price = BlackScholes.calculate(type: option_type, **option_params)
  puts "#{option_type.name.capitalize} Option Price: #{price}"
end
(base) alex:~/projects/ruby-3.3.6-bin$ ./bin/ruby bs3.rb 
Call Option Price: 10.450584
Put Option Price: 5.573526

Here are some of Ruby's most notable and unique features:

1. Everything is an Object

  • True: In Ruby, every entity (including primitives like numbers, booleans, nil, and even classes) is an object. This means all these entities have methods and can respond to messages. For example, you can call the to_s method on any object to get its string representation.
5.to_s   # Returns "5"
nil.to_s # Returns ""

2. Anonymous Functions (Blocks, Procs, and Lambdas)

  • Supported: Ruby offers several forms of anonymous functions, each with slightly different behaviors:
  • Blocks: Not objects by themselves but can be converted to Proc objects. They are defined with do...end or {...} and are commonly used with methods that yield.
[1, 2, 3].each do |element|
    puts element
end
  • Procs: Are objects and can be defined using Proc.new or the proc keyword. They have lenient argument handling (ignoring extra args).
my_proc = proc { |a, b| puts a + b }
my_proc.call(2, 3) # Outputs: 5
  • Lambdas: Also objects, defined with the -> syntax or lambda keyword. They enforce argument counts strictly.
my_lambda = ->(a, b) { puts a + b }
my_lambda.call(2, 3) # Outputs: 5
3. Open Classes
  • Ruby allows you to reopen and modify classes even after they've been defined. This is useful for adding functionality to core classes or third-party libraries.
class String
    def greet
    "Hello, #{self}!"
    end
end

"World".greet # Returns "Hello, World!"

This is sometimes referred to as monkey patching.

4. Metaprogramming
  • Ruby's syntax and nature make it extremely conducive to metaprogramming techniques, allowing for dynamic creation and modification of code at runtime.
5. Dynamic Typing
  • Variables in Ruby do not have explicit types. The data type of a variable is determined at runtime, making the language very flexible but also potentially error-prone if not managed carefully.
6. Modules (Mixins)
  • Modules are similar to classes but cannot be instantiated. They're primarily used for namespacing and as mixins to provide a way to compose a class's behavior from multiple sources.
module Greetable
    def greet
    "Hello!"
    end
end

class Person
    include Greetable
end

Person.new.greet # Returns "Hello!"