# Theme only; all other functions are namespaced with pkg::fun library(shinythemes) # ---------------- CSS: reduce layout thrash ---------------- app_head <- shiny::tags$head( shiny::tags$style(shiny::HTML(" .sidebar-panel, .well { will-change: contents; } .form-group { margin-bottom: 10px; } ")) ) # ---------------- Helpers ---------------- parse_clean <- function(x) readr::parse_number(as.character(x)) # Detect columns that LOOK numeric (e.g., '$1.2B', '12.3%', '1,234') get_numeric_candidates <- function(df, min_non_na = 1) { keep <- sapply(df, function(x) { parsed <- suppressWarnings(readr::parse_number(as.character(x))) sum(!is.na(parsed)) >= min_non_na }) names(df)[keep] } # Quick DCF (10y two-stage + terminal multiple) quick_dcf_value_per_share <- function( fcf_t0, growth_1_5, growth_6_10, discount, terminal_mult, shares_out, net_debt = 0 ){ if (!is.finite(fcf_t0) || fcf_t0 <= 0 || !is.finite(shares_out) || shares_out <= 0) return(NA_real_) fcf <- numeric(10) fcf[1] <- fcf_t0 * (1 + growth_1_5) for (t in 2:5) fcf[t] <- fcf[t-1] * (1 + growth_1_5) for (t in 6:10) fcf[t] <- fcf[t-1] * (1 + growth_6_10) term_val <- fcf[10] * terminal_mult disc <- (1 + discount)^(1:10) ev <- sum(fcf / disc) + term_val / disc[10] as.numeric((ev - net_debt) / shares_out) } clean_data <- function(df){ names(df) <- trimws(names(df)) num_cols <- c( "Analyst Price Target","Analyst Price Target %", "One Day%","One Month%","Three Month%","Six Month%","One Year%", "Year To Date%","3 Years%","5 Years%","Price", "Market Cap","Relative Volume","P/E Ratio", "Best Price Target Upside","Beta","PEG Ratio", "Enterprise Value (Last Report)","Employees","Smart Score", "Outstanding Shares","Net Debt","Free Cash Flow","Revenue" ) df <- df |> dplyr::mutate(dplyr::across(tidyr::any_of(num_cols), parse_clean)) |> dplyr::mutate(dplyr::across(where(is.character), ~ tidyr::replace_na(.x, "Unknown"))) # Parse any other numeric-looking columns extra_numeric <- setdiff(get_numeric_candidates(df), num_cols) if (length(extra_numeric)) { df <- df |> dplyr::mutate(dplyr::across(dplyr::all_of(extra_numeric), ~ suppressWarnings(readr::parse_number(as.character(.x))))) } numeric_columns <- names(df)[sapply(df, is.numeric)] # Impute by Sector if present; else global median if ("Sector" %in% names(df)) { df <- df |> dplyr::group_by(.data$Sector) |> dplyr::mutate(dplyr::across(dplyr::all_of(numeric_columns), ~ ifelse( is.na(.x), ifelse(all(is.na(.x)), stats::median(df[[dplyr::cur_column()]], na.rm = TRUE), stats::median(.x, na.rm = TRUE)), .x))) |> dplyr::ungroup() } else { df <- df |> dplyr::mutate(dplyr::across(dplyr::all_of(numeric_columns), ~ ifelse(is.na(.x), stats::median(.x, na.rm = TRUE), .x))) } if (all(c("Enterprise Value (Last Report)","Market Cap") %in% names(df))) { df <- df |> dplyr::mutate(EV_MarketCap = `Enterprise Value (Last Report)` / `Market Cap`) } df } dual_range_ui <- function(minId, maxId, sliderId, label, minV, maxV, stepV){ shiny::tagList( shiny::fluidRow( shiny::column(6, shiny::numericInput(minId, paste(label, "Min:"), value = minV, step = stepV)), shiny::column(6, shiny::numericInput(maxId, paste(label, "Max:"), value = maxV, step = stepV)) ), shiny::sliderInput(sliderId, paste("Range for", label), min = minV, max = maxV, value = c(minV, maxV), step = stepV) ) } # Debounced, loop-safe two-way sync sync_dual <- function(input, session, sliderId, minId, maxId, delay_ms = 200){ min_rx <- shiny::debounce(shiny::reactive(input[[minId]]), delay_ms) max_rx <- shiny::debounce(shiny::reactive(input[[maxId]]), delay_ms) # Slider -> numerics shiny::observeEvent(input[[sliderId]], { v <- input[[sliderId]]; if (is.null(v) || length(v) != 2) return() if (!identical(input[[minId]], v[1])) shiny::updateNumericInput(session, minId, value = v[1]) if (!identical(input[[maxId]], v[2])) shiny::updateNumericInput(session, maxId, value = v[2]) }, ignoreInit = TRUE, priority = 10) # Numerics (debounced) -> slider shiny::observeEvent({ min_rx(); max_rx() }, { s <- isolate(input[[sliderId]]); if (is.null(s) || length(s) != 2) return() a <- suppressWarnings(as.numeric(min_rx())) b <- suppressWarnings(as.numeric(max_rx())) if (!is.finite(a) || !is.finite(b)) return() if (a > b) { tmp <- a; a <- b; b <- tmp } new_val <- c(a, b) if (!identical(s, new_val)) { shiny::freezeReactiveValue(input, sliderId) shiny::updateSliderInput(session, sliderId, value = new_val) } }, ignoreInit = TRUE, priority = 10) } # ---------------- UI ---------------- ui <- shiny::fluidPage( app_head, theme = shinythemes::shinytheme("flatly"), shiny::titlePanel("Stock Screener • Stable Dual-Inputs + 2-min Valuation"), shiny::sidebarLayout( shiny::sidebarPanel( width = 4, # stabilize sidebar width shiny::fileInput("file", "Upload CSV", accept = ".csv"), shiny::uiOutput("sectorFilterUI"), shiny::uiOutput("peRangeUI"), shiny::uiOutput("smartScoreRangeUI"), shiny::uiOutput("employeeSliderUI"), # Placeholders so controls render immediately shiny::selectInput("xAxis", "X-Axis", choices = "-- load a CSV --", selected = "-- load a CSV --"), shiny::uiOutput("xRangeUI"), shiny::selectInput("yAxis", "Y-Axis", choices = "-- load a CSV --", selected = "-- load a CSV --"), shiny::uiOutput("yRangeUI"), shiny::checkboxGroupInput("positiveFilters", "Show only positive values for:", choices = c("One Day%","One Month%","Three Month%","Six Month%","Year To Date%","5 Years%")), shiny::checkboxInput("showTable", "Display filtered table", FALSE), shiny::downloadButton("downloadReport", "Download HTML Report"), shiny::downloadButton("downloadData", "Download Filtered CSV") ), shiny::mainPanel( shiny::tabsetPanel( shiny::tabPanel("Scatter Plot", plotly::plotlyOutput("customPlot", height = "600px")), shiny::tabPanel("Outliers", DT::DTOutput("outlierTable")), shiny::tabPanel("Correlation Heatmap", shiny::plotOutput("heatmapPlot", height = "600px")), shiny::tabPanel("Filtered Table", shiny::conditionalPanel("input.showTable == true", DT::DTOutput("filteredTable"))), # 2-minute Valuation shiny::tabPanel("Valuation (2-min)", shiny::fluidRow( shiny::column( width = 4, shiny::uiOutput("val_ticker_ui"), shiny::hr(), shiny::numericInput("val_fcf", "Current FCF (or Owner Earnings)", value = NA, min = 0, step = 1), shiny::numericInput("val_growth_1_5", "Growth Y1–5 (decimal, e.g., 0.10 = 10%)", value = 0.10, step = 0.01), shiny::numericInput("val_growth_6_10","Growth Y6–10 (decimal)", value = 0.05, step = 0.01), shiny::numericInput("val_discount", "Discount Rate (decimal)", value = 0.10, step = 0.005), shiny::numericInput("val_term_mult","Terminal EV/FCF Multiple (Year 10)", value = 15, step = 0.5), shiny::numericInput("val_shares", "Shares Outstanding", value = NA, min = 0, step = 1), shiny::numericInput("val_net_debt","Net Debt (Debt - Cash)", value = 0, step = 1), shiny::actionButton("val_calc", "Calculate") ), shiny::column( width = 8, shiny::uiOutput("val_summary_ui"), shiny::br(), shiny::plotOutput("val_heatmap", height = "400px"), shiny::br(), shiny::downloadButton("val_download", "Download Valuation Report") ) ) ) ) ) ) ) # ---------------- Server ---------------- server <- function(input, output, session){ # Robust CSV reader: try comma, fallback to semicolon dataset <- shiny::reactive({ shiny::req(input$file) df_try <- tryCatch(readr::read_csv(input$file$datapath, show_col_types = FALSE), error = function(e) NULL) if (is.null(df_try) || ncol(df_try) <= 1) { df_try <- tryCatch(readr::read_delim(input$file$datapath, delim = ";", show_col_types = FALSE), error = function(e) NULL) } shiny::validate(shiny::need(!is.null(df_try) && ncol(df_try) > 0, "Could not read the CSV. Make sure it has a header row.")) clean_data(df_try) }) output$sectorFilterUI <- shiny::renderUI({ shiny::req(dataset()) if ("Sector" %in% names(dataset())) { shiny::selectInput("sectorFilter", "Sector", choices = c("All", sort(unique(dataset()$Sector))), selected = "All") } }) # Populate X/Y dropdowns (numeric + numeric-looking) shiny::observeEvent(dataset(), { df <- dataset() already_numeric <- names(df)[vapply(df, is.numeric, logical(1))] parsed_candidates <- get_numeric_candidates(df) num_cols <- sort(unique(c(already_numeric, parsed_candidates))) if (length(num_cols) == 0) { shiny::showNotification("No numeric columns detected in the uploaded CSV.", type = "error", duration = 8) shiny::updateSelectInput(session, "xAxis", choices = "-- no numeric columns --", selected = "-- no numeric columns --") shiny::updateSelectInput(session, "yAxis", choices = "-- no numeric columns --", selected = "-- no numeric columns --") return() } default_x <- intersect(c("P/E Ratio","Price","Market Cap"), num_cols) default_y <- intersect(c("Smart Score","Analyst Price Target %","Analyst Price Target"), num_cols) shiny::onFlushed(function() { shiny::updateSelectInput(session, "xAxis", choices = num_cols, selected = if (length(default_x)) default_x[1] else num_cols[1]) shiny::updateSelectInput(session, "yAxis", choices = num_cols, selected = if (length(default_y)) default_y[1] else num_cols[1]) }, once = TRUE) }, ignoreInit = FALSE) # -------- Range UIs (parse_number before range()) -------- output$peRangeUI <- shiny::renderUI({ shiny::req(dataset()); if (!"P/E Ratio" %in% names(dataset())) return(NULL) vals <- suppressWarnings(readr::parse_number(as.character(dataset()[["P/E Ratio"]]))) rng <- round(range(vals, na.rm = TRUE), 2) dual_range_ui("peMin","peMax","peRange","P/E Ratio", rng[1], rng[2], 0.01) }) output$smartScoreRangeUI <- shiny::renderUI({ dual_range_ui("smartMin","smartMax","smartScoreRange","Smart Score", 0, 10, 1) }) output$employeeSliderUI <- shiny::renderUI({ shiny::req(dataset()); if (!"Employees" %in% names(dataset())) return(NULL) vals <- suppressWarnings(readr::parse_number(as.character(dataset()[["Employees"]]))) rng <- round(range(vals, na.rm = TRUE), 0) dual_range_ui("empMin","empMax","employeeRange","Employees", rng[1], rng[2], 1) }) output$xRangeUI <- shiny::renderUI({ shiny::req(dataset(), input$xAxis) vals <- suppressWarnings(readr::parse_number(as.character(dataset()[[input$xAxis]]))) rng <- round(range(vals, na.rm = TRUE), 2) shiny::validate(shiny::need(all(is.finite(rng)), paste("No numeric data detected for", input$xAxis))) dual_range_ui("xMin","xMax","xRange", input$xAxis, rng[1], rng[2], 0.01) }) output$yRangeUI <- shiny::renderUI({ shiny::req(dataset(), input$yAxis) vals <- suppressWarnings(readr::parse_number(as.character(dataset()[[input$yAxis]]))) rng <- round(range(vals, na.rm = TRUE), 2) shiny::validate(shiny::need(all(is.finite(rng)), paste("No numeric data detected for", input$yAxis))) dual_range_ui("yMin","yMax","yRange", input$yAxis, rng[1], rng[2], 0.01) }) # -------- Stable sync for all pairs -------- shiny::observe({ if (!is.null(input$peRange)) sync_dual(input, session, "peRange","peMin","peMax") }) shiny::observe({ if (!is.null(input$smartScoreRange)) sync_dual(input, session, "smartScoreRange","smartMin","smartMax") }) shiny::observe({ if (!is.null(input$employeeRange)) sync_dual(input, session, "employeeRange","empMin","empMax") }) shiny::observe({ if (!is.null(input$xRange)) sync_dual(input, session, "xRange","xMin","xMax") }) shiny::observe({ if (!is.null(input$yRange)) sync_dual(input, session, "yRange","yMin","yMax") }) # -------- Filtering (parse numbers before filtering) -------- filteredData <- shiny::reactive({ shiny::req(dataset(), input$xAxis, input$yAxis) d <- dataset() if (!is.null(input$peRange) && "P/E Ratio" %in% names(d)) { pe <- suppressWarnings(readr::parse_number(as.character(d[["P/E Ratio"]]))) d <- d[!is.na(pe) & pe >= input$peRange[1] & pe <= input$peRange[2], , drop = FALSE] } if (!is.null(input$smartScoreRange) && "Smart Score" %in% names(d)) { ss <- suppressWarnings(readr::parse_number(as.character(d[["Smart Score"]]))) d <- d[!is.na(ss) & ss >= input$smartScoreRange[1] & ss <= input$smartScoreRange[2], , drop = FALSE] } xv <- suppressWarnings(readr::parse_number(as.character(d[[input$xAxis]]))) yv <- suppressWarnings(readr::parse_number(as.character(d[[input$yAxis]]))) if (!is.null(input$xRange)) d <- d[!is.na(xv) & xv >= input$xRange[1] & xv <= input$xRange[2], , drop = FALSE] if (!is.null(input$yRange)) d <- d[!is.na(yv) & yv >= input$yRange[1] & yv <= input$yRange[2], , drop = FALSE] if (!is.null(input$employeeRange) && "Employees" %in% names(d)) { emp <- suppressWarnings(readr::parse_number(as.character(d[["Employees"]]))) d <- d[!is.na(emp) & emp >= input$employeeRange[1] & emp <= input$employeeRange[2], , drop = FALSE] } for (col in input$positiveFilters) { if (col %in% names(d)) { pv <- suppressWarnings(readr::parse_number(as.character(d[[col]]))) d <- d[!is.na(pv) & pv > 0, , drop = FALSE] } } if (!is.null(input$sectorFilter) && "Sector" %in% names(d) && input$sectorFilter != "All") { d <- dplyr::filter(d, Sector == input$sectorFilter) } d }) # -------- Outputs -------- output$customPlot <- plotly::renderPlotly({ shiny::req(input$xAxis, input$yAxis) plotly::plot_ly( filteredData(), x = ~suppressWarnings(readr::parse_number(as.character(get(input$xAxis)))), y = ~suppressWarnings(readr::parse_number(as.character(get(input$yAxis)))), text = ~Ticker, type = 'scatter', mode = 'markers+text', textposition = 'top center' ) |> plotly::layout(title = paste(input$xAxis, "vs", input$yAxis), xaxis = list(title = input$xAxis), yaxis = list(title = input$yAxis)) }) output$filteredTable <- DT::renderDT({ shiny::req(input$showTable) DT::datatable(filteredData(), options = list(scrollX = TRUE)) }) output$outlierTable <- DT::renderDT({ shiny::req(filteredData(), input$xAxis, input$yAxis) d <- filteredData() x <- suppressWarnings(readr::parse_number(as.character(d[[input$xAxis]]))) y <- suppressWarnings(readr::parse_number(as.character(d[[input$yAxis]]))) out <- d[ (x < stats::quantile(x, 0.05, na.rm = TRUE) | x > stats::quantile(x, 0.95, na.rm = TRUE)) | (y < stats::quantile(y, 0.05, na.rm = TRUE) | y > stats::quantile(y, 0.95, na.rm = TRUE)), , drop = FALSE ] DT::datatable(out, options = list(scrollX = TRUE)) }) output$heatmapPlot <- shiny::renderPlot({ shiny::req(filteredData()) num <- dplyr::select(filteredData(), dplyr::where(is.numeric)) if (ncol(num) < 2) return(NULL) corr <- stats::cor(num, use = "complete.obs") m <- reshape2::melt(corr) ggplot2::ggplot(m, ggplot2::aes(Var1, Var2, fill = value)) + ggplot2::geom_tile() + ggplot2::scale_fill_gradient2(low = "blue", mid = "white", high = "red", midpoint = 0) + ggplot2::theme_minimal() + ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1)) + ggplot2::labs(title = "Correlation Heatmap", x = "", y = "") }) # ----- Valuation tab ----- output$val_ticker_ui <- shiny::renderUI({ shiny::req(dataset()) df <- dataset() choice_col <- if ("Ticker" %in% names(df)) "Ticker" else if ("Name" %in% names(df)) "Name" else NULL if (is.null(choice_col)) shiny::helpText("No Ticker/Name column found.") else shiny::selectInput("val_ticker", "Select Ticker/Name", choices = unique(df[[choice_col]])) }) shiny::observeEvent(input$val_ticker, { shiny::req(dataset(), input$val_ticker) df <- dataset() row <- df[(df$Ticker == input$val_ticker) | (df$Name == input$val_ticker), , drop = FALSE] if (nrow(row) == 0) return() fcf_guess <- if ("Free Cash Flow" %in% names(row)) row$`Free Cash Flow`[1] else NA if (!is.finite(fcf_guess) || is.na(fcf_guess) || fcf_guess <= 0) { if (all(c("Market Cap","P/E Ratio") %in% names(row))) { if (is.finite(row$`Market Cap`[1]) && is.finite(row$`P/E Ratio`[1]) && row$`P/E Ratio`[1] > 0) { fcf_guess <- 0.8 * (row$`Market Cap`[1] / row$`P/E Ratio`[1]) } } } shares_guess <- if ("Outstanding Shares" %in% names(row)) row$`Outstanding Shares`[1] else NA if (!is.finite(shares_guess) || is.na(shares_guess) || shares_guess <= 0) { if (all(c("Market Cap","Price") %in% names(row))) { if (is.finite(row$`Market Cap`[1]) && is.finite(row$Price[1]) && row$Price[1] > 0) { shares_guess <- row$`Market Cap`[1] / row$Price[1] } } } net_debt_guess <- if ("Net Debt" %in% names(row)) row$`Net Debt`[1] else 0 shiny::updateNumericInput(session, "val_fcf", value = if (is.finite(fcf_guess)) round(fcf_guess, 2) else NA) shiny::updateNumericInput(session, "val_shares", value = if (is.finite(shares_guess)) round(shares_guess, 0) else NA) shiny::updateNumericInput(session, "val_net_debt", value = if (is.finite(net_debt_guess)) round(net_debt_guess, 2) else 0) }, ignoreInit = TRUE) output$val_summary_ui <- shiny::renderUI({ shiny::req(input$val_calc) shiny::isolate({ price_now <- NA_real_ df <- dataset() if ("Price" %in% names(df) && !is.null(input$val_ticker)) { row <- df[(df$Ticker == input$val_ticker) | (df$Name == input$val_ticker), , drop = FALSE] if (nrow(row)) price_now <- row$Price[1] } iv <- quick_dcf_value_per_share( fcf_t0 = input$val_fcf, growth_1_5 = input$val_growth_1_5, growth_6_10 = input$val_growth_6_10, discount = input$val_discount, terminal_mult= input$val_term_mult, shares_out = input$val_shares, net_debt = input$val_net_debt ) mos <- if (is.finite(iv) && is.finite(price_now)) (iv - price_now) / price_now else NA_real_ verdict <- if (is.finite(mos)) { if (mos >= 0.3) "BUY (≥30% MOS)" else if (mos >= 0.0) "HOLD (0–30% MOS)" else "SELL (<0% MOS)" } else "—" shiny::tagList( shiny::h4("Valuation Summary"), shiny::HTML(sprintf("Intrinsic Value / Share: %s
", if (is.finite(iv)) format(round(iv, 2), big.mark = ",") else "—")), shiny::HTML(sprintf("Current Price: %s
", if (is.finite(price_now)) format(round(price_now, 2), big.mark = ",") else "—")), shiny::HTML(sprintf("Margin of Safety: %s", if (is.finite(mos)) paste0(round(100*mos, 1), "%") else "—")), shiny::hr(), shiny::strong("Verdict: "), shiny::span(verdict) ) }) }) output$val_heatmap <- shiny::renderPlot({ shiny::req(input$val_calc) shiny::isolate({ disc_grid <- seq(max(0.05, input$val_discount - 0.03), input$val_discount + 0.03, by = 0.01) mult_grid <- seq(max(5, input$val_term_mult - 5), input$val_term_mult + 5, by = 1) grid <- expand.grid(discount = disc_grid, multiple = mult_grid) grid$iv <- mapply(function(d, m) { quick_dcf_value_per_share( fcf_t0 = input$val_fcf, growth_1_5 = input$val_growth_1_5, growth_6_10 = input$val_growth_6_10, discount = d, terminal_mult= m, shares_out = input$val_shares, net_debt = input$val_net_debt ) }, grid$discount, grid$multiple) ggplot2::ggplot(grid, ggplot2::aes(x = discount, y = multiple, fill = iv)) + ggplot2::geom_tile() + ggplot2::scale_fill_gradient(low = "white", high = "darkgreen", na.value = "grey90") + ggplot2::scale_x_continuous(labels = scales::percent) + ggplot2::labs(title = "Intrinsic Value Sensitivity", x = "Discount Rate", y = "Terminal EV/FCF Multiple", fill = "IV/Share") + ggplot2::theme_minimal() }) }) # ----- Downloads ----- output$val_download <- shiny::downloadHandler( filename = function(){ "valuation_report.html" }, content = function(file){ tempRmd <- tempfile(fileext = ".Rmd") report <- sprintf(" --- title: '2-Minute Valuation Report' output: html_document --- ```{r setup, include=FALSE} library(dplyr); library(ggplot2); library(scales) quick_dcf_value_per_share <- %s ``` ## Inputs - Ticker/Name: %s - FCF now: %s - Growth Y1–5: %s - Growth Y6–10: %s - Discount: %s - Terminal EV/FCF: %s - Shares Out: %s - Net Debt: %s ```{r} iv <- quick_dcf_value_per_share(%s, %s, %s, %s, %s, %s, %s) iv ``` ", deparse(quick_dcf_value_per_share), ifelse(is.null(input$val_ticker), "—", input$val_ticker), input$val_fcf, input$val_growth_1_5, input$val_growth_6_10, input$val_discount, input$val_term_mult, input$val_shares, input$val_net_debt, input$val_fcf, input$val_growth_1_5, input$val_growth_6_10, input$val_discount, input$val_term_mult, input$val_shares, input$val_net_debt ) writeLines(report, tempRmd) rmarkdown::render(tempRmd, output_file = file, envir = new.env(parent = globalenv())) } ) output$downloadReport <- shiny::downloadHandler( filename = function(){ "stock_analysis_report.html" }, content = function(file){ tempRmd <- tempfile(fileext = ".Rmd") report <- " --- title: 'Stock Analysis Report' output: html_document --- ```{r setup, include=FALSE} library(dplyr) ``` ```{r} df <- read.csv('${input$file$datapath}') summary(df) ``` " writeLines(report, tempRmd) rmarkdown::render(tempRmd, output_file = file, envir = new.env(parent = globalenv())) } ) output$downloadData <- shiny::downloadHandler( filename = function(){ 'filtered_stock_data.csv' }, content = function(file){ utils::write.csv(filteredData(), file, row.names = FALSE) } ) } shiny::shinyApp(ui = ui, server = server)