repos
7.8 KB raw
//! `GET /search` — search and browse the symbol universe.
//!
//! With no query the page lists the whole universe (filterable by kind), so it
//! doubles as a browser. With a query it matches against both ticker and
//! company name. When the query names a plausible ticker the universe does not
//! hold yet, the page offers to add it; the Search page's script does that
//! through `POST /api/symbols` (see `routes::symbols`).

use std::collections::HashMap;

use axum::{
    extract::{Query, State},
    response::{IntoResponse, Redirect, Response},
    routing::get,
    Router,
};
use serde::Deserialize;

use crate::compute;
use crate::models::{self, to_card, Card};
use crate::render::render;
use crate::routes::symbols::valid_ticker;
use crate::AppState;

pub fn router() -> Router<AppState> {
    Router::new().route("/search", get(search_page))
}

#[derive(Deserialize)]
struct SearchQuery {
    q: Option<String>,
    /// Kind filter: `index` | `future` | `etf` | `stock`, or absent / empty for all.
    kind: Option<String>,
}

/// Escape SQL `LIKE` wildcards in user input so a literal `%` or `_` is
/// matched as itself. Paired with `ESCAPE '\'` in the query.
fn escape_like(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    for c in s.chars() {
        if matches!(c, '\\' | '%' | '_') {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState>) -> Response {
    let raw = sq.q.unwrap_or_default();
    // Tickers are stored uppercase; matching is uppercased throughout. `LIKE`
    // is ASCII-case-insensitive, so company names still match in any case.
    let query = raw.trim().to_uppercase();
    // Normalise the kind filter to one of the three known kinds, else "all".
    let kind = match sq.kind.as_deref().map(str::trim).unwrap_or("") {
        k @ ("index" | "future" | "crypto" | "etf" | "stock") => k,
        _ => "",
    };

    let escaped = escape_like(&query);
    let like = format!("%{escaped}%");
    let prefix = format!("{escaped}%");

    // One query covers both browse (empty `q`) and search: the `? = ''` guards
    // make the ticker/name and kind filters no-ops when their input is empty.
    // Ordering puts an exact ticker hit first, then ticker prefix matches,
    // then indexes before futures before ETFs before stocks, then alphabetical.
    type SearchRow = (String, String, String, Option<f64>, Option<f64>, Option<i64>);
    let rows: Vec<SearchRow> = sqlx::query_as(
        "SELECT s.ticker, s.name, s.kind, \
           COALESCE(s.last_price, \
             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \
           COALESCE(s.prev_close, \
             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \
           s.last_quote_at \
         FROM symbols s \
         WHERE (? = '' OR s.ticker LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\') \
           AND (? = '' OR s.kind = ?) \
         ORDER BY (s.ticker = ?) DESC, (s.ticker LIKE ? ESCAPE '\\') DESC, \
                  CASE s.kind WHEN 'index' THEN 0 WHEN 'future' THEN 1 \
                              WHEN 'crypto' THEN 2 WHEN 'etf' THEN 3 ELSE 4 END, s.ticker \
         LIMIT 240",
    )
    .bind(&query)
    .bind(&like)
    .bind(&like)
    .bind(kind)
    .bind(kind)
    .bind(&query)
    .bind(&prefix)
    .fetch_all(&state.pool)
    .await
    .unwrap_or_default();

    // The freshest quote across the matched symbols backs the page's "prices
    // as of ..." caption.
    let asof: Option<i64> = rows.iter().filter_map(|r| r.5).max();
    let mut results: Vec<Card> = rows
        .into_iter()
        .map(|(t, n, k, last, prev, _)| to_card((t, n, k, last, prev)))
        .collect();

    // A search that pinpoints exactly one symbol jumps straight to its page,
    // rather than rendering a single card the user must then click (Phase 21).
    // Browse mode (an empty query) never redirects.
    if !query.is_empty() && results.len() == 1 {
        let target = format!("/s/{}", urlencoding::encode(&results[0].ticker));
        return Redirect::to(&target).into_response();
    }

    let result_count = results.len() as i64;

    // Attach each stock card's strong / fair / weak verdict badge (Phase 20).
    // ETFs, indexes and futures carry no badge — only stocks have the SEC
    // fundamentals a standing is rolled from.
    attach_standings(&state, &mut results).await;

    // Does the query land on at least one tracked ticker (as a substring)? If
    // so it reads as a ticker search: a bare ticker like "W" shares letters
    // with the tickers it returns. A pure company-name search ("Apple",
    // "bank") matches only names, with the query absent from every ticker.
    let ticker_hit = results.iter().any(|c| c.ticker.contains(query.as_str()));

    // Offer "Add" when the query names a ticker the universe does not hold.
    // The gate is an exact-ticker check (EXISTS on `symbols`), not "zero
    // results": searching "W" for Wayfair partially LIKE-matches a crowd of
    // other tickers, yet "W" itself is still untracked and addable. The query
    // must be a plausible ticker (one `POST /api/symbols` would accept). The
    // offer is kept off a pure company-name search, where it would be noise:
    // it shows only when the query matched nothing, or matched as a ticker.
    let show_add = valid_ticker(&query).is_some()
        && (results.is_empty() || ticker_hit)
        && !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM symbols WHERE ticker = ?)")
            .bind(&query)
            .fetch_one(&state.pool)
            .await
            .unwrap_or(false);

    let extra = minijinja::context! {
        title => "Search",
        q => raw.trim(),
        kind => kind,
        results => results,
        result_count => result_count,
        asof => asof,
        show_add => show_add,
        add_ticker => query,
    };
    render(&state, "pages/search.html", "/search", extra)
}

/// Fill in the `strength` badge for the stock cards in `cards`, in one batch
/// query over their stored SEC fundamentals. Non-stock cards are left
/// untouched. The badge reflects fundamental strength only — the home page is
/// where the trajectory half is read — so no daily-close series is needed.
async fn attach_standings(state: &AppState, cards: &mut [Card]) {
    let stock_tickers: Vec<&str> = cards
        .iter()
        .filter(|c| c.kind == "stock")
        .map(|c| c.ticker.as_str())
        .collect();
    if stock_tickers.is_empty() {
        return;
    }

    // The `IN` list is built from tickers already in `symbols`, not raw user
    // input, so the placeholder count is bounded and safe.
    let placeholders = vec!["?"; stock_tickers.len()].join(",");
    let sql = format!(
        "SELECT ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end \
         FROM fundamentals WHERE ticker IN ({placeholders})"
    );
    let mut q = sqlx::query_as::<_, (String, String, String, i64, Option<i64>, f64, String)>(&sql);
    for t in &stock_tickers {
        q = q.bind(*t);
    }
    let fact_rows = q.fetch_all(&state.pool).await.unwrap_or_default();

    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();
    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {
        facts.entry(ticker).or_default().push(models::FundFact {
            metric,
            period,
            fiscal_year,
            fiscal_qtr,
            value,
            period_end,
        });
    }

    for card in cards.iter_mut().filter(|c| c.kind == "stock") {
        card.strength = facts.get(&card.ticker).and_then(|f| {
            let inputs = models::latest_annual_inputs(f, card.price)?;
            compute::standing(&compute::compute_ratios(&inputs), &[])
        });
    }
}