repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

7.8 KB · 198 lines · Rust Raw History
  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}