Files
m5papers3-stockticker/m5paper-stockticker.ino
T

436 lines
14 KiB
Arduino

#include <M5Unified.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <esp_sleep.h>
#include <esp_bt.h>
#include <esp_wifi.h>
#include "images.h"
#include "config.h"
// -------------------------------------------------------
// Configuration - edit config.h before flashing
// -------------------------------------------------------
const char* ssid = WIFI_SSID;
const char* password = WIFI_PASSWORD;
const char* apiKey = FINNHUB_API_KEY;
const char* avApiKey = ALPHAVANTAGE_KEY;
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = GMT_OFFSET_SEC;
const int daylightOffset_sec = DAYLIGHT_OFFSET_SEC;
// -------------------------------------------------------
// Portfolio - symbol / shares / total cost basis ($)
// -------------------------------------------------------
struct Stock { const char* symbol; float shares; float costBasis; bool useAV; };
Stock myPortfolio[] = {
{ "AMD", 2.0000f, 53.02f, false },
{ "SWPPX", 41.2180f, 743.39f, true },
{ "TSLA", 1.0000f, 370.50f, false },
{ "SPYI", 6.0000f, 313.15f, false },
{ "JEPQ", 3.0000f, 178.52f, false },
{ "SCHD", 3.0000f, 95.97f, false },
};
// To add an entry: { "SYMBOL", shares, costBasis, useAV }
// useAV = false -> Finnhub (stocks & ETFs)
// useAV = true -> Alpha Vantage (mutual funds e.g. SWPPX)
// costBasis -> total amount paid (Value $ - Total Gain $ from your broker CSV)
// The display auto-scales font size to fit all entries on one page.
const int NUM_STOCKS = sizeof(myPortfolio) / sizeof(myPortfolio[0]);
// -------------------------------------------------------
// Runtime stock data
// -------------------------------------------------------
struct StockData { float price, change, pct; bool valid; };
void prepareForSleepAndEnter(uint64_t sleepUs) {
// Disable all wake sources first, then allow only timer wake.
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
esp_sleep_enable_timer_wakeup(sleepUs);
// Shut down radios and protocol stacks.
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
esp_wifi_stop();
esp_wifi_deinit();
btStop();
esp_bt_controller_disable();
esp_bt_controller_deinit();
// Power down optional domains where this core/target exposes them.
#ifdef ESP_PD_DOMAIN_RTC_PERIPH
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);
#endif
#ifdef ESP_PD_DOMAIN_RTC_FAST_MEM
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_OFF);
#endif
#ifdef ESP_PD_DOMAIN_RTC_SLOW_MEM
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_OFF);
#endif
#ifdef ESP_PD_DOMAIN_XTAL
esp_sleep_pd_config(ESP_PD_DOMAIN_XTAL, ESP_PD_OPTION_OFF);
#endif
esp_deep_sleep_start();
}
bool fetchStock(const char* sym, StockData& out) {
out = { 0.0f, 0.0f, 0.0f, false };
if (WiFi.status() != WL_CONNECTED) { Serial.println("[FH] no WiFi"); return false; }
char url[220];
snprintf(url, sizeof(url),
"https://finnhub.io/api/v1/quote?symbol=%s&token=%s", sym, apiKey);
for (int attempt = 1; attempt <= 3; attempt++) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.setConnectTimeout(8000);
http.setTimeout(8000);
if (!http.begin(client, url)) {
Serial.printf("[FH] %s begin() failed (try %d)\n", sym, attempt);
http.end();
delay(300);
continue;
}
int code = http.GET();
if (code != HTTP_CODE_OK) {
Serial.printf("[FH] %s HTTP %d (try %d)\n", sym, code, attempt);
http.end();
delay(400);
continue;
}
String body = http.getString();
http.end();
StaticJsonDocument<512> doc;
DeserializationError err = deserializeJson(doc, body);
if (err) {
Serial.printf("[FH] %s JSON err: %s\n", sym, err.c_str());
delay(300);
continue;
}
out.price = doc["c"] | 0.0f;
out.change = doc["d"] | 0.0f;
out.pct = doc["dp"] | 0.0f;
out.valid = (out.price > 0.0f);
if (out.valid) return true;
Serial.printf("[FH] %s price 0 (body: %s)\n", sym, body.c_str());
delay(300);
}
return false;
}
// Alpha Vantage fetch (for mutual funds not covered by Finnhub)
bool fetchStockAV(const char* sym, StockData& out) {
out = { 0.0f, 0.0f, 0.0f, false };
if (WiFi.status() != WL_CONNECTED) { Serial.println("[AV] no WiFi"); return false; }
char url[256];
snprintf(url, sizeof(url),
"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=%s&apikey=%s",
sym, avApiKey);
for (int attempt = 1; attempt <= 3; attempt++) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.setConnectTimeout(8000);
http.setTimeout(8000);
if (!http.begin(client, url)) {
Serial.printf("[AV] %s begin() failed (try %d)\n", sym, attempt);
http.end();
delay(300);
continue;
}
int code = http.GET();
if (code != HTTP_CODE_OK) {
Serial.printf("[AV] %s HTTP %d (try %d)\n", sym, code, attempt);
http.end();
delay(400);
continue;
}
String body = http.getString();
http.end();
StaticJsonDocument<1024> doc;
DeserializationError err = deserializeJson(doc, body);
if (err) {
Serial.printf("[AV] %s JSON err: %s\n", sym, err.c_str());
delay(300);
continue;
}
JsonObject q = doc["Global Quote"];
if (q.isNull()) {
// Free tier limit / rate note comes back without "Global Quote".
Serial.printf("[AV] %s no quote (body: %s)\n", sym, body.c_str());
delay(300);
continue;
}
out.price = atof(q["05. price"] | "0");
out.change = atof(q["09. change"] | "0");
// "10. change percent" is like "0.0500%" - strip the trailing '%'
char pctBuf[16];
strncpy(pctBuf, q["10. change percent"] | "0%", sizeof(pctBuf) - 1);
pctBuf[sizeof(pctBuf) - 1] = '\0';
char* pctMark = strchr(pctBuf, '%');
if (pctMark) *pctMark = '\0';
out.pct = atof(pctBuf);
out.valid = (out.price > 0.0f);
if (out.valid) return true;
Serial.printf("[AV] %s price 0\n", sym);
delay(300);
}
return false;
}
// -------------------------------------------------------
// Drawing helpers
// -------------------------------------------------------
void drawUpArrow(int x, int y, int sz) {
M5.Display.fillTriangle(x + sz / 2, y, x, y + sz, x + sz, y + sz, TFT_BLACK);
}
void drawDownArrow(int x, int y, int sz) {
M5.Display.fillTriangle(x, y, x + sz, y, x + sz / 2, y + sz, TFT_BLACK);
}
void drawBattery(int x, int y, int pct) {
M5.Display.drawRect(x, y, 42, 20, TFT_BLACK);
M5.Display.fillRect(x + 42, y + 6, 4, 8, TFT_BLACK);
int fw = (int)(38.0f * constrain(pct, 0, 100) / 100.0f);
if (fw > 0) M5.Display.fillRect(x + 2, y + 2, fw, 16, TFT_BLACK);
}
// Format a float as dollars with comma separators e.g. 1234.56 -> "1,234.56"
void fmtMoney(char* buf, size_t len, float val) {
float absVal = val < 0.0f ? -val : val;
int dollars = (int)absVal;
int cents = (int)((absVal - dollars) * 100.0f + 0.5f);
if (cents >= 100) { dollars++; cents -= 100; }
if (dollars >= 1000000)
snprintf(buf, len, "%d,%03d,%03d.%02d", dollars/1000000, (dollars%1000000)/1000, dollars%1000, cents);
else if (dollars >= 1000)
snprintf(buf, len, "%d,%03d.%02d", dollars/1000, dollars%1000, cents);
else
snprintf(buf, len, "%d.%02d", dollars, cents);
}
// -------------------------------------------------------
// US market hours (Eastern time) helpers
// -------------------------------------------------------
// Approximate US Eastern DST window: 2nd Sunday of March .. 1st Sunday of Nov.
// (The exact 2 AM changeover happens on a Sunday when markets are closed, so
// day-level precision is sufficient here.)
bool usEasternDST(const struct tm& u) {
int m = u.tm_mon + 1, d = u.tm_mday, wday = u.tm_wday;
if (m < 3 || m > 11) return false;
if (m > 3 && m < 11) return true;
int prevSunday = d - wday;
if (m == 3) return prevSunday >= 8; // on/after 2nd Sunday -> DST
return prevSunday <= 0; // Nov: before 1st Sunday -> DST
}
// Break current UTC time down into US Eastern local time.
void getEasternTime(struct tm& etTm) {
time_t utc = time(nullptr);
struct tm u;
gmtime_r(&utc, &u);
int off = usEasternDST(u) ? -4 * 3600 : -5 * 3600;
time_t et = utc + off;
gmtime_r(&et, &etTm);
}
// Daily market events, as seconds-of-day in US Eastern time.
const long EVT_OPEN = 9 * 3600 + 30 * 60; // 09:30 -> 34200 s
const long EVT_MID = 12 * 3600 + 45 * 60; // 12:45 -> 45900 s (session midpoint)
const long EVT_CLOSE = 16 * 3600; // 16:00 -> 57600 s
// Which event this update corresponds to: nearest of open / mid / close.
const char* marketPhase(const struct tm& et) {
long s = et.tm_hour * 3600 + et.tm_min * 60 + et.tm_sec;
long dOpen = labs(s - EVT_OPEN);
long dMid = labs(s - EVT_MID);
long dClose = labs(s - EVT_CLOSE);
if (dOpen <= dMid && dOpen <= dClose) return "open";
if (dClose <= dMid && dClose <= dOpen) return "close";
return "mid";
}
// Microseconds until the next market event: the opening bell (9:30 AM ET),
// midday (12:45 PM ET), or the closing bell (4:00 PM ET), whichever comes
// first, Mon-Fri only. We wake for these three events, so the ticker refreshes
// three times per session.
uint64_t usUntilNextEvent(const struct tm& et) {
const long events[3] = { EVT_OPEN, EVT_MID, EVT_CLOSE };
long secsToday = et.tm_hour * 3600 + et.tm_min * 60 + et.tm_sec;
for (int d = 0; d <= 7; d++) {
int wd = (et.tm_wday + d) % 7;
if (wd < 1 || wd > 5) continue; // skip Sat/Sun
long base = (long)d * 86400 - secsToday;
long best = -1;
for (int e = 0; e < 3; e++) {
long cand = base + events[e];
if (cand > 0 && (best < 0 || cand < best)) best = cand;
}
if (best > 0) return (uint64_t)best * 1000000ULL;
}
return 24ULL * 3600ULL * 1000000ULL; // fallback: 24h
}
// -------------------------------------------------------
// Setup
// -------------------------------------------------------
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
M5.Display.setRotation(1);
Serial.begin(115200);
// Connect to WiFi
WiFi.begin(ssid, password);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500);
if (WiFi.status() != WL_CONNECTED) {
int batPct = M5.Power.getBatteryLevel();
M5.Display.fillScreen(TFT_WHITE);
M5.Display.setFont(&fonts::AsciiFont8x16);
M5.Display.setTextColor(TFT_BLACK, TFT_WHITE);
M5.Display.setTextSize(2);
M5.Display.setCursor(20, 20);
M5.Display.print("WiFi connection failed.");
M5.Display.setCursor(20, 55);
M5.Display.printf("SSID: %s", ssid);
M5.Display.setCursor(20, 90);
M5.Display.print("Battery:");
drawBattery(150, 93, batPct);
M5.Display.setCursor(205, 90);
M5.Display.printf("%d%%", batPct);
delay(2000);
M5.Power.powerOff();
return;
}
// Sync time via NTP
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
struct tm now = {};
for (int i = 0; i < 10 && !getLocalTime(&now); i++) delay(500);
// Current US Eastern time -> tells us which event (open/mid/close) this is
// and, later, when the next event is.
struct tm etTm;
getEasternTime(etTm);
// Fetch market data
StockData sd[NUM_STOCKS];
float dayGain = 0.0f, openValue = 0.0f;
for (int i = 0; i < NUM_STOCKS; i++) {
if (myPortfolio[i].useAV)
fetchStockAV(myPortfolio[i].symbol, sd[i]);
else
fetchStock(myPortfolio[i].symbol, sd[i]);
if (sd[i].valid) {
dayGain += sd[i].change * myPortfolio[i].shares;
openValue += (sd[i].price - sd[i].change) * myPortfolio[i].shares;
}
}
float dayPct = (openValue > 0.0f) ? (dayGain / openValue) * 100.0f : 0.0f;
int batPct = M5.Power.getBatteryLevel();
// ---- Render ----
M5.Display.startWrite();
M5.Display.fillScreen(TFT_WHITE);
M5.Display.setFont(&fonts::AsciiFont8x16);
M5.Display.setTextColor(TFT_BLACK, TFT_WHITE);
M5.Display.setTextSize(2);
// -- Row 1: timestamp (left) | "zZz [battery] xx%" (right) --
char timeStr[32];
strftime(timeStr, sizeof(timeStr), "%m/%d %I:%M %p", &now);
M5.Display.setCursor(20, 22);
M5.Display.print("U: ");
M5.Display.print(timeStr);
M5.Display.printf(" (%s)", marketPhase(etTm));
M5.Display.setCursor(740, 15);
M5.Display.print("zZz");
drawBattery(806, 18, batPct);
M5.Display.setCursor(856, 15);
M5.Display.printf("%d%%", batPct);
// -- Row 2: Day's gain / loss --
char moneyBuf[24];
fmtMoney(moneyBuf, sizeof(moneyBuf), dayGain);
M5.Display.setCursor(20, 55);
if (dayGain >= 0.0f)
M5.Display.printf("DAY'S GAIN / LOSS: +$%s (+%.2f%%)", moneyBuf, dayPct);
else
M5.Display.printf("DAY'S GAIN / LOSS: -$%s (%.2f%%)", moneyBuf, dayPct);
// Divider
M5.Display.drawLine(20, 100, 940, 100, TFT_BLACK);
// -- Stock rows: auto-scale to fit all entries on one page --
const int ROW0 = 110, SCREEN_BOTTOM = 530;
int ROWY = (SCREEN_BOTTOM - ROW0) / NUM_STOCKS;
// Pick the largest font that fits the row height, with matching column positions
int textSz, charW, colPrice, colArrow, arrowSz, arrowOff;
if (ROWY >= 48) { textSz=3; charW=24; colPrice=240; colArrow=460; arrowSz=20; arrowOff=14; }
else if (ROWY >= 32) { textSz=2; charW=16; colPrice=168; colArrow=310; arrowSz=14; arrowOff= 9; }
else { textSz=1; charW= 8; colPrice= 84; colArrow=152; arrowSz= 8; arrowOff= 4; }
M5.Display.setTextSize(textSz);
for (int i = 0; i < NUM_STOCKS; i++) {
int y = ROW0 + i * ROWY;
M5.Display.setCursor(20, y);
M5.Display.printf("%-6s", myPortfolio[i].symbol);
if (sd[i].valid) {
M5.Display.setCursor(colPrice, y);
M5.Display.printf("$%7.2f", sd[i].price);
if (sd[i].change >= 0.0f) drawUpArrow (colArrow, y + arrowOff, arrowSz);
else drawDownArrow(colArrow, y + arrowOff, arrowSz);
// Right-align change text so all closing ')' line up at x=940
char chgBuf[32];
snprintf(chgBuf, sizeof(chgBuf), "%+.2f (%+.2f%%)", sd[i].change, sd[i].pct);
M5.Display.setCursor(940 - (int)strlen(chgBuf) * charW, y);
M5.Display.print(chgBuf);
} else {
M5.Display.setCursor(240, y);
M5.Display.print(" N/A");
}
}
// Page indicator
// M5.Display.setTextSize(1);
// M5.Display.setCursor(890, 525);
//M5.Display.print("Pa. 1/1");
M5.Display.endWrite();
delay(1000);
// We wake at open, midday, and close, so update now and sleep until
// whichever event comes next (Mon-Fri).
prepareForSleepAndEnter(usUntilNextEvent(etTm));
}
void loop() {}