diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b0bdc0 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# stooq_google_sheets_integration.gs + +![stooq_integration.png](screenshots/stooq_integration.png?raw=true "Arkusz pobierający dane z portalu Stooq.com") + +`stooq_google_sheets_integration.gs` to skrypt który w bardzo podstawowy sposób "integruje" portal [stooq.com](https://stooq.com) z aplikacją Google Arkusze. Skrypt wprowadza nową funkcję która pobiera kurs wybranego waloru ze strony [stooq.com](https://stooq.com) i umieszcza ten kurs w naszym arkuszu kalkulacyjnym. + +## Instrukcja + +1. Utwórz swój nowy arkusz kalkulacyjny w aplikacji "Google Arkusze" + +2. Dodaj do nowo utworzonego arkusza skrypt `stooq_google_sheets_integration.gs`. Aby to zrobić kliknij w swoim arkuszu "Narzędzia -> Edytor skryptów" i otwarta zostanie nowa zakładka przeglądarki z zawartością edytora skryptów. Usuń treść skryptu w edytorze (jeśli jest jakaś domyślna) i wklej do edytora CAŁĄ (łącznie z komentarzami) zawartość pliku [`stooq_google_sheets_integration.gs`](stooq_google_sheets_integration.gs). Zapisz skrypt wykonując: "Plik -> Zapisz". Jeśli aplikacja zapyta o nazwę projektu to możesz podać "stooq_google_sheets_integration.gs". + +![stooq_script_editor.png](screenshots/stooq_script_editor.png?raw=true "Widok edytora skryptów") + +3. Zamknij zakładkę przeglądarki z edytorem skryptów. + +4. Zamknij i ponownie otwórz zakładkę przeglądarki z arkuszem kalkulacyjnym. Po otwarciu pojawi się nowe menu "STOOQ" u góry arkusza. + +5. W swoim arkuszu obowiązkowo musisz zarezerwować jedną komórkę na "datę notowań" dla skryptu `stooq_google_sheets_integration.gs`. Bez tej komórki skrypt nie może prawidłowo funkcjonować. Domyślnie jest to komórka "B1", ale możesz ją zmienić w skrypcie (linijka 25 w treści skryptu). + +6. Teraz możesz zacząć używać w swoim arkuszu nowej funkcji którą dostarcza skrypt: + +`STOOQ_GET_PRICE("WIG20"; $B$1)` - funkcja zwróci ostatnią wartość kursu indeksu "WIG20" (pobierze ją wprost ze strony https://stooq.com/q/?s=wig20). Zamiast "WIG20" możesz podać dowolny inny symbol ze Stooq, np. "^SPX" czy "BTCUSD". Funkcja musi korzystać z daty w komórce `$B$1`. Naciśnięcie przycisku w menu "STOOQ -> Aktualizuj kursy walorów" spowoduje zaktualizowanie daty w komórce `B1` i pobranie przez wszystkie funkcje `STOOQ_GET_PRICE` najnowszych kursów. +Można w komórce `B1` również ręcznie wpisać datę z przeszłości co spowoduje pobranie historycznego kursu zamknięcia w danym dniu. + +Komórka daty `B1` zawiera oprócz daty również czas. Czas jest ignorowany przez funkcję `STOOQ_GET_PRICE` ale niestety gdy będzie tam tylko sama data to Google Arkusze potraktują to jako argument funkcji `STOOQ_GET_PRICE` który się nie zmienia i będą zwracać zawsze tę samą wartość tej funkcji, to jest wartość uzyskaną podczas pierwszego uruchomienia. +Funkcja `STOOQ_GET_PRICE` uruchamia się zawsze automatycznie zaraz po otwarciu arkusza kalkulacyjnego. + + diff --git a/screenshots/stooq_integration.png b/screenshots/stooq_integration.png new file mode 100644 index 0000000..35cd2a8 Binary files /dev/null and b/screenshots/stooq_integration.png differ diff --git a/screenshots/stooq_script_editor.png b/screenshots/stooq_script_editor.png new file mode 100644 index 0000000..7e911bb Binary files /dev/null and b/screenshots/stooq_script_editor.png differ diff --git a/stooq_google_sheets_integration.gs b/stooq_google_sheets_integration.gs new file mode 100644 index 0000000..1355c36 --- /dev/null +++ b/stooq_google_sheets_integration.gs @@ -0,0 +1,260 @@ +/** + * The MIT License (MIT) + * + * Copyright (c) 2018 Marcin P (https://github.com/yu55) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +var LAST_REFRESH_DATE_CELL = "B1"; // komórka w której przechowujemy datę ostatniej aktualizacji + +function onOpen() { + var activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); + var menuEntries = [{ + name : "Aktualizuj kursy walorów", + functionName : "updateLastRefreshDate" + }]; + activeSpreadsheet.addMenu("STOOQ", menuEntries); + updateLastRefreshDate(); +}; + +function updateLastRefreshDate() { + SpreadsheetApp.getActiveSpreadsheet().getRange(LAST_REFRESH_DATE_CELL).setValue(new Date()); +} + +/** + * Download share price for a gicen ticker from stooq.com web page. + * Date parameter is optional. If date is not provided or provided date is today + * then function will download current share price for a given ticker from stooq.com web page. + * Otherwise function will download historic share price for a given ticker and date. + * + * @param {string} ticker share ticker name; e.g. "^SPX". + * @param {Date=} date given date. + * @return share price at given date if date is provided or current share price. + * @customfunction + */ +function STOOQ_GET_PRICE(ticker, date) { + if (isRequestForCurrentPrice(date)) { + return stooqGetCurrentPrice(ticker); + } else { + return stooqGetHistoricClosingPrice(ticker, date); + } +} + +function isRequestForCurrentPrice(date) { + var now = initNowDateWithoutHours(); + return (date == null || setZeroHours(date).getTime() == now.getTime()); +} + +function initNowDateWithoutHours() { + var now = new Date(); + return setZeroHours(now); +} + +function setZeroHours(date) { + date.setHours(0,0,0,0); + return date; +} + + + +function stooqGetCurrentPrice(ticker) { + validateTicker(ticker); + var stooqWebPageSource = downloadStooqWebPageSource(ticker) + var priceValue = extractSharePrice(stooqWebPageSource, ticker); + return priceValue; +} + +function validateTicker(ticker) { + if (!ticker || 0 === ticker.trim().length) { + throw ("ERROR: function argument \"ticker\" cannot be empty"); + } +} + +function downloadStooqWebPageSource(ticker) { + var url = prepareStooqWebPageUrl(ticker) + var stooqResponse = UrlFetchApp.fetch(url, {'muteHttpExceptions': true}); + if (stooqResponse.getResponseCode() != 200) { + throw ("ERROR: Cannot download \"" + ticker + "\" data. HTTP code: " + stooqResponse.getResponseCode()); + } + return stooqResponse.getContentText(); +} + +function prepareStooqWebPageUrl(ticker) { + var normalizedTicker = normalizeTicker(ticker); + return 'https://stooq.com/q/?s=' + encodeURIComponent(normalizedTicker); +} + +function normalizeTicker(ticker) { + return ticker.toLowerCase().trim(); +} + +function extractSharePrice(stooqWebPageSource, ticker) { + var priceText = findPriceText(stooqWebPageSource, ticker); + var priceAsNumber = convertTextToNumber(priceText); + return priceAsNumber; +} + +function findPriceText(stooqWebPageSource, ticker) { + // we're looking for a HTML fragment like this: "2833.28" + var priceHtmlSpanTagIdLocation = findPriceHtmlSpanTagIdLocation(stooqWebPageSource, ticker); + var priceTextStartLocation = stooqWebPageSource.indexOf(">", priceHtmlSpanTagIdLocation); + var priceTextEndLocation = stooqWebPageSource.indexOf("<", priceHtmlSpanTagIdLocation); + return stooqWebPageSource.substring(priceTextStartLocation + 1, priceTextEndLocation); +} + +function findPriceHtmlSpanTagIdLocation(stooqWebPageSource, ticker) { + var priceHtmlSpanTagId = preparePriceHtmlSpanTagId(ticker); + var priceHtmlSpanTagIdLocation = stooqWebPageSource.indexOf(priceHtmlSpanTagId); + if (priceHtmlSpanTagIdLocation == -1) { + throw ("ERROR: cannot find price of \"" + ticker + "\" ticker on STOOQ web page"); + } + return priceHtmlSpanTagIdLocation; +} + +function preparePriceHtmlSpanTagId(ticker) { + var normalizedTicker = normalizeTicker(ticker); + return "aq_" + normalizedTicker + "_c"; +} + +function convertTextToNumber(text) { + return Number(text); +} + + + +function stooqGetHistoricClosingPrice(ticker, date) { + validateTicker(ticker); + // TODO validateDate(date); + var historyCsv = downloadStooqHistoryCsv(ticker, date); + var historicClosingPrice = extractHistoricSharePrice(historyCsv); + return convertTextToNumber(historicClosingPrice); +} + +function downloadStooqHistoryCsv(ticker, date) { + var url = prepareStooqHistoryCsvUrl(ticker, date) + var stooqResponse = UrlFetchApp.fetch(url, {'muteHttpExceptions': true}); + if (stooqResponse.getResponseCode() != 200) { + throw ("ERROR: Cannot download \"" + ticker + "\" history CSV data. HTTP code: " + stooqResponse.getResponseCode()); + } + return stooqResponse.getContentText(); +} + +function prepareStooqHistoryCsvUrl(ticker, date) { + var normalizedTicker = normalizeTicker(ticker); + var dateParam = Utilities.formatDate(date, "GMT", "yyyyMMdd"); + var url = "https://stooq.com/q/d/l/?s="+ encodeURIComponent(ticker) + "&d1=" + encodeURIComponent(dateParam) + "&d2=" + encodeURIComponent(dateParam) + "&i=d"; + return url; +} + +function extractHistoricSharePrice(historyCsv) { + var historyCsvWthoutCR = historyCsv.replace(/[\r]/g, ""); + var csvLines = historyCsvWthoutCR.split("\n"); + var data = csvLines.map(function(line) {return line.split(",")}); + if (data.length < 2 || data[1].length < 5) { + throw ("ERROR: Cannot find price data in Stooq history CSV file. historyCsv=\"" + historyCsv + "\" data=" + data); + } + const CSV_DATA_ROW_INDEX = 1; + const CLOSING_PRICE_DATA_INDEX = 4; + return data[CSV_DATA_ROW_INDEX][CLOSING_PRICE_DATA_INDEX]; +} + + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Tests + +function shouldReturnCurrentPriceForValidExistingTicker() { + Logger.log(stooqGetCurrentPrice("^spx")); +} + +function shouldReturnCurrentPriceForExistingTickerWithCapitalLetters() { + Logger.log(stooqGetCurrentPrice("^SPX")); +} + +function shouldReturnCurrentPriceForUntrimmedExistingTicker() { + Logger.log(stooqGetCurrentPrice(" ^SPX ")); +} + +function shouldThrowExceptionForCurrentNonExistingTicker() { + Logger.log(stooqGetCurrentPrice("castar")); +} + +function shouldThrowExceptionForCurrentNullTicker() { + Logger.log(stooqGetCurrentPrice(null)); +} + +function shouldThrowExceptionForCurrentEmptyTicker() { + Logger.log(stooqGetCurrentPrice("")); +} + +function shouldThrowExceptionForCurrentBlankTicker() { + Logger.log(stooqGetCurrentPrice(" ")); +} + + + +function shouldReturnHistoricPriceForValidExistingTicker() { + Logger.log(stooqGetHistoricClosingPrice("^spx", new Date(2017, 7, 11))); +} + +function shouldReturnHistoricPriceForExistingTickerWithCapitalLetters() { + Logger.log(stooqGetHistoricClosingPrice("^SPX", new Date(2017, 7, 11))); +} + +function shouldReturnHistoricPriceForUntrimmedExistingTicker() { + Logger.log(stooqGetHistoricClosingPrice(" ^SPX ", new Date(2017, 7, 11))); +} + +function shouldThrowExceptionForHistoricNonExistingTicker() { + Logger.log(stooqGetHistoricClosingPrice("castar", new Date(2017, 7, 11))); +} + +function shouldThrowExceptionForHistoricNullTicker() { + Logger.log(stooqGetHistoricClosingPrice(null, new Date(2017, 7, 11))); +} + +function shouldThrowExceptionForHistoricEmptyTicker() { + Logger.log(stooqGetHistoricClosingPrice("", new Date(2017, 7, 11))); +} + +function shouldThrowExceptionForHistoricBlankTicker() { + Logger.log(stooqGetHistoricClosingPrice(" ", new Date(2017, 7, 11))); +} + +function shouldThrowExceptionForHistoricNullDate() { + Logger.log(stooqGetHistoricClosingPrice("^spx", null)); +} + + + +function shouldInvokeGetCurrentPriceFunctionWhenNoDatePrivided() { + Logger.log(STOOQ_GET_PRICE("^spx")); + Logger.log(STOOQ_GET_PRICE("^spx", new Date())); +} + +function shouldInvokeGetCurrentPriceFunctionWhenTodayDatePrivided() { + Logger.log(STOOQ_GET_PRICE("^spx", new Date())); +} + +function shouldInvokeGetHistoricPriceFunction() { + Logger.log(STOOQ_GET_PRICE("^spx", new Date(2017, 7, 11))); +} + +