This project builds reproducible investment and class-survey datasets
from several external sources. Massive supplies the main daily
stock-price data, Alpha Vantage supplies company fundamentals, FRED
supplies macroeconomic indicators, and rvest collects
finance headlines. The report cleans and combines these sources,
calculates investment measures, creates charts, and exports CSV files
for the group.
required_packages <- c(
"httr", "jsonlite", "dplyr", "tidyr", "purrr", "readr",
"lubridate", "ggplot2", "quantmod", "rvest", "stringr",
"tidytext", "scales", "knitr", "zoo", "readxl"
)
missing_packages <- required_packages[
!required_packages %in% rownames(installed.packages())
]
if (length(missing_packages) > 0) {
install.packages(missing_packages, repos = "https://cloud.r-project.org")
}
invisible(lapply(required_packages, library, character.only = TRUE))
knitr::opts_chunk$set(
echo = TRUE,
message = FALSE,
warning = FALSE,
fig.width = 10,
fig.height = 6
)
dir.create("output", showWarnings = FALSE)
dir.create("figures", showWarnings = FALSE)
massive_key <- Sys.getenv("MASSIVE_API_KEY")
alpha_key <- Sys.getenv("ALPHAVANTAGE_API_KEY")
if (!nzchar(massive_key)) {
message("MASSIVE_API_KEY is missing. Yahoo Finance will be used as a fallback.")
}
if (!nzchar(alpha_key)) {
message("ALPHAVANTAGE_API_KEY is missing. Fundamentals will be skipped.")
}
ticker_table <- tibble::tribble(
~ticker, ~company_name, ~asset_type, ~portfolio_weight,
"AAPL", "Apple", "Stock", 0.20,
"MSFT", "Microsoft", "Stock", 0.20,
"NVDA", "NVIDIA", "Stock", 0.20,
"AMZN", "Amazon", "Stock", 0.15,
"JPM", "JPMorgan Chase", "Stock", 0.10,
"SPY", "SPDR S&P 500 ETF", "ETF", 0.15
)
tickers <- ticker_table$ticker
end_date <- Sys.Date() - 1
start_date <- end_date - lubridate::years(2) + lubridate::days(7)
risk_free_rate <- 0.04
initial_investment <- 10000
ticker_table %>%
knitr::kable(caption = "Selected investments and portfolio weights")
| ticker | company_name | asset_type | portfolio_weight |
|---|---|---|---|
| AAPL | Apple | Stock | 0.20 |
| MSFT | Microsoft | Stock | 0.20 |
| NVDA | NVIDIA | Stock | 0.20 |
| AMZN | Amazon | Stock | 0.15 |
| JPM | JPMorgan Chase | Stock | 0.10 |
| SPY | SPDR S&P 500 ETF | ETF | 0.15 |
The function below sends an HTTP GET request to Massive’s daily aggregate endpoint. It checks the HTTP status, checks the API response, converts timestamps to dates, and returns a consistent table.
get_massive_stock <- function(ticker, from_date, to_date, api_key) {
if (!nzchar(api_key)) {
stop("MASSIVE_API_KEY is not available.")
}
endpoint <- paste0(
"https://api.massive.com/v2/aggs/ticker/", ticker,
"/range/1/day/", from_date, "/", to_date
)
response <- httr::GET(
endpoint,
query = list(
adjusted = "true",
sort = "asc",
limit = 50000,
apiKey = api_key
),
httr::timeout(30)
)
httr::stop_for_status(response)
parsed <- jsonlite::fromJSON(
httr::content(response, "text", encoding = "UTF-8"),
simplifyDataFrame = TRUE
)
if (!is.null(parsed$error)) stop(parsed$error)
if (!is.null(parsed$message) && is.null(parsed$results)) stop(parsed$message)
if (is.null(parsed$results) || nrow(parsed$results) == 0) {
stop(paste("No Massive data returned for", ticker))
}
result <- as.data.frame(parsed$results)
tibble::tibble(
ticker = ticker,
date = as.Date(lubridate::as_datetime(result$t / 1000, tz = "America/New_York")),
open = as.numeric(result$o),
high = as.numeric(result$h),
low = as.numeric(result$l),
close = as.numeric(result$c),
volume = as.numeric(result$v),
vwap = if ("vw" %in% names(result)) as.numeric(result$vw) else NA_real_,
transactions = if ("n" %in% names(result)) as.numeric(result$n) else NA_real_,
data_source = "Massive API"
)
}
This fallback keeps the report usable when a key is absent or an
individual Massive request fails. The data_source column
clearly records which source supplied each ticker.
get_yahoo_stock <- function(ticker, from_date, to_date) {
x <- quantmod::getSymbols(
ticker,
src = "yahoo",
from = from_date,
to = to_date + 1,
auto.assign = FALSE,
warnings = FALSE
)
colnames(x) <- c("open", "high", "low", "close", "volume", "adjusted")
tibble::tibble(
ticker = ticker,
date = as.Date(zoo::index(x)),
open = as.numeric(x[, "open"]),
high = as.numeric(x[, "high"]),
low = as.numeric(x[, "low"]),
close = as.numeric(x[, "adjusted"]),
volume = as.numeric(x[, "volume"]),
vwap = NA_real_,
transactions = NA_real_,
data_source = "Yahoo Finance fallback"
)
}
stock_results <- purrr::map(tickers, function(symbol) {
tryCatch(
{
get_massive_stock(symbol, start_date, end_date, massive_key)
},
error = function(e) {
message("Massive request failed for ", symbol, ": ", conditionMessage(e))
message("Using Yahoo Finance fallback for ", symbol, ".")
get_yahoo_stock(symbol, start_date, end_date)
}
)
})
stock_prices <- dplyr::bind_rows(stock_results) %>%
dplyr::arrange(ticker, date) %>%
dplyr::distinct(ticker, date, .keep_all = TRUE)
readr::write_csv(stock_prices, "output/stock_prices.csv")
stock_prices %>%
dplyr::group_by(ticker, data_source) %>%
dplyr::summarise(
first_date = min(date),
last_date = max(date),
observations = dplyr::n(),
.groups = "drop"
) %>%
knitr::kable(caption = "Downloaded stock data")
| ticker | data_source | first_date | last_date | observations |
|---|---|---|---|---|
| AAPL | Massive API | 2024-08-09 | 2026-07-31 | 495 |
| AMZN | Massive API | 2024-08-09 | 2026-07-31 | 495 |
| JPM | Massive API | 2024-08-09 | 2026-07-31 | 495 |
| MSFT | Massive API | 2024-08-09 | 2026-07-31 | 495 |
| NVDA | Massive API | 2024-08-09 | 2026-07-31 | 495 |
| SPY | Yahoo Finance fallback | 2024-08-09 | 2026-07-31 | 495 |
data_quality <- stock_prices %>%
dplyr::group_by(ticker) %>%
dplyr::summarise(
observations = dplyr::n(),
duplicate_dates = sum(duplicated(date)),
missing_open = sum(is.na(open)),
missing_close = sum(is.na(close)),
missing_volume = sum(is.na(volume)),
nonpositive_close = sum(close <= 0, na.rm = TRUE),
first_date = min(date),
last_date = max(date),
.groups = "drop"
)
readr::write_csv(data_quality, "output/data_quality_summary.csv")
knitr::kable(data_quality, caption = "Stock-data quality summary")
| ticker | observations | duplicate_dates | missing_open | missing_close | missing_volume | nonpositive_close | first_date | last_date |
|---|---|---|---|---|---|---|---|---|
| AAPL | 495 | 0 | 0 | 0 | 0 | 0 | 2024-08-09 | 2026-07-31 |
| AMZN | 495 | 0 | 0 | 0 | 0 | 0 | 2024-08-09 | 2026-07-31 |
| JPM | 495 | 0 | 0 | 0 | 0 | 0 | 2024-08-09 | 2026-07-31 |
| MSFT | 495 | 0 | 0 | 0 | 0 | 0 | 2024-08-09 | 2026-07-31 |
| NVDA | 495 | 0 | 0 | 0 | 0 | 0 | 2024-08-09 | 2026-07-31 |
| SPY | 495 | 0 | 0 | 0 | 0 | 0 | 2024-08-09 | 2026-07-31 |
technical_indicators <- stock_prices %>%
dplyr::group_by(ticker) %>%
dplyr::arrange(date, .by_group = TRUE) %>%
dplyr::mutate(
daily_return = close / dplyr::lag(close) - 1,
sma_20 = zoo::rollmean(close, 20, fill = NA, align = "right"),
sma_50 = zoo::rollmean(close, 50, fill = NA, align = "right"),
average_volume_20 = zoo::rollmean(volume, 20, fill = NA, align = "right"),
rolling_volatility_20 = zoo::rollapply(
daily_return,
width = 20,
FUN = function(x) stats::sd(x, na.rm = TRUE) * sqrt(252),
fill = NA,
align = "right"
)
) %>%
dplyr::ungroup()
daily_returns <- technical_indicators %>%
dplyr::select(ticker, date, daily_return) %>%
dplyr::filter(!is.na(daily_return))
readr::write_csv(daily_returns, "output/daily_returns.csv")
readr::write_csv(technical_indicators, "output/technical_indicators.csv")
risk_metrics <- technical_indicators %>%
dplyr::filter(!is.na(daily_return)) %>%
dplyr::group_by(ticker) %>%
dplyr::summarise(
start_date = min(date),
end_date = max(date),
trading_days = dplyr::n(),
total_return = prod(1 + daily_return, na.rm = TRUE) - 1,
annualized_return = prod(1 + daily_return, na.rm = TRUE)^(252 / dplyr::n()) - 1,
annualized_volatility = stats::sd(daily_return, na.rm = TRUE) * sqrt(252),
sharpe_ratio = (annualized_return - risk_free_rate) / annualized_volatility,
max_drawdown = min(
cumprod(1 + daily_return) / cummax(cumprod(1 + daily_return)) - 1,
na.rm = TRUE
),
average_daily_volume = mean(volume, na.rm = TRUE),
.groups = "drop"
) %>%
dplyr::arrange(dplyr::desc(sharpe_ratio))
readr::write_csv(risk_metrics, "output/risk_metrics.csv")
risk_metrics %>%
dplyr::mutate(
dplyr::across(
c(total_return, annualized_return, annualized_volatility, max_drawdown),
scales::percent,
accuracy = 0.1
),
sharpe_ratio = round(sharpe_ratio, 2),
average_daily_volume = scales::comma(average_daily_volume)
) %>%
knitr::kable(caption = "Return and risk metrics")
| ticker | start_date | end_date | trading_days | total_return | annualized_return | annualized_volatility | sharpe_ratio | max_drawdown | average_daily_volume |
|---|---|---|---|---|---|---|---|---|---|
| JPM | 2024-08-12 | 2026-07-31 | 494 | 70.9% | 31.5% | 25.2% | 1.09 | -24.9% | 9,398,474 |
| SPY | 2024-08-12 | 2026-07-31 | 494 | 43.5% | 20.2% | 16.7% | 0.97 | -18.8% | 66,281,054 |
| NVDA | 2024-08-12 | 2026-07-31 | 494 | 91.6% | 39.4% | 45.6% | 0.78 | -36.9% | 211,587,888 |
| AMZN | 2024-08-12 | 2026-07-31 | 494 | 62.7% | 28.2% | 34.1% | 0.71 | -30.9% | 44,582,546 |
| AAPL | 2024-08-12 | 2026-07-31 | 494 | 42.9% | 20.0% | 28.8% | 0.55 | -33.4% | 51,837,965 |
| MSFT | 2024-08-12 | 2026-07-31 | 494 | 14.5% | 7.1% | 28.6% | 0.11 | -34.9% | 26,302,059 |
The portfolio uses the weights defined in the project-settings section of this R Markdown file. Returns are calculated only for dates shared by all investments.
returns_wide <- daily_returns %>%
tidyr::pivot_wider(names_from = ticker, values_from = daily_return) %>%
tidyr::drop_na(dplyr::all_of(tickers)) %>%
dplyr::arrange(date)
weights <- ticker_table$portfolio_weight
names(weights) <- ticker_table$ticker
weights <- weights[tickers]
weights <- weights / sum(weights)
portfolio_performance <- returns_wide %>%
dplyr::mutate(
portfolio_return = as.numeric(as.matrix(dplyr::select(., dplyr::all_of(tickers))) %*% weights),
portfolio_value = initial_investment * cumprod(1 + portfolio_return),
cumulative_return = portfolio_value / initial_investment - 1,
drawdown = portfolio_value / cummax(portfolio_value) - 1
) %>%
dplyr::select(date, portfolio_return, portfolio_value, cumulative_return, drawdown)
readr::write_csv(portfolio_performance, "output/portfolio_performance.csv")
portfolio_summary <- tibble::tibble(
metric = c(
"Beginning value", "Ending value", "Total return",
"Annualized return", "Annualized volatility", "Sharpe ratio", "Maximum drawdown"
),
value = c(
initial_investment,
dplyr::last(portfolio_performance$portfolio_value),
dplyr::last(portfolio_performance$cumulative_return),
prod(1 + portfolio_performance$portfolio_return)^(252 / nrow(portfolio_performance)) - 1,
stats::sd(portfolio_performance$portfolio_return) * sqrt(252),
(prod(1 + portfolio_performance$portfolio_return)^(252 / nrow(portfolio_performance)) - 1 - risk_free_rate) /
(stats::sd(portfolio_performance$portfolio_return) * sqrt(252)),
min(portfolio_performance$drawdown)
)
)
knitr::kable(portfolio_summary, digits = 3, caption = "Equal-weight portfolio summary")
| metric | value |
|---|---|
| Beginning value | 10000.000 |
| Ending value | 15818.207 |
| Total return | 0.582 |
| Annualized return | 0.264 |
| Annualized volatility | 0.228 |
| Sharpe ratio | 0.980 |
| Maximum drawdown | -0.239 |
fred_symbols <- c("FEDFUNDS", "CPIAUCSL", "UNRATE", "VIXCLS", "UMCSENT")
fred_list <- purrr::map(fred_symbols, function(symbol) {
x <- quantmod::getSymbols(
symbol,
src = "FRED",
from = start_date,
to = end_date,
auto.assign = FALSE
)
tibble::tibble(
date = as.Date(zoo::index(x)),
value = as.numeric(x),
indicator = symbol
)
})
fred_economic_data <- dplyr::bind_rows(fred_list) %>%
tidyr::pivot_wider(names_from = indicator, values_from = value) %>%
dplyr::arrange(date)
readr::write_csv(fred_economic_data, "output/fred_economic_data.csv")
fred_economic_data <- fred_economic_data %>%
dplyr::filter(
date >= max(date, na.rm = TRUE) - 183
) %>%
dplyr::slice_tail(n = 12) %>%
knitr::kable(
caption = "Recent FRED observations"
)
This section uses the OVERVIEW endpoint. It pauses
between requests to reduce the chance of exceeding a free-plan rate
limit. When the key is absent, an empty CSV is created so the report
still knits.
get_alpha_overview <- function(ticker, api_key) {
if (!nzchar(api_key)) stop("ALPHAVANTAGE_API_KEY is not available.")
response <- httr::GET(
"https://www.alphavantage.co/query",
query = list(`function` = "OVERVIEW", symbol = ticker, apikey = api_key),
httr::timeout(30)
)
httr::stop_for_status(response)
parsed <- jsonlite::fromJSON(
httr::content(response, "text", encoding = "UTF-8"),
simplifyVector = TRUE
)
if (length(parsed) == 0 || !is.null(parsed$Information) || !is.null(parsed$Note)) {
stop(paste("No Alpha Vantage overview returned for", ticker))
}
value_or_na <- function(name) {
if (!is.null(parsed[[name]]) && parsed[[name]] != "None") parsed[[name]] else NA_character_
}
tibble::tibble(
ticker = ticker,
company_name = value_or_na("Name"),
description = value_or_na("Description"),
exchange = value_or_na("Exchange"),
currency = value_or_na("Currency"),
country = value_or_na("Country"),
sector = value_or_na("Sector"),
industry = value_or_na("Industry"),
market_capitalization = suppressWarnings(as.numeric(value_or_na("MarketCapitalization"))),
pe_ratio = suppressWarnings(as.numeric(value_or_na("PERatio"))),
peg_ratio = suppressWarnings(as.numeric(value_or_na("PEGRatio"))),
book_value = suppressWarnings(as.numeric(value_or_na("BookValue"))),
dividend_per_share = suppressWarnings(as.numeric(value_or_na("DividendPerShare"))),
dividend_yield = suppressWarnings(as.numeric(value_or_na("DividendYield"))),
eps = suppressWarnings(as.numeric(value_or_na("EPS"))),
profit_margin = suppressWarnings(as.numeric(value_or_na("ProfitMargin"))),
beta = suppressWarnings(as.numeric(value_or_na("Beta"))),
fifty_two_week_high = suppressWarnings(as.numeric(value_or_na("52WeekHigh"))),
fifty_two_week_low = suppressWarnings(as.numeric(value_or_na("52WeekLow"))),
analyst_target_price = suppressWarnings(as.numeric(value_or_na("AnalystTargetPrice")))
)
}
if (nzchar(alpha_key)) {
fundamental_tickers <- ticker_table %>%
dplyr::filter(asset_type == "Stock") %>%
dplyr::pull(ticker)
fundamental_results <- vector("list", length(fundamental_tickers))
for (i in seq_along(fundamental_tickers)) {
symbol <- fundamental_tickers[i]
fundamental_results[[i]] <- tryCatch(
get_alpha_overview(symbol, alpha_key),
error = function(e) {
message("Alpha Vantage request failed for ", symbol, ": ", conditionMessage(e))
tibble::tibble(ticker = symbol)
}
)
if (i < length(fundamental_tickers)) Sys.sleep(12)
}
company_fundamentals <- dplyr::bind_rows(fundamental_results)
} else {
company_fundamentals <- tibble::tibble(
ticker = character(), company_name = character(), sector = character(),
industry = character(), market_capitalization = double(), pe_ratio = double(),
dividend_yield = double(), beta = double()
)
}
readr::write_csv(company_fundamentals, "output/company_fundamentals.csv")
if (nrow(company_fundamentals) > 0) {
company_fundamentals %>%
dplyr::select(dplyr::any_of(c(
"ticker", "company_name", "sector", "industry",
"market_capitalization", "pe_ratio", "dividend_yield", "beta"
))) %>%
knitr::kable(caption = "Alpha Vantage company fundamentals")
} else {
cat("Alpha Vantage fundamentals were skipped because no API key was available.")
}
| ticker |
|---|
| AAPL |
| MSFT |
| NVDA |
| AMZN |
| JPM |
The scraper tries multiple finance pages and several common headline
selectors. This is true HTML scraping with rvest. Because
websites change, the code fails gracefully and records the source that
worked.
absolute_url <- function(link, base_url) {
ifelse(
stringr::str_detect(link, "^https?://"),
link,
xml2::url_absolute(link, base_url)
)
}
scrape_headlines <- function(page_url, source_name) {
page <- rvest::read_html(
httr::GET(
page_url,
httr::user_agent("Mozilla/5.0 investment analytics class project"),
httr::timeout(30)
)
)
selectors <- c(
"h3 a", "h2 a", "a[data-testid*='headline']",
"a[href*='/news/']", "a[href*='/markets/']"
)
nodes <- purrr::map(selectors, ~ rvest::html_elements(page, .x)) %>%
purrr::list_flatten()
if (length(nodes) == 0) stop("No headline elements found.")
tibble::tibble(
headline = rvest::html_text2(nodes),
url = rvest::html_attr(nodes, "href")
) %>%
dplyr::mutate(
headline = stringr::str_squish(headline),
url = absolute_url(url, page_url),
source = source_name,
scraped_at = Sys.time()
) %>%
dplyr::filter(
!is.na(url),
stringr::str_length(headline) >= 25,
stringr::str_detect(url, "^https?://")
) %>%
dplyr::distinct(headline, .keep_all = TRUE) %>%
dplyr::slice_head(n = 40)
}
headline_sources <- tibble::tribble(
~page_url, ~source_name,
"https://finance.yahoo.com/topic/stock-market-news/", "Yahoo Finance",
"https://www.cnbc.com/markets/", "CNBC Markets",
"https://www.marketwatch.com/markets", "MarketWatch"
)
headline_attempts <- purrr::pmap(
headline_sources,
function(page_url, source_name) {
tryCatch(
scrape_headlines(page_url, source_name),
error = function(e) {
message("Headline scraping failed for ", source_name, ": ", conditionMessage(e))
NULL
}
)
}
)
finance_headlines <- dplyr::bind_rows(headline_attempts)
if (
nrow(finance_headlines) == 0 ||
!"headline" %in% names(finance_headlines)
) {
finance_headlines <- tibble::tibble(
headline = character(),
url = character(),
source = character(),
scraped_at = as.POSIXct(character())
)
}
finance_headlines <- finance_headlines %>%
dplyr::distinct(headline, .keep_all = TRUE)
if (nrow(finance_headlines) == 0) {
finance_headlines <- tibble::tibble(
headline = paste(
"No headlines were returned because all websites",
"blocked or changed their HTML structure."
),
url = NA_character_,
source = "No successful source",
scraped_at = Sys.time()
)
}
readr::write_csv(finance_headlines, "output/finance_headlines.csv")
finance_headlines %>%
dplyr::select(source, headline, url) %>%
dplyr::slice_head(n = 20) %>%
knitr::kable(caption = "Scraped finance headlines")
| source | headline | url |
|---|---|---|
| No successful source | No headlines were returned because all websites blocked or changed their HTML structure. | NA |
headline_word_counts <- finance_headlines %>%
dplyr::filter(source != "No successful source") %>%
tidytext::unnest_tokens(word, headline) %>%
dplyr::anti_join(tidytext::stop_words, by = "word") %>%
dplyr::filter(stringr::str_detect(word, "[a-z]")) %>%
dplyr::count(word, sort = TRUE)
readr::write_csv(headline_word_counts, "output/headline_word_counts.csv")
if (nrow(headline_word_counts) > 0) {
headline_word_counts %>%
dplyr::slice_head(n = 15) %>%
knitr::kable(caption = "Most common meaningful headline words")
}
The group survey is a conjoint-style investment preference study. Each of the 20 respondents evaluates five hypothetical investment profiles and ranks them from 1 (most preferred) to 5 (least preferred). The profiles vary across five attributes that connect directly to the external market datasets used in this project:
Before knitting, upload the completed workbook to the Posit Cloud project folder and name it exactly:
Investment_Conjoint_Survey Group1.xlsx
Enter completed rankings in the Investment Survey sheet. The code below reads that sheet directly, so respondents do not need to copy their answers into another worksheet.
survey_file <- "Investment_Conjoint_Survey Group1.xlsx"
survey_sheet <- "Investment Survey"
clean_variable_names <- function(x) {
x %>%
stringr::str_trim() %>%
stringr::str_to_lower() %>%
stringr::str_replace_all("[^a-z0-9]+", "_") %>%
stringr::str_replace_all("^_+|_+$", "") %>%
make.unique(sep = "_")
}
if (file.exists(survey_file)) {
survey_raw <- readxl::read_excel(
path = survey_file,
sheet = survey_sheet,
skip = 4,
na = c("", "NA", "N/A")
)
names(survey_raw) <- clean_variable_names(names(survey_raw))
survey_clean <- survey_raw %>%
dplyr::transmute(
respondent_id = suppressWarnings(as.integer(respondent_id)),
profile = stringr::str_squish(as.character(profile)),
investment_type = stringr::str_squish(as.character(investment_type)),
expected_return = stringr::str_squish(as.character(expected_annual_return)),
risk_level = stringr::str_squish(as.character(risk_level)),
information_source = stringr::str_squish(as.character(primary_information_source)),
dividend = stringr::str_squish(as.character(dividend)),
rank = suppressWarnings(as.integer(rank))
) %>%
dplyr::filter(!is.na(respondent_id), !is.na(profile)) %>%
dplyr::distinct(respondent_id, profile, .keep_all = TRUE) %>%
dplyr::mutate(
expected_return_numeric = readr::parse_number(expected_return) / 100,
preference_score = dplyr::if_else(rank %in% 1:5, 6 - rank, NA_integer_),
completed = rank %in% 1:5
) %>%
dplyr::arrange(respondent_id, profile)
respondent_quality <- survey_clean %>%
dplyr::group_by(respondent_id) %>%
dplyr::summarise(
profiles_assigned = dplyr::n(),
profiles_ranked = sum(completed),
unique_ranks_used = dplyr::n_distinct(rank[completed]),
missing_ranks = sum(!completed),
valid_ranking = profiles_assigned == 5 & profiles_ranked == 5 &
unique_ranks_used == 5 & setequal(rank[completed], 1:5),
.groups = "drop"
)
survey_quality_summary <- tibble::tibble(
measure = c(
"Profiles in survey design",
"Expected respondents",
"Respondents represented",
"Completed ranking rows",
"Respondents with five valid unique ranks",
"Respondents requiring correction",
"Missing or invalid rank entries"
),
value = c(
nrow(survey_clean),
25,
dplyr::n_distinct(survey_clean$respondent_id),
sum(survey_clean$completed),
sum(respondent_quality$valid_ranking),
sum(!respondent_quality$valid_ranking),
sum(!survey_clean$completed)
)
)
survey_variable_documentation <- tibble::tribble(
~variable, ~data_type, ~nonmissing_values, ~missing_values, ~unique_values, ~example_value, ~description,
"respondent_id", "integer", sum(!is.na(survey_clean$respondent_id)), sum(is.na(survey_clean$respondent_id)), dplyr::n_distinct(survey_clean$respondent_id, na.rm = TRUE), "1", "Anonymous respondent identifier from 1 through 25",
"profile", "character", sum(!is.na(survey_clean$profile)), sum(is.na(survey_clean$profile)), dplyr::n_distinct(survey_clean$profile, na.rm = TRUE), "A", "Profile label shown to the respondent",
"investment_type", "character", sum(!is.na(survey_clean$investment_type)), sum(is.na(survey_clean$investment_type)), dplyr::n_distinct(survey_clean$investment_type, na.rm = TRUE), "ETF", "Investment vehicle presented in the profile",
"expected_return", "character", sum(!is.na(survey_clean$expected_return)), sum(is.na(survey_clean$expected_return)), dplyr::n_distinct(survey_clean$expected_return, na.rm = TRUE), "8%", "Expected annual return displayed in the profile",
"risk_level", "character", sum(!is.na(survey_clean$risk_level)), sum(is.na(survey_clean$risk_level)), dplyr::n_distinct(survey_clean$risk_level, na.rm = TRUE), "Moderate", "Investment risk level displayed in the profile",
"information_source", "character", sum(!is.na(survey_clean$information_source)), sum(is.na(survey_clean$information_source)), dplyr::n_distinct(survey_clean$information_source, na.rm = TRUE), "Company Financials", "Primary information source used to evaluate the investment",
"dividend", "character", sum(!is.na(survey_clean$dividend)), sum(is.na(survey_clean$dividend)), dplyr::n_distinct(survey_clean$dividend, na.rm = TRUE), "Pays Dividend", "Whether the investment pays a dividend",
"rank", "integer", sum(!is.na(survey_clean$rank)), sum(is.na(survey_clean$rank)), dplyr::n_distinct(survey_clean$rank, na.rm = TRUE), "1", "Preference rank where 1 is most preferred and 5 is least preferred",
"preference_score", "integer", sum(!is.na(survey_clean$preference_score)), sum(is.na(survey_clean$preference_score)), dplyr::n_distinct(survey_clean$preference_score, na.rm = TRUE), "5", "Reverse-coded preference score where larger values indicate stronger preference"
)
readr::write_csv(survey_clean, "output/investment_conjoint_responses_clean.csv")
readr::write_csv(respondent_quality, "output/investment_conjoint_response_quality.csv")
readr::write_csv(survey_quality_summary, "output/investment_conjoint_quality_summary.csv")
readr::write_csv(survey_variable_documentation, "output/investment_conjoint_variable_documentation.csv")
knitr::kable(survey_quality_summary, caption = "Investment survey completion and quality summary")
} else {
survey_raw <- tibble::tibble()
survey_clean <- tibble::tibble()
respondent_quality <- tibble::tibble()
survey_quality_summary <- tibble::tibble(
measure = "Survey workbook status",
value = paste(survey_file, "was not found")
)
survey_variable_documentation <- tibble::tibble(
variable = character(), data_type = character(), nonmissing_values = integer(),
missing_values = integer(), unique_values = integer(), example_value = character(),
description = character()
)
cat("**Survey workbook not found:** Upload `Investment_Conjoint_Survey Group1.xlsx` to the Posit Cloud project folder and knit again. The investment-data sections will still run.")
}
| measure | value |
|---|---|
| Profiles in survey design | 100 |
| Expected respondents | 25 |
| Respondents represented | 20 |
| Completed ranking rows | 55 |
| Respondents with five valid unique ranks | 9 |
| Respondents requiring correction | 11 |
| Missing or invalid rank entries | 45 |
if (nrow(survey_clean) > 0) {
survey_clean %>%
dplyr::slice_head(n = 15) %>%
knitr::kable(caption = "Preview of cleaned investment conjoint responses")
}
| respondent_id | profile | investment_type | expected_return | risk_level | information_source | dividend | rank | expected_return_numeric | preference_score | completed |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | A | ETF | 8% | High | Market News & Economic Indicators | No Dividend | NA | 0.08 | NA | FALSE |
| 1 | B | ETF | 15% | High | Company Financials | Pays Dividend | NA | 0.15 | NA | FALSE |
| 1 | C | ETF | 15% | Moderate | Company Financials | No Dividend | NA | 0.15 | NA | FALSE |
| 1 | D | Individual Stock | 8% | Moderate | Market News & Economic Indicators | Pays Dividend | NA | 0.08 | NA | FALSE |
| 1 | E | ETF | 15% | Moderate | Company Financials | Pays Dividend | NA | 0.15 | NA | FALSE |
| 2 | A | ETF | 15% | Moderate | Market News & Economic Indicators | Pays Dividend | NA | 0.15 | NA | FALSE |
| 2 | B | Individual Stock | 15% | Moderate | Company Financials | No Dividend | NA | 0.15 | NA | FALSE |
| 2 | C | ETF | 8% | Moderate | Company Financials | Pays Dividend | NA | 0.08 | NA | FALSE |
| 2 | D | ETF | 8% | Moderate | Market News & Economic Indicators | No Dividend | NA | 0.08 | NA | FALSE |
| 2 | E | Individual Stock | 15% | Moderate | Market News & Economic Indicators | No Dividend | NA | 0.15 | NA | FALSE |
| 3 | A | Individual Stock | 15% | Moderate | Company Financials | Pays Dividend | NA | 0.15 | NA | FALSE |
| 3 | B | ETF | 8% | High | Company Financials | Pays Dividend | NA | 0.08 | NA | FALSE |
| 3 | C | Individual Stock | 8% | Moderate | Market News & Economic Indicators | No Dividend | NA | 0.08 | NA | FALSE |
| 3 | D | Individual Stock | 8% | High | Market News & Economic Indicators | Pays Dividend | NA | 0.08 | NA | FALSE |
| 3 | E | ETF | 8% | Moderate | Company Financials | No Dividend | NA | 0.08 | NA | FALSE |
Every respondent should assign each rank from 1 through 5 exactly once. This table identifies incomplete responses or duplicate ranks before the data are used in the conjoint model.
if (nrow(respondent_quality) > 0) {
respondent_quality %>%
dplyr::mutate(valid_ranking = ifelse(valid_ranking, "Valid", "Review")) %>%
knitr::kable(caption = "Ranking validation by respondent")
}
| respondent_id | profiles_assigned | profiles_ranked | unique_ranks_used | missing_ranks | valid_ranking |
|---|---|---|---|---|---|
| 1 | 5 | 0 | 0 | 5 | Review |
| 2 | 5 | 0 | 0 | 5 | Review |
| 3 | 5 | 0 | 0 | 5 | Review |
| 4 | 5 | 0 | 0 | 5 | Review |
| 5 | 5 | 5 | 5 | 0 | Valid |
| 6 | 5 | 0 | 0 | 5 | Review |
| 7 | 5 | 5 | 5 | 0 | Valid |
| 8 | 5 | 0 | 0 | 5 | Review |
| 9 | 5 | 5 | 5 | 0 | Valid |
| 10 | 5 | 5 | 5 | 0 | Valid |
| 11 | 5 | 5 | 5 | 0 | Valid |
| 12 | 5 | 0 | 0 | 5 | Review |
| 13 | 5 | 5 | 5 | 0 | Valid |
| 14 | 5 | 5 | 5 | 0 | Valid |
| 15 | 5 | 0 | 0 | 5 | Review |
| 16 | 5 | 5 | 5 | 0 | Valid |
| 17 | 5 | 5 | 5 | 0 | Valid |
| 18 | 5 | 5 | 3 | 0 | Review |
| 19 | 5 | 0 | 0 | 5 | Review |
| 20 | 5 | 5 | 3 | 0 | Review |
Lower average rank means stronger preference. The table also reports the reverse-coded preference score, where a larger value means the profile was preferred more strongly.
if (nrow(survey_clean) > 0) {
valid_survey <- survey_clean %>%
dplyr::filter(completed) %>%
dplyr::semi_join(
respondent_quality %>% dplyr::filter(valid_ranking),
by = "respondent_id"
)
} else {
valid_survey <- tibble::tibble()
}
if (nrow(valid_survey) > 0) {
conjoint_level_summary <- dplyr::bind_rows(
valid_survey %>% dplyr::group_by(level = investment_type) %>% dplyr::summarise(attribute = "Investment Type", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
valid_survey %>% dplyr::group_by(level = expected_return) %>% dplyr::summarise(attribute = "Expected Annual Return", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
valid_survey %>% dplyr::group_by(level = risk_level) %>% dplyr::summarise(attribute = "Risk Level", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
valid_survey %>% dplyr::group_by(level = information_source) %>% dplyr::summarise(attribute = "Information Source", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop"),
valid_survey %>% dplyr::group_by(level = dividend) %>% dplyr::summarise(attribute = "Dividend", responses = dplyr::n(), average_rank = mean(rank), average_preference_score = mean(preference_score), .groups = "drop")
) %>%
dplyr::arrange(attribute, dplyr::desc(average_preference_score))
readr::write_csv(conjoint_level_summary, "output/investment_conjoint_level_summary.csv")
conjoint_level_summary %>%
dplyr::mutate(
average_rank = round(average_rank, 2),
average_preference_score = round(average_preference_score, 2)
) %>%
knitr::kable(caption = "Average investment preference by attribute level")
}
| level | attribute | responses | average_rank | average_preference_score |
|---|---|---|---|---|
| No Dividend | Dividend | 23 | 2.87 | 3.13 |
| Pays Dividend | Dividend | 22 | 3.14 | 2.86 |
| 15% | Expected Annual Return | 24 | 2.83 | 3.17 |
| 8% | Expected Annual Return | 21 | 3.19 | 2.81 |
| Company Financials | Information Source | 22 | 3.00 | 3.00 |
| Market News & Economic Indicators | Information Source | 23 | 3.00 | 3.00 |
| ETF | Investment Type | 23 | 3.00 | 3.00 |
| Individual Stock | Investment Type | 22 | 3.00 | 3.00 |
| Moderate | Risk Level | 20 | 2.55 | 3.45 |
| High | Risk Level | 25 | 3.36 | 2.64 |
if (exists("conjoint_level_summary") && nrow(conjoint_level_summary) > 0) {
ggplot2::ggplot(
conjoint_level_summary,
ggplot2::aes(x = reorder(level, average_preference_score), y = average_preference_score)
) +
ggplot2::geom_col() +
ggplot2::coord_flip() +
ggplot2::facet_wrap(~ attribute, scales = "free_y", ncol = 2) +
ggplot2::labs(
title = "Average Preference Score by Investment Attribute Level",
subtitle = "Higher scores indicate stronger preference",
x = NULL,
y = "Average preference score"
) +
ggplot2::theme_minimal()
}
The regression uses the reverse-coded preference score as the outcome. Respondent fixed effects control for differences in how individual students use the ranking scale. Positive coefficients indicate that an attribute level increases predicted preference relative to its reference level.
if (nrow(valid_survey) >= 20 && dplyr::n_distinct(valid_survey$respondent_id) >= 4) {
conjoint_model <- stats::lm(
preference_score ~ factor(respondent_id) + investment_type + expected_return +
risk_level + information_source + dividend,
data = valid_survey
)
coefficient_table <- as.data.frame(summary(conjoint_model)$coefficients) %>%
tibble::rownames_to_column("term") %>%
dplyr::rename(
estimate = Estimate,
standard_error = `Std. Error`,
t_value = `t value`,
p_value = `Pr(>|t|)`
) %>%
dplyr::filter(
!stringr::str_detect(term, "^factor\\(respondent_id\\)"),
term != "(Intercept)"
) %>%
dplyr::mutate(
attribute = dplyr::case_when(
stringr::str_starts(term, "investment_type") ~ "Investment Type",
stringr::str_starts(term, "expected_return") ~ "Expected Annual Return",
stringr::str_starts(term, "risk_level") ~ "Risk Level",
stringr::str_starts(term, "information_source") ~ "Information Source",
stringr::str_starts(term, "dividend") ~ "Dividend",
TRUE ~ "Other"
),
level = term %>%
stringr::str_remove("^investment_type") %>%
stringr::str_remove("^expected_return") %>%
stringr::str_remove("^risk_level") %>%
stringr::str_remove("^information_source") %>%
stringr::str_remove("^dividend")
)
readr::write_csv(coefficient_table, "output/investment_conjoint_coefficients.csv")
coefficient_table %>%
dplyr::mutate(
estimate = round(estimate, 3),
standard_error = round(standard_error, 3),
p_value = round(p_value, 4)
) %>%
dplyr::select(attribute, level, estimate, standard_error, p_value) %>%
knitr::kable(caption = "Estimated conjoint preference effects")
} else {
conjoint_model <- NULL
coefficient_table <- tibble::tibble()
cat("A conjoint model will be estimated after at least four respondents provide complete, valid rankings.")
}
| attribute | level | estimate | standard_error | p_value |
|---|---|---|---|---|
| Investment Type | Individual Stock | -0.023 | 0.539 | 0.9669 |
| Expected Annual Return | 8% | -0.303 | 0.512 | 0.5577 |
| Risk Level | Moderate | 0.961 | 0.530 | 0.0794 |
| Information Source | Market News & Economic Indicators | 0.129 | 0.499 | 0.7981 |
| Dividend | Pays Dividend | -0.354 | 0.563 | 0.5338 |
if (nrow(coefficient_table) > 0) {
ggplot2::ggplot(
coefficient_table,
ggplot2::aes(x = reorder(paste(attribute, level, sep = ": "), estimate), y = estimate)
) +
ggplot2::geom_col() +
ggplot2::geom_hline(yintercept = 0, linetype = "dashed") +
ggplot2::coord_flip() +
ggplot2::labs(
title = "Estimated Effects on Investment Preference",
subtitle = "Positive values increase predicted preference relative to the reference level",
x = NULL,
y = "Estimated preference effect"
) +
ggplot2::theme_minimal()
}
This section scores every unique investment profile in the survey design using the fitted conjoint model. The highest predicted preference identifies the combination of features that best matches the class sample.
if (!is.null(conjoint_model)) {
profile_design <- survey_clean %>%
dplyr::distinct(
investment_type,
expected_return,
risk_level,
information_source,
dividend
)
reference_respondent <- valid_survey$respondent_id[1]
prediction_data <- profile_design %>%
dplyr::mutate(
respondent_id = reference_respondent
)
predicted_values <- stats::predict(
conjoint_model,
newdata = prediction_data
)
profile_predictions <- prediction_data %>%
dplyr::mutate(
predicted_preference = as.numeric(predicted_values)
) %>%
dplyr::arrange(dplyr::desc(predicted_preference)) %>%
dplyr::mutate(
predicted_rank = dplyr::row_number()
) %>%
dplyr::select(
predicted_rank,
investment_type,
expected_return,
risk_level,
information_source,
dividend,
predicted_preference
)
readr::write_csv(
profile_predictions,
"output/investment_conjoint_profile_predictions.csv"
)
profile_predictions %>%
dplyr::slice_head(n = 10) %>%
dplyr::mutate(
predicted_preference = round(predicted_preference, 3)
) %>%
knitr::kable(
caption = "Top predicted investment profiles"
)
}
| predicted_rank | investment_type | expected_return | risk_level | information_source | dividend | predicted_preference |
|---|---|---|---|---|---|---|
| 1 | ETF | 15% | Moderate | Market News & Economic Indicators | No Dividend | 4.103 |
| 2 | Individual Stock | 15% | Moderate | Market News & Economic Indicators | No Dividend | 4.081 |
| 3 | ETF | 15% | Moderate | Company Financials | No Dividend | 3.974 |
| 4 | Individual Stock | 15% | Moderate | Company Financials | No Dividend | 3.952 |
| 5 | ETF | 8% | Moderate | Market News & Economic Indicators | No Dividend | 3.800 |
| 6 | Individual Stock | 8% | Moderate | Market News & Economic Indicators | No Dividend | 3.777 |
| 7 | ETF | 15% | Moderate | Market News & Economic Indicators | Pays Dividend | 3.749 |
| 8 | Individual Stock | 15% | Moderate | Market News & Economic Indicators | Pays Dividend | 3.727 |
| 9 | ETF | 8% | Moderate | Company Financials | No Dividend | 3.671 |
| 10 | Individual Stock | 8% | Moderate | Company Financials | No Dividend | 3.649 |
if (exists("conjoint_level_summary") && nrow(conjoint_level_summary) > 0) {
preferred_levels <- conjoint_level_summary %>%
dplyr::group_by(attribute) %>%
dplyr::slice_max(average_preference_score, n = 1, with_ties = FALSE) %>%
dplyr::ungroup()
cat("Among the completed and valid class responses, the most preferred level within each attribute was: ")
cat(paste0(preferred_levels$attribute, " = ", preferred_levels$level, collapse = "; "))
cat(". These findings describe the participating class sample and should not be generalized to all investors.")
} else {
cat("Survey interpretations will appear after completed rankings are entered in the workbook.")
}
Among the completed and valid class responses, the most preferred level within each attribute was: Dividend = No Dividend; Expected Annual Return = 15%; Information Source = Company Financials; Investment Type = ETF; Risk Level = Moderate. These findings describe the participating class sample and should not be generalized to all investors.
The table below documents the principal variables created for the
stock, portfolio, economic, fundamental, headline, and survey datasets.
This file is exported for the group as
output/data_dictionary.csv.
core_data_dictionary <- tibble::tribble(
~dataset, ~variable, ~data_type, ~description,
"stock_prices", "ticker", "character", "Trading symbol for the stock or ETF",
"stock_prices", "date", "Date", "Trading date",
"stock_prices", "open", "numeric", "Opening market price",
"stock_prices", "high", "numeric", "Highest market price during the day",
"stock_prices", "low", "numeric", "Lowest market price during the day",
"stock_prices", "close", "numeric", "Adjusted closing price used in the analysis",
"stock_prices", "volume", "numeric", "Number of shares traded",
"stock_prices", "vwap", "numeric", "Volume-weighted average price when supplied by Massive",
"stock_prices", "transactions", "numeric", "Reported transaction count when supplied by Massive",
"stock_prices", "data_source", "character", "Source used for the ticker: Massive or Yahoo fallback",
"technical_indicators", "daily_return", "numeric", "One-day percentage price return in decimal form",
"technical_indicators", "sma_20", "numeric", "Twenty-trading-day simple moving average",
"technical_indicators", "sma_50", "numeric", "Fifty-trading-day simple moving average",
"technical_indicators", "average_volume_20", "numeric", "Twenty-day average trading volume",
"technical_indicators", "rolling_volatility_20", "numeric", "Annualized volatility calculated over twenty trading days",
"risk_metrics", "total_return", "numeric", "Cumulative return over the analysis period",
"risk_metrics", "annualized_return", "numeric", "Return converted to an annual rate",
"risk_metrics", "annualized_volatility", "numeric", "Annualized standard deviation of daily returns",
"risk_metrics", "sharpe_ratio", "numeric", "Annualized excess return divided by annualized volatility",
"risk_metrics", "max_drawdown", "numeric", "Largest percentage decline from a prior peak",
"portfolio_performance", "portfolio_return", "numeric", "Weighted daily portfolio return",
"portfolio_performance", "portfolio_value", "numeric", "Value of the hypothetical investment",
"portfolio_performance", "cumulative_return", "numeric", "Portfolio return since the beginning of the period",
"portfolio_performance", "drawdown", "numeric", "Portfolio decline from its prior peak",
"fred_economic_data", "FEDFUNDS", "numeric", "Effective federal funds rate",
"fred_economic_data", "CPIAUCSL", "numeric", "Consumer Price Index for All Urban Consumers",
"fred_economic_data", "UNRATE", "numeric", "United States unemployment rate",
"fred_economic_data", "VIXCLS", "numeric", "CBOE volatility index closing value",
"fred_economic_data", "UMCSENT", "numeric", "University of Michigan consumer sentiment index",
"company_fundamentals", "market_capitalization", "numeric", "Company market capitalization from Alpha Vantage",
"company_fundamentals", "pe_ratio", "numeric", "Price-to-earnings ratio",
"company_fundamentals", "dividend_yield", "numeric", "Dividend yield in decimal form",
"company_fundamentals", "beta", "numeric", "Estimated market beta",
"finance_headlines", "headline", "character", "Finance headline collected with rvest",
"finance_headlines", "url", "character", "Web address associated with the headline",
"finance_headlines", "source", "character", "Website from which the headline was collected",
"finance_headlines", "scraped_at", "POSIXct", "Date and time the headline was collected"
)
survey_dictionary_for_export <- survey_variable_documentation %>%
dplyr::transmute(
dataset = "investment_conjoint_responses_clean",
variable,
data_type,
description
)
data_dictionary <- dplyr::bind_rows(
core_data_dictionary,
survey_dictionary_for_export
)
readr::write_csv(data_dictionary, "output/data_dictionary.csv")
knitr::kable(
data_dictionary,
caption = "Documentation of variables",
row.names = FALSE
)
| dataset | variable | data_type | description |
|---|---|---|---|
| stock_prices | ticker | character | Trading symbol for the stock or ETF |
| stock_prices | date | Date | Trading date |
| stock_prices | open | numeric | Opening market price |
| stock_prices | high | numeric | Highest market price during the day |
| stock_prices | low | numeric | Lowest market price during the day |
| stock_prices | close | numeric | Adjusted closing price used in the analysis |
| stock_prices | volume | numeric | Number of shares traded |
| stock_prices | vwap | numeric | Volume-weighted average price when supplied by Massive |
| stock_prices | transactions | numeric | Reported transaction count when supplied by Massive |
| stock_prices | data_source | character | Source used for the ticker: Massive or Yahoo fallback |
| technical_indicators | daily_return | numeric | One-day percentage price return in decimal form |
| technical_indicators | sma_20 | numeric | Twenty-trading-day simple moving average |
| technical_indicators | sma_50 | numeric | Fifty-trading-day simple moving average |
| technical_indicators | average_volume_20 | numeric | Twenty-day average trading volume |
| technical_indicators | rolling_volatility_20 | numeric | Annualized volatility calculated over twenty trading days |
| risk_metrics | total_return | numeric | Cumulative return over the analysis period |
| risk_metrics | annualized_return | numeric | Return converted to an annual rate |
| risk_metrics | annualized_volatility | numeric | Annualized standard deviation of daily returns |
| risk_metrics | sharpe_ratio | numeric | Annualized excess return divided by annualized volatility |
| risk_metrics | max_drawdown | numeric | Largest percentage decline from a prior peak |
| portfolio_performance | portfolio_return | numeric | Weighted daily portfolio return |
| portfolio_performance | portfolio_value | numeric | Value of the hypothetical investment |
| portfolio_performance | cumulative_return | numeric | Portfolio return since the beginning of the period |
| portfolio_performance | drawdown | numeric | Portfolio decline from its prior peak |
| fred_economic_data | FEDFUNDS | numeric | Effective federal funds rate |
| fred_economic_data | CPIAUCSL | numeric | Consumer Price Index for All Urban Consumers |
| fred_economic_data | UNRATE | numeric | United States unemployment rate |
| fred_economic_data | VIXCLS | numeric | CBOE volatility index closing value |
| fred_economic_data | UMCSENT | numeric | University of Michigan consumer sentiment index |
| company_fundamentals | market_capitalization | numeric | Company market capitalization from Alpha Vantage |
| company_fundamentals | pe_ratio | numeric | Price-to-earnings ratio |
| company_fundamentals | dividend_yield | numeric | Dividend yield in decimal form |
| company_fundamentals | beta | numeric | Estimated market beta |
| finance_headlines | headline | character | Finance headline collected with rvest |
| finance_headlines | url | character | Web address associated with the headline |
| finance_headlines | source | character | Website from which the headline was collected |
| finance_headlines | scraped_at | POSIXct | Date and time the headline was collected |
| investment_conjoint_responses_clean | respondent_id | integer | Anonymous respondent identifier from 1 through 25 |
| investment_conjoint_responses_clean | profile | character | Profile label shown to the respondent |
| investment_conjoint_responses_clean | investment_type | character | Investment vehicle presented in the profile |
| investment_conjoint_responses_clean | expected_return | character | Expected annual return displayed in the profile |
| investment_conjoint_responses_clean | risk_level | character | Investment risk level displayed in the profile |
| investment_conjoint_responses_clean | information_source | character | Primary information source used to evaluate the investment |
| investment_conjoint_responses_clean | dividend | character | Whether the investment pays a dividend |
| investment_conjoint_responses_clean | rank | integer | Preference rank where 1 is most preferred and 5 is least preferred |
| investment_conjoint_responses_clean | preference_score | integer | Reverse-coded preference score where larger values indicate stronger preference |
chart_ticker <- "AAPL"
price_chart <- technical_indicators %>%
dplyr::filter(ticker == chart_ticker) %>%
ggplot2::ggplot(ggplot2::aes(x = date)) +
ggplot2::geom_line(ggplot2::aes(y = close), linewidth = 0.6) +
ggplot2::geom_line(ggplot2::aes(y = sma_20), linewidth = 0.7, linetype = "dashed") +
ggplot2::geom_line(ggplot2::aes(y = sma_50), linewidth = 0.7, linetype = "dotted") +
ggplot2::labs(
title = paste(chart_ticker, "closing price with moving averages"),
subtitle = "Solid: close | Dashed: 20-day SMA | Dotted: 50-day SMA",
x = NULL,
y = "Price"
) +
ggplot2::theme_minimal()
price_chart
ggplot2::ggsave("figures/price_moving_averages.png", price_chart, width = 10, height = 6)
volume_chart <- technical_indicators %>%
dplyr::filter(ticker == chart_ticker) %>%
ggplot2::ggplot(ggplot2::aes(x = date)) +
ggplot2::geom_col(ggplot2::aes(y = volume), width = 1) +
ggplot2::geom_line(ggplot2::aes(y = average_volume_20), linewidth = 0.7) +
ggplot2::scale_y_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
ggplot2::labs(
title = paste(chart_ticker, "daily volume and 20-day average"),
x = NULL,
y = "Volume"
) +
ggplot2::theme_minimal()
volume_chart
ggplot2::ggsave("figures/volume_trend.png", volume_chart, width = 10, height = 6)
risk_return_chart <- risk_metrics %>%
ggplot2::ggplot(
ggplot2::aes(
x = annualized_volatility,
y = annualized_return,
label = ticker,
size = average_daily_volume
)
) +
ggplot2::geom_point(alpha = 0.7) +
ggplot2::geom_text(nudge_y = 0.015, show.legend = FALSE) +
ggplot2::scale_x_continuous(labels = scales::label_percent()) +
ggplot2::scale_y_continuous(labels = scales::label_percent()) +
ggplot2::scale_size_continuous(labels = scales::label_number(scale_cut = scales::cut_short_scale())) +
ggplot2::labs(
title = "Annualized risk versus return",
x = "Annualized volatility",
y = "Annualized return",
size = "Average volume"
) +
ggplot2::theme_minimal()
risk_return_chart
ggplot2::ggsave("figures/risk_return.png", risk_return_chart, width = 10, height = 6)
portfolio_chart <- portfolio_performance %>%
ggplot2::ggplot(ggplot2::aes(x = date, y = portfolio_value)) +
ggplot2::geom_line(linewidth = 0.8) +
ggplot2::scale_y_continuous(labels = scales::label_dollar()) +
ggplot2::labs(
title = "Growth of the hypothetical portfolio",
x = NULL,
y = "Portfolio value"
) +
ggplot2::theme_minimal()
portfolio_chart
ggplot2::ggsave("figures/portfolio_growth.png", portfolio_chart, width = 10, height = 6)
volatility_chart <- technical_indicators %>%
dplyr::filter(!is.na(rolling_volatility_20)) %>%
ggplot2::ggplot(ggplot2::aes(x = date, y = rolling_volatility_20, group = ticker)) +
ggplot2::geom_line(alpha = 0.8) +
ggplot2::facet_wrap(~ticker, scales = "free_y") +
ggplot2::scale_y_continuous(labels = scales::label_percent()) +
ggplot2::labs(
title = "Twenty-day rolling annualized volatility",
x = NULL,
y = "Volatility"
) +
ggplot2::theme_minimal()
volatility_chart
ggplot2::ggsave("figures/rolling_volatility.png", volatility_chart, width = 11, height = 8)
best_return <- risk_metrics %>% dplyr::slice_max(annualized_return, n = 1)
best_sharpe <- risk_metrics %>% dplyr::slice_max(sharpe_ratio, n = 1)
highest_risk <- risk_metrics %>% dplyr::slice_max(annualized_volatility, n = 1)
worst_drawdown <- risk_metrics %>% dplyr::slice_min(max_drawdown, n = 1)
cat(
paste0(
"The investment with the highest annualized return was **", best_return$ticker,
"** at approximately **", scales::percent(best_return$annualized_return, accuracy = 0.1),
"**. The strongest risk-adjusted result based on the Sharpe ratio was **",
best_sharpe$ticker, "**. **", highest_risk$ticker,
"** had the highest annualized volatility, while **", worst_drawdown$ticker,
"** experienced the largest maximum drawdown. The hypothetical portfolio ended at **",
scales::dollar(dplyr::last(portfolio_performance$portfolio_value)),
"**, compared with a starting value of **", scales::dollar(initial_investment),
"**. These results describe historical performance and should not be treated as investment advice."
)
)
The investment with the highest annualized return was NVDA at approximately 39.4%. The strongest risk-adjusted result based on the Sharpe ratio was JPM. NVDA had the highest annualized volatility, while NVDA experienced the largest maximum drawdown. The hypothetical portfolio ended at $15,818.21, compared with a starting value of $10,000. These results describe historical performance and should not be treated as investment advice.
output_files <- list.files("output", pattern = "\\.csv$", full.names = TRUE)
tibble::tibble(
file = basename(output_files),
size_kb = round(file.info(output_files)$size / 1024, 1)
) %>%
knitr::kable(caption = "CSV files created for the group")
| file | size_kb |
|---|---|
| company_fundamentals.csv | 0.0 |
| daily_returns.csv | 107.2 |
| data_dictionary.csv | 3.7 |
| data_quality_summary.csv | 0.4 |
| finance_headlines.csv | 0.2 |
| fred_economic_data.csv | 14.9 |
| headline_word_counts.csv | 0.0 |
| investment_conjoint_coefficients.csv | 0.7 |
| investment_conjoint_level_summary.csv | 0.5 |
| investment_conjoint_profile_predictions.csv | 2.7 |
| investment_conjoint_quality_summary.csv | 0.2 |
| investment_conjoint_response_quality.csv | 0.4 |
| investment_conjoint_responses_clean.csv | 8.0 |
| investment_conjoint_variable_documentation.csv | 0.9 |
| portfolio_performance.csv | 43.2 |
| risk_metrics.csv | 1.0 |
| stock_prices.csv | 258.9 |
| technical_indicators.csv | 484.7 |
This report demonstrates the full data-collection and preprocessing
role in one reproducible workflow, including investment conjoint-survey
cleaning and analysis, REST API requests with httr, JSON
processing with jsonlite, market and economic retrieval
with quantmod, HTML scraping with rvest, data
cleaning with dplyr, and export of reusable CSV datasets.
The outputs can be shared with group members for visualization,
modeling, forecasting, or presentation work.