Add app.R

this is shiny app for analysis of CSV file
This commit is contained in:
root 2025-08-31 05:10:51 +00:00
commit e6cb19c86f

545
app.R Normal file
View File

@ -0,0 +1,545 @@
# 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 Y15 (decimal, e.g., 0.10 = 10%)", value = 0.10, step = 0.01),
shiny::numericInput("val_growth_6_10","Growth Y610 (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 (030% 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 Y15: %s
- Growth Y610: %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)