Last active
December 19, 2023 12:59
-
-
Save bric3/2338aad81b5bba614020a17a1fd0884f to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Stonks.java | |
* | |
* Copyright (c) 2021,today - Brice Dutheil <[email protected]> | |
* | |
* This Source Code Form is subject to the terms of the Mozilla Public | |
* License, v. 2.0. If a copy of the MPL was not distributed with this | |
* file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
*/ | |
///usr/bin/env jbang "$0" ; exit $? | |
//JAVA 21 | |
//JAVAC_OPTIONS --enable-preview --source 21 | |
//JAVA_OPTIONS --enable-preview | |
/* Usage, with a [free finnhub token](https://finnhub.io/register). | |
* | |
* With [jbang](https://www.jbang.dev/) | |
* env FINNHUB_TOKEN=<token> jbang https://gist.github.com/bric3/2338aad81b5bba614020a17a1fd0884f | |
* | |
* Or after downloading | |
* env FINNHUB_TOKEN=<token> jbang jdk21/src/main/java/Stonks.java DDOG | |
* | |
* Or even with Java 21 directly | |
* env FINNHUB_TOKEN=<token> java --enable-preview --source 21 path/to/Stonks.java NET | |
*/ | |
import java.io.IOException; | |
import java.io.PrintStream; | |
import java.io.UncheckedIOException; | |
import java.net.URI; | |
import java.net.URLEncoder; | |
import java.net.http.HttpClient; | |
import java.net.http.HttpRequest; | |
import java.net.http.HttpResponse; | |
import java.net.http.HttpResponse.BodyHandlers; | |
import java.nio.charset.StandardCharsets; | |
import java.time.Duration; | |
import java.time.Instant; | |
import java.time.LocalDateTime; | |
import java.time.ZoneId; | |
import java.time.temporal.ChronoUnit; | |
import java.util.List; | |
import java.util.concurrent.*; | |
import java.util.function.Predicate; | |
import java.util.regex.Pattern; | |
import java.util.stream.Collectors; | |
/* | |
Run with | |
environment variable: FINNHUB_TOKEN={the token} | |
JFR: -XX:StartFlightRecording:filename=stonks.jfr,+jdk.VirtualThreadStart#enabled=true,+jdk.VirtualThreadEnd#enabled=true,+jdk.VirtualThreadPinned=true | |
*/ | |
@SuppressWarnings({"SpellCheckingInspection", "UastIncorrectHttpHeaderInspection"}) | |
public class Stonks { | |
public static final List<String> DEFAULT_TICKERS = List.of( | |
"DDOG", | |
"NFLX", | |
"AAPL", | |
"TSLA", | |
"GOOG", | |
"AMZN", | |
"MSFT", | |
"NET", | |
"TWTR", | |
"BABA", | |
"NDAQ", | |
"IBM", | |
"UBER", | |
"ROKU", | |
"NVDA", | |
"AMD", | |
"INTC", | |
"AKAM", | |
"XIACF", | |
"PHG", | |
"ADBE", | |
"ORCL", | |
"TTE", | |
"SNY", | |
"PFE", | |
"ORAN", | |
"CRTO", | |
"GC=F", // no company profile for gold | |
"SI=F", // no company profile for silver | |
"CL=F", // no company profile for crude oil | |
"EURUSD=X", // no company profile for euro/usd | |
"BTC-USD", | |
"BTC-EUR", | |
"ETH-USD", | |
"ETH-EUR", | |
// "A2R82Y.DU", // no company profile for Dow Futures | |
// "^IXIC", // no company profile for nasdaq | |
// not available in the free tier | |
// "NASDAQ", | |
// "^HSI", | |
// "^FCHI", | |
// "DOW J", | |
// "^N225", | |
// "^GDAXI", | |
// "^FTSE", | |
"" | |
); | |
private static final String default_fg = "\u001B[39m"; | |
private static final String red = "\u001B[31m"; | |
private static final String green = "\u001B[32m"; | |
private static final String blue = "\u001B[34m"; | |
private static final String gray = "\u001B[37m"; | |
private static final String bold = "\u001B[01m"; | |
private static final String italic = "\u001B[03m"; | |
private static final String underline = "\u001B[04m"; | |
private static final String reset = "\u001B[00m"; | |
private volatile boolean rateLimitAnnounceInProgress = false; | |
private volatile long rateLimitResetSeconds = 0; | |
private final Pattern fieldMatcher = Pattern.compile("\"([^\"]+)\"\\s*:\\s*(null|\"?(?:-?\\d+(?:.\\d+)?|[^\"]+)\"?)"); | |
private final HttpClient httpClient; | |
public static void main(String[] args) throws Exception { | |
// https://finnhub.io/pricing | |
// 60 API calls/minute | |
var finnhubToken = System.getenv("FINNHUB_TOKEN"); | |
if (finnhubToken == null || finnhubToken.isBlank() || finnhubToken.equals("null")) { | |
System.out.println("Needs non empty token set in the environment variable FINNHUB_TOKEN"); | |
System.exit(1); | |
} | |
new Stonks().run(finnhubToken, args); | |
} | |
public Stonks() { | |
var httpClientCarrierThreadsEnv = System.getenv("HTTP_CLIENT_CARRIER_THREADS"); | |
int httpClientCarrierThreads = switch (httpClientCarrierThreadsEnv) { | |
case "null" -> 1; | |
case null -> 1; | |
default -> Integer.parseInt(httpClientCarrierThreadsEnv); | |
}; | |
httpClient = HttpClient.newBuilder() | |
.executor(Executors.newFixedThreadPool(httpClientCarrierThreads, Thread.ofVirtual().name("HttpClient-virtual").factory())) | |
.connectTimeout(Duration.ofSeconds(10)) | |
.build(); | |
} | |
private void run(String finnhubToken, String... tickersArgs) throws InterruptedException, ExecutionException, TimeoutException { | |
var tickers = tickersArgs.length > 0 ? List.of(tickersArgs) : DEFAULT_TICKERS; | |
try (var es = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("finnhub-fetcher").factory())) { | |
var tickerTasks = tickers.stream().filter(Predicate.not(String::isBlank)).map(ticker -> CompletableFuture.runAsync(() -> { | |
try { | |
var quoteResponse = CompletableFuture.supplyAsync(() -> sendWithRetry( | |
HttpRequest.newBuilder(URI.create(STR."https://finnhub.io/api/v1/quote?symbol=\{URLEncoder.encode(ticker, StandardCharsets.UTF_8)}")) | |
.GET() | |
.header("X-Finnhub-Token", finnhubToken) | |
.build(), | |
BodyHandlers.ofString() | |
), es); | |
var profile2Response = CompletableFuture.supplyAsync(() -> sendWithRetry( | |
HttpRequest.newBuilder(URI.create(STR."https://finnhub.io/api/v1/stock/profile2?symbol=\{URLEncoder.encode(ticker, StandardCharsets.UTF_8)}")) | |
.GET() | |
.header("X-Finnhub-Token", finnhubToken) | |
.build(), | |
BodyHandlers.ofString() | |
), es); | |
displayQuote( | |
ticker, | |
quoteResponse.get(), | |
profile2Response.get() | |
); | |
} catch (Exception e) { | |
System.err.println(STR."Failure on \{ticker}"); | |
e.printStackTrace(System.err); | |
} | |
}, es)).toArray(CompletableFuture[]::new); | |
CompletableFuture.allOf(tickerTasks).get(10, TimeUnit.MINUTES); | |
} | |
// continuous refresh ? | |
// clear console "\033[H\033[2J" | |
} | |
private void waitIfRateLimited(long reset) { | |
if (reset > this.rateLimitResetSeconds) { | |
this.rateLimitResetSeconds = reset; | |
} | |
var rateLimitResetSeconds = this.rateLimitResetSeconds; | |
var announceInProgress = this.rateLimitAnnounceInProgress; | |
if (rateLimitResetSeconds > 0) { | |
Thread waitingThread = null; | |
var amount = rateLimitResetSeconds - ((int) (System.currentTimeMillis() / 1000)); | |
if (!announceInProgress) { | |
this.rateLimitAnnounceInProgress = true; | |
waitingThread = LoadingIndicator.infinite(System.err) | |
.loadingChars(LoadingIndicator.BRAILLE) | |
.withPrefix(STR."[Rate limit] Pausing for \{amount}s") | |
.withTerminateString("[Rate limit] Resuming") | |
.asVirtualThread(); | |
waitingThread.start(); | |
} | |
try { | |
Thread.sleep(Duration.of(amount, ChronoUnit.SECONDS)); | |
} catch (InterruptedException e) { | |
Thread.currentThread().interrupt(); | |
} finally { | |
if (!announceInProgress) { | |
waitingThread.interrupt(); | |
} | |
} | |
} | |
} | |
private <T> HttpResponse<T> sendWithRetry(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) { | |
try { | |
var response = httpClient.send(request, responseBodyHandler); | |
// var limit = response.headers().firstValueAsLong("x-ratelimit-limit").orElseThrow(); | |
// var remaining = response.headers().firstValueAsLong("x-ratelimit-remaining").orElseThrow(); | |
// if (remaining < 4) { | |
// System.err.println("[Rate limit] remaining: " + remaining + " / " + limit); | |
// } | |
while (response.statusCode() == 429) { | |
waitIfRateLimited(response.headers().firstValueAsLong("x-ratelimit-reset").orElseThrow()); | |
response = httpClient.send(request, responseBodyHandler); | |
} | |
return response; | |
} catch (IOException e) { | |
throw new UncheckedIOException(e); | |
} catch (InterruptedException e) { | |
Thread.currentThread().interrupt(); | |
throw new RuntimeException(e); | |
} | |
} | |
private void displayQuote(String ticker, HttpResponse<String> quoteResponse, HttpResponse<String> profile2Response) { | |
// https://finnhub.io/docs/api/quote | |
var matcher = fieldMatcher.matcher(quoteResponse.body()); | |
var quotePayload = matcher.results().collect(Collectors.toMap( | |
m -> m.group(1), | |
m -> m.group(2) | |
)); | |
String error = quotePayload.get("error"); | |
if ("null".equals(quotePayload.get("d")) || error != null) { | |
System.out.println(STR."► \{bold}\{ticker}\{reset} \{gray}SKIPPED\{reset}%n"); | |
if (error != null) { | |
System.out.println(STR." \{red}\{error}\{reset}%n"); | |
} | |
return; | |
} | |
var currentPrice = Double.parseDouble(quotePayload.get("c")); | |
var change = Double.parseDouble(quotePayload.get("d")); | |
var percentChange = Double.parseDouble(quotePayload.get("dp")); | |
var highPriceOfTheDay = Double.parseDouble(quotePayload.get("h")); | |
var lowPriceOfTheDay = Double.parseDouble(quotePayload.get("l")); | |
// https://finnhub.io/docs/api/company-profile2 | |
matcher = fieldMatcher.matcher(profile2Response.body()); | |
var profile2Payload = matcher.results().collect(Collectors.toMap( | |
m -> m.group(1), | |
m -> m.group(2) | |
)); | |
System.out.println( | |
STR.""" | |
► \{bold}\{ticker}\{reset} \{(change < 0 ? STR."\{red}↘︎" : STR."\{green}↗︎")}\{reset} \{gray}\{LocalDateTime.ofInstant(Instant.ofEpochSecond(Long.parseLong(quotePayload.get("t"))), ZoneId.systemDefault())}\{reset} | |
\{italic}\{profile2Payload.get("name")}\{reset} (\{profile2Payload.get("exchange")}) currency: \{profile2Payload.get("currency")} | |
\{bold + underline + blue}\{currentPrice}\{reset} | |
\{change < 0 ? change : STR."+\{change}"} (\{change < 0 ? red : green}\{percentChange}%\{reset}) \{green}↑\{reset}\{highPriceOfTheDay} \{red}↓\{reset}\{lowPriceOfTheDay} \{reset} | |
""" | |
); | |
} | |
} | |
class LoadingIndicator { | |
public static final String BASIC = "|/-\\"; | |
public static final String BRAILLE = "⡿⣟⣯⣷⣾⣽⣻⢿"; | |
public static final String CLOCK = "◷◶◵◴"; | |
public static final String UNICODE_HALF_WIDTH = "▏ ▎ ▍ ▌ ▋ ▊ ▉ █ "; | |
public static final String UNICODE_FULL_WIDTH = "▁ ▂ ▃ ▄ ▅ ▆ ▇ █ "; | |
public static LoadingIndicator.InfiniteLoaderIndicator infinite(PrintStream printStream) { | |
return new LoadingIndicator.InfiniteLoaderIndicator(printStream); | |
} | |
public static class InfiniteLoaderIndicator extends LoadingIndicator { | |
private final PrintStream printStream; | |
private String loadingChars = BASIC; | |
private String prefix = ""; | |
private String terminateString = ""; | |
public InfiniteLoaderIndicator(PrintStream printStream) { | |
this.printStream = printStream; | |
} | |
public InfiniteLoaderIndicator loadingChars(String loadingChars) { | |
this.loadingChars = loadingChars; | |
return this; | |
} | |
public InfiniteLoaderIndicator withPrefix(String prefix) { | |
if (prefix.indexOf('\n') >= 0) { | |
throw new IllegalArgumentException("Prefix cannot contain newline"); | |
} | |
this.prefix = prefix; | |
return this; | |
} | |
public InfiniteLoaderIndicator withTerminateString(String terminateString) { | |
this.terminateString = terminateString; | |
return this; | |
} | |
public Thread asVirtualThread() { | |
return Thread.ofVirtual().unstarted(() -> startLoadingIndicator(printStream, prefix, terminateString, loadingChars)); | |
} | |
@SuppressWarnings("BusyWait") // loom | |
private static void startLoadingIndicator(PrintStream printStream, String prefix, String terminatedString, String chars) { | |
while (true) { | |
for (char c : chars.toCharArray()) { | |
try { | |
Thread.sleep(500); | |
} catch (InterruptedException e) { | |
Thread.currentThread().interrupt(); | |
printStream.print(STR."\r\{prefix}✅"); | |
// for some reason using print("\n") introduce a flush and outputs one too many | |
// 'new line' along the way, using the char overload to output a new line works | |
printStream.print('\n'); | |
if (!terminatedString.isEmpty()) { | |
printStream.println(terminatedString); | |
} | |
return; | |
} | |
printStream.print(STR."\{prefix}\{c}\r"); | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment