Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Insider trades #196

Merged
merged 9 commits into from
Nov 20, 2024
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