546 lines
24 KiB
R
546 lines
24 KiB
R
# 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("<b>Intrinsic Value / Share:</b> %s<br/>",
|
||
if (is.finite(iv)) format(round(iv, 2), big.mark = ",") else "—")),
|
||
shiny::HTML(sprintf("<b>Current Price:</b> %s<br/>",
|
||
if (is.finite(price_now)) format(round(price_now, 2), big.mark = ",") else "—")),
|
||
shiny::HTML(sprintf("<b>Margin of Safety:</b> %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)
|