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:
parent
0399f43252
commit
379103d229
39 changed files with 771 additions and 4 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
@ -0,0 +1 @@
|
|||
TWELVEDATA_KEY=YOUR_TWELVEDATA_KEY
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -10,6 +10,7 @@
|
|||
# Ignore all environment files (except templates).
|
||||
/.env*
|
||||
!/.env*.erb
|
||||
!/.env.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -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 ]
|
||||
|
|
20
Gemfile.lock
20
Gemfile.lock
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
85
app/controllers/holdings_controller.rb
Normal file
85
app/controllers/holdings_controller.rb
Normal 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
|
2
app/helpers/holdings_helper.rb
Normal file
2
app/helpers/holdings_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module HoldingsHelper
|
||||
end
|
45
app/jobs/sync_security_job.rb
Normal file
45
app/jobs/sync_security_job.rb
Normal 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
5
app/models/holding.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class Holding < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :security
|
||||
belongs_to :portfolio
|
||||
end
|
|
@ -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
5
app/models/security.rb
Normal 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
|
3
app/models/security_price.rb
Normal file
3
app/models/security_price.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class SecurityPrice < ApplicationRecord
|
||||
belongs_to :security
|
||||
end
|
|
@ -4,4 +4,5 @@ class User < ApplicationRecord
|
|||
:confirmable, :lockable, :timeoutable, :trackable
|
||||
|
||||
has_many :portfolios, dependent: :destroy
|
||||
has_many :holdings, through: :portfolios
|
||||
end
|
||||
|
|
37
app/views/holdings/_form.html.erb
Normal file
37
app/views/holdings/_form.html.erb
Normal 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 %>
|
32
app/views/holdings/_holding.html.erb
Normal file
32
app/views/holdings/_holding.html.erb
Normal 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>
|
8
app/views/holdings/edit.html.erb
Normal file
8
app/views/holdings/edit.html.erb
Normal 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>
|
14
app/views/holdings/index.html.erb
Normal file
14
app/views/holdings/index.html.erb
Normal 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>
|
7
app/views/holdings/new.html.erb
Normal file
7
app/views/holdings/new.html.erb
Normal 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>
|
15
app/views/holdings/show.html.erb
Normal file
15
app/views/holdings/show.html.erb
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
88
db/migrate/20240102161615_create_good_jobs.rb
Normal file
88
db/migrate/20240102161615_create_good_jobs.rb
Normal 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
|
5
db/migrate/20240102162023_add_admin_to_users.rb
Normal file
5
db/migrate/20240102162023_add_admin_to_users.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddAdminToUsers < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :users, :admin, :boolean, default: false
|
||||
end
|
||||
end
|
15
db/migrate/20240102162707_create_securities.rb
Normal file
15
db/migrate/20240102162707_create_securities.rb
Normal 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
|
14
db/migrate/20240102163116_create_security_prices.rb
Normal file
14
db/migrate/20240102163116_create_security_prices.rb
Normal 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
|
14
db/migrate/20240102164204_create_holdings.rb
Normal file
14
db/migrate/20240102164204_create_holdings.rb
Normal 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
|
|
@ -0,0 +1,5 @@
|
|||
class AddLastSyncedAtToSecurities < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :securities, :last_synced_at, :datetime
|
||||
end
|
||||
end
|
|
@ -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
124
db/schema.rb
generated
|
@ -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
|
||||
|
|
48
test/controllers/holdings_controller_test.rb
Normal file
48
test/controllers/holdings_controller_test.rb
Normal 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
15
test/fixtures/holdings.yml
vendored
Normal 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
15
test/fixtures/securities.yml
vendored
Normal 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
17
test/fixtures/security_prices.yml
vendored
Normal 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
|
7
test/jobs/sync_security_job_test.rb
Normal file
7
test/jobs/sync_security_job_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class SyncSecurityJobTest < ActiveJob::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/models/holding_test.rb
Normal file
7
test/models/holding_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class HoldingTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/models/security_price_test.rb
Normal file
7
test/models/security_price_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class SecurityPriceTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/models/security_test.rb
Normal file
7
test/models/security_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class SecurityTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
49
test/system/holdings_test.rb
Normal file
49
test/system/holdings_test.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue