Skip to content

Commit

Permalink
feat: stock portfolio backtest calculator
Browse files Browse the repository at this point in the history
  • Loading branch information
neo773 committed Aug 24, 2024
1 parent b797879 commit 03a975c
Show file tree
Hide file tree
Showing 17 changed files with 2,055 additions and 20 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@
# Ignore Cursor AI rules
.cursorrules
.vscode/settings.json
node_modules
.DS_Store
4 changes: 4 additions & 0 deletions app/assets/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
@apply relative appearance-none h-5 w-5 rounded-full bg-white border border-solid border-alpha-black-100 shadow-sm cursor-pointer top-[-6px];
}

input[type="number"] {
@apply [-moz-appearance:_textfield] [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none
}

.tab-item {
@apply w-1/2 text-gray-500 text-sm;
}
Expand Down
19 changes: 19 additions & 0 deletions app/controllers/tickers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class TickersController < ApplicationController
def open_close
tickers = params[:tickers]
start_date = params[:start_date]
end_date = params[:end_date]

results = tickers.map do |ticker|
response = Faraday.get(
"https://api.synthfinance.com/tickers/#{ticker}/open-close",
{ start_date: start_date, end_date: end_date, interval: "month", limit: 500 },
{ "Authorization" => "Bearer #{ENV['SYNTH_API_KEY']}" }
)

{ ticker => JSON.parse(response.body)["prices"] }
end

render json: results
end
end
6 changes: 6 additions & 0 deletions app/controllers/tools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ def show
@loan_interest_rate_15 = fetch_mortgage_rate("MORTGAGE15US")
end

if @tool.slug == "stock-portfolio-backtest"
@stocks = Rails.cache.fetch("all_stocks", expires_in: 24.hours) do
Stock.select(:name, :symbol).map { |stock| { name: stock.name, value: stock.symbol } }
end
end

if @tool.needs_stock_data?
@stock_prices = StockPrice.fetch_stock_data
end
Expand Down
192 changes: 192 additions & 0 deletions app/javascript/controllers/search_select_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static classes = ["active"];
static targets = ["option", "input", "list", "hiddenInput"];
static values = { selected: String, count: Number, list: String };

initialize() {
this.show = false;
this.countValue = this.countValue || 1;

const selectedElement = this.optionTargets.find(
(option) => option.dataset.value === this.selectedValue
);
if (selectedElement) {
this.updateAriaAttributesAndClasses(selectedElement);
this.syncInputWithSelected();
}
}

connect() {
this.syncInputWithSelected();
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
this.element.addEventListener("turbo:load", this.handleTurboLoad);
this.inputTarget.addEventListener("input", this.filterOptions.bind(this));
}

disconnect() {
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
}

resetInput() {
this.inputTarget.value = '';
this.hiddenInputTarget.value = '';
this.close();
}

selectedValueChanged() {
this.syncInputWithSelected();
}

handleOutsideClick = (event) => {
if (!this.element.contains(event.target)) {
this.close();
}
};

handleTurboLoad = () => {
this.close();
this.syncInputWithSelected();
};

handleKeydown = (event) => {
switch (event.key) {
case " ":
case "Enter":
event.preventDefault();
this.selectOption(event);
break;
case "ArrowDown":
event.preventDefault();
this.focusNextOption();
break;
case "ArrowUp":
event.preventDefault();
this.focusPreviousOption();
break;
case "Escape":
this.close();
break;
case "Tab":
this.close();
break;
}
};

focusNextOption() {
this.focusOptionInDirection(1);
}

focusPreviousOption() {
this.focusOptionInDirection(-1);
}

focusOptionInDirection(direction) {
const currentFocusedIndex = this.optionTargets.findIndex(
(option) => option === document.activeElement
);
const optionsCount = this.optionTargets.length;
const nextIndex =
(currentFocusedIndex + direction + optionsCount) % optionsCount;
this.optionTargets[nextIndex].focus();
}

toggleList = () => {
this.show = !this.show;
this.listTarget.classList.toggle("hidden", !this.show);

if (this.show) {
this.filterInputTarget.focus();
}
};

close() {
this.show = false;
this.listTarget.classList.add("hidden");
}

selectOption(event) {
const selectedOption =
event.type === "keydown" ? document.activeElement : event.currentTarget;
this.updateAriaAttributesAndClasses(selectedOption);
if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) {
this.updateInputValueAndEmitEvent(selectedOption);
}
this.inputTarget.value = selectedOption.textContent.trim();
this.hiddenInputTarget.value = selectedOption.getAttribute("data-value");
this.close();
}

updateAriaAttributesAndClasses(selectedOption) {
this.optionTargets.forEach((option) => {
option.setAttribute("aria-selected", "false");
option.setAttribute("tabindex", "-1");
option.classList.remove(...this.activeClasses);
});
selectedOption.classList.add(...this.activeClasses);
selectedOption.setAttribute("aria-selected", "true");
selectedOption.focus();
}

updateInputValueAndEmitEvent(selectedOption) {
const selectedValue = selectedOption.getAttribute("data-value");
this.inputTarget.value = selectedValue;
this.syncInputWithSelected();

const inputEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
this.inputTarget.dispatchEvent(inputEvent);
}

syncInputWithSelected() {
const matchingOption = this.optionTargets.find(
(option) => option.getAttribute("data-value") === this.inputTarget.value
);
if (matchingOption) {
this.inputTarget.value = matchingOption.textContent.trim();
}
}

filterOptions(event) {
const filterValue = event.target.value.toLowerCase();
this.listTarget.innerHTML = '';

if (filterValue === '') {
this.close();
return;
}

const dataList = window[this.listValue];
const filteredList = dataList
.filter(item =>
item.name.toLowerCase().includes(filterValue) ||
item.value.toLowerCase().includes(filterValue)
)
.slice(0, 5);

if (filteredList.length > 0) {
filteredList.forEach(item => {
const li = document.createElement('li');
li.tabIndex = 0;
li.dataset.searchSelectTarget = 'option';
li.dataset.action = 'click->search-select#selectOption';
li.dataset.value = item.value;
li.className = 'flex items-center justify-start w-full px-2 py-1 min-h-9 text-sm text-black rounded cursor-pointer hover:bg-alpha-black-50';
li.textContent = `${item.name} (${item.value})`;
this.listTarget.appendChild(li);
});

this.listTarget.classList.remove("hidden");
this.show = true;
} else {
this.close();
}
}

}
Loading

0 comments on commit 03a975c

Please sign in to comment.