Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1//! `GET /search` — search and browse the symbol universe.
2//!
3//! With no query the page lists the whole universe (filterable by kind), so it
4//! doubles as a browser. With a query it matches against both ticker and
5//! company name. When the query names a plausible ticker the universe does not
6//! hold yet, the page offers to add it; the Search page's script does that
7//! through `POST /api/symbols` (see `routes::symbols`).
8
9use std::collections::HashMap;
10
11use axum::{
12 extract::{Query, State},
13 response::{IntoResponse, Redirect, Response},
14 routing::get,
15 Router,
16};
17use serde::Deserialize;
18
19use crate::compute;
20use crate::models::{self, to_card, Card};
21use crate::render::render;
22use crate::routes::symbols::valid_ticker;
23use crate::AppState;
24
25pub fn router() -> Router<AppState> {
26 Router::new().route("/search", get(search_page))
27}
28
29#[derive(Deserialize)]
30struct SearchQuery {
31 q: Option<String>,
32 /// Kind filter: `index` | `future` | `etf` | `stock`, or absent / empty for all.
33 kind: Option<String>,
34}
35
36/// Escape SQL `LIKE` wildcards in user input so a literal `%` or `_` is
37/// matched as itself. Paired with `ESCAPE '\'` in the query.
38fn escape_like(s: &str) -> String {
39 let mut out = String::with_capacity(s.len() + 2);
40 for c in s.chars() {
41 if matches!(c, '\\' | '%' | '_') {
42 out.push('\\');
43 }
44 out.push(c);
45 }
46 out
47}
48
49async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState>) -> Response {
50 let raw = sq.q.unwrap_or_default();
51 // Tickers are stored uppercase; matching is uppercased throughout. `LIKE`
52 // is ASCII-case-insensitive, so company names still match in any case.
53 let query = raw.trim().to_uppercase();
54 // Normalise the kind filter to one of the three known kinds, else "all".
55 let kind = match sq.kind.as_deref().map(str::trim).unwrap_or("") {
56 k @ ("index" | "future" | "crypto" | "etf" | "stock") => k,
57 _ => "",
58 };
59
60 let escaped = escape_like(&query);
61 let like = format!("%{escaped}%");
62 let prefix = format!("{escaped}%");
63
64 // One query covers both browse (empty `q`) and search: the `? = ''` guards
65 // make the ticker/name and kind filters no-ops when their input is empty.
66 // Ordering puts an exact ticker hit first, then ticker prefix matches,
67 // then indexes before futures before ETFs before stocks, then alphabetical.
68 type SearchRow = (String, String, String, Option<f64>, Option<f64>, Option<i64>);
69 let rows: Vec<SearchRow> = sqlx::query_as(
70 "SELECT s.ticker, s.name, s.kind, \
71 COALESCE(s.last_price, \
72 (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \
73 COALESCE(s.prev_close, \
74 (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \
75 s.last_quote_at \
76 FROM symbols s \
77 WHERE (? = '' OR s.ticker LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\') \
78 AND (? = '' OR s.kind = ?) \
79 ORDER BY (s.ticker = ?) DESC, (s.ticker LIKE ? ESCAPE '\\') DESC, \
80 CASE s.kind WHEN 'index' THEN 0 WHEN 'future' THEN 1 \
81 WHEN 'crypto' THEN 2 WHEN 'etf' THEN 3 ELSE 4 END, s.ticker \
82 LIMIT 240",
83 )
84 .bind(&query)
85 .bind(&like)
86 .bind(&like)
87 .bind(kind)
88 .bind(kind)
89 .bind(&query)
90 .bind(&prefix)
91 .fetch_all(&state.pool)
92 .await
93 .unwrap_or_default();
94
95 // The freshest quote across the matched symbols backs the page's "prices
96 // as of ..." caption.
97 let asof: Option<i64> = rows.iter().filter_map(|r| r.5).max();
98 let mut results: Vec<Card> = rows
99 .into_iter()
100 .map(|(t, n, k, last, prev, _)| to_card((t, n, k, last, prev)))
101 .collect();
102
103 // A search that pinpoints exactly one symbol jumps straight to its page,
104 // rather than rendering a single card the user must then click (Phase 21).
105 // Browse mode (an empty query) never redirects.
106 if !query.is_empty() && results.len() == 1 {
107 let target = format!("/s/{}", urlencoding::encode(&results[0].ticker));
108 return Redirect::to(&target).into_response();
109 }
110
111 let result_count = results.len() as i64;
112
113 // Attach each stock card's strong / fair / weak verdict badge (Phase 20).
114 // ETFs, indexes and futures carry no badge — only stocks have the SEC
115 // fundamentals a standing is rolled from.
116 attach_standings(&state, &mut results).await;
117
118 // Does the query land on at least one tracked ticker (as a substring)? If
119 // so it reads as a ticker search: a bare ticker like "W" shares letters
120 // with the tickers it returns. A pure company-name search ("Apple",
121 // "bank") matches only names, with the query absent from every ticker.
122 let ticker_hit = results.iter().any(|c| c.ticker.contains(query.as_str()));
123
124 // Offer "Add" when the query names a ticker the universe does not hold.
125 // The gate is an exact-ticker check (EXISTS on `symbols`), not "zero
126 // results": searching "W" for Wayfair partially LIKE-matches a crowd of
127 // other tickers, yet "W" itself is still untracked and addable. The query
128 // must be a plausible ticker (one `POST /api/symbols` would accept). The
129 // offer is kept off a pure company-name search, where it would be noise:
130 // it shows only when the query matched nothing, or matched as a ticker.
131 let show_add = valid_ticker(&query).is_some()
132 && (results.is_empty() || ticker_hit)
133 && !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM symbols WHERE ticker = ?)")
134 .bind(&query)
135 .fetch_one(&state.pool)
136 .await
137 .unwrap_or(false);
138
139 let extra = minijinja::context! {
140 title => "Search",
141 q => raw.trim(),
142 kind => kind,
143 results => results,
144 result_count => result_count,
145 asof => asof,
146 show_add => show_add,
147 add_ticker => query,
148 };
149 render(&state, "pages/search.html", "/search", extra)
150}
151
152/// Fill in the `strength` badge for the stock cards in `cards`, in one batch
153/// query over their stored SEC fundamentals. Non-stock cards are left
154/// untouched. The badge reflects fundamental strength only — the home page is
155/// where the trajectory half is read — so no daily-close series is needed.
156async fn attach_standings(state: &AppState, cards: &mut [Card]) {
157 let stock_tickers: Vec<&str> = cards
158 .iter()
159 .filter(|c| c.kind == "stock")
160 .map(|c| c.ticker.as_str())
161 .collect();
162 if stock_tickers.is_empty() {
163 return;
164 }
165
166 // The `IN` list is built from tickers already in `symbols`, not raw user
167 // input, so the placeholder count is bounded and safe.
168 let placeholders = vec!["?"; stock_tickers.len()].join(",");
169 let sql = format!(
170 "SELECT ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end \
171 FROM fundamentals WHERE ticker IN ({placeholders})"
172 );
173 let mut q = sqlx::query_as::<_, (String, String, String, i64, Option<i64>, f64, String)>(&sql);
174 for t in &stock_tickers {
175 q = q.bind(*t);
176 }
177 let fact_rows = q.fetch_all(&state.pool).await.unwrap_or_default();
178
179 let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();
180 for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {
181 facts.entry(ticker).or_default().push(models::FundFact {
182 metric,
183 period,
184 fiscal_year,
185 fiscal_qtr,
186 value,
187 period_end,
188 });
189 }
190
191 for card in cards.iter_mut().filter(|c| c.kind == "stock") {
192 card.strength = facts.get(&card.ticker).and_then(|f| {
193 let inputs = models::latest_annual_inputs(f, card.price)?;
194 compute::standing(&compute::compute_ratios(&inputs), &[])
195 });
196 }
197}