root e6cb19c86f Add app.R
this is shiny app for analysis of CSV file
2025-08-31 05:10:51 +00:00

546 lines
24 KiB
R
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)