1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-08 15:05:22 +02:00

First rough pass at tracking holdings

This commit is contained in:
Josh Pigford 2024-01-02 11:56:49 -06:00
parent 0399f43252
commit 379103d229
39 changed files with 771 additions and 4 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
TWELVEDATA_KEY=YOUR_TWELVEDATA_KEY

1
.gitignore vendored
View file

@ -10,6 +10,7 @@
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
!/.env.example
# Ignore all logfiles and tempfiles.
/log/*

View file

@ -41,6 +41,10 @@ gem "bootsnap", require: false
# Authentication
gem "devise"
# Data processing
gem "good_job"
gem "faraday"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ]

View file

@ -141,9 +141,26 @@ GEM
ruby2_keywords
error_highlight (0.6.0)
erubi (1.12.0)
et-orbi (1.2.7)
tzinfo
faraday (2.8.1)
base64
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-net_http (3.0.2)
ffi (1.16.3)
fugit (1.9.0)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (3.21.5)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
fugit (>= 1.1)
railties (>= 6.0.0)
thor (>= 0.14.1)
hotwire-livereload (1.3.0)
actioncable (>= 6.0.0)
listen (>= 3.0.0)
@ -210,6 +227,7 @@ GEM
public_suffix (5.0.4)
puma (6.4.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.7.3)
rack (3.0.8)
rack-session (2.0.0)
@ -334,6 +352,8 @@ DEPENDENCIES
debug
devise
error_highlight (>= 0.4.0)
faraday
good_job
hotwire-livereload
importmap-rails
jbuilder

View file

@ -20,6 +20,7 @@ You'll need:
- ruby >3 (specific version is in `Gemfile`)
- postgresql (if using stock `config/database.yml`)
- TwelveData API key (free) - https://twelvedata.com
```shell
cd maybe

View file

@ -0,0 +1,85 @@
class HoldingsController < ApplicationController
before_action :authenticate_user!
before_action :set_holding, only: %i[ show edit update destroy ]
# GET /holdings or /holdings.json
def index
@holdings = Holding.all
end
# GET /holdings/1 or /holdings/1.json
def show
end
# GET /holdings/new
def new
@portfolio = current_user.portfolios.find(params[:portfolio_id])
@holding = @portfolio.holdings.new
end
# GET /holdings/1/edit
def edit
end
# POST /holdings or /holdings.json
def create
@portfolio = current_user.portfolios.find(params[:portfolio_id])
security = Security.find_by(symbol: params[:holding][:symbol])
if security.nil?
security = Security.create(symbol: params[:holding][:symbol])
SyncSecurityJob.perform_later(security.id)
end
params[:holding].delete(:symbol)
@holding = @portfolio.holdings.new(holding_params)
@holding.security_id = security.id
@holding.user_id = current_user.id
respond_to do |format|
if @holding.save
format.html { redirect_to portfolio_path(@portfolio), notice: "Holding was successfully created." }
format.json { render :show, status: :created, location: @holding }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @holding.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /holdings/1 or /holdings/1.json
def update
respond_to do |format|
if @holding.update(holding_params)
format.html { redirect_to holding_url(@holding), notice: "Holding was successfully updated." }
format.json { render :show, status: :ok, location: @holding }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @holding.errors, status: :unprocessable_entity }
end
end
end
# DELETE /holdings/1 or /holdings/1.json
def destroy
@holding.destroy!
respond_to do |format|
format.html { redirect_to holdings_url, notice: "Holding was successfully destroyed." }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_holding
@portfolio = current_user.portfolios.find(params[:portfolio_id])
@holding = @portfolio.holdings.find(params[:id])
end
# Only allow a list of trusted parameters through.
def holding_params
params.require(:holding).permit(:symbol, :security_id, :value, :quantity, :cost_basis)
end
end

View file

@ -0,0 +1,2 @@
module HoldingsHelper
end

View file

@ -0,0 +1,45 @@
class SyncSecurityJob < ApplicationJob
queue_as :default
def perform(security_id)
security = Security.find_by(id: security_id)
return unless security
profile_data = Faraday.get("https://api.twelvedata.com/profile?symbol=#{security.symbol}&apikey=#{ENV['TWELVEDATA_KEY']}")
profile_details = JSON.parse(profile_data.body)
security.update(
name: profile_details['name'],
exchange: profile_details['exchange'],
mic_code: profile_details['mic_code']
)
# Pull price history
earliest_date_connection = Faraday.get("https://api.twelvedata.com/earliest_timestamp?symbol=#{security.symbol}&interval=1day&apikey=#{ENV['TWELVEDATA_KEY']}")
earliest_date = JSON.parse(earliest_date_connection.body)['datetime']
prices_connection = Faraday.get("https://api.twelvedata.com/time_series?apikey=#{ENV['TWELVEDATA_KEY']}&interval=1day&symbol=#{security.symbol}&start_date=#{earliest_date}&outputsize=5000")
prices = JSON.parse(prices_connection.body)['values']
all_prices = []
prices.each do |price|
all_prices << {
security_id: security.id,
date: price['datetime'],
open: price['open'],
high: price['high'],
low: price['low'],
close: price['close'],
}
end
all_prices.uniq! { |price| price[:date] }
SecurityPrice.upsert_all(all_prices, unique_by: :index_security_prices_on_security_id_and_date)
security.update(last_synced_at: DateTime.now)
end
end

5
app/models/holding.rb Normal file
View file

@ -0,0 +1,5 @@
class Holding < ApplicationRecord
belongs_to :user
belongs_to :security
belongs_to :portfolio
end

View file

@ -1,5 +1,6 @@
class Portfolio < ApplicationRecord
belongs_to :user
has_many :holdings, dependent: :destroy
validates :name, presence: true
end

5
app/models/security.rb Normal file
View file

@ -0,0 +1,5 @@
class Security < ApplicationRecord
has_many :holdings, dependent: :destroy
has_many :portfolios, through: :holdings
has_many :security_prices, dependent: :destroy
end

View file

@ -0,0 +1,3 @@
class SecurityPrice < ApplicationRecord
belongs_to :security
end

View file

@ -4,4 +4,5 @@ class User < ApplicationRecord
:confirmable, :lockable, :timeoutable, :trackable
has_many :portfolios, dependent: :destroy
has_many :holdings, through: :portfolios
end

View file

@ -0,0 +1,37 @@
<%= form_with(model: [portfolio, holding], class: "contents") do |form| %>
<% if holding.errors.any? %>
<div id="error_explanation" class="px-3 py-2 mt-3 font-medium text-red-500 rounded-lg bg-red-50">
<h2><%= pluralize(holding.errors.count, "error") %> prohibited this holding from being saved:</h2>
<ul>
<% holding.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5">
<%= form.label :symbol, "Security (AAPL, MSFT, TSLA, etc.)" %>
<%= form.text_field :symbol, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :value, "Total holding value (in USD)" %>
<%= form.number_field :value, step: :any, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :quantity, "Number of shares" %>
<%= form.number_field :quantity, step: :any, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :cost_basis, "Cost basis (in USD)" %>
<%= form.number_field :cost_basis, step: :any, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>

View file

@ -0,0 +1,32 @@
<div id="<%= dom_id holding %>">
<p class="my-5">
<strong class="block font-medium mb-1">User:</strong>
<%= holding.user_id %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Security:</strong>
<%= holding.security_id %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Value:</strong>
<%= holding.value %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Quantity:</strong>
<%= holding.quantity %>
</p>
<p class="my-5">
<strong class="block font-medium mb-1">Cost basis:</strong>
<%= holding.cost_basis %>
</p>
<% if action_name != "show" %>
<%= link_to "Show this holding", holding, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Edit this holding", edit_holding_path(holding), class: "rounded-lg py-3 ml-2 px-5 bg-gray-100 inline-block font-medium" %>
<hr class="mt-6">
<% end %>
</div>

View file

@ -0,0 +1,8 @@
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl">Editing holding</h1>
<%= render "form", holding: @holding %>
<%= link_to "Show this holding", @holding, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<%= link_to "Back to holdings", holdings_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View file

@ -0,0 +1,14 @@
<div class="w-full">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Holdings</h1>
<%= link_to "New holding", new_holding_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
</div>
<div id="holdings" class="min-w-full">
<%= render @holdings %>
</div>
</div>

View file

@ -0,0 +1,7 @@
<div class="w-full mx-auto md:w-2/3">
<h1 class="text-4xl font-bold">New holding</h1>
<%= render "form", portfolio: @portfolio, holding: @holding %>
<%= link_to "Back to holdings", portfolio_path(@portfolio), class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>

View file

@ -0,0 +1,15 @@
<div class="mx-auto md:w-2/3 w-full flex">
<div class="mx-auto">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<%= render @holding %>
<%= link_to "Edit this holding", edit_holding_path(@holding), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<div class="inline-block ml-2">
<%= button_to "Destroy this holding", holding_path(@holding), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
</div>
<%= link_to "Back to holdings", holdings_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>
</div>

View file

@ -1,4 +1,4 @@
<div class="flex w-full">
<div class="w-full">
<% if notice.present? %>
<p class="inline-block px-3 py-2 mb-5 font-medium text-green-500 rounded-lg bg-green-50" id="notice"><%= notice %></p>
<% end %>
@ -14,4 +14,36 @@
<%= link_to "Back to portfolios", portfolios_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
</div>
</div>
<div>
<h3 class="text-2xl font-bold">Holdings</h3>
<div class="inline-block ml-2">
<%= link_to "New holding", new_portfolio_holding_path(@portfolio), class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
</div>
<table class="w-full mt-4 table-auto">
<thead>
<tr>
<th class="px-4 py-2">Security</th>
<th class="px-4 py-2">Quantity</th>
<th class="px-4 py-2">Current Price</th>
<th class="px-4 py-2">Current Value</th>
<th class="px-4 py-2">Cost Basis</th>
<th class="px-4 py-2">Gain/Loss</th>
</tr>
</thead>
<tbody>
<% @portfolio.holdings.each do |holding| %>
<tr>
<td class="px-4 py-2 border"><%= holding.security.symbol %></td>
<td class="px-4 py-2 border"><%= holding.quantity %></td>
<td class="px-4 py-2 border"><%= number_to_currency(holding.security.security_prices.order(date: :desc).first&.close) %></td>
<td class="px-4 py-2 border"><%= number_to_currency(holding.quantity * (holding.security.security_prices.order(date: :desc).first&.close || 0)) %></td>
<td class="px-4 py-2 border"><%= number_to_currency(holding.cost_basis) %></td>
<td class="px-4 py-2 border"><%= number_to_currency((holding.quantity * (holding.security.security_prices.order(date: :desc).first&.close || 0)) - holding.cost_basis) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

View file

@ -23,5 +23,7 @@ module Maybe
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
config.active_job.queue_adapter = :good_job
end
end

View file

@ -1,6 +1,12 @@
Rails.application.routes.draw do
resources :portfolios
authenticate :user, ->(user) { user.admin? } do
mount GoodJob::Engine => 'background'
end
resources :portfolios do
resources :holdings
end
devise_for :users
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.

View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
class CreateGoodJobs < ActiveRecord::Migration[7.2]
def change
# Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support
# enable_extension 'pgcrypto'
create_table :good_jobs, id: :uuid do |t|
t.text :queue_name
t.integer :priority
t.jsonb :serialized_params
t.datetime :scheduled_at
t.datetime :performed_at
t.datetime :finished_at
t.text :error
t.timestamps
t.uuid :active_job_id
t.text :concurrency_key
t.text :cron_key
t.uuid :retried_good_job_id
t.datetime :cron_at
t.uuid :batch_id
t.uuid :batch_callback_id
t.boolean :is_discrete
t.integer :executions_count
t.text :job_class
t.integer :error_event, limit: 2
end
create_table :good_job_batches, id: :uuid do |t|
t.timestamps
t.text :description
t.jsonb :serialized_properties
t.text :on_finish
t.text :on_success
t.text :on_discard
t.text :callback_queue_name
t.integer :callback_priority
t.datetime :enqueued_at
t.datetime :discarded_at
t.datetime :finished_at
end
create_table :good_job_executions, id: :uuid do |t|
t.timestamps
t.uuid :active_job_id, null: false
t.text :job_class
t.text :queue_name
t.jsonb :serialized_params
t.datetime :scheduled_at
t.datetime :finished_at
t.text :error
t.integer :error_event, limit: 2
end
create_table :good_job_processes, id: :uuid do |t|
t.timestamps
t.jsonb :state
end
create_table :good_job_settings, id: :uuid do |t|
t.timestamps
t.text :key
t.jsonb :value
t.index :key, unique: true
end
add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at
add_index :good_jobs, [ :queue_name, :scheduled_at ], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at
add_index :good_jobs, [ :active_job_id, :created_at ], name: :index_good_jobs_on_active_job_id_and_created_at
add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished
add_index :good_jobs, [ :cron_key, :created_at ], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond
add_index :good_jobs, [ :cron_key, :cron_at ], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond
add_index :good_jobs, [ :active_job_id ], name: :index_good_jobs_on_active_job_id
add_index :good_jobs, [ :finished_at ], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at
add_index :good_jobs, [ :priority, :created_at ], order: { priority: "DESC NULLS LAST", created_at: :asc },
where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
add_index :good_jobs, [ :batch_id ], where: "batch_id IS NOT NULL"
add_index :good_jobs, [ :batch_callback_id ], where: "batch_callback_id IS NOT NULL"
add_index :good_job_executions, [ :active_job_id, :created_at ], name: :index_good_job_executions_on_active_job_id_and_created_at
end
end

View file

@ -0,0 +1,5 @@
class AddAdminToUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :admin, :boolean, default: false
end
end

View file

@ -0,0 +1,15 @@
class CreateSecurities < ActiveRecord::Migration[7.2]
def change
create_table :securities, id: :uuid do |t|
t.string :name
t.string :symbol
t.string :exchange
t.string :mic_code
t.string :currency_code
t.decimal :real_time_price, precision: 10, scale: 2
t.datetime :real_time_price_updated_at
t.timestamps
end
end
end

View file

@ -0,0 +1,14 @@
class CreateSecurityPrices < ActiveRecord::Migration[7.2]
def change
create_table :security_prices, id: :uuid do |t|
t.references :security, null: false, foreign_key: true, type: :uuid
t.date :date, null: false
t.decimal :open, precision: 20, scale: 11
t.decimal :high, precision: 20, scale: 11
t.decimal :low, precision: 20, scale: 11
t.decimal :close, precision: 20, scale: 11
t.timestamps
end
end
end

View file

@ -0,0 +1,14 @@
class CreateHoldings < ActiveRecord::Migration[7.2]
def change
create_table :holdings, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.references :security, null: false, foreign_key: true, type: :uuid
t.references :portfolio, null: false, foreign_key: true, type: :uuid
t.decimal :value, precision: 19, scale: 4
t.decimal :quantity, precision: 36, scale: 18
t.decimal :cost_basis, precision: 23, scale: 8
t.timestamps
end
end
end

View file

@ -0,0 +1,5 @@
class AddLastSyncedAtToSecurities < ActiveRecord::Migration[7.2]
def change
add_column :securities, :last_synced_at, :datetime
end
end

View file

@ -0,0 +1,5 @@
class AddSecurityDateIndexToSecurityPrices < ActiveRecord::Migration[7.2]
def change
add_index :security_prices, [:security_id, :date], unique: true
end
end

124
db/schema.rb generated
View file

@ -10,11 +10,102 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_01_02_020519) do
ActiveRecord::Schema[7.2].define(version: 2024_01_02_174350) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "description"
t.jsonb "serialized_properties"
t.text "on_finish"
t.text "on_success"
t.text "on_discard"
t.text "callback_queue_name"
t.integer "callback_priority"
t.datetime "enqueued_at"
t.datetime "discarded_at"
t.datetime "finished_at"
end
create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "active_job_id", null: false
t.text "job_class"
t.text "queue_name"
t.jsonb "serialized_params"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.text "error"
t.integer "error_event", limit: 2
t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at"
end
create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "state"
end
create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "key"
t.jsonb "value"
t.index ["key"], name: "index_good_job_settings_on_key", unique: true
end
create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.text "queue_name"
t.integer "priority"
t.jsonb "serialized_params"
t.datetime "scheduled_at"
t.datetime "performed_at"
t.datetime "finished_at"
t.text "error"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.uuid "active_job_id"
t.text "concurrency_key"
t.text "cron_key"
t.uuid "retried_good_job_id"
t.datetime "cron_at"
t.uuid "batch_id"
t.uuid "batch_callback_id"
t.boolean "is_discrete"
t.integer "executions_count"
t.text "job_class"
t.integer "error_event", limit: 2
t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at"
t.index ["active_job_id"], name: "index_good_jobs_on_active_job_id"
t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)"
t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)"
t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)"
t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)"
t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)"
t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))"
t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)"
t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)"
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.uuid "security_id", null: false
t.uuid "portfolio_id", null: false
t.decimal "value", precision: 19, scale: 4
t.decimal "quantity", precision: 36, scale: 18
t.decimal "cost_basis", precision: 23, scale: 8
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["portfolio_id"], name: "index_holdings_on_portfolio_id"
t.index ["security_id"], name: "index_holdings_on_security_id"
t.index ["user_id"], name: "index_holdings_on_user_id"
end
create_table "portfolios", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.string "name"
@ -23,6 +114,32 @@ ActiveRecord::Schema[7.2].define(version: 2024_01_02_020519) do
t.index ["user_id"], name: "index_portfolios_on_user_id"
end
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "name"
t.string "symbol"
t.string "exchange"
t.string "mic_code"
t.string "currency_code"
t.decimal "real_time_price", precision: 10, scale: 2
t.datetime "real_time_price_updated_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "last_synced_at"
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "security_id", null: false
t.date "date", null: false
t.decimal "open", precision: 20, scale: 11
t.decimal "high", precision: 20, scale: 11
t.decimal "low", precision: 20, scale: 11
t.decimal "close", precision: 20, scale: 11
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["security_id", "date"], name: "index_security_prices_on_security_id_and_date", unique: true
t.index ["security_id"], name: "index_security_prices_on_security_id"
end
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@ -43,11 +160,16 @@ ActiveRecord::Schema[7.2].define(version: 2024_01_02_020519) do
t.datetime "locked_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "admin", default: false
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
end
add_foreign_key "holdings", "portfolios"
add_foreign_key "holdings", "securities"
add_foreign_key "holdings", "users"
add_foreign_key "portfolios", "users"
add_foreign_key "security_prices", "securities"
end

View file

@ -0,0 +1,48 @@
require "test_helper"
class HoldingsControllerTest < ActionDispatch::IntegrationTest
setup do
@holding = holdings(:one)
end
test "should get index" do
get holdings_url
assert_response :success
end
test "should get new" do
get new_holding_url
assert_response :success
end
test "should create holding" do
assert_difference("Holding.count") do
post holdings_url, params: { holding: { cost_basis: @holding.cost_basis, quantity: @holding.quantity, security_id: @holding.security_id, user_id: @holding.user_id, value: @holding.value } }
end
assert_redirected_to holding_url(Holding.last)
end
test "should show holding" do
get holding_url(@holding)
assert_response :success
end
test "should get edit" do
get edit_holding_url(@holding)
assert_response :success
end
test "should update holding" do
patch holding_url(@holding), params: { holding: { cost_basis: @holding.cost_basis, quantity: @holding.quantity, security_id: @holding.security_id, user_id: @holding.user_id, value: @holding.value } }
assert_redirected_to holding_url(@holding)
end
test "should destroy holding" do
assert_difference("Holding.count", -1) do
delete holding_url(@holding)
end
assert_redirected_to holdings_url
end
end

15
test/fixtures/holdings.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
security: one
value: 9.99
quantity: 9.99
cost_basis: 9.99
two:
user: two
security: two
value: 9.99
quantity: 9.99
cost_basis: 9.99

15
test/fixtures/securities.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
symbol: MyString
currency_code: MyString
real_time_price: 9.99
real_time_price_updated_at: 2024-01-02 10:27:07
two:
name: MyString
symbol: MyString
currency_code: MyString
real_time_price: 9.99
real_time_price_updated_at: 2024-01-02 10:27:07

17
test/fixtures/security_prices.yml vendored Normal file
View file

@ -0,0 +1,17 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
security: one
date: 2024-01-02
open: 9.99
high: 9.99
low: 9.99
close: 9.99
two:
security: two
date: 2024-01-02
open: 9.99
high: 9.99
low: 9.99
close: 9.99

View file

@ -0,0 +1,7 @@
require "test_helper"
class SyncSecurityJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class HoldingTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class SecurityPriceTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class SecurityTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,49 @@
require "application_system_test_case"
class HoldingsTest < ApplicationSystemTestCase
setup do
@holding = holdings(:one)
end
test "visiting the index" do
visit holdings_url
assert_selector "h1", text: "Holdings"
end
test "should create holding" do
visit holdings_url
click_on "New holding"
fill_in "Cost basis", with: @holding.cost_basis
fill_in "Quantity", with: @holding.quantity
fill_in "Security", with: @holding.security_id
fill_in "User", with: @holding.user_id
fill_in "Value", with: @holding.value
click_on "Create Holding"
assert_text "Holding was successfully created"
click_on "Back"
end
test "should update Holding" do
visit holding_url(@holding)
click_on "Edit this holding", match: :first
fill_in "Cost basis", with: @holding.cost_basis
fill_in "Quantity", with: @holding.quantity
fill_in "Security", with: @holding.security_id
fill_in "User", with: @holding.user_id
fill_in "Value", with: @holding.value
click_on "Update Holding"
assert_text "Holding was successfully updated"
click_on "Back"
end
test "should destroy Holding" do
visit holding_url(@holding)
click_on "Destroy this holding", match: :first
assert_text "Holding was successfully destroyed"
end
end