library(shiny)
library(quantmod)
library(forecast)
library(lubridate)
library(ggplot2)
library(dplyr)
library(readr)
library(scales) # Added for better number formatting
# Custom CSS for styling
custom_css <- "
/* Main color scheme from Coolors */
:root {
--primary-dark: #003366;
--primary-light: #00c4ff;
--secondary: #00a0a0;
--background: #f0f7ff;
--text: #4a5568;
--card-bg: #ffffff;
--border: #cbd5e0;
}
/* Overall app styling */
body {
background-color: var(--background) !important;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--text);
}
/* Header branding */
.app-title {
background: linear-gradient(90deg, var(--primary-dark) 0%, var(--secondary) 100%);
color: white !important;
padding: 15px 25px !important;
margin-bottom: 20px !important;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.app-title h2 {
color: white !important;
margin: 0 !important;
font-weight: 600 !important;
}
.brand-subtitle {
font-size: 14px;
opacity: 0.9;
margin-top: 5px !important;
}
/* Sidebar panel */
.sidebar {
background-color: var(--card-bg) !important;
border-radius: 10px !important;
padding: 20px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid var(--border) !important;
}
.sidebar h3 {
color: var(--primary-dark) !important;
border-bottom: 2px solid var(--primary-light);
padding-bottom: 10px;
margin-top: 0;
}
/* Input controls */
.form-group label {
color: var(--primary-dark) !important;
font-weight: 600 !important;
margin-bottom: 5px !important;
}
.form-control {
border: 1px solid var(--border) !important;
border-radius: 6px !important;
padding: 8px 12px !important;
}
.form-control:focus {
border-color: var(--primary-light) !important;
box-shadow: 0 0 0 3px rgba(0, 196, 255, 0.1) !important;
}
/* Slider customization */
.irs-bar, .irs-bar-edge {
background: var(--secondary) !important;
border: none !important;
}
.irs-from, .irs-to, .irs-single {
background: var(--primary-dark) !important;
}
.irs-grid-pol {
background: var(--border) !important;
}
/* Main panel */
.main-panel {
padding-left: 25px !important;
}
/* Tab panels */
.nav-tabs {
border-bottom: 2px solid var(--border) !important;
}
.nav-tabs > li > a {
color: var(--text) !important;
font-weight: 500 !important;
border-radius: 6px 6px 0 0 !important;
margin-right: 5px !important;
border: 1px solid transparent !important;
}
.nav-tabs > li.active > a,
.nav-tabs > li.active > a:hover,
.nav-tabs > li.active > a:focus {
color: var(--primary-dark) !important;
background-color: var(--card-bg) !important;
border: 1px solid var(--border) !important;
border-bottom-color: transparent !important;
font-weight: 600 !important;
}
.tab-content {
background-color: var(--card-bg) !important;
padding: 25px !important;
border-radius: 0 8px 8px 8px !important;
border: 1px solid var(--border) !important;
border-top: none !important;
min-height: 400px;
}
/* Buttons and action items */
.btn-primary {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--secondary) 100%) !important;
border: none !important;
border-radius: 6px !important;
font-weight: 500 !important;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--secondary) 0%, var(--primary-dark) 100%) !important;
transform: translateY(-1px);
transition: all 0.3s ease;
}
/* Help text */
.help-block {
color: var(--text) !important;
font-size: 13px !important;
font-style: italic !important;
margin-top: 5px !important;
}
/* Plot containers */
.shiny-plot-output {
background-color: white !important;
border-radius: 8px !important;
padding: 15px !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--border) !important;
}
/* HR styling */
hr {
border-top: 1px solid var(--border) !important;
margin: 25px 0 !important;
}
/* Brand header */
.brand-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.brand-logo {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--primary-dark), var(--primary-light));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
color: white;
font-weight: bold;
font-size: 18px;
}
.brand-text {
flex: 1;
}
.brand-text h3 {
margin: 0;
color: var(--primary-dark);
font-weight: 600;
}
.brand-text p {
margin: 0;
color: var(--text);
font-size: 14px;
opacity: 0.8;
}
"
ui <- shinyUI(fluidPage(
# Include custom CSS
tags$head(
tags$style(HTML(custom_css))
),
# Custom Brand Header
div(class = "app-title",
div(class = "brand-header",
div(class = "brand-logo", "SA"),
div(class = "brand-text",
h3("Stock Analytics Dashboard"),
p("Advanced Anomaly Detection & Dividend Analysis")
)
)
),
sidebarLayout(
sidebarPanel(
h3("Stock Analysis Controls"),
# Stock Ticker Input
div(class = "form-group",
textInput("ticker",
label = tags$b("Stock Ticker Symbol:"),
value = "AAPL",
placeholder = "e.g., AAPL, GOOGL, MSFT")
),
# Date Range Input
div(class = "form-group",
dateRangeInput("dateRange",
label = tags$b("Analysis Period:"),
start = "2020-01-01",
end = Sys.Date(),
format = "yyyy-mm-dd",
startview = "year")
),
# Sensitivity Slider
div(class = "form-group",
sliderInput("alpha",
label = tags$b("Anomaly Sensitivity:"),
min = 0.03,
max = 0.5,
value = 0.05,
step = 0.05,
ticks = FALSE)
),
hr(),
# Help text with icons
div(class = "help-block",
icon("lightbulb", style = "color: #00c4ff;"),
tags$span(" Detects unusual price movements using ARIMA modeling")
),
div(class = "help-block",
icon("sliders-h", style = "color: #00a0a0;"),
tags$span(" Higher sensitivity = more anomalies detected")
),
div(class = "help-block",
icon("chart-line", style = "color: #003366;"),
tags$span(" Includes dividend history and yield comparison")
),
hr(),
# Quick stats placeholder
div(class = "quick-stats",
style = "background: linear-gradient(135deg, #f0f7ff, #e0efff); padding: 15px; border-radius: 8px;",
h5("Quick Stats", style = "color: #003366; margin-top: 0;"),
verbatimTextOutput("quickStats")
)
),
mainPanel(
tabsetPanel(
tabPanel(
title = icon("chart-line") %>% span(" Price Analysis"),
br(),
plotOutput("distPlot", height = "500px"),
hr(),
fluidRow(
column(6,
h4("Dividend History", style = "color: #003366; text-align: center;"),
plotOutput("dividendHistoryPlot", height = "400px")
),
column(6,
h4("Top 10 Dividend Yields", style = "color: #003366; text-align: center;"),
plotOutput("yieldSummaryPlot", height = "400px")
)
)
),
tabPanel(
title = icon("search") %>% span(" Anomaly Details"),
br(),
uiOutput("selectUI"),
plotOutput("quantPlot", height = "500px"),
hr(),
h4("Anomaly Summary", style = "color: #003366;"),
tableOutput("anomalySummary")
),
tabPanel(
title = icon("info-circle") %>% span(" About"),
br(),
div(class = "about-content",
style = "padding: 20px;",
h3("Stock Analytics Dashboard", style = "color: #003366;"),
p("This application provides advanced analytics for stock market data, featuring:"),
tags$ul(
tags$li(tags$b("Anomaly Detection:"), " Uses ARIMA modeling to identify unusual price movements"),
tags$li(tags$b("Dividend Analysis:"), " Tracks dividend history and compares yields"),
tags$li(tags$b("Interactive Exploration:"), " Drill down into specific anomalies for detailed analysis")
),
hr(),
h4("Methodology", style = "color: #00a0a0;"),
p("The anomaly detection algorithm:"),
tags$ol(
tags$li("Fetches historical stock price data from Yahoo Finance"),
tags$li("Applies log transformation to the adjusted closing prices"),
tags$li("Fits an ARIMA model using auto.arima() from the forecast package"),
tags$li("Identifies anomalies based on residual analysis"),
tags$li("Adjusts sensitivity using the alpha parameter")
),
hr(),
div(style = "text-align: center; margin-top: 30px;",
p(icon("database"), " Data Source: Yahoo Finance API"),
p(icon("code"), " Powered by R, Shiny, and Quantmod"),
p(style = "color: #4a5568; font-size: 12px; margin-top: 20px;",
"© Stock Analytics Dashboard | Color Scheme: #003366 #00c4ff #00a0a0 #f0f7ff #4a5568")
)
)
)
)
)
)
))
# Function to fit ARIMA model, detect largest residuals, and create plot
detect_anom = function(cur_symb = "AAPL",
alpha = 0.05,
start.date = "2020-01-01",
end.date = Sys.Date()){
tryCatch({
# Download stock data
getSymbols(cur_symb, from = start.date, to = end.date, auto.assign = TRUE, env = .GlobalEnv)
cur_data = get(cur_symb, envir = .GlobalEnv)[, 6]
# Create date sequence matching actual data length
dates = index(cur_data)
# Fit model to the log of the adjusted closing price
model = auto.arima(log(as.numeric(cur_data)))
estimate = fitted(model)
# Outliers are classified by those above the sensitivity level
anom_index = which(residuals(model) > alpha)
if(length(anom_index) > 0){
anom_dates = dates[anom_index]
mydata = data.frame(date = dates, value = as.numeric(cur_data))
points = data.frame(date = anom_dates, value = as.numeric(cur_data)[anom_index])
point.size = pmax(residuals(model)[anom_index] * 100, 2)
# Create plot with custom theme matching app colors
ggplot_1 =
ggplot(mydata, aes(date, value)) +
geom_line(col = "#00a0a0", linewidth = 1) +
geom_point(data = points, aes(date, value), size = point.size, col = "#00c4ff", alpha = 0.7) +
geom_text(data = points, aes(date, value), hjust = 0, vjust = -0.5,
label = format(points$date, "%Y-%m-%d"), size = 3, color = "#003366") +
ggtitle(paste(cur_symb, "Adjusted Closing Price with Anomalies")) +
xlab("Date") +
ylab("Adjusted Close (USD)") +
theme_minimal() +
theme(
plot.title = element_text(color = "#003366", face = "bold", size = 16),
axis.title = element_text(color = "#4a5568", face = "bold"),
axis.text = element_text(color = "#4a5568"),
panel.background = element_rect(fill = "#f0f7ff", color = NA),
plot.background = element_rect(fill = "white", color = "#cbd5e0"),
panel.grid.major = element_line(color = "#e2e8f0"),
panel.grid.minor = element_blank()
) +
scale_y_continuous(labels = scales::dollar)
} else {
mydata = data.frame(date = dates, value = as.numeric(cur_data))
ggplot_1 =
ggplot(mydata, aes(date, value)) +
geom_line(col = "#00a0a0", linewidth = 1) +
ggtitle(paste(cur_symb, "Adjusted Closing Price - No Anomalies Detected")) +
xlab("Date") +
ylab("Adjusted Close (USD)") +
theme_minimal() +
theme(
plot.title = element_text(color = "#003366", face = "bold", size = 16),
axis.title = element_text(color = "#4a5568", face = "bold"),
panel.background = element_rect(fill = "#f0f7ff", color = NA)
) +
scale_y_continuous(labels = scales::dollar)
anom_dates = character(0)
}
output = list(date = anom_dates, plot = ggplot_1, model = model)
return(output)
}, error = function(e){
return(list(date = character(0),
plot = ggplot() +
annotate("text", x = 0.5, y = 0.5,
label = paste("Error:", e$message),
color = "#003366", size = 6) +
theme_void(),
model = NULL))
})
}
# Creates stock info for "details" page
stockinfo = function(ticker, date){
tryCatch({
start.date = ymd(date) - months(1)
end.date = ymd(date) + months(1)
getSymbols(ticker, from = start.date, to = end.date, auto.assign = TRUE, env = .GlobalEnv)
# Create a custom ggplot instead of chartSeries
stock_data = get(ticker, envir = .GlobalEnv)
# Prepare data for plotting
df = data.frame(
Date = index(stock_data),
Open = as.numeric(Op(stock_data)),
High = as.numeric(Hi(stock_data)),
Low = as.numeric(Lo(stock_data)),
Close = as.numeric(Cl(stock_data))
)
# Highlight the anomaly date
anomaly_date = ymd(date)
ggplot(df, aes(x = Date)) +
geom_linerange(aes(ymin = Low, ymax = High), color = "#4a5568", size = 0.5) +
geom_rect(aes(xmin = Date - 0.3, xmax = Date + 0.3,
ymin = pmin(Open, Close), ymax = pmax(Open, Close),
fill = Close > Open)) +
geom_vline(xintercept = anomaly_date, color = "#00c4ff", linetype = "dashed", size = 1) +
scale_fill_manual(values = c("FALSE" = "#003366", "TRUE" = "#00a0a0"), guide = "none") +
ggtitle(paste(ticker, "Stock Chart Around", format(anomaly_date, "%Y-%m-%d"))) +
xlab("Date") +
ylab("Price (USD)") +
theme_minimal() +
theme(
plot.title = element_text(color = "#003366", face = "bold", size = 16),
axis.title = element_text(color = "#4a5568", face = "bold"),
panel.background = element_rect(fill = "#f0f7ff", color = NA),
plot.background = element_rect(fill = "white", color = "#cbd5e0")
)
}, error = function(e){
ggplot() +
annotate("text", x = 0.5, y = 0.5,
label = paste("Error loading chart:", e$message),
color = "#003366", size = 5) +
theme_void()
})
}
# Define server logic
server <- shinyServer(function(input, output, session) {
# Reactive expressions for data loading with fallback options
buffett_dividends_10y <- reactive({
# Try multiple possible file paths
possible_paths <- c(
"data/MAKE-MONEY-HUSTLE.csv",
"MAKE-MONEY-HUSTLE.csv",
"dividend_data.csv"
)
file_path <- NULL
for (path in possible_paths) {
if (file.exists(path)) {
file_path <- path
break
}
}
if (is.null(file_path)) {
# Return a sample data frame if no file is found
return(data.frame(
Ticker = c("AAPL", "MSFT", "GOOGL"),
Ex_Dividend_Date = as.Date(c("2023-01-01", "2023-02-01", "2023-03-01")),
Amount = c(0.24, 0.68, 0.00)
))
}
tryCatch({
read_csv(file_path) %>%
mutate(Ex_Dividend_Date = as.Date(Ex_Dividend_Date)) %>%
arrange(Ticker, Ex_Dividend_Date)
}, error = function(e) {
# Return sample data if reading fails
data.frame(
Ticker = c("AAPL", "MSFT", "GOOGL"),
Ex_Dividend_Date = as.Date(c("2023-01-01", "2023-02-01", "2023-03-01")),
Amount = c(0.24, 0.68, 0.00)
)
})
})
buffett_yield_summary <- reactive({
# Try multiple possible file paths
possible_paths <- c(
"data/DIVIDEND_YIELD.csv",
"DIVIDEND_YIELD.csv",
"yield_data.csv"
)
file_path <- NULL
for (path in possible_paths) {
if (file.exists(path)) {
file_path <- path
break
}
}
if (is.null(file_path)) {
# Return sample data if no file is found
set.seed(123)
return(data.frame(
Ticker = c("AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "JPM", "JNJ", "V", "WMT", "PG"),
divident_yield_percentage = runif(10, 0.5, 4.5)
))
}
tryCatch({
df <- read_csv(file_path)
# Handle different column names
if ("Dividend_Yield_%" %in% names(df)) {
df <- df %>% rename(divident_yield_percentage = `Dividend_Yield_%`)
} else if ("Dividend_Yield" %in% names(df)) {
df <- df %>% rename(divident_yield_percentage = Dividend_Yield)
} else if ("Yield" %in% names(df)) {
df <- df %>% rename(divident_yield_percentage = Yield)
}
df %>%
arrange(desc(divident_yield_percentage))
}, error = function(e) {
# Return sample data if reading fails
set.seed(123)
data.frame(
Ticker = c("AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "JPM", "JNJ", "V", "WMT", "PG"),
divident_yield_percentage = runif(10, 0.5, 4.5)
)
})
})
# Reactive data input for anomaly detection
dataInput <- reactive({
req(input$ticker, input$dateRange)
detect_anom(cur_symb = input$ticker,
alpha = input$alpha,
start.date = input$dateRange[1],
end.date = input$dateRange[2])
})
# Quick stats output
output$quickStats <- renderText({
req(dataInput())
dates <- dataInput()$date
paste(
"Ticker:", input$ticker, "\n",
"Anomalies detected:", length(dates), "\n",
if(length(dates) > 0) {
paste("Latest anomaly:", format(max(as.Date(dates)), "%b %d, %Y"))
} else {
"No anomalies in selected range"
}
)
})
output$distPlot <- renderPlot({
dataInput()$plot
})
# Reactive input selection for anomaly dates
output$selectUI <- renderUI({
dates = dataInput()$date
if(length(dates) > 0){
tagList(
div(class = "form-group",
selectInput("anomDates",
label = tags$b("Select Anomaly Date:"),
choices = as.character(dates),
selected = as.character(dates[1]))
),
hr(),
h4("Anomaly Details", style = "color: #003366;"),
verbatimTextOutput("anomalyDetails")
)
} else {
div(class = "alert alert-info",
style = "background-color: #f0f7ff; border-color: #00c4ff; color: #003366; padding: 15px; border-radius: 8px;",
icon("info-circle"),
" No anomalies detected in the selected range. Try adjusting the sensitivity or date range."
)
}
})
# Anomaly details text
output$anomalyDetails <- renderText({
req(input$anomDates, dataInput())
if(input$anomDates != "No anomalies detected"){
paste(
"Selected Anomaly Date:", input$anomDates, "\n",
"Ticker Symbol:", input$ticker, "\n",
"Sensitivity (alpha):", input$alpha, "\n\n",
"This anomaly represents an unusual price movement detected by the ARIMA model.",
"Click the chart tab to see detailed price action around this date."
)
}
})
output$quantPlot <- renderPlot({
req(input$anomDates)
if(input$anomDates != "No anomalies detected"){
stockinfo(ticker = input$ticker, date = input$anomDates)
} else {
ggplot() +
annotate("text", x = 0.5, y = 0.5,
label = "No anomalies to display",
color = "#003366", size = 6) +
theme_void()
}
})
# Anomaly summary table
output$anomalySummary <- renderTable({
dates = dataInput()$date
if(length(dates) > 0){
data.frame(
Date = as.character(dates),
Count = 1:length(dates),
Ticker = input$ticker,
`Detection Method` = "ARIMA Residual Analysis"
)
}
}, width = "100%")
# Chart for dividend history over time
output$dividendHistoryPlot <- renderPlot({
req(input$ticker)
dividends_data <- buffett_dividends_10y()
# Filter dividends for the selected ticker
ticker_dividends <- dividends_data |>
filter(Ticker == input$ticker)
if(nrow(ticker_dividends) > 0) {
ggplot(ticker_dividends, aes(x = Ex_Dividend_Date, y = Amount)) +
geom_line(col = "#00a0a0", linewidth = 1) +
geom_point(col = "#003366", size = 3, shape = 21, fill = "white") +
geom_area(fill = "#00a0a0", alpha = 0.2) +
ggtitle(paste(input$ticker, "Dividend History")) +
xlab("Ex-Dividend Date") +
ylab("Dividend Amount (USD)") +
theme_minimal() +
theme(
plot.title = element_text(color = "#003366", face = "bold", size = 14, hjust = 0.5),
axis.title = element_text(color = "#4a5568", face = "bold"),
axis.text = element_text(color = "#4a5568"),
panel.background = element_rect(fill = "#f0f7ff", color = NA),
plot.background = element_rect(fill = "white", color = "#cbd5e0"),
panel.grid.major = element_line(color = "#e2e8f0"),
panel.grid.minor = element_blank()
) +
scale_y_continuous(labels = scales::dollar_format(prefix = "$")) +
scale_x_date(date_labels = "%b %Y", date_breaks = "6 months")
} else {
# Create a message plot if no data
ggplot() +
annotate("text", x = 0.5, y = 0.5,
label = paste("No dividend data available for", input$ticker, "\nUsing sample data for demonstration"),
color = "#003366", size = 5, hjust = 0.5, vjust = 0.5) +
ggtitle(paste(input$ticker, "Dividend History")) +
theme_void() +
theme(
plot.title = element_text(color = "#003366", face = "bold", size = 14, hjust = 0.5)
)
}
})
# Chart for dividend yield summary comparison
output$yieldSummaryPlot <- renderPlot({
top_n <- 10 # Reduced from 30 to avoid RColorBrewer warning
yield_data <- buffett_yield_summary()
# Ensure we have data
if (nrow(yield_data) == 0) {
yield_data <- data.frame(
Ticker = c("AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "JPM", "JNJ", "V", "WMT", "PG"),
divident_yield_percentage = c(0.59, 0.81, 0.00, 0.00, 0.00, 2.48, 2.92, 0.76, 1.43, 2.42)
)
}
plot_data <- yield_data %>%
arrange(desc(divident_yield_percentage)) %>%
slice_head(n = top_n) %>%
mutate(
Ticker = factor(Ticker, levels = Ticker[order(divident_yield_percentage)]),
Highlight = ifelse(Ticker == input$ticker, "Current Ticker", "Other")
)
# Create a color palette that works for any number of items
# Use viridis or another continuous palette
base_color <- "#003366"
highlight_color <- "#00c4ff"
ggplot(plot_data, aes(x = Ticker, y = divident_yield_percentage)) +
geom_col(aes(fill = Highlight), show.legend = FALSE) +
geom_text(aes(label = sprintf("%.2f%%", divident_yield_percentage)),
hjust = -0.1, size = 3.5, color = "#4a5568") +
coord_flip() +
scale_fill_manual(values = c("Current Ticker" = highlight_color, "Other" = base_color)) +
ggtitle("Top Dividend Yields Comparison") +
xlab("Ticker Symbol") +
ylab("Dividend Yield (%)") +
theme_minimal() +
theme(
plot.title = element_text(color = "#003366", face = "bold", size = 14, hjust = 0.5),
axis.title = element_text(color = "#4a5568", face = "bold"),
axis.text = element_text(color = "#4a5568"),
axis.text.y = element_text(size = 10, face = "bold"),
panel.background = element_rect(fill = "#f0f7ff", color = NA),
plot.background = element_rect(fill = "white", color = "#cbd5e0"),
panel.grid.major = element_line(color = "#e2e8f0"),
panel.grid.minor = element_blank()
) +
scale_y_continuous(expand = expansion(mult = c(0, 0.15)))
})
})
# Run the app
shinyApp(ui = ui, server = server)