mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Enhance ticker search and validation in "Convert to Trade" form (#688)
- Updated resolution logic to support combobox-based ticker selection and validation. - Added market price display with validation against entered prices to detect significant mismatches. - Improved messaging and UI for custom ticker input and market price warnings. Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
@@ -366,17 +366,25 @@ class TransactionsController < ApplicationController
|
|||||||
|
|
||||||
def resolve_security_for_conversion
|
def resolve_security_for_conversion
|
||||||
if params[:security_id] == "__custom__"
|
if params[:security_id] == "__custom__"
|
||||||
ticker = params[:custom_ticker].presence
|
# User selected "Enter custom ticker" - check for combobox selection or manual entry
|
||||||
unless ticker.present?
|
if params[:ticker].present?
|
||||||
|
# Combobox selection: format is "SYMBOL|EXCHANGE"
|
||||||
|
ticker_symbol, exchange_operating_mic = params[:ticker].split("|")
|
||||||
|
Security::Resolver.new(
|
||||||
|
ticker_symbol.strip,
|
||||||
|
exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence
|
||||||
|
).resolve
|
||||||
|
elsif params[:custom_ticker].present?
|
||||||
|
# Manual entry from combobox's name_when_new or fallback text field
|
||||||
|
Security::Resolver.new(
|
||||||
|
params[:custom_ticker].strip,
|
||||||
|
exchange_operating_mic: params[:exchange_operating_mic].presence
|
||||||
|
).resolve
|
||||||
|
else
|
||||||
flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker")
|
flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker")
|
||||||
redirect_back_or_to transactions_path
|
redirect_back_or_to transactions_path
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
Security::Resolver.new(
|
|
||||||
ticker.strip,
|
|
||||||
exchange_operating_mic: params[:exchange_operating_mic].presence
|
|
||||||
).resolve
|
|
||||||
elsif params[:security_id].present?
|
elsif params[:security_id].present?
|
||||||
found = Security.find_by(id: params[:security_id])
|
found = Security.find_by(id: params[:security_id])
|
||||||
unless found
|
unless found
|
||||||
@@ -386,8 +394,16 @@ class TransactionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
found
|
found
|
||||||
elsif params[:ticker].present?
|
elsif params[:ticker].present?
|
||||||
|
# Direct combobox (no existing holdings) - format is "SYMBOL|EXCHANGE"
|
||||||
|
ticker_symbol, exchange_operating_mic = params[:ticker].split("|")
|
||||||
Security::Resolver.new(
|
Security::Resolver.new(
|
||||||
params[:ticker].strip,
|
ticker_symbol.strip,
|
||||||
|
exchange_operating_mic: exchange_operating_mic.presence || params[:exchange_operating_mic].presence
|
||||||
|
).resolve
|
||||||
|
elsif params[:custom_ticker].present?
|
||||||
|
# Manual entry from combobox's name_when_new (no existing holdings path)
|
||||||
|
Security::Resolver.new(
|
||||||
|
params[:custom_ticker].strip,
|
||||||
exchange_operating_mic: params[:exchange_operating_mic].presence
|
exchange_operating_mic: params[:exchange_operating_mic].presence
|
||||||
).resolve
|
).resolve
|
||||||
end.tap do |security|
|
end.tap do |security|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["customWrapper", "customField", "tickerSelect"]
|
static targets = ["customWrapper", "customField", "tickerSelect", "qtyField", "priceField", "priceWarning", "priceWarningMessage"]
|
||||||
|
static values = {
|
||||||
|
amountCents: Number,
|
||||||
|
currency: String
|
||||||
|
}
|
||||||
|
|
||||||
toggleCustomTicker(event) {
|
toggleCustomTicker(event) {
|
||||||
const value = event.target.value
|
const value = event.target.value
|
||||||
@@ -18,4 +22,76 @@ export default class extends Controller {
|
|||||||
this.customFieldTarget.value = ""
|
this.customFieldTarget.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validatePrice() {
|
||||||
|
// Get the selected security's market price (in cents)
|
||||||
|
const selectedOption = this.tickerSelectTarget.selectedOptions[0]
|
||||||
|
if (!selectedOption || selectedOption.value === "" || selectedOption.value === "__custom__") {
|
||||||
|
this.hidePriceWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketPriceCents = selectedOption.dataset.priceCents
|
||||||
|
const ticker = selectedOption.dataset.ticker
|
||||||
|
|
||||||
|
// If no market price data, can't validate
|
||||||
|
if (!marketPriceCents || marketPriceCents === "null") {
|
||||||
|
this.hidePriceWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the implied/entered price
|
||||||
|
let enteredPriceCents = null
|
||||||
|
const qty = Number.parseFloat(this.qtyFieldTarget?.value)
|
||||||
|
const enteredPrice = Number.parseFloat(this.priceFieldTarget?.value)
|
||||||
|
|
||||||
|
if (enteredPrice && enteredPrice > 0) {
|
||||||
|
// User entered a price directly
|
||||||
|
enteredPriceCents = enteredPrice * 100
|
||||||
|
} else if (qty && qty > 0 && this.amountCentsValue > 0) {
|
||||||
|
// Calculate price from amount / qty
|
||||||
|
enteredPriceCents = this.amountCentsValue / qty
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enteredPriceCents || enteredPriceCents <= 0) {
|
||||||
|
this.hidePriceWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare prices - warn if they differ by more than 50%
|
||||||
|
const marketPrice = Number.parseFloat(marketPriceCents)
|
||||||
|
const ratio = enteredPriceCents / marketPrice
|
||||||
|
|
||||||
|
if (ratio < 0.5 || ratio > 2.0) {
|
||||||
|
this.showPriceWarning(ticker, enteredPriceCents, marketPrice)
|
||||||
|
} else {
|
||||||
|
this.hidePriceWarning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showPriceWarning(ticker, enteredPriceCents, marketPriceCents) {
|
||||||
|
if (!this.hasPriceWarningTarget) return
|
||||||
|
|
||||||
|
const enteredPrice = this.formatMoney(enteredPriceCents)
|
||||||
|
const marketPrice = this.formatMoney(marketPriceCents)
|
||||||
|
|
||||||
|
// Build warning message
|
||||||
|
const message = `Your price (${enteredPrice}/share) differs significantly from ${ticker}'s current market price (${marketPrice}). If this seems wrong, you may have selected the wrong security — try using "Enter custom ticker" to specify the correct one.`
|
||||||
|
|
||||||
|
this.priceWarningMessageTarget.textContent = message
|
||||||
|
this.priceWarningTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePriceWarning() {
|
||||||
|
if (!this.hasPriceWarningTarget) return
|
||||||
|
this.priceWarningTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMoney(cents) {
|
||||||
|
const dollars = cents / 100
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: this.currencyValue || 'USD'
|
||||||
|
}).format(dollars)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,13 @@ class Security::Resolver
|
|||||||
filtered_candidates = filtered_candidates.select { |s| s.country_code.upcase.to_s == country_code.upcase.to_s }
|
filtered_candidates = filtered_candidates.select { |s| s.country_code.upcase.to_s == country_code.upcase.to_s }
|
||||||
end
|
end
|
||||||
|
|
||||||
# 1. Prefer exact exchange_operating_mic matches (if one was provided)
|
# 1. Prefer exact ticker matches (MSTR before MSTRX when searching for "MSTR")
|
||||||
# 2. Rank by country relevance (lower index in the list is more relevant)
|
# 2. Prefer exact exchange_operating_mic matches (if one was provided)
|
||||||
# 3. Rank by exchange_operating_mic relevance (lower index in the list is more relevant)
|
# 3. Rank by country relevance (lower index in the list is more relevant)
|
||||||
|
# 4. Rank by exchange_operating_mic relevance (lower index in the list is more relevant)
|
||||||
sorted_candidates = filtered_candidates.sort_by do |s|
|
sorted_candidates = filtered_candidates.sort_by do |s|
|
||||||
[
|
[
|
||||||
|
s.ticker.upcase.to_s == symbol.upcase.to_s ? 0 : 1,
|
||||||
exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1,
|
exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1,
|
||||||
sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length,
|
sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length,
|
||||||
sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length
|
sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length
|
||||||
|
|||||||
@@ -5,12 +5,24 @@
|
|||||||
default_label = params[:activity_label].presence || (@entry.amount > 0 ? "Sell" : "Buy")
|
default_label = params[:activity_label].presence || (@entry.amount > 0 ? "Sell" : "Buy")
|
||||||
|
|
||||||
# Get unique securities from account holdings for ticker suggestions
|
# Get unique securities from account holdings for ticker suggestions
|
||||||
|
# Include current price for display and validation
|
||||||
account_securities = @entry.account.holdings
|
account_securities = @entry.account.holdings
|
||||||
.includes(:security)
|
.includes(:security)
|
||||||
.where.not(security: nil)
|
.where.not(security: nil)
|
||||||
.map(&:security)
|
.map(&:security)
|
||||||
.uniq(&:ticker)
|
.uniq(&:ticker)
|
||||||
.sort_by(&:ticker)
|
.sort_by(&:ticker)
|
||||||
|
|
||||||
|
# Build options with price data for validation
|
||||||
|
security_options_with_prices = account_securities.map do |s|
|
||||||
|
price = s.current_price
|
||||||
|
price_display = price ? " (#{format_money(price)})" : ""
|
||||||
|
label = "#{s.ticker}#{price_display}"
|
||||||
|
label += " - #{s.name.truncate(25)}" if s.name.present?
|
||||||
|
# Store price as cents for JS comparison (amount is BigDecimal, multiply by 100 for cents)
|
||||||
|
price_cents = price ? (price.amount * 100).to_i : nil
|
||||||
|
[label, s.id, { "data-price-cents" => price_cents, "data-ticker" => s.ticker }]
|
||||||
|
end
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<%= render DS::Dialog.new(variant: "modal", reload_on_close: true) do |dialog| %>
|
<%= render DS::Dialog.new(variant: "modal", reload_on_close: true) do |dialog| %>
|
||||||
@@ -20,7 +32,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% dialog.with_body do %>
|
<% dialog.with_body do %>
|
||||||
<%= form_with url: create_trade_from_transaction_transaction_path(@transaction), method: :post, class: "space-y-4", data: { controller: "convert-to-trade", turbo_frame: "_top" } do |f| %>
|
<%= form_with url: create_trade_from_transaction_transaction_path(@transaction), method: :post, class: "space-y-4", data: { controller: "convert-to-trade", turbo_frame: "_top", convert_to_trade_amount_cents_value: (@entry.amount_money.abs.amount * 100).to_i, convert_to_trade_currency_value: @entry.currency } do |f| %>
|
||||||
<!-- Pre-filled Transaction Info (Read-only) -->
|
<!-- Pre-filled Transaction Info (Read-only) -->
|
||||||
<div class="space-y-2 p-3 bg-surface-inset rounded-lg border border-primary">
|
<div class="space-y-2 p-3 bg-surface-inset rounded-lg border border-primary">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -45,7 +57,7 @@
|
|||||||
<%= f.select :security_id,
|
<%= f.select :security_id,
|
||||||
options_for_select(
|
options_for_select(
|
||||||
[[t(".security_prompt"), ""]] +
|
[[t(".security_prompt"), ""]] +
|
||||||
account_securities.map { |s| ["#{s.ticker}#{s.name.present? ? " - #{s.name.truncate(30)}" : ""}", s.id] } +
|
security_options_with_prices +
|
||||||
[[t(".security_custom"), "__custom__"]],
|
[[t(".security_custom"), "__custom__"]],
|
||||||
nil
|
nil
|
||||||
),
|
),
|
||||||
@@ -53,27 +65,50 @@
|
|||||||
{
|
{
|
||||||
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
||||||
data: {
|
data: {
|
||||||
action: "change->convert-to-trade#toggleCustomTicker",
|
action: "change->convert-to-trade#toggleCustomTicker change->convert-to-trade#validatePrice",
|
||||||
convert_to_trade_target: "tickerSelect"
|
convert_to_trade_target: "tickerSelect"
|
||||||
}
|
}
|
||||||
} %>
|
} %>
|
||||||
|
<p class="text-xs text-secondary mt-1"><%= t(".security_not_listed_hint") %></p>
|
||||||
<div class="hidden mt-2" data-convert-to-trade-target="customWrapper">
|
<div class="hidden mt-2" data-convert-to-trade-target="customWrapper">
|
||||||
<%= f.text_field :custom_ticker,
|
<% if Security.provider.present? %>
|
||||||
|
<div class="form-field combobox">
|
||||||
|
<%= f.combobox :ticker,
|
||||||
|
securities_path(country_code: Current.family.country),
|
||||||
|
name_when_new: "custom_ticker",
|
||||||
|
placeholder: t(".ticker_search_placeholder"),
|
||||||
|
data: { convert_to_trade_target: "customField" } %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-secondary mt-1"><%= t(".ticker_search_hint") %></p>
|
||||||
|
<% else %>
|
||||||
|
<%= f.text_field :custom_ticker,
|
||||||
|
placeholder: t(".ticker_placeholder"),
|
||||||
|
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
||||||
|
pattern: "[A-Za-z0-9.:-]{1,20}",
|
||||||
|
title: t(".ticker_hint"),
|
||||||
|
data: { convert_to_trade_target: "customField" } %>
|
||||||
|
<p class="text-xs text-secondary mt-1"><%= t(".ticker_hint") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<% if Security.provider.present? %>
|
||||||
|
<div class="form-field combobox">
|
||||||
|
<%= f.combobox :ticker,
|
||||||
|
securities_path(country_code: Current.family.country),
|
||||||
|
name_when_new: "custom_ticker",
|
||||||
|
placeholder: t(".ticker_search_placeholder"),
|
||||||
|
required: true %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-secondary"><%= t(".ticker_search_hint") %></p>
|
||||||
|
<% else %>
|
||||||
|
<%= f.text_field :ticker,
|
||||||
placeholder: t(".ticker_placeholder"),
|
placeholder: t(".ticker_placeholder"),
|
||||||
|
required: true,
|
||||||
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
||||||
pattern: "[A-Za-z0-9.:-]{1,20}",
|
pattern: "[A-Za-z0-9.:-]{1,20}",
|
||||||
title: t(".ticker_hint"),
|
title: t(".ticker_hint") %>
|
||||||
data: { convert_to_trade_target: "customField" } %>
|
<p class="text-xs text-secondary"><%= t(".ticker_hint") %></p>
|
||||||
</div>
|
<% end %>
|
||||||
<p class="text-xs text-secondary"><%= t(".security_hint") %></p>
|
|
||||||
<% else %>
|
|
||||||
<%= f.text_field :ticker,
|
|
||||||
placeholder: t(".ticker_placeholder"),
|
|
||||||
required: true,
|
|
||||||
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
|
||||||
pattern: "[A-Za-z0-9.:-]{1,20}",
|
|
||||||
title: t(".ticker_hint") %>
|
|
||||||
<p class="text-xs text-secondary"><%= t(".ticker_hint") %></p>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,7 +118,11 @@
|
|||||||
<%= f.number_field :qty,
|
<%= f.number_field :qty,
|
||||||
step: "any",
|
step: "any",
|
||||||
placeholder: t(".quantity_placeholder"),
|
placeholder: t(".quantity_placeholder"),
|
||||||
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container" %>
|
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
||||||
|
data: {
|
||||||
|
action: "input->convert-to-trade#validatePrice",
|
||||||
|
convert_to_trade_target: "qtyField"
|
||||||
|
} %>
|
||||||
<p class="text-xs text-secondary"><%= t(".quantity_hint") %></p>
|
<p class="text-xs text-secondary"><%= t(".quantity_hint") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,11 +132,26 @@
|
|||||||
step: "0.0001",
|
step: "0.0001",
|
||||||
min: "0",
|
min: "0",
|
||||||
placeholder: t(".price_placeholder"),
|
placeholder: t(".price_placeholder"),
|
||||||
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container" %>
|
class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container",
|
||||||
|
data: {
|
||||||
|
action: "input->convert-to-trade#validatePrice",
|
||||||
|
convert_to_trade_target: "priceField"
|
||||||
|
} %>
|
||||||
<p class="text-xs text-secondary"><%= t(".price_hint", currency: @entry.currency) %></p>
|
<p class="text-xs text-secondary"><%= t(".price_hint", currency: @entry.currency) %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Price mismatch warning -->
|
||||||
|
<div class="hidden p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg" data-convert-to-trade-target="priceWarning">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<%= icon "alert-triangle", size: "sm", class: "text-yellow-600 dark:text-yellow-500 mt-0.5 shrink-0" %>
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-medium text-yellow-800 dark:text-yellow-200"><%= t(".price_mismatch_title") %></p>
|
||||||
|
<p class="text-yellow-700 dark:text-yellow-300" data-convert-to-trade-target="priceWarningMessage"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-secondary bg-surface-inset p-2 rounded">
|
<p class="text-xs text-secondary bg-surface-inset p-2 rounded">
|
||||||
<%= icon "info", size: "xs", class: "inline-block mr-1" %>
|
<%= icon "info", size: "xs", class: "inline-block mr-1" %>
|
||||||
<%= t(".qty_or_price_hint", amount: format_money(@entry.amount_money.abs)) %>
|
<%= t(".qty_or_price_hint", amount: format_money(@entry.amount_money.abs)) %>
|
||||||
|
|||||||
@@ -115,9 +115,13 @@ en:
|
|||||||
security_label: Security
|
security_label: Security
|
||||||
security_prompt: Select a security...
|
security_prompt: Select a security...
|
||||||
security_custom: "+ Enter custom ticker"
|
security_custom: "+ Enter custom ticker"
|
||||||
security_hint: Select from your holdings or enter a custom ticker
|
security_not_listed_hint: Don't see your security? Select "Enter custom ticker" at the bottom of the list.
|
||||||
ticker_placeholder: AAPL
|
ticker_placeholder: AAPL
|
||||||
ticker_hint: Enter the stock/ETF ticker symbol
|
ticker_hint: Enter the stock/ETF ticker symbol (e.g., AAPL, MSFT)
|
||||||
|
ticker_search_placeholder: Search for a ticker...
|
||||||
|
ticker_search_hint: Search by ticker symbol or company name, or type a custom ticker
|
||||||
|
price_mismatch_title: Price may not match
|
||||||
|
price_mismatch_message: "Your price (%{entered_price}/share) differs significantly from %{ticker}'s current market price (%{market_price}). If this seems wrong, you may have selected the wrong security — try using \"Enter custom ticker\" to specify the correct one."
|
||||||
quantity_label: Quantity (Shares)
|
quantity_label: Quantity (Shares)
|
||||||
quantity_placeholder: e.g. 20
|
quantity_placeholder: e.g. 20
|
||||||
quantity_hint: Number of shares traded
|
quantity_hint: Number of shares traded
|
||||||
|
|||||||
Reference in New Issue
Block a user