Executive Summary
Did the event move Close? The measured gap vs its expected path.
The short answer
Tesla's stock close fell about 20.8% below its expected path in the 375 days after the August 7, 2018 "funding secured" tweet, averaging -79.09 per day for a cumulative loss of -29,658. The 95% confidence band excludes zero, meaning the pattern is consistent with a real effect of the event rather than random noise.
The detail
Pre-event baseline: 2041 days of trading data fit a trend plus day-of-week adjustments (R-squared 0.863), establishing the expected path. Post-event window: 375 days. The average daily deviation was -79.09, with 199 of 375 post-event points falling outside the 95% prediction band (184 below, 15 above). The 95% interval for the average effect is -83.34 to -74.84, which excludes zero. A Welch t-test comparing post-event gaps to pre-event residual noise yielded p < 0.001, confirming the post-event pattern deviates from ordinary day-to-day variation.
What this can't tell you
This is an observational before-and-after design: the measured gap is consistent with an effect of the tweet, but concurrent changes—regulatory developments, broader market shifts, Tesla's own operational or financial announcements—could have contributed to or driven the shortfall. The pre-event residual SD of 41.846 means daily movements smaller than that are difficult to isolate from noise; the -79.09 average is large enough to stand apart, but individual days remain noisy.
Analysis Overview
Interrupted time-series measurement of the event's impact on Close.
The short answer
The expected path for Tesla's stock was estimated from 2041 pre-event days of data using a linear trend and day-of-week adjustments, then projected forward across the post-event window. The effect is the gap between what actually happened and what the pre-event pattern would predict — this matters because the stock was already rising (+0.123 per day) before the event.
The detail
An ordinary least squares model fit a time trend and day-of-week effects to all 2041 pre-event daily observations, then projected that model across the 375 post-event days with 95% prediction intervals. The effect is the vertical distance between actual Close values and the expected path. This approach avoids the trap of naive before/after averaging, which would falsely credit the pre-existing upward drift to the event. The 95% intervals on the average and cumulative effect assume post-event errors are independent; if autocorrelation is present, the true uncertainty is wider than reported.
What this can't tell you
The counterfactual assumes the pre-event trend and day-of-week patterns would have continued unchanged in the absence of the event — a strong assumption in an active market where other events, regulatory changes, or investor sentiment shifts could alter the trajectory. A longer pre-event window or external control series would strengthen confidence in this assumption.
Data Preparation
How the raw rows became a before/after time series.
The short answer
All 2,416 rows loaded without duplicates or gaps; each date appeared once. The 'After tweet' label in the Period column was assigned as the post-event marker (August 7, 2018 onward), yielding 2041 pre-event days and 375 post-event days.
The detail
The raw dataset contained 2,416 rows with no duplicate dates, so no aggregation was needed. The cadence was inferred as daily from the median gap between consecutive dates. In the Period column, 'After tweet' was treated as the post-event label and 'Before tweet' as pre-event because 'After tweet' dates fall later on average and neither is a standard before/after keyword. This assignment produced 2041 pre-event observations (before August 7, 2018) and 375 post-event observations (August 7, 2018 onward), with the event date fixed at 2018-08-07.
What this can't tell you
The Period labels are domain-specific and required inference about their meaning; if the original labeling scheme differs from this interpretation, the pre/post split would shift. Consider verifying the label assignment against source documentation if available.
Actual vs Expected Path
Close over time against its pre-event counterfactual.
The short answer
From the chart, Tesla's actual Close price (shown as individual points) tracks well above its expected counterfactual path early in the pre-event window, then diverges sharply downward after day 2961 (August 7, 2018). The post-event points cluster consistently below the expected line, with the vertical gap representing the measured effect.
The detail
The chart plots actual Close (points) against the counterfactual expected path (line) across the full time span, with the event occurring at day 2961. Pre-event, the actual series follows the fitted trend closely, validating the model fit. After day 2961, the actual points pull away below the expected series, creating a sustained negative gap. The average vertical distance of -79.09 per day is the measured effect; 184 of 375 post-event points fall below the 95% prediction band, while only 15 fall above it, confirming the downward shift is not random scatter.
What this can't tell you
The chart shows association between timing and price movement but does not isolate the tweet's causal role from other concurrent events (regulatory announcements, earnings surprises, market-wide volatility) that may have occurred around August 7, 2018. A visual pattern alone cannot separate the tweet's effect from coincident shocks.
Effect Size
The event's measured effect: per period, relative, and cumulative, with 95% intervals.
| Measure | Value | CI Low | CI High | Interpretation |
|---|---|---|---|---|
| Average effect per day | -79.09 | -83.34 | -74.84 | Actual Close minus its expected value, averaged over the post-event window; the 95% interval excludes zero |
| Relative effect (% of expected level) | -20.8 | -21.9 | -19.7 | The average gap as a share of the expected post-event level (380.23) |
| Cumulative effect over 375 post-event days | -29658 | -3.125e+04 | -2.806e+04 | Total Close gained (positive) or lost (negative) relative to the expected path |
| Post-event points outside 95% band (%) | 53.1 | — | — | 199 of 375 post-event points escape the band (15 above, 184 below); under a correct no-effect model about 5% would |
The short answer
Tesla's stock fell an average of -79.09 per day below its expected path in the 375 days after August 7, 2018 — a relative decline of 20.8% — with a 95% confidence interval (-83.34 to -74.84) that excludes zero. Cumulatively, the stock underperformed its expected path by -29,658 over the post-event window.
The detail
The average effect per day is -79.09 (95% CI -83.34 to -74.84), meaning actual Close fell that much below the predicted level on a typical post-event day. Relative to the expected post-event level (380.23), this represents -20.8% (95% CI -21.9 to -19.7). Over 375 post-event days, the cumulative effect is -29,658 (95% CI -31,252.7 to -28,063.3). The key diagnostic: 199 of 375 post-event points (53.1%) fall outside the 95% prediction band, with 184 below and only 15 above. Under a correct no-effect model, about 5% of points would escape the band by chance alone. The interval exclusion of zero and the band-escape rate both support a real departure from the expected path.
What this can't tell you
These intervals assume post-event errors are independent; if the residuals are autocorrelated, the true uncertainty is wider. The effect size describes this observed 375-day window and does not forecast beyond it. Attribution to the tweet alone requires ruling out concurrent market shocks, regulatory actions, or company announcements — data not present here.
Model Check
How trustworthy the counterfactual is.
| Metric | Value | Interpretation |
|---|---|---|
| Pre-event model R-squared | 0.863 | Share of pre-event variation in Close explained by the trend plus day-of-week adjustments — a strong counterfactual |
| Pre-event residual SD | 41.846 | Typical day-level wiggle of Close around the pre-event fit, in the metric's own units |
| Post vs pre residual t-test p-value | < 0.001 | Welch t-test comparing post-event gaps to pre-event residuals: p < 0.001 — the post-event points deviate from the pre-event noise pattern |
| Seasonality adjustment | — | Day-of-week adjustments — the series is daily with 2041 pre-event days — enough to estimate day-of-week effects |
The short answer
The counterfactual is strong: the pre-event model explains 86.3% of Close's variation, and post-event deviations are large enough to stand apart from ordinary noise (p < 0.001).
The detail
Pre-event R-squared is 0.863, meaning the trend plus day-of-week adjustments captured the bulk of the pre-event pattern. Pre-event residual SD is 41.846—the typical daily wiggle around the fitted path. The Welch t-test comparing post-event gaps to this pre-event noise distribution yields p < 0.001, confirming the post-event shortfall is not a random fluctuation. Seasonality was adjusted via day-of-week effects, feasible with 2041 pre-event trading days.
What this can't tell you
A high R-squared and a significant residual test do not rule out confounding: other events or conditions that shifted around August 2018 could explain part of the observed gap. The model captures trend and day-of-week seasonality only; it does not account for volatility spikes, regime changes, or event-specific shocks that the tweet itself may have triggered.
Event Impact — Did the Change Move the Metric?
Interrupted time-series analysis: the user marks which rows fall before and after an event (launch, price change, campaign). A counterfactual — the expected path of the metric had nothing changed — is fitted on the pre-event rows alone (linear trend + day-of-week or month-of-year adjustments when the history supports them), projected across the post-event window with 95% prediction intervals, and the effect is the gap between actual and expected: average per period, relative %, and cumulative, each with a 95% interval.
Why This Method?
A naive before/after comparison of averages credits any pre-existing trend to the event. The counterfactual subtracts the path the metric was already on, which is the honest base-R answer to "causal impact". Base R only (stats::lm + prediction intervals); no CausalImpact/bsts.
What This Analysis Covers
- Actual vs expected counterfactual path (chart)
- Average / relative / cumulative effect with 95% intervals
- Share of post-event points outside the 95% prediction band
- Model diagnostics (pre-event R-squared, residual t-test, seasonality)
Honest Framing
This is OBSERVATIONAL: the result can be consistent with an effect of the event, never proof of it — concurrent changes can contribute.
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {date, value, period}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
Step 1: Parse dates robustly (init() may already have Date-coerced)
raw_date <- df$date
non_blank <- !(is.na(raw_date) | !nzchar(trimws(as.character(raw_date))))
d <- parse_dates_robust(raw_date)
n_nonblank <- sum(non_blank)
if (n_nonblank == 0) {
stop(sprintf("The '%s' column is empty — no dates to anchor the event to.", date_name))
}
n_unparsed <- sum(non_blank & is.na(d))
if (n_unparsed / n_nonblank > 0.05) {
stop(sprintf(
"%d of %d values in '%s' (%.0f%%) could not be read as dates. Please use a recognizable date format (e.g. 2024-01-31 or 01/31/2024).",
n_unparsed, n_nonblank, date_name, 100 * n_unparsed / n_nonblank))
}Step 2: Coerce the metric (95% rule)
v <- df$value
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & nzchar(as.character(v)))
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
v <- conv
} else {
stop(sprintf(
"The '%s' column is not numeric — measuring an event impact needs a numeric metric.",
value_name))
}
}
v <- as.numeric(v)Step 3: Binarize the period column — exactly 2 levels; the after
level matches (after|post|1|true|treatment|new) case-insensitive, else the level whose dates fall later on average. The choice is stated.
per <- trimws(as.character(df$period))
per[is.na(df$period) | !nzchar(per)] <- NA_character_
keep <- !is.na(d) & !is.na(v) & !is.na(per)
n_dropped_rows <- initial_rows - sum(keep)
work <- data.frame(date = d[keep], value = v[keep], period = per[keep],
stringsAsFactors = FALSE)
levs <- sort(unique(work$period))
if (length(levs) != 2) {
stop(sprintf(
"The '%s' column must contain exactly 2 labels marking before vs after the event — found %d (%s).",
period_name, length(levs), paste(head(levs, 5), collapse = ", ")))
}
after_regex <- "^(after|post|1|true|treatment|new)$"
is_after <- grepl(after_regex, levs, ignore.case = TRUE)
if (sum(is_after) == 1) {
after_level <- levs[is_after]
after_reason <- sprintf(
"'%s' matches a standard after-the-event label (after/post/1/true/treatment/new)",
after_level)
} else {
mean_dates <- tapply(as.numeric(work$date), work$period, mean)
after_level <- names(mean_dates)[which.max(mean_dates)]
after_reason <- sprintf(
"neither label is a standard before/after word, and '%s' dates fall later on average",
after_level)
}
pre_level <- setdiff(levs, after_level)Step 4: Aggregate duplicate dates by SUM within each period group
(rows treated as amounts that add up within a period — stated in prose)
agg <- aggregate(value ~ date + period, data = work, FUN = sum)
n_dup_rows <- nrow(work) - nrow(agg)
both <- intersect(agg$date[agg$period == after_level],
agg$date[agg$period == pre_level])
if (length(both) > 0) {
stop(sprintf(
"%d %s in '%s' %s marked both '%s' and '%s' in '%s' — every date must fall entirely before or after the event.",
length(both), pl(length(both), "date"), date_name,
pl(length(both), "is", "are"), pre_level, after_level, period_name))
}
agg <- agg[order(agg$date), , drop = FALSE]
pre <- agg[agg$period == pre_level, , drop = FALSE]
post <- agg[agg$period == after_level, , drop = FALSE]
n_pre <- nrow(pre); n_post <- nrow(post)
if (n_pre < 8) {
stop(sprintf(
"Only %d %s before the event('%s' in '%s') — at least 8 pre-event points are needed to build a counterfactual for '%s'.",
n_pre, pl(n_pre, "point"), pre_level, period_name, value_name))
}
if (n_post < 3) {
stop(sprintf(
"Only %d %s after the event('%s' in '%s') — at least 3 post-event points are needed to measure an impact on '%s'.",
n_post, pl(n_post, "point"), after_level, period_name, value_name))
}
event_date <- min(post$date)
n_interleaved <- sum(pre$date >= event_date)Step 5: Infer cadence from the median date gap
gaps <- as.numeric(diff(sort(unique(agg$date))))
med_gap <- median(gaps)
if (med_gap <= 1.5) {
freq_label <- "daily"; period_word <- "day"; step_days <- 1
} else if (med_gap >= 5.5 && med_gap <= 8.5) {
freq_label <- "weekly"; period_word <- "week"; step_days <- 7
} else if (med_gap >= 26 && med_gap <= 35) {
freq_label <- "monthly"; period_word <- "month"; step_days <- 30.44
} else {
step_days <- max(1, round(med_gap)); freq_label <- "irregular"
period_word <- sprintf("%d-day period", round(step_days))
}Step 6: Time index (days since first date) + seasonality choice:
day-of-week dummies if daily with >=21 pre points (3 full weeks), month dummies if monthly with >=24 pre points, else trend-only.
t0 <- min(agg$date)
pre$tidx <- as.numeric(pre$date - t0)
post$tidx <- as.numeric(post$date - t0)
event_tidx <- min(post$tidx)
seasonality <- "none"
season_reason <- ""
if (freq_label == "daily") {
if (n_pre >= 21) {
seasonality <- "dow"
} else {
season_reason <- sprintf(
"only %d pre-event days are available and 3 full weeks are needed, so day-of-week effects were not estimated",
n_pre)
}
} else if (freq_label == "monthly") {
if (n_pre >= 24) {
seasonality <- "month"
} else {
season_reason <- sprintf(
"only %d pre-event months are available and 24 are needed, so month-of-year effects were not estimated",
n_pre)
}
} else {
season_reason <- sprintf(
"the series cadence is %s, so only the trend is modelled", freq_label)
}
if (seasonality != "none") {
fmt_code <- if (seasonality == "dow") "%u" else "%m"
pre$season <- format(pre$date, fmt_code)
post$season <- format(post$date, fmt_code)
if (!all(unique(post$season) %in% unique(pre$season))) {
seasonality <- "none"
season_reason <- "the post-event window contains calendar positions never seen before the event, so seasonal adjustment was dropped"
} else if ((n_pre - 1 - length(unique(pre$season))) < 3) {
seasonality <- "none"
season_reason <- "too few pre-event points remain once seasonal terms are added, so seasonal adjustment was dropped"
} else {
season_reason <- if (seasonality == "dow") {
sprintf("the series is daily with %d pre-event days — enough to estimate day-of-week effects", n_pre)
} else {
sprintf("the series is monthly with %d pre-event months — enough to estimate month-of-year effects", n_pre)
}
}
}
seasonality_label <- switch(seasonality,
dow = "day-of-week adjustments",
month = "month-of-year adjustments",
none = "no seasonal adjustment")Step 7: Fit the counterfactual on PRE only; project over POST with
95% prediction intervals
fml <- if (seasonality != "none") value ~ tidx + season else value ~ tidx
fit <- lm(fml, data = pre)
pr <- suppressWarnings(
predict(fit, newdata = post, interval = "prediction", level = 0.95))
if (any(!is.finite(pr))) {
stop(sprintf(
"The pre-event model for '%s' could not produce finite predictions over the post-event window — the pre-event history may be too sparse.",
value_name))
}
expected <- as.numeric(pr[, "fit"])
lwr <- as.numeric(pr[, "lwr"])
upr <- as.numeric(pr[, "upr"])Step 9: Diagnostics — pre-event fit quality + t-test of post gaps
vs pre residuals
resid_pre <- as.numeric(residuals(fit))
r2 <- suppressWarnings(summary(fit)$r.squared)
sd_pre <- sd(resid_pre)
p_t <- tryCatch(stats::t.test(gap, resid_pre)$p.value,
error = function(e) NA_real_)
significant <- all(is.finite(ci_avg)) && (ci_avg[1] > 0 || ci_avg[2] < 0)
direction_word <- if (avg_effect >= 0) "above" else "below"
verdict <- if (significant) {
"consistent with a real impact of the event"
} else {
"not consistent with a clearly measurable impact of the event"
}Step 10: Round ONCE — the same rounded numbers feed both the
tables and every sentence that quotes them.
avg_r <- round(avg_effect, 2)
avg_lo <- round(ci_avg[1], 2); avg_hi <- round(ci_avg[2], 2)
rel_r <- if (is.na(rel_effect)) NA_real_ else round(rel_effect, 1)
rel_lo <- if (is.na(ci_rel[1])) NA_real_ else round(ci_rel[1], 1)
rel_hi <- if (is.na(ci_rel[2])) NA_real_ else round(ci_rel[2], 1)
cum_r <- round(cum_effect, 1)
cum_lo <- round(ci_cum[1], 1); cum_hi <- round(ci_cum[2], 1)
pct_out_r <- round(100 * n_outside / n_post, 1)
pct_above_r <- round(100 * n_above / n_post, 1)
r2_r <- if (is.finite(r2)) round(r2, 3) else NA_real_
sd_pre_r <- if (is.finite(sd_pre)) round(sd_pre, 3) else NA_real_
p_tab <- if (is.na(p_t)) NA_real_ else max(round(signif(p_t, 3), 4), 0.001)
trend_per_period <- round(as.numeric(coef(fit)[["tidx"]]) * step_days, 3)
baseline_r <- round(baseline, 2)