diff --git a/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/Utils.kt b/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/Utils.kt index 1ba95dd..23256ea 100644 --- a/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/Utils.kt +++ b/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/Utils.kt @@ -4,13 +4,17 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.github.kittinunf.fuel.core.ResponseDeserializable import com.uchuhimo.konf.Config import eu.slomkowski.octoglow.octoglowd.hardware.Hardware import io.dvlopt.linux.i2c.I2CBuffer import kotlinx.coroutines.delay import mu.KLogger +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVParser +import org.apache.commons.csv.CSVRecord import org.shredzone.commons.suncalc.SunTimes -import java.io.InputStream +import java.io.* import java.nio.charset.StandardCharsets import java.time.* import java.util.* @@ -132,3 +136,13 @@ fun I2CBuffer.contentToString(): String = (0 until this.length).map { this[it] } fun I2CBuffer.toList(): List = (0 until this.length).map { this[it] }.toList() fun InputStream.readToString(): String = this.bufferedReader(StandardCharsets.UTF_8).readText() + +inline fun csvDeserializerOf(csvFormat: CSVFormat = CSVFormat.DEFAULT, crossinline recordMappingFunction: (CSVRecord) -> T) = object : ResponseDeserializable> { + override fun deserialize(reader: Reader): List = CSVParser(reader, csvFormat).records.map(recordMappingFunction) + + override fun deserialize(content: String): List = StringReader(content).use { deserialize(it) } + + override fun deserialize(bytes: ByteArray): List = ByteArrayInputStream(bytes).use { deserialize(it) } + + override fun deserialize(inputStream: InputStream): List = InputStreamReader(inputStream, StandardCharsets.UTF_8).use { deserialize(it) } +} \ No newline at end of file diff --git a/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/StockView.kt b/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/StockView.kt new file mode 100644 index 0000000..12c8a9b --- /dev/null +++ b/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/StockView.kt @@ -0,0 +1,93 @@ +package eu.slomkowski.octoglow.octoglowd.daemon.frontdisplay + +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.coroutines.awaitObject +import com.uchuhimo.konf.Config +import eu.slomkowski.octoglow.octoglowd.csvDeserializerOf +import eu.slomkowski.octoglow.octoglowd.hardware.Hardware +import kotlinx.coroutines.coroutineScope +import mu.KLogging +import org.apache.commons.csv.CSVFormat +import java.math.BigDecimal +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.* + +class StockView( + private val config: Config, + hardware: Hardware) + : FrontDisplayView(hardware, + "Warsaw Stock Exchange index", + Duration.ofMinutes(15), + Duration.ofSeconds(15), + Duration.ofSeconds(13)) { + + + data class StockInfoDto( + val ticker: String, + val interval: Duration, + val timestamp: LocalDateTime, + val open: BigDecimal, + val high: BigDecimal, + val low: BigDecimal, + val close: BigDecimal, + val volume: Int, + val openInt: Int) { + init { + require(ticker.isNotBlank()) + require(interval > Duration.ZERO) + require(high >= low) + require(volume >= 0) + } + } + + companion object : KLogging() { + private val cookie: String = UUID.randomUUID().toString() + + private val shortTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HHmmss") + + private const val STOOQ_URL = "https://stooq.pl/db/d/" + + suspend fun downloadStockData(date: LocalDate): List { + val parameters = listOf( + "t" to "h", + "d" to date.format(DateTimeFormatter.BASIC_ISO_DATE), + "u" to cookie) + + logger.debug("Downloading stock data for $date from stooq.pl.") + + return Fuel.get(STOOQ_URL, parameters).awaitObject(csvDeserializerOf(CSVFormat.DEFAULT.withFirstRecordAsHeader()) { record -> + val rowDate = LocalDate.parse(record[2], DateTimeFormatter.BASIC_ISO_DATE) + val time = LocalTime.parse(record[3], shortTimeFormatter) + StockInfoDto( + record.get(0), + Duration.ofMinutes(record[1].toLong()), + LocalDateTime.of(rowDate, time), + record[4].toBigDecimal(), + record[5].toBigDecimal(), + record[6].toBigDecimal(), + record[7].toBigDecimal(), + record[8].toInt(), + record[9].toInt()) + }) + } + } + + override suspend fun redrawDisplay(redrawStatic: Boolean, redrawStatus: Boolean) = coroutineScope { + TODO() + + Unit + } + + /** + * Progress bar is dependent only on current time so always success. + */ + override suspend fun poolInstantData(): UpdateStatus = UpdateStatus.FULL_SUCCESS + + override suspend fun poolStatusData(): UpdateStatus = coroutineScope { + TODO() + } +} \ No newline at end of file diff --git a/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/WigView.kt b/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/WigView.kt deleted file mode 100644 index 763a9bc..0000000 --- a/software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/WigView.kt +++ /dev/null @@ -1,212 +0,0 @@ -package eu.slomkowski.octoglow.octoglowd.daemon.frontdisplay - -import com.fasterxml.jackson.annotation.JsonProperty -import com.github.kittinunf.fuel.Fuel -import com.github.kittinunf.fuel.coroutines.awaitObject -import com.github.kittinunf.fuel.jackson.jacksonDeserializerOf -import com.uchuhimo.konf.Config -import com.uchuhimo.konf.RequiredItem -import eu.slomkowski.octoglow.octoglowd.NbpKey -import eu.slomkowski.octoglow.octoglowd.hardware.Hardware -import eu.slomkowski.octoglow.octoglowd.jacksonObjectMapper -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import mu.KLogging -import java.time.Duration -import java.time.LocalDate -import java.time.LocalDateTime - -class WigView( - private val config: Config, - hardware: Hardware) - : FrontDisplayView(hardware, - "Warsaw Stock Exchange index", - Duration.ofMinutes(15), - Duration.ofSeconds(15), - Duration.ofSeconds(13)) { - - interface DatedPrice { - val date: LocalDate - val price: Double - } - - data class RateDto( - val no: String, - val effectiveDate: LocalDate, - val mid: Double) : DatedPrice { - override val date: LocalDate - get() = effectiveDate - override val price: Double - get() = mid - } - - data class CurrencyDto( - val table: String, - val currency: String, - val code: String, - val rates: List) - - data class SingleCurrencyReport( - val code: String, - val isLatestFromToday: Boolean, - val latest: Double, - val historical: List) { - init { - require(historical.size == HISTORIC_VALUES_LENGTH) - } - } - - data class CurrentReport( - val timestamp: LocalDateTime, - val currencies: Map, SingleCurrencyReport>) - - data class GoldPrice( - @JsonProperty("data") - override val date: LocalDate, - @JsonProperty("cena") - override val price: Double) : DatedPrice - - companion object : KLogging() { - private const val OUNCE = 31.1034768 // grams - - private const val HISTORIC_VALUES_LENGTH = 14 - - private const val NBP_API_BASE = "http://api.nbp.pl/api/" - - suspend fun getCurrencyRates(currencyCode: String, howMany: Int): List { - require(currencyCode.isNotBlank()) - require(howMany > 0) - logger.debug { "Downloading currency rates for $currencyCode." } - val url = "$NBP_API_BASE/exchangerates/rates/a/$currencyCode/last/$howMany" - - val resp = Fuel.get(url).awaitObject(jacksonDeserializerOf(jacksonObjectMapper)) - - return resp.let { - check(it.code == currencyCode) - check(it.rates.isNotEmpty()) - it.rates - } - } - - suspend fun getGoldRates(howMany: Int): List { - require(howMany > 0) - logger.debug { "Downloading gold price." } - val url = "$NBP_API_BASE/cenyzlota/last/$howMany" - - val resp = Fuel.get(url).awaitObject>(jacksonDeserializerOf(jacksonObjectMapper)) - - return resp.apply { - check(isNotEmpty()) - } - } - - fun createReport(code: String, rates: List, today: LocalDate): SingleCurrencyReport { - val mostRecentRate = checkNotNull(rates.maxBy { it.date }) - - val historical = ((1)..HISTORIC_VALUES_LENGTH).map { dayNumber -> - val day = today.minusDays(dayNumber.toLong()) - rates.singleOrNull { it.date == day }?.price - }.asReversed() - - return SingleCurrencyReport( - code, - mostRecentRate.date == today, - mostRecentRate.price, - historical) - } - - suspend fun getCurrencyReport(code: String, today: LocalDate): SingleCurrencyReport { - val rates = when (code) { - "XAU" -> getGoldRates(HISTORIC_VALUES_LENGTH).map { r -> GoldPrice(r.date, OUNCE * r.price) } - else -> getCurrencyRates(code, HISTORIC_VALUES_LENGTH) - } - return createReport(code, rates, today) - } - - fun formatZloty(amount: Double?): String { - return when (amount) { - null -> "----zł" - in 10_000.0..100_000.0 -> String.format("%5.0f", amount) - in 1000.0..10_000.0 -> String.format("%4.0fzł", amount) - in 100.0..1000.0 -> String.format("%3.0f zł", amount) - in 10.0..100.0 -> String.format("%4.1fzł", amount) - in 0.0..10.0 -> String.format("%3.2fzł", amount) - else -> " MUCH " - } - } - } - - private val currencyKeys = listOf(NbpKey.currency1, NbpKey.currency2, NbpKey.currency3).apply { - forEach { - val code = config[it] - check(code.length == 3) { "invalid currency code $code" } - } - } - - private var currentReport: CurrentReport? = null - - private suspend fun drawCurrencyInfo(cr: SingleCurrencyReport?, offset: Int, diffChartStep: Double) { - require(diffChartStep > 0) - hardware.frontDisplay.apply { - setStaticText(offset, when (cr?.isLatestFromToday) { - true -> cr.code.toUpperCase() - false -> cr.code.toLowerCase() - null -> "---" - }) - setStaticText(offset + 20, formatZloty(cr?.latest)) - - if (cr != null) { - val unit = cr.latest * diffChartStep - setOneLineDiffChart(5 * (offset + 3), cr.latest, cr.historical, unit) - } - } - } - - override suspend fun redrawDisplay(redrawStatic: Boolean, redrawStatus: Boolean) = coroutineScope { - val report = currentReport - - if (redrawStatus) { - val diffChartStep = config[NbpKey.diffChartFraction] - logger.debug { "Refreshing NBP screen, diff chart step: $diffChartStep." } - launch { drawCurrencyInfo(report?.currencies?.get(NbpKey.currency1), 0, diffChartStep) } - launch { drawCurrencyInfo(report?.currencies?.get(NbpKey.currency2), 7, diffChartStep) } - launch { drawCurrencyInfo(report?.currencies?.get(NbpKey.currency3), 14, diffChartStep) } - } - - drawProgressBar(report?.timestamp) - - Unit - } - - /** - * Progress bar is dependent only on current time so always success. - */ - override suspend fun poolInstantData(): UpdateStatus = UpdateStatus.FULL_SUCCESS - - override suspend fun poolStatusData(): UpdateStatus = coroutineScope { - val now = LocalDateTime.now() - - - val newReport = CurrentReport(now, currencyKeys.map { currencyKey -> - async { - val code = config[currencyKey] - try { - currencyKey to getCurrencyReport(code, now.toLocalDate()) - } catch (e: Exception) { - logger.error(e) { "Failed to update status on $code." } - null - } - } - }.awaitAll().filterNotNull().toMap()) - - currentReport = newReport - - when (newReport.currencies.size) { - 3 -> UpdateStatus.FULL_SUCCESS - 0 -> UpdateStatus.FAILURE - else -> UpdateStatus.PARTIAL_SUCCESS - } - } -} \ No newline at end of file diff --git a/software/octoglowd/src/test/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/StockViewTest.kt b/software/octoglowd/src/test/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/StockViewTest.kt new file mode 100644 index 0000000..d459739 --- /dev/null +++ b/software/octoglowd/src/test/kotlin/eu/slomkowski/octoglow/octoglowd/daemon/frontdisplay/StockViewTest.kt @@ -0,0 +1,31 @@ +package eu.slomkowski.octoglow.octoglowd.daemon.frontdisplay + +import kotlinx.coroutines.runBlocking +import mu.KLogging +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.DayOfWeek +import java.time.LocalDate + +internal class StockViewTest { + + companion object : KLogging() + + @Test + fun testDownloadStockData() { + val lastWeekDay = checkNotNull(object : Iterator { + var d = LocalDate.now().minusDays(7) + + override fun hasNext() = d <= LocalDate.now() + + override fun next(): LocalDate { + d = d.plusDays(1) + return d + } + }.asSequence().findLast { it.dayOfWeek !in setOf(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY) }) + + val stockData = runBlocking { StockView.downloadStockData(lastWeekDay) } + assertTrue(stockData.isNotEmpty()) + logger.info("Downloaded {} stocks.", stockData.size) + } +} \ No newline at end of file