Skip to content

Commit

Permalink
Insider trades (#196)
Browse files Browse the repository at this point in the history
* First pass an insider trading tracker

* clean up routes

* Add support for insider trades and recent insider trades

* Refactor adding rows to table body in insider trades controller

* Refactor insider trades filtering and API call handling

* Update insider trades limit to 250 & start date to 180 days ago, plus some UI tweaks

* Update insider trading limits and cache expiration times

* Update cached content to be HTML safe and add action_name attribute

* Add Cross-Site Scripting warning to brakeman.ignore
  • Loading branch information
Shpigford authored Nov 20, 2024
1 parent 128e703 commit ce3cde7
Show file tree
Hide file tree
Showing 15 changed files with 623 additions and 4 deletions.
2 changes: 1 addition & 1 deletion app/controllers/stocks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def show
end

if cached = Rails.cache.read("stock_page/#{@stock.symbol}:#{@stock.mic_code}")
@cached_content = cached
@cached_content = cached.html_safe
end

redirect_to stocks_path unless @stock && @stock.country_code.present?
Expand Down
6 changes: 4 additions & 2 deletions app/controllers/tools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ def index
end

def show
@tool = Tool.presenter_from tool_params.compact_blank
@tool = Tool.presenter_from tool_params.compact_blank.merge(action_name: action_name)
end

private
Expand All @@ -31,6 +31,8 @@ def tool_params
# Stock Portfolio Backtest
:benchmark_stock, :investment_amount, :start_date, :end_date, { stocks: [], stock_allocations: [] },
# Exchange Rate Calculator
:amount, :from_currency, :to_currency
:amount, :from_currency, :to_currency,
# Insider Trading Tracker
:symbol, :filter
end
end
17 changes: 17 additions & 0 deletions app/javascript/controllers/insider_form_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["input"]

connect() {
}

submit(event) {
const symbol = event.target.value.split(' ')[0]

if (symbol) {
const url = `/tools/inside-trading-tracker/${symbol.toUpperCase()}`
Turbo.visit(url)
}
}
}
78 changes: 78 additions & 0 deletions app/javascript/controllers/insider_trades_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["tableBody", "loading"]

connect() {
this.sortDirection = {}
}

sort(event) {
const column = event.currentTarget.dataset.column
this.sortDirection[column] = !this.sortDirection[column]

const rows = Array.from(this.tableBodyTarget.children)
const sortedRows = this.sortRows(rows, column)

this.showLoading()

setTimeout(() => {
this.tableBodyTarget.innerHTML = ''
for (const row of sortedRows) {
this.tableBodyTarget.appendChild(row)
}
this.hideLoading()
}, 200)
}

sortRows(rows, column) {
return rows.sort((a, b) => {
let aVal = this.getCellValue(a, column)
let bVal = this.getCellValue(b, column)

if (column === 'date') {
aVal = new Date(aVal)
bVal = new Date(bVal)
} else if (column === 'shares' || column === 'value') {
aVal = this.parseNumericValue(aVal)
bVal = this.parseNumericValue(bVal)
}

return this.sortDirection[column] ?
this.compareValues(aVal, bVal) :
this.compareValues(bVal, aVal)
})
}

getCellValue(row, column) {
const index = {
name: 0,
position: 1,
date: 2,
shares: 3,
value: 4,
holdings: 5,
type: 6
}[column]

return row.children[index].textContent.trim()
}

parseNumericValue(value) {
return Number.parseFloat(value.replace(/[^-\d.]/g, ''))
}

compareValues(a, b) {
return a > b ? 1 : a < b ? -1 : 0
}

showLoading() {
this.loadingTarget.classList.remove('hidden')
this.tableBodyTarget.classList.add('opacity-50')
}

hideLoading() {
this.loadingTarget.classList.add('hidden')
this.tableBodyTarget.classList.remove('opacity-50')
}
}
79 changes: 79 additions & 0 deletions app/models/provider/synth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,49 @@ def exchange_rates(from_currency:, to_currency:, start_date:, end_date:)
end
end

def insider_trades(ticker:, start_date: 365.days.ago, end_date: Date.today, limit: 100)
response = fetch_insider_trades(
ticker: ticker,
start_date: start_date,
end_date: end_date,
limit: limit
)

if response.success?
InsiderTradesResponse.new(
ticker: ticker,
trades: response.parsed_response["data"],
success?: true,
raw_response: response
)
else
InsiderTradesResponse.new(
ticker: ticker,
success?: false,
raw_response: response
)
end
end

def recent_insider_trades(filters = {})
response = fetch_recent_insider_trades(**filters)

if response.success?
InsiderTradesResponse.new(
ticker: nil,
trades: response.parsed_response["data"],
success?: true,
raw_response: response
)
else
InsiderTradesResponse.new(
ticker: nil,
success?: false,
raw_response: response
)
end
end

private
BASE_URL = "https://api.synthfinance.com"

Expand All @@ -107,6 +150,13 @@ def exchange_rates(from_currency:, to_currency:, start_date:, end_date:)
:raw_response,
keyword_init: true
)
InsiderTradesResponse = Struct.new(
:ticker,
:trades,
:success?,
:raw_response,
keyword_init: true
)

def fetch_stock_prices(ticker:, start_date:, end_date:, interval: "day", limit: 100)
HTTParty.get "#{BASE_URL}/tickers/#{ticker}/open-close",
Expand All @@ -131,6 +181,35 @@ def fetch_exchange_rates(from_currency:, to_currency:, start_date:, end_date:)
headers: default_headers
end

def fetch_insider_trades(ticker:, start_date:, end_date:, limit: 100)
url = "#{BASE_URL}/insider-trades"
query = {
ticker: ticker,
start_date: start_date.to_s,
end_date: end_date.to_s,
limit: limit,
sort: "transaction_date",
direction: "desc"
}

HTTParty.get url,
query: query,
headers: default_headers
end

def fetch_recent_insider_trades(**filters)
url = "#{BASE_URL}/insider-trades"
query_params = {
limit: 250,
sort: "transaction_date",
direction: "desc"
}.merge(filters)

HTTParty.get url,
query: query_params,
headers: default_headers
end

def default_headers
{
"Authorization" => "Bearer #{api_key}",
Expand Down
2 changes: 2 additions & 0 deletions app/presenters/tool/presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ class Tool::Presenter
include ActiveModel::Model
include ActiveModel::Attributes

attribute :action_name, :string

delegate :slug, :name, :intro, :content, :meta_image_url, to: :active_record

private
Expand Down
153 changes: 153 additions & 0 deletions app/presenters/tool/presenter/inside_trading_tracker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
class Tool::Presenter::InsideTradingTracker < Tool::Presenter
attribute :symbol, :string
attribute :action_name, :string
attribute :filter, :string

def blank?
insider_trades.empty?
end

def company_name
return "Recent Insider Trading Activity" if symbol.blank?
insider_data.dig(:meta, :name) || symbol&.upcase
end

def insider_trades(filter = nil)
if symbol.present?
return [] unless insider_data[:trades]&.any?
format_trades(insider_data[:trades])
else
case filter
when "top-owners"
fetch_filtered_trades(ten_percent_owner: true, min_value: 100_000, sort: "value")
when "biggest-trades"
fetch_filtered_trades(min_value: 1_000_000, sort: "value")
when "top-officers"
fetch_filtered_trades(officer: true, min_value: 100_000, sort: "value")
else
recent_insider_trades
end
end
end

def total_value
insider_trades.sum { |trade| trade[:value] }
end

def total_shares
insider_trades.sum { |trade| trade[:shares] }
end

def top_trader
insider_trades
.group_by { |trade| trade[:full_name] }
.transform_values { |trades| trades.sum { |t| t[:value].abs } }
.max_by { |_, value| value }
&.then { |name, value| { name: name, value: value } }
end

def largest_transaction
insider_trades.max_by { |trade| trade[:value].abs }
end

def recent_trend
last_month_trades = insider_trades
.select { |t| Date.parse(t[:date_reported]) >= 30.days.ago }

total_value = last_month_trades.sum { |t| t[:value] }
total_volume = last_month_trades.sum { |t| t[:shares] }

{
value: total_value,
volume: total_volume,
count: last_month_trades.size
}
end

private
def active_record
@active_record ||= Tool.find_by! slug: "inside-trading-tracker"
end

def insider_data
@insider_data ||= Rails.cache.fetch("insider_trades/#{symbol}/#{180.days.ago.to_date}/#{Date.today}/250", expires_in: 6.hours) do
response = Provider::Synth.new.insider_trades(
ticker: symbol,
start_date: 180.days.ago,
end_date: Date.today,
limit: 250
)

if response.success?
{ trades: response.trades }
else
{ trades: [] }
end
end
end

def recent_insider_trades
Rails.cache.fetch("recent_insider_trades/#{Date.today}", expires_in: 6.hours) do
response = Provider::Synth.new.recent_insider_trades(limit: 250)
return [] unless response[:trades]&.any?
format_trades(response[:trades])
end
end

def format_trades(trades)
trades.map do |trade|
next unless trade["transaction_type"].present?

transaction_type = trade["transaction_type"]
is_positive = [ "Purchase", "Grant", "Exercise/Conversion" ].include?(transaction_type)
is_negative = [ "Sale", "Sale to Issuer", "Payment of Exercise Price" ].include?(transaction_type)
next unless is_positive || is_negative || transaction_type == "Discretionary Transaction"

shares = trade["shares"].to_i.abs
value = trade["value"].to_f.abs

{
full_name: trade["full_name"],
title: trade["position"],
date_reported: trade["transaction_date"],
description: trade["formatted_transaction_code"],
shares: is_positive ? shares : -shares,
value: is_positive ? value : -value,
price: trade["price"],
roles: trade["formatted_roles"],
ownership_type: trade["formatted_ownership_type"],
post_transaction_shares: trade["post_transaction_shares"],
filing_link: trade["filing_link"],
summary: trade["summary"],
company: trade.dig("company", "name") || trade["company_name"] || trade["ticker"],
company_description: trade.dig("company", "description"),
company_industry: trade.dig("company", "industry"),
company_sector: trade.dig("company", "sector"),
company_employees: trade.dig("company", "total_employees"),
ticker: trade["ticker"],
position: trade["position"],
exchange: trade.dig("exchange", "acronym"),
exchange_country: trade.dig("exchange", "country_code"),
footnotes: trade["footnotes"],
transaction_type: transaction_type
}
end.compact
end

def fetch_filtered_trades(filters = {})
cache_key = "filtered_insider_trades/#{filters.to_json}/#{90.days.ago.to_date}/#{Date.today}"

Rails.cache.fetch(cache_key, expires_in: 30.minutes) do
Rails.logger.warn "Fetching filtered trades with filters: #{filters}"
response = Provider::Synth.new.recent_insider_trades(
start_date: 90.days.ago,
end_date: Date.today,
limit: 250,
**filters
)

return [] unless response.success? && response.trades&.any?
format_trades(response.trades)
end
end
end
2 changes: 1 addition & 1 deletion app/views/stocks/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

<% if @cached_content %>
<div class="grid items-start grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<%= @cached_content.html_safe %>
<%= @cached_content %>
</div>
<% else %>
<div data-controller="stock-cache"
Expand Down
Loading

0 comments on commit ce3cde7

Please sign in to comment.