Class: SQA::Portfolio

Inherits:
Object
  • Object
show all
Defined in:
lib/sqa/portfolio.rb

Defined Under Namespace

Classes: Position, Trade

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(initial_cash: 10_000.0, commission: 0.0) ⇒ Portfolio

Returns a new instance of Portfolio.



41
42
43
44
45
46
47
# File 'lib/sqa/portfolio.rb', line 41

def initialize(initial_cash: 10_000.0, commission: 0.0)
  @initial_cash = initial_cash
  @cash = initial_cash
  @commission = commission  # Commission per trade (flat fee or percentage)
  @positions = {}  # { ticker => Position }
  @trades = []     # Array of Trade objects
end

Instance Attribute Details

#cashObject

Returns the value of attribute cash.



8
9
10
# File 'lib/sqa/portfolio.rb', line 8

def cash
  @cash
end

#commissionObject

Returns the value of attribute commission.



8
9
10
# File 'lib/sqa/portfolio.rb', line 8

def commission
  @commission
end

#initial_cashObject

Returns the value of attribute initial_cash.



8
9
10
# File 'lib/sqa/portfolio.rb', line 8

def initial_cash
  @initial_cash
end

#positionsObject

Returns the value of attribute positions.



8
9
10
# File 'lib/sqa/portfolio.rb', line 8

def positions
  @positions
end

#tradesObject

Returns the value of attribute trades.



8
9
10
# File 'lib/sqa/portfolio.rb', line 8

def trades
  @trades
end

Class Method Details

.load_from_csv(filename) ⇒ Object

Load portfolio from CSV file

Parameters:

  • filename (String)

    Path to CSV file



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/sqa/portfolio.rb', line 290

def self.load_from_csv(filename)
  portfolio = new(initial_cash: 0)

  CSV.foreach(filename, headers: true) do |row|
    ticker = row['ticker']
    shares = row['shares'].to_i
    avg_cost = row['avg_cost'].to_f
    total_cost = row['total_cost'].to_f

    portfolio.positions[ticker] = Position.new(
      ticker,
      shares,
      avg_cost,
      total_cost
    )
  end

  portfolio
end

Instance Method Details

#all_positionsHash

Get all current positions

Returns:

  • (Hash)

    Hash of ticker => Position



167
168
169
# File 'lib/sqa/portfolio.rb', line 167

def all_positions
  @positions
end

#buy(ticker, shares:, price:, date: Date.today) ⇒ Trade

Buy shares of a stock

Examples:

Buy 10 shares of AAPL

portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
trade = portfolio.buy('AAPL', shares: 10, price: 150.0)
trade.action  # => :buy
trade.total   # => 1500.0
portfolio.cash  # => 8499.0 (10_000 - 1500 - 1.0 commission)

Buy multiple stocks

portfolio.buy('AAPL', shares: 10, price: 150.0)
portfolio.buy('MSFT', shares: 5, price: 300.0)
portfolio.positions.size  # => 2

Parameters:

  • ticker (String)

    Stock ticker symbol

  • shares (Integer)

    Number of shares to buy

  • price (Float)

    Price per share

  • date (Date) (defaults to: Date.today)

    Date of trade

Returns:

  • (Trade)

    The executed trade

Raises:



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/sqa/portfolio.rb', line 68

def buy(ticker, shares:, price:, date: Date.today)
  raise BadParameterError, "Shares must be positive" if shares <= 0
  raise BadParameterError, "Price must be positive" if price <= 0

  total_cost = shares * price
  commission = calculate_commission(total_cost)
  total_with_commission = total_cost + commission

  raise "Insufficient funds: need #{total_with_commission}, have #{@cash}" if total_with_commission > @cash

  # Update or create position
  if @positions[ticker]
    pos = @positions[ticker]
    total_shares = pos.shares + shares
    total_cost_basis = pos.total_cost + total_cost
    pos.shares = total_shares
    pos.avg_cost = total_cost_basis / total_shares
    pos.total_cost = total_cost_basis
  else
    @positions[ticker] = Position.new(
      ticker,
      shares,
      price,
      total_cost
    )
  end

  # Deduct cash
  @cash -= total_with_commission

  # Record trade
  trade = Trade.new(ticker, :buy, shares, price, date, total_cost, commission)
  @trades << trade

  trade
end

#position(ticker) ⇒ Position?

Get current position for a ticker

Parameters:

  • ticker (String)

    Stock ticker symbol

Returns:

  • (Position, nil)

    The position or nil if not found



161
162
163
# File 'lib/sqa/portfolio.rb', line 161

def position(ticker)
  @positions[ticker]
end

#profit_loss(current_prices = {}) ⇒ Float

Calculate total profit/loss across all positions

Parameters:

  • current_prices (Hash) (defaults to: {})

    Hash of ticker => current_price

Returns:

  • (Float)

    Total P&L



198
199
200
# File 'lib/sqa/portfolio.rb', line 198

def profit_loss(current_prices = {})
  value(current_prices) - @initial_cash
end

#profit_loss_percent(current_prices = {}) ⇒ Float

Calculate profit/loss percentage

Parameters:

  • current_prices (Hash) (defaults to: {})

    Hash of ticker => current_price

Returns:

  • (Float)

    P&L percentage



205
206
207
208
# File 'lib/sqa/portfolio.rb', line 205

def profit_loss_percent(current_prices = {})
  return 0.0 if @initial_cash.zero?
  (profit_loss(current_prices) / @initial_cash) * 100.0
end

#save_to_csv(filename) ⇒ Object

Save portfolio to CSV file

Parameters:

  • filename (String)

    Path to CSV file



260
261
262
263
264
265
266
267
# File 'lib/sqa/portfolio.rb', line 260

def save_to_csv(filename)
  CSV.open(filename, 'wb') do |csv|
    csv << ['ticker', 'shares', 'avg_cost', 'total_cost']
    @positions.each do |ticker, pos|
      csv << [ticker, pos.shares, pos.avg_cost, pos.total_cost]
    end
  end
end

#save_trades_to_csv(filename) ⇒ Object

Save trade history to CSV file

Parameters:

  • filename (String)

    Path to CSV file



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/sqa/portfolio.rb', line 271

def save_trades_to_csv(filename)
  CSV.open(filename, 'wb') do |csv|
    csv << ['date', 'ticker', 'action', 'shares', 'price', 'total', 'commission']
    @trades.each do |trade|
      csv << [
        trade.date,
        trade.ticker,
        trade.action,
        trade.shares,
        trade.price,
        trade.total,
        trade.commission
      ]
    end
  end
end

#sell(ticker, shares:, price:, date: Date.today) ⇒ Trade

Sell shares of a stock

Examples:

Sell entire position for profit

portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
portfolio.buy('AAPL', shares: 10, price: 150.0)
trade = portfolio.sell('AAPL', shares: 10, price: 160.0)
trade.total  # => 1600.0
portfolio.cash  # => 8498.0 + 1599.0 = 10097.0 (after commissions)

Partial sale

portfolio.buy('AAPL', shares: 100, price: 150.0)
portfolio.sell('AAPL', shares: 50, price: 160.0)  # Sell half
portfolio.position('AAPL').shares  # => 50

Parameters:

  • ticker (String)

    Stock ticker symbol

  • shares (Integer)

    Number of shares to sell

  • price (Float)

    Price per share

  • date (Date) (defaults to: Date.today)

    Date of trade

Returns:

  • (Trade)

    The executed trade

Raises:



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/sqa/portfolio.rb', line 124

def sell(ticker, shares:, price:, date: Date.today)
  raise BadParameterError, "Shares must be positive" if shares <= 0
  raise BadParameterError, "Price must be positive" if price <= 0
  raise "No position in #{ticker}" unless @positions[ticker]

  pos = @positions[ticker]
  raise "Insufficient shares: trying to sell #{shares}, have #{pos.shares}" if shares > pos.shares

  total_sale = shares * price
  commission = calculate_commission(total_sale)
  net_proceeds = total_sale - commission

  # Update position
  if shares == pos.shares
    # Selling entire position
    @positions.delete(ticker)
  else
    # Partial sale - reduce shares and total cost proportionally
    cost_per_share = pos.total_cost / pos.shares
    pos.shares -= shares
    pos.total_cost -= (cost_per_share * shares)
    # avg_cost stays the same
  end

  # Add cash
  @cash += net_proceeds

  # Record trade
  trade = Trade.new(ticker, :sell, shares, price, date, total_sale, commission)
  @trades << trade

  trade
end

#summary(current_prices = {}) ⇒ Hash

Get summary statistics

Examples:

Get portfolio performance summary

portfolio = SQA::Portfolio.new(initial_cash: 10_000, commission: 1.0)
portfolio.buy('AAPL', shares: 10, price: 150.0)
portfolio.sell('AAPL', shares: 5, price: 160.0)

summary = portfolio.summary({ 'AAPL' => 165.0 })
summary[:initial_cash]        # => 10_000.0
summary[:current_cash]        # => 8798.0
summary[:positions_count]     # => 1
summary[:total_value]         # => 9623.0
summary[:profit_loss_percent] # => -3.77%
summary[:total_trades]        # => 2
summary[:buy_trades]          # => 1
summary[:sell_trades]         # => 1

Parameters:

  • current_prices (Hash) (defaults to: {})

    Hash of ticker => current_price

Returns:

  • (Hash)

    Summary statistics



243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/sqa/portfolio.rb', line 243

def summary(current_prices = {})
  {
    initial_cash: @initial_cash,
    current_cash: @cash,
    positions_count: @positions.size,
    total_value: value(current_prices),
    profit_loss: profit_loss(current_prices),
    profit_loss_percent: profit_loss_percent(current_prices),
    total_return: total_return(current_prices),
    total_trades: @trades.size,
    buy_trades: @trades.count { |t| t.action == :buy },
    sell_trades: @trades.count { |t| t.action == :sell }
  }
end

#total_return(current_prices = {}) ⇒ Float

Calculate total return (including dividends if tracked)

Parameters:

  • current_prices (Hash) (defaults to: {})

    Hash of ticker => current_price

Returns:

  • (Float)

    Total return as decimal (e.g., 0.15 for 15%)



213
214
215
216
# File 'lib/sqa/portfolio.rb', line 213

def total_return(current_prices = {})
  return 0.0 if @initial_cash.zero?
  profit_loss(current_prices) / @initial_cash
end

#trade_historyArray<Trade>

Get trade history

Returns:

  • (Array<Trade>)

    Array of all trades



220
221
222
# File 'lib/sqa/portfolio.rb', line 220

def trade_history
  @trades
end

#value(current_prices = {}) ⇒ Float

Calculate total portfolio value

Examples:

Calculate portfolio value with current prices

portfolio = SQA::Portfolio.new(initial_cash: 10_000)
portfolio.buy('AAPL', shares: 10, price: 150.0)
portfolio.buy('MSFT', shares: 5, price: 300.0)

current_prices = { 'AAPL' => 160.0, 'MSFT' => 310.0 }
portfolio.value(current_prices)  # => 10_000 - 1500 - 1500 + 1600 + 1550 = 10_150

Without current prices (uses avg_cost)

portfolio.value  # Uses purchase prices if no current prices provided

Parameters:

  • current_prices (Hash) (defaults to: {})

    Hash of ticker => current_price

Returns:

  • (Float)

    Total portfolio value (cash + positions)



186
187
188
189
190
191
192
193
# File 'lib/sqa/portfolio.rb', line 186

def value(current_prices = {})
  positions_value = @positions.sum do |ticker, pos|
    current_price = current_prices[ticker] || pos.avg_cost
    pos.value(current_price)
  end

  @cash + positions_value
end