From a62a86235ccfd536374e2484de7a70d061eed433 Mon Sep 17 00:00:00 2001 From: Blake Felt Date: Thu, 9 Apr 2020 22:32:01 -0600 Subject: [PATCH 01/25] Added parameters first-time and previous-pattern. Added text box to notify that values will not be saved. Valid first-time options are: yes/true/1 no/false/0. Valid first-time options are: 0/fluctuating 1/large-spike 2/decreasing 3/small-spike anything else defaults to unknown. Test with: https://turnipprophet.io/?pattern=large-spike&prices=90.91..93.94..&first=true --- index.html | 7 ++++ js/scripts.js | 113 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/index.html b/index.html index 00b94c6..8521e34 100644 --- a/index.html +++ b/index.html @@ -273,6 +273,13 @@

This app is still in development, but will improve over time!

+ +

Daisy Mae

diff --git a/js/scripts.js b/js/scripts.js index 24f1376..d1f5234 100644 --- a/js/scripts.js +++ b/js/scripts.js @@ -65,9 +65,10 @@ const fillFields = function (prices, first_buy, previous_pattern) { const initialize = function () { try { - const prices = getPrices() - const first_buy = getFirstBuyState(); - const previous_pattern = getPreviousPatternState(); + const previous = getPrevious(); + const first_buy = previous[0]; + const previous_pattern = previous[1]; + const prices = previous[2]; if (prices === null) { fillFields([], first_buy, previous_pattern) } else { @@ -101,14 +102,65 @@ const isEmpty = function (arr) { return filtered.length == 0 } -const getFirstBuyState = function () { +const getFirstBuyStateFromQuery = function (param) { + try { + const params = new URLSearchParams(window.location.search.substr(1)); + const firstbuy_str = params.get(param); + + if (firstbuy_str == null) { + return null; + } + + firstbuy = null; + if (firstbuy_str == "1" || firstbuy_str == "yes" || firstbuy_str == "true") { + firstbuy = true; + } else if (firstbuy_str == "0" || firstbuy_str == "no" || firstbuy_str == "false") { + firstbuy = false; + } + + return firstbuy; + + } catch (e) { + return null; + } +} + +const getFirstBuyStateFromLocalstorage = function () { return JSON.parse(localStorage.getItem('first_buy')) } -const getPreviousPatternState = function () { +const getPreviousPatternStateFromLocalstorage = function () { return JSON.parse(localStorage.getItem('previous_pattern')) } +const getPreviousPatternStateFromQuery = function (param) { + try { + const params = new URLSearchParams(window.location.search.substr(1)); + const pattern_str = params.get(param); + + if (pattern_str == null) { + return null; + } + + if (pattern_str == "0" || pattern_str == "fluctuating") { + pattern = 0; + } else if (pattern_str == "1" || pattern_str == "large-spike") { + pattern = 1; + } else if (pattern_str == "2" || pattern_str == "decreasing") { + pattern = 2; + } else if (pattern_str == "3" || pattern_str == "small-spike") { + pattern = 3; + } else { + pattern = -1; + } + + return pattern; + + } catch (e) { + return null; + } +} + const getPricesFromLocalstorage = function () { try { const sell_prices = JSON.parse(localStorage.getItem("sell_prices")); @@ -123,10 +175,10 @@ const getPricesFromLocalstorage = function () { } }; -const getPricesFromQuery = function () { +const getPricesFromQuery = function (param) { try { const params = new URLSearchParams(window.location.search.substr(1)); - const sell_prices = params.get("prices").split(".").map((x) => parseInt(x, 10)); + const sell_prices = params.get(param).split(".").map((x) => parseInt(x, 10)); if (!Array.isArray(sell_prices)) { return null; @@ -141,15 +193,52 @@ const getPricesFromQuery = function () { sell_prices.push(0); } - window.price_from_query = true; return sell_prices; } catch (e) { return null; } }; -const getPrices = function () { - return getPricesFromQuery() || getPricesFromLocalstorage(); +const getPreviousFromQuery = function () { + const queries = [ + ["first", getFirstBuyStateFromQuery], + ["pattern", getPreviousPatternStateFromQuery], + ["prices", getPricesFromQuery] + ]; + + + found = null; /* value to save if we found any parameters */ + ret = []; + for (q of queries) { + val = q[1](q[0]); /* run the function with the parameter */ + found = found || val; + ret.push(val); + } + + if (found != null) { /* if we found any parameter */ + window.from_query = true; + document.getElementById("from_query").style.visibility = "visible"; + return ret; + } + return null; +}; + +const getPreviousFromLocalstorage = function () { + return [ + getFirstBuyStateFromLocalstorage(), + getPreviousPatternStateFromQuery(), + getPricesFromLocalstorage() + ]; +}; + + +/** + * Gets previous values. First tries to parse parameters, + * if none of them match then it looks in local storage. + * @return {[first time, previous pattern, prices]} + */ +const getPrevious = function () { + return getPreviousFromQuery() || getPreviousFromLocalstorage(); }; const getSellPrices = function () { @@ -187,13 +276,13 @@ const update = function () { const buy_price = parseInt(buy_input.val()); const first_buy = getCheckedRadio(first_buy_radios) == 'true'; const previous_pattern = parseInt(getCheckedRadio(previous_pattern_radios)); - + buy_input[0].disabled = first_buy; buy_input[0].placeholder = first_buy ? '—' : '...' const prices = [buy_price, buy_price, ...sell_prices]; - if (!window.price_from_query) { + if (!window.from_query) { updateLocalStorage(prices, first_buy, previous_pattern); } From fb1cb3476a7c962c784a2f55b65d86e6e69e4547 Mon Sep 17 00:00:00 2001 From: Tim Plunkett Date: Mon, 13 Apr 2020 11:55:30 -0400 Subject: [PATCH 02/25] Clarify price should be from your island --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index d4dc76e..de27703 100644 --- a/index.html +++ b/index.html @@ -98,7 +98,7 @@

Sunday
-
- AM - 8:00 am to 11:59 am PM - 12:00 pm to 10:00 pm
@@ -284,6 +283,19 @@ Support, comments and contributions are available through Github

+

Oh! And let's not forget to thank those who have contributed so far!

+

Contributors: + Mike Bryant, + Trevor Welch, + Lou Huang, + Tyler Matchett, + Valerio Riva, + Naji Astier, + Pheonix Meadowlark, + Peter Shih, + Michael Ritter, + jtplatt99 +

From c2559fedbf3d24e44ea73a678c271d0662fb1622 Mon Sep 17 00:00:00 2001 From: Ryan Carbotte Date: Mon, 13 Apr 2020 18:49:26 -0500 Subject: [PATCH 04/25] feat: Add dynamic list of contributors --- index.html | 14 +++----------- js/contributors.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 js/contributors.js diff --git a/index.html b/index.html index 85f0f5e..fb34203 100644 --- a/index.html +++ b/index.html @@ -284,23 +284,15 @@ href="https://github.com/mikebryant/ac-nh-turnip-prices/issues">Github

Oh! And let's not forget to thank those who have contributed so far!

-

Contributors: - Mike Bryant, - Trevor Welch, - Lou Huang, - Tyler Matchett, - Valerio Riva, - Naji Astier, - Pheonix Meadowlark, - Peter Shih, - Michael Ritter, - jtplatt99 +

+ Contributors:

+ diff --git a/js/contributors.js b/js/contributors.js new file mode 100644 index 0000000..90b9940 --- /dev/null +++ b/js/contributors.js @@ -0,0 +1,17 @@ +function getContributors() { + if (window.jQuery) { + const container = $('#contributors'); + jQuery.ajax('https://api.github.com/repos/mikebryant/ac-nh-turnip-prices/contributors', {}) + .done(function (data) { + data.forEach((contributor, idx) => { + console.debug('DEBUG:', contributor); + container.append(`${contributor.login}`); + if (idx < data.length - 1) { + container.append(', '); + } + }); + }); + } +} + +$(document).ready(getContributors); From e20e884611cd0474fc3c2b920001b640f607b26a Mon Sep 17 00:00:00 2001 From: Ryan Carbotte Date: Mon, 13 Apr 2020 19:36:16 -0500 Subject: [PATCH 05/25] fix: Changed to use HTML URL for contributor link --- js/contributors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/contributors.js b/js/contributors.js index 90b9940..c3d09e5 100644 --- a/js/contributors.js +++ b/js/contributors.js @@ -5,7 +5,7 @@ function getContributors() { .done(function (data) { data.forEach((contributor, idx) => { console.debug('DEBUG:', contributor); - container.append(`${contributor.login}`); + container.append(`${contributor.login}`); if (idx < data.length - 1) { container.append(', '); } From 8791c5fa92e71e4ffccf493d536bb06c91fbbe85 Mon Sep 17 00:00:00 2001 From: Harry Peach Date: Thu, 9 Apr 2020 21:46:43 +0100 Subject: [PATCH 06/25] Added basic service-worker support --- .netlify/state.json | 3 ++ img/192.png | Bin 0 -> 852 bytes img/512.png | Bin 0 -> 1746 bytes index.html | 24 ++++++++++++ manifest.json | 19 ++++++++++ service-worker.js | 89 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 .netlify/state.json create mode 100644 img/192.png create mode 100644 img/512.png create mode 100644 manifest.json create mode 100644 service-worker.js diff --git a/.netlify/state.json b/.netlify/state.json new file mode 100644 index 0000000..ac0c201 --- /dev/null +++ b/.netlify/state.json @@ -0,0 +1,3 @@ +{ + "siteId": "492c17c8-1ecc-479f-8abc-7eb13736dfb9" +} \ No newline at end of file diff --git a/img/192.png b/img/192.png new file mode 100644 index 0000000000000000000000000000000000000000..f3d0ebaae948b4ee3b4c5bfac7550a5c49a24bab GIT binary patch literal 852 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvG8AvYpRA>UEqyv0HT+f_2Gi}Z3=e-!gK)v6?hP=;y3;lmG1!=sBzUOWBtyqV=O zC+o^Z=Pa~Xj4gO|4&7I=b#Ts7+%(_)Tw50894%yjF?8iT9dqUsNS?=0-LE%>-|lHJV4oku=PK8TT>$$0-@ z>ap*JyKc|9^XBY;HXf)rq&BxGecr zc8%JGjV_yfTOvl-dfgbIMKZI-tN-DBpL0Iv^PJ~=&hz=Kw4aP{Lye6QBDy z+brb{vQ|SQ@YVjrdI8HB$&e5EqRF*6YlM8g~^~8MZnGcVbWyu zVj1sD>H=|y<9cX$IC=1Xv6m`Z?L6FI zx5jEy$+H8r3*~{AIVAkC#JY%wJ{|jLN1u+`ZV)gnaE~^eLeprP`*?PFZb@w7xtaBF zMw5cdA`fvKlLGuD!CeKRCvhjbl)oKLAC7rh=~J)KPctGsbx8NM|D`ImJE%A)yqxIKUDe$kH<}n4H-CBr4=Xv{a&eNaQPYB6 zTV2hWe@Z)F^r}}!DzWWWO8;3{_0t-S1NGg77#BT3+Wf$KQGi^lV4f7?6gQU41`C>*S3-ez7IJ=kr zed>xW>*xqE34&wVnVMhzn#HH98>tt+{kk^);_l`E=&e8*^F_Z{v?wL{k4i)~Ea#B6 zbO>&%NBp#2-Qc0cw|qm9sH*V2h>^=_2_nA3Z-HM>QT4-N)eS(=$_2)W=i@pRENkef zKxt9lE=@`cSX>a@_{xbM8H>fVrp`$N5dvj(Ov@+JtBi1LN^?TuV{H07>h@aQS3xH_ zq%9i~73na}&R*Dbw@3>=^fh)^c&X86R8K~~(r|6xgb(P_)REG->@%N8&iA08Kc+P* zEX5wt;ZC@D74g2}Z?iv#z6}?T$8;{Pl{>toIdnOcNxiAK7pprrYIa=sv#iy(E6I2HgYGin5rqY&>e}`8qJySa zt+1J`fyC1rI5+K;*X9|UQ6M!3KVwd7_`H**op0L2KWRTYl1&^{q;;5ljet|(oFM<> z`(X&4eGOaKVM&}+M)sAQ1Cl-lm{#efxZEIZ&{qzh+C5!`gugtSvmTW`58XrR#tPr+ znWjzQay0{ppD(RoxJW0T%-{Dvl6+%ufv(m%1+qIR-C6`UC40Z!-jA+wF`5QN$?j~r z&=PjWUp5!|09!1^y=2GPyHt_X%*Dap1PmB~n4Qm2!-N)}Q z1!=(`2Lgzw2i)8AKbtn1scNJab$d@cL*ufeWqCX5p4~EYMDB`6wDqg*;%v|%nrU0r z>g>5=-awG@XRXR{MV!7fH1pq`WbLQ}w8ugZ6H=0vL7wkaR(y)HlsmQvoR+?c(+{ip zWpDtQf(RHWi>ig8U~{F9G<|?KS^QZ8(qC)`I4V?MfCLvrTGoIyQP z)jghl6LD + + @@ -293,6 +295,28 @@ + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..94f06a7 --- /dev/null +++ b/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "Turnip Prophet - ACNH Turnip Tracker", + "short_name": "Turnip Prophet", + "description": "An app to track your Animal Crossing: New Horizons turnip prices daily!", + "start_url": "index.html", + "display": "standalone", + "background_color": "#def2d9", + "theme_color": "#def2d9", + "icons": [ + { + "src": "/img/192.png", + "sizes": "192x192" + }, + { + "src": "/img/512.png", + "sizes": "512x512" + } + ] +} diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..a0bcf11 --- /dev/null +++ b/service-worker.js @@ -0,0 +1,89 @@ +// PWA Code adapted from https://github.com/pwa-builder/PWABuilder +const CACHE = "pwa-precache"; +const precacheFiles = [ + "/index.html", + "/js/predictions.js", + "/js/scripts.js", + "/css/styles.css", +]; + +self.addEventListener("install", function (event) { + console.log("[PWA] Install Event processing"); + + console.log("[PWA] Skip waiting on install"); + self.skipWaiting(); + + event.waitUntil( + caches.open(CACHE).then(function (cache) { + console.log("[PWA] Caching pages during install"); + return cache.addAll(precacheFiles); + }) + ); +}); + +// Allow sw to control of current page +self.addEventListener("activate", function (event) { + console.log("[PWA] Claiming clients for current page"); + event.waitUntil(self.clients.claim()); +}); + +// If any fetch fails, it will look for the request in the cache and serve it from there first +self.addEventListener("fetch", function (event) { + if (event.request.method !== "GET") return; + + event.respondWith( + fromCache(event.request).then( + (response) => { + // The response was found in the cache so we responde with it and update the entry + + // This is where we call the server to get the newest version of the + // file to use the next time we show view + event.waitUntil( + fetch(event.request).then(function (response) { + return updateCache(event.request, response); + }) + ); + + return response; + }, + () => { + // The response was not found in the cache so we look for it on the server + return fetch(event.request) + .then(function (response) { + // If request was success, add or update it in the cache + event.waitUntil( + updateCache(event.request, response.clone()) + ); + + return response; + }) + .catch(function (error) { + console.log( + "[PWA] Network request failed and no cache." + error + ); + }); + } + ) + ); +}); + +function fromCache(request) { + // Check to see if you have it in the cache + // Return response + // If not in the cache, then return + return caches.open(CACHE).then(function (cache) { + return cache.match(request).then(function (matching) { + if (!matching || matching.status === 404) { + return Promise.reject("no-match"); + } + + return matching; + }); + }); +} + +function updateCache(request, response) { + return caches.open(CACHE).then(function (cache) { + return cache.put(request, response); + }); +} From a50ad6a332219a467c1c1743cb5a7819f31f99e0 Mon Sep 17 00:00:00 2001 From: Harry Peach Date: Thu, 9 Apr 2020 21:52:30 +0100 Subject: [PATCH 07/25] Removed netlify leftover files --- .netlify/state.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .netlify/state.json diff --git a/.netlify/state.json b/.netlify/state.json deleted file mode 100644 index ac0c201..0000000 --- a/.netlify/state.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "siteId": "492c17c8-1ecc-479f-8abc-7eb13736dfb9" -} \ No newline at end of file From 1c65137cb6cae01999facc610b8652ea0ac86cf9 Mon Sep 17 00:00:00 2001 From: Harry Peach Date: Fri, 10 Apr 2020 12:56:02 +0100 Subject: [PATCH 08/25] Added icons for the PWA --- img/192.png | Bin 852 -> 11526 bytes img/512.png | Bin 1746 -> 37849 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/img/192.png b/img/192.png index f3d0ebaae948b4ee3b4c5bfac7550a5c49a24bab..68b4ae50f2b890946f87784820363b75ca6e247c 100644 GIT binary patch literal 11526 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 TFm}mn0e*IbMzU+Sa&!L$Vtf22 literal 1746 zcmcIl|2Go~8{cNu6|BxGecr zc8%JGjV_yfTOvl-dfgbIMKZI-tN-DBpL0Iv^PJ~=&hz=Kw4aP{Lye6QBDy z+brb{vQ|SQ@YVjrdI8HB$&e5EqRF*6YlM8g~^~8MZnGcVbWyu zVj1sD>H=|y<9cX$IC=1Xv6m`Z?L6FI zx5jEy$+H8r3*~{AIVAkC#JY%wJ{|jLN1u+`ZV)gnaE~^eLeprP`*?PFZb@w7xtaBF zMw5cdA`fvKlLGuD!CeKRCvhjbl)oKLAC7rh=~J)KPctGsbx8NM|D`ImJE%A)yqxIKUDe$kH<}n4H-CBr4=Xv{a&eNaQPYB6 zTV2hWe@Z)F^r}}!DzWWWO8;3{_0t-S1NGg77#BT3+Wf$KQGi^lV4f7?6gQU41`C>*S3-ez7IJ=kr zed>xW>*xqE34&wVnVMhzn#HH98>tt+{kk^);_l`E=&e8*^F_Z{v?wL{k4i)~Ea#B6 zbO>&%NBp#2-Qc0cw|qm9sH*V2h>^=_2_nA3Z-HM>QT4-N)eS(=$_2)W=i@pRENkef zKxt9lE=@`cSX>a@_{xbM8H>fVrp`$N5dvj(Ov@+JtBi1LN^?TuV{H07>h@aQS3xH_ zq%9i~73na}&R*Dbw@3>=^fh)^c&X86R8K~~(r|6xgb(P_)REG->@%N8&iA08Kc+P* zEX5wt;ZC@D74g2}Z?iv#z6}?T$8;{Pl{>toIdnOcNxiAK7pprrYIa=sv#iy(E6I2HgYGin5rqY&>e}`8qJySa zt+1J`fyC1rI5+K;*X9|UQ6M!3KVwd7_`H**op0L2KWRTYl1&^{q;;5ljet|(oFM<> z`(X&4eGOaKVM&}+M)sAQ1Cl-lm{#efxZEIZ&{qzh+C5!`gugtSvmTW`58XrR#tPr+ znWjzQay0{ppD(RoxJW0T%-{Dvl6+%ufv(m%1+qIR-C6`UC40Z!-jA+wF`5QN$?j~r z&=PjWUp5!|09!1^y=2GPyHt_X%*Dap1PmB~n4Qm2!-N)}Q z1!=(`2Lgzw2i)8AKbtn1scNJab$d@cL*ufeWqCX5p4~EYMDB`6wDqg*;%v|%nrU0r z>g>5=-awG@XRXR{MV!7fH1pq`WbLQ}w8ugZ6H=0vL7wkaR(y)HlsmQvoR+?c(+{ip zWpDtQf(RHWi>ig8U~{F9G<|?KS^QZ8(qC)`I4V?MfCLvrTGoIyQP z)jghl6LD Date: Mon, 13 Apr 2020 12:21:39 +0100 Subject: [PATCH 09/25] Added extra precached files --- index.html | 2 ++ service-worker.js | 1 + 2 files changed, 3 insertions(+) diff --git a/index.html b/index.html index c52edad..574ac37 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ Animal Crossing - Turnip Prophet + @@ -264,6 +265,7 @@ + diff --git a/service-worker.js b/service-worker.js index a0bcf11..e68b22d 100644 --- a/service-worker.js +++ b/service-worker.js @@ -5,6 +5,7 @@ const precacheFiles = [ "/js/predictions.js", "/js/scripts.js", "/css/styles.css", + "https://code.jquery.com/jquery-3.4.1.min.js", ]; self.addEventListener("install", function (event) { From baff942960aa53f689e6a776ff5018ee5a53c352 Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Tue, 14 Apr 2020 13:29:01 +0100 Subject: [PATCH 10/25] docs: Add some notes on scope --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 9477740..e602b0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,16 @@ # ac-nh-turnip-prices Price calculator/predictor for Turnip prices + +## Scope + +This tool is: +- A predictor for future prices that week +- Able to calculate probabilities for different futures +- Able to show data from a query string +- A single page web-based app + +This tool is not: +- A calculator for how much money you'll make +- A way to count your turnips +- A way to store multiple people's islands +- Something with a backend From 4a711fde74ac97e89ae8ec91b4abc2cb7f8c85fc Mon Sep 17 00:00:00 2001 From: Ryan Carbotte Date: Tue, 14 Apr 2020 09:44:17 -0500 Subject: [PATCH 11/25] fix: Removed a debug statement left behind --- js/contributors.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/contributors.js b/js/contributors.js index c3d09e5..e3cf7eb 100644 --- a/js/contributors.js +++ b/js/contributors.js @@ -4,7 +4,6 @@ function getContributors() { jQuery.ajax('https://api.github.com/repos/mikebryant/ac-nh-turnip-prices/contributors', {}) .done(function (data) { data.forEach((contributor, idx) => { - console.debug('DEBUG:', contributor); container.append(`${contributor.login}`); if (idx < data.length - 1) { container.append(', '); From 0d9e0d8f8d8840c68123b2a3e5350d43b13e9dfe Mon Sep 17 00:00:00 2001 From: Ryan Carbotte Date: Tue, 14 Apr 2020 12:02:53 -0500 Subject: [PATCH 12/25] feat: Implements a more true to game code intceil calculation This replaces all instances of Math.floor() and Math.ceil() with a calculation that more closely matches the C++ implementation in game. --- js/predictions.js | 56 +++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index 96fe462..f0b1573 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -39,6 +39,10 @@ const PROBABILITY_MATRIX = { }, }; +function intceil(val) { + return Math.trunc(val + 0.99999); +} + function minimum_rate_from_given_and_base(given_price, buy_price) { return 10000 * (given_price - 1) / buy_price; } @@ -98,8 +102,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph // High Phase 1 for (var i = 2; i < 2 + high_phase_1_len; i++) { - min_pred = Math.floor(0.9 * buy_price); - max_pred = Math.ceil(1.4 * buy_price); + min_pred = intceil(0.9 * buy_price); + max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern @@ -119,8 +123,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph var min_rate = 6000; var max_rate = 8000; for (var i = 2 + high_phase_1_len; i < 2 + high_phase_1_len + dec_phase_1_len; i++) { - min_pred = Math.floor(min_rate * buy_price / 10000); - max_pred = Math.ceil(max_rate * buy_price / 10000); + min_pred = intceil(min_rate * buy_price / 10000); + max_pred = intceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { @@ -145,8 +149,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph // High Phase 2 for (var i = 2 + high_phase_1_len + dec_phase_1_len; i < 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len; i++) { - min_pred = Math.floor(0.9 * buy_price); - max_pred = Math.ceil(1.4 * buy_price); + min_pred = intceil(0.9 * buy_price); + max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern @@ -166,8 +170,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph var min_rate = 6000; var max_rate = 8000; for (var i = 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len; i < 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len; i++) { - min_pred = Math.floor(min_rate * buy_price / 10000); - max_pred = Math.ceil(max_rate * buy_price / 10000); + min_pred = intceil(min_rate * buy_price / 10000); + max_pred = intceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { @@ -195,8 +199,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph throw new Error("Phase lengths don't add up"); } for (var i = 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len; i < 14; i++) { - min_pred = Math.floor(0.9 * buy_price); - max_pred = Math.ceil(1.4 * buy_price); + min_pred = intceil(0.9 * buy_price); + max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern @@ -273,8 +277,8 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { var max_rate = 9000; for (var i = 2; i < peak_start; i++) { - min_pred = Math.floor(min_rate * buy_price / 10000); - max_pred = Math.ceil(max_rate * buy_price / 10000); + min_pred = intceil(min_rate * buy_price / 10000); + max_pred = intceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { @@ -301,8 +305,8 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { min_randoms = [0.9, 1.4, 2.0, 1.4, 0.9, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4] max_randoms = [1.4, 2.0, 6.0, 2.0, 1.4, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9] for (var i = peak_start; i < 14; i++) { - min_pred = Math.floor(min_randoms[i - peak_start] * buy_price); - max_pred = Math.ceil(max_randoms[i - peak_start] * buy_price); + min_pred = intceil(min_randoms[i - peak_start] * buy_price); + max_pred = intceil(max_randoms[i - peak_start] * buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred || given_prices[i] > max_pred) { @@ -361,8 +365,8 @@ function* generate_pattern_2(given_prices) { var min_rate = 8500; var max_rate = 9000; for (var i = 2; i < 14; i++) { - min_pred = Math.floor(min_rate * buy_price / 10000); - max_pred = Math.ceil(max_rate * buy_price / 10000); + min_pred = intceil(min_rate * buy_price / 10000); + max_pred = intceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { @@ -439,8 +443,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { var max_rate = 9000; for (var i = 2; i < peak_start; i++) { - min_pred = Math.floor(min_rate * buy_price / 10000); - max_pred = Math.ceil(max_rate * buy_price / 10000); + min_pred = intceil(min_rate * buy_price / 10000); + max_pred = intceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { @@ -466,8 +470,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { // The peak for (var i = peak_start; i < peak_start + 2; i++) { - min_pred = Math.floor(0.9 * buy_price); - max_pred = Math.ceil(1.4 * buy_price); + min_pred = intceil(0.9 * buy_price); + max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern @@ -484,8 +488,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { } // Main spike 1 - min_pred = Math.floor(1.4 * buy_price) - 1; - max_pred = Math.ceil(2.0 * buy_price) - 1; + min_pred = intceil(1.4 * buy_price) - 1; + max_pred = intceil(2.0 * buy_price) - 1; if (!isNaN(given_prices[peak_start + 2])) { if (given_prices[peak_start + 2] < min_pred || given_prices[peak_start + 2] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern @@ -501,7 +505,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { // Main spike 2 min_pred = predicted_prices[peak_start + 2].min; - max_pred = Math.ceil(2.0 * buy_price); + max_pred = intceil(2.0 * buy_price); if (!isNaN(given_prices[peak_start + 3])) { if (given_prices[peak_start + 3] < min_pred || given_prices[peak_start + 3] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern @@ -516,7 +520,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { }); // Main spike 3 - min_pred = Math.floor(1.4 * buy_price) - 1; + min_pred = intceil(1.4 * buy_price) - 1; max_pred = predicted_prices[peak_start + 3].max - 1; if (!isNaN(given_prices[peak_start + 4])) { if (given_prices[peak_start + 4] < min_pred || given_prices[peak_start + 4] > max_pred) { @@ -536,8 +540,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { var max_rate = 9000; for (var i = peak_start + 5; i < 14; i++) { - min_pred = Math.floor(min_rate * buy_price / 10000); - max_pred = Math.ceil(max_rate * buy_price / 10000); + min_pred = intceil(min_rate * buy_price / 10000); + max_pred = intceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { From a893a5617b553cf9e33a4176b391c306d3d1cd49 Mon Sep 17 00:00:00 2001 From: Blake Felt Date: Tue, 14 Apr 2020 17:20:04 -0600 Subject: [PATCH 13/25] Removed UI notification when query is used. A console log shows instead. Cleaned up getPreviousFromQuery. It now returns null when the "prices" parameter is not used. Fixed getPreviousFromLocalStorage to only look at local storage. Changed from_query to populated_from_query to be more descriptive. --- index.html | 7 ------- js/scripts.js | 33 +++++++++++++-------------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/index.html b/index.html index 8521e34..00b94c6 100644 --- a/index.html +++ b/index.html @@ -273,13 +273,6 @@

This app is still in development, but will improve over time!

- -

Daisy Mae

diff --git a/js/scripts.js b/js/scripts.js index d1f5234..1fe174b 100644 --- a/js/scripts.js +++ b/js/scripts.js @@ -200,33 +200,26 @@ const getPricesFromQuery = function (param) { }; const getPreviousFromQuery = function () { - const queries = [ - ["first", getFirstBuyStateFromQuery], - ["pattern", getPreviousPatternStateFromQuery], - ["prices", getPricesFromQuery] - ]; - - found = null; /* value to save if we found any parameters */ - ret = []; - for (q of queries) { - val = q[1](q[0]); /* run the function with the parameter */ - found = found || val; - ret.push(val); + /* Check if valid prices are entered. Exit immediately if not. */ + prices = getPricesFromQuery("prices"); + if (prices == null) { + return null; } - if (found != null) { /* if we found any parameter */ - window.from_query = true; - document.getElementById("from_query").style.visibility = "visible"; - return ret; - } - return null; + console.log("Using data from query."); + window.populated_from_query = true; + return [ + getFirstBuyStateFromQuery("first"), + getPreviousPatternStateFromQuery("pattern"), + prices + ]; }; const getPreviousFromLocalstorage = function () { return [ getFirstBuyStateFromLocalstorage(), - getPreviousPatternStateFromQuery(), + getPreviousPatternStateFromLocalstorage(), getPricesFromLocalstorage() ]; }; @@ -282,7 +275,7 @@ const update = function () { const prices = [buy_price, buy_price, ...sell_prices]; - if (!window.from_query) { + if (!window.populated_from_query) { updateLocalStorage(prices, first_buy, previous_pattern); } From a41f2d59752753c31f0e71859f2b1ecfcf06c070 Mon Sep 17 00:00:00 2001 From: Peter Shih Date: Wed, 15 Apr 2020 15:25:37 +0800 Subject: [PATCH 14/25] Update rate range to match intceil I doubt this would change any prediction at all, but this is more correct according to the code. --- js/predictions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index f0b1573..5710fb8 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -44,11 +44,11 @@ function intceil(val) { } function minimum_rate_from_given_and_base(given_price, buy_price) { - return 10000 * (given_price - 1) / buy_price; + return 10000 * (given_price - 0.99999) / buy_price; } function maximum_rate_from_given_and_base(given_price, buy_price) { - return 10000 * given_price / buy_price; + return 10000 * (given_price + 0.00001) / buy_price; } function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, high_phase_2_len, dec_phase_2_len, high_phase_3_len) { From 7d1f46fe8c23a8bf08f2f6064f817a266d76bbb7 Mon Sep 17 00:00:00 2001 From: Peter Shih Date: Wed, 15 Apr 2020 16:06:56 +0800 Subject: [PATCH 15/25] Change prediction of rate to consider not only the previous known value This also makes the predicted range more strict, so won't help with all those invalid pattern issues, but should give an more accurate result. --- js/predictions.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index f0b1573..0dd1825 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -134,8 +134,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph } min_pred = given_prices[i]; max_pred = given_prices[i]; - min_rate = minimum_rate_from_given_and_base(given_prices[i], buy_price); - max_rate = maximum_rate_from_given_and_base(given_prices[i], buy_price); + min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); + max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); } predicted_prices.push({ @@ -181,8 +181,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph } min_pred = given_prices[i]; max_pred = given_prices[i]; - min_rate = minimum_rate_from_given_and_base(given_prices[i], buy_price); - max_rate = maximum_rate_from_given_and_base(given_prices[i], buy_price); + min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); + max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); } predicted_prices.push({ @@ -288,8 +288,8 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { } min_pred = given_prices[i]; max_pred = given_prices[i]; - min_rate = minimum_rate_from_given_and_base(given_prices[i], buy_price); - max_rate = maximum_rate_from_given_and_base(given_prices[i], buy_price); + min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); + max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); } predicted_prices.push({ @@ -376,8 +376,8 @@ function* generate_pattern_2(given_prices) { } min_pred = given_prices[i]; max_pred = given_prices[i]; - min_rate = minimum_rate_from_given_and_base(given_prices[i], buy_price); - max_rate = maximum_rate_from_given_and_base(given_prices[i], buy_price); + min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); + max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); } predicted_prices.push({ @@ -454,8 +454,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { } min_pred = given_prices[i]; max_pred = given_prices[i]; - min_rate = minimum_rate_from_given_and_base(given_prices[i], buy_price); - max_rate = maximum_rate_from_given_and_base(given_prices[i], buy_price); + min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); + max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); } predicted_prices.push({ @@ -551,8 +551,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { } min_pred = given_prices[i]; max_pred = given_prices[i]; - min_rate = minimum_rate_from_given_and_base(given_prices[i], buy_price); - max_rate = maximum_rate_from_given_and_base(given_prices[i], buy_price); + min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); + max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); } predicted_prices.push({ From 21cb1a0ed2cfe2d711a1dc5ad9e84162ef573f50 Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Wed, 15 Apr 2020 11:50:12 +0100 Subject: [PATCH 16/25] fix: Make guaranteed minimum correct for all patterns, and fix sorting Fixes #98 Fixes #76 --- js/predictions.js | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index 231b1c5..a87c5d2 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -624,6 +624,27 @@ function analyze_possibilities(sell_prices, first_buy, previous_pattern) { generated_possibilities = Array.from(generate_possibilities(sell_prices, first_buy)); generated_possibilities = get_probabilities(generated_possibilities, previous_pattern); + for (let poss of generated_possibilities) { + var weekMins = []; + var weekMaxes = []; + for (let day of poss.prices.slice(2)) { + weekMins.push(day.min); + weekMaxes.push(day.max); + } + poss.weekGuaranteedMinimum = Math.max(...weekMins); + poss.weekMax = Math.max(...weekMaxes); + } + + generated_possibilities.sort((a, b) => { + if (a.weekMax < b.weekMax) { + return 1; + } else if (a.weekMax > b.weekMax) { + return -1; + } else { + return 0; + } + }); + global_min_max = []; for (var day = 0; day < 14; day++) { prices = { @@ -641,31 +662,12 @@ function analyze_possibilities(sell_prices, first_buy, previous_pattern) { global_min_max.push(prices); } - generated_possibilities.push({ + generated_possibilities.unshift({ pattern_description: "All patterns", pattern_number: 4, prices: global_min_max, - }); - - for (let poss of generated_possibilities) { - var weekMins = []; - var weekMaxes = []; - for (let day of poss.prices.slice(2)) { - weekMins.push(day.min); - weekMaxes.push(day.max); - } - poss.weekGuaranteedMinimum = Math.max(...weekMins); - poss.weekMax = Math.max(...weekMaxes); - } - - generated_possibilities.sort((a, b) => { - if (a.weekMax < b.weekMax) { - return 1; - } else if (a.weekMax > b.weekMax) { - return -1; - } else { - return 0; - } + weekGuaranteedMinimum: Math.max(...generated_possibilities.map(poss => poss.weekGuaranteedMinimum)), + weekMax: Math.max(...generated_possibilities.map(poss => poss.weekMax)), }); return generated_possibilities; From 89dfc6ce6eddf2139b012274cf715505530e83b1 Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Wed, 15 Apr 2020 12:07:17 +0100 Subject: [PATCH 17/25] feat: Add chart visual --- css/styles.css | 10 +++++++++- index.html | 6 ++++++ js/chart.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ js/scripts.js | 9 ++++++--- 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 js/chart.js diff --git a/css/styles.css b/css/styles.css index 8ccf924..af322c6 100644 --- a/css/styles.css +++ b/css/styles.css @@ -368,7 +368,15 @@ input[type=number] { white-space: nowrap; } - +.chart-wrapper { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + height: 400px; + width: 100%; + max-width: 1080px; + justify-content: center; +} .waves { diff --git a/index.html b/index.html index 574ac37..0bfb4dd 100644 --- a/index.html +++ b/index.html @@ -191,6 +191,10 @@

Output

+
+ +
+
@@ -294,6 +298,8 @@ + + diff --git a/js/chart.js b/js/chart.js new file mode 100644 index 0000000..9ea48c5 --- /dev/null +++ b/js/chart.js @@ -0,0 +1,54 @@ +let chart_instance = null; + +Chart.defaults.global.defaultFontFamily = "'Varela Round', sans-serif"; + +const chart_options = { + elements: { + line: { + backgroundColor: "#DEF2D9", + backgroundColor: "#DEF2D9", + cubicInterpolationMode: "monotone", + }, + }, + maintainAspectRatio: false, + tooltips: { + intersect: false, + mode: "index", + }, +}; + +function update_chart(input_data, possibilities) { + var ctx = $("#chart"); + + datasets = [ + { + label: "Input Price", + data: input_data.slice(1), + fill: false, + }, + { + label: "Minimum", + data: possibilities[0].prices.slice(1).map(day => day.min), + fill: false, + }, + { + label: "Maximum", + data: possibilities[0].prices.slice(1).map(day => day.max), + fill: "-1", + }, + ]; + + if (chart_instance) { + chart_instance.data.datasets = datasets; + chart_instance.update(); + } else { + chart_instance = new Chart(ctx, { + data: { + datasets: datasets, + labels: ["Sunday", "Mon AM", "Mon PM", "Tue AM", "Tue PM", "Wed AM", "Wed PM", "Thu AM", "Thu PM", "Fri AM", "Fri PM", "Sat AM", "Sat PM"], + }, + options: chart_options, + type: "line", + }); + } +} diff --git a/js/scripts.js b/js/scripts.js index 1fe174b..65cf904 100644 --- a/js/scripts.js +++ b/js/scripts.js @@ -218,7 +218,7 @@ const getPreviousFromQuery = function () { const getPreviousFromLocalstorage = function () { return [ - getFirstBuyStateFromLocalstorage(), + getFirstBuyStateFromLocalstorage(), getPreviousPatternStateFromLocalstorage(), getPricesFromLocalstorage() ]; @@ -247,7 +247,8 @@ const calculateOutput = function (data, first_buy, previous_pattern) { return; } let output_possibilities = ""; - for (let poss of analyze_possibilities(data, first_buy, previous_pattern)) { + let analyzed_possibilities = analyze_possibilities(data, first_buy, previous_pattern); + for (let poss of analyzed_possibilities) { var out_line = "" out_line += ``; for (let day of poss.prices.slice(1)) { @@ -262,6 +263,8 @@ const calculateOutput = function (data, first_buy, previous_pattern) { } $("#output").html(output_possibilities) + + update_chart(data, analyzed_possibilities); } const update = function () { @@ -269,7 +272,7 @@ const update = function () { const buy_price = parseInt(buy_input.val()); const first_buy = getCheckedRadio(first_buy_radios) == 'true'; const previous_pattern = parseInt(getCheckedRadio(previous_pattern_radios)); - + buy_input[0].disabled = first_buy; buy_input[0].placeholder = first_buy ? '—' : '...' From 46929bd5562ba9d159ab93b1928c07dd34e381f7 Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Wed, 15 Apr 2020 13:18:13 +0100 Subject: [PATCH 18/25] fix: Use min for guaranteed minimum Should be, max of mins for each pattern, then min of those --- js/predictions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/predictions.js b/js/predictions.js index a87c5d2..7e8e82e 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -666,7 +666,7 @@ function analyze_possibilities(sell_prices, first_buy, previous_pattern) { pattern_description: "All patterns", pattern_number: 4, prices: global_min_max, - weekGuaranteedMinimum: Math.max(...generated_possibilities.map(poss => poss.weekGuaranteedMinimum)), + weekGuaranteedMinimum: Math.min(...generated_possibilities.map(poss => poss.weekGuaranteedMinimum)), weekMax: Math.max(...generated_possibilities.map(poss => poss.weekMax)), }); From 6cfc140173fd1f4d6d9e96a0698d976e14171c90 Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Wed, 15 Apr 2020 13:47:40 +0100 Subject: [PATCH 19/25] fix: Add a fudge factor The reversed code isn't a perfect match for ARM 32-bit floating point assembly. This should allow us to cope with things that are a little out of range due to floating point weirdness --- js/predictions.js | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index 7e8e82e..b6f6103 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -1,3 +1,7 @@ +// The reverse-engineered code is not perfectly accurate, especially as it's not +// 32-bit ARM floating point. So, be tolerant of slightly unexpected inputs +const FUDGE_FACTOR = 5; + const PATTERN = { FLUCTUATING: 0, LARGE_SPIKE: 1, @@ -105,7 +109,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph min_pred = intceil(0.9 * buy_price); max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -128,7 +132,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -152,7 +156,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph min_pred = intceil(0.9 * buy_price); max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -175,7 +179,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -202,7 +206,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph min_pred = intceil(0.9 * buy_price); max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -282,7 +286,7 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -309,7 +313,7 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { max_pred = intceil(max_randoms[i - peak_start] * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -370,7 +374,7 @@ function* generate_pattern_2(given_prices) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -448,7 +452,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -473,7 +477,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { min_pred = intceil(0.9 * buy_price); max_pred = intceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -491,7 +495,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { min_pred = intceil(1.4 * buy_price) - 1; max_pred = intceil(2.0 * buy_price) - 1; if (!isNaN(given_prices[peak_start + 2])) { - if (given_prices[peak_start + 2] < min_pred || given_prices[peak_start + 2] > max_pred) { + if (given_prices[peak_start + 2] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 2] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -507,7 +511,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { min_pred = predicted_prices[peak_start + 2].min; max_pred = intceil(2.0 * buy_price); if (!isNaN(given_prices[peak_start + 3])) { - if (given_prices[peak_start + 3] < min_pred || given_prices[peak_start + 3] > max_pred) { + if (given_prices[peak_start + 3] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 3] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -523,7 +527,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { min_pred = intceil(1.4 * buy_price) - 1; max_pred = predicted_prices[peak_start + 3].max - 1; if (!isNaN(given_prices[peak_start + 4])) { - if (given_prices[peak_start + 4] < min_pred || given_prices[peak_start + 4] > max_pred) { + if (given_prices[peak_start + 4] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 4] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -545,7 +549,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return; } From 28e73937bc28c1d15ed94ebb23e2567a131e4716 Mon Sep 17 00:00:00 2001 From: Pi-Hsun Shih Date: Wed, 15 Apr 2020 21:35:32 +0800 Subject: [PATCH 20/25] Refactor and move common parts out. --- js/predictions.js | 522 ++++++++++++++++++++-------------------------- 1 file changed, 230 insertions(+), 292 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index b6f6103..27b5e93 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -14,7 +14,7 @@ const PATTERN_COUNTS = { [PATTERN.LARGE_SPIKE]: 7, [PATTERN.DECREASING]: 1, [PATTERN.SMALL_SPIKE]: 8, -} +}; const PROBABILITY_MATRIX = { [PATTERN.FLUCTUATING]: { @@ -43,19 +43,187 @@ const PROBABILITY_MATRIX = { }, }; +const RATE_MULTIPLIER = 10000; + function intceil(val) { return Math.trunc(val + 0.99999); } function minimum_rate_from_given_and_base(given_price, buy_price) { - return 10000 * (given_price - 0.99999) / buy_price; + return RATE_MULTIPLIER * (given_price - 0.99999) / buy_price; } function maximum_rate_from_given_and_base(given_price, buy_price) { - return 10000 * (given_price + 0.00001) / buy_price; + return RATE_MULTIPLIER * (given_price + 0.00001) / buy_price; +} + +function get_price(rate, basePrice) { + return intceil(rate * basePrice / RATE_MULTIPLIER); +} + +/* + * This corresponds to the code: + * for (int i = start; i < start + length; i++) + * { + * sellPrices[work++] = + * intceil(randfloat(rate_min / RATE_MULTIPLIER, rate_max / RATE_MULTIPLIER) * basePrice); + * } + * + * Would modify the predicted_prices array. + * If the given_prices won't match, returns false, otherwise returns true + */ +function generate_individual_random_price( + given_prices, predicted_prices, start, length, rate_min, rate_max) { + rate_min *= RATE_MULTIPLIER; + rate_max *= RATE_MULTIPLIER; + + const buy_price = given_prices[0]; + + for (let i = start; i < start + length; i++) { + let min_pred = get_price(rate_min, buy_price); + let max_pred = get_price(rate_max, buy_price); + if (!isNaN(given_prices[i])) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } + min_pred = given_prices[i]; + max_pred = given_prices[i]; + } + + predicted_prices.push({ + min: min_pred, + max: max_pred, + }); + } + return true; +} + +/* + * This corresponds to the code: + * rate = randfloat(start_rate_min, start_rate_max); + * for (int i = start; i < start + length; i++) + * { + * sellPrices[work++] = intceil(rate * basePrice); + * rate -= randfloat(rate_decay_min, rate_decay_max); + * } + * + * Would modify the predicted_prices array. + * If the given_prices won't match, returns false, otherwise returns true + */ +function generate_decreasing_random_price( + given_prices, predicted_prices, start, length, rate_min, + rate_max, rate_decay_min, rate_decay_max) { + rate_min *= RATE_MULTIPLIER; + rate_max *= RATE_MULTIPLIER; + rate_decay_min *= RATE_MULTIPLIER; + rate_decay_max *= RATE_MULTIPLIER; + + const buy_price = given_prices[0]; + + for (let i = start; i < start + length; i++) { + let min_pred = get_price(rate_min, buy_price); + let max_pred = get_price(rate_max, buy_price); + if (!isNaN(given_prices[i])) { + if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } + if (given_prices[i] >= min_pred || given_prices[i] <= max_pred) { + // The value in the FUDGE_FACTOR range is ignored so the rate range would not be empty. + const real_rate_min = minimum_rate_from_given_and_base(given_prices[i], buy_price); + const real_rate_max = maximum_rate_from_given_and_base(given_prices[i], buy_price); + rate_min = Math.max(rate_min, real_rate_min); + rate_max = Math.min(rate_max, real_rate_max); + } + min_pred = given_prices[i]; + max_pred = given_prices[i]; + } + + predicted_prices.push({ + min: min_pred, + max: max_pred, + }); + + rate_min -= rate_decay_max; + rate_max -= rate_decay_min; + } + return true; +} + + +/* + * This corresponds to the code: + * rate = randfloat(rate_min, rate_max); + * sellPrices[work++] = intceil(randfloat(rate_min, rate) * basePrice) - 1; + * sellPrices[work++] = intceil(rate * basePrice); + * sellPrices[work++] = intceil(randfloat(rate_min, rate) * basePrice) - 1; + * + * Would modify the predicted_prices array. + * If the given_prices won't match, returns false, otherwise returns true + */ +function generate_peak_price( + given_prices, predicted_prices, start, rate_min, rate_max) { + rate_min *= RATE_MULTIPLIER; + rate_max *= RATE_MULTIPLIER; + + const buy_price = given_prices[0]; + + // Main spike 1 + min_pred = get_price(rate_min, buy_price) - 1; + max_pred = get_price(rate_max, buy_price) - 1; + if (!isNaN(given_prices[start])) { + if (given_prices[start] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 2] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } + min_pred = given_prices[start]; + max_pred = given_prices[start]; + } + predicted_prices.push({ + min: min_pred, + max: max_pred, + }); + + // Main spike 2 + min_pred = predicted_prices[start].min; + max_pred = intceil(2.0 * buy_price); + if (!isNaN(given_prices[start + 1])) { + if (given_prices[start + 1] < min_pred - FUDGE_FACTOR || given_prices[start + 1] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } + min_pred = given_prices[start + 1]; + max_pred = given_prices[start + 1]; + } + predicted_prices.push({ + min: min_pred, + max: max_pred, + }); + + // Main spike 3 + min_pred = intceil(1.4 * buy_price) - 1; + max_pred = predicted_prices[start + 1].max - 1; + if (!isNaN(given_prices[start + 2])) { + if (given_prices[start + 2] < min_pred - FUDGE_FACTOR || given_prices[start + 2] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } + min_pred = given_prices[start + 2]; + max_pred = given_prices[start + 2]; + } + predicted_prices.push({ + min: min_pred, + max: max_pred, + }); + + return true; } -function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, high_phase_2_len, dec_phase_2_len, high_phase_3_len) { +function* + generate_pattern_0_with_lengths( + given_prices, high_phase_1_len, dec_phase_1_len, high_phase_2_len, + dec_phase_2_len, high_phase_3_len) { /* // PATTERN 0: high, decreasing, high, decreasing, high work = 2; @@ -92,8 +260,8 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph } */ - buy_price = given_prices[0]; - var predicted_prices = [ + const buy_price = given_prices[0]; + const predicted_prices = [ { min: buy_price, max: buy_price, @@ -105,120 +273,45 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph ]; // High Phase 1 - for (var i = 2; i < 2 + high_phase_1_len; i++) { - min_pred = intceil(0.9 * buy_price); - max_pred = intceil(1.4 * buy_price); - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); + if (!generate_individual_random_price( + given_prices, predicted_prices, 2, high_phase_1_len, 0.9, 1.4)) { + return; } // Dec Phase 1 - var min_rate = 6000; - var max_rate = 8000; - for (var i = 2 + high_phase_1_len; i < 2 + high_phase_1_len + dec_phase_1_len; i++) { - min_pred = intceil(min_rate * buy_price / 10000); - max_pred = intceil(max_rate * buy_price / 10000); - - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); - max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - min_rate -= 1000; - max_rate -= 400; + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2 + high_phase_1_len, dec_phase_1_len, + 0.6, 0.8, 0.04, 0.1)) { + return; } // High Phase 2 - for (var i = 2 + high_phase_1_len + dec_phase_1_len; i < 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len; i++) { - min_pred = intceil(0.9 * buy_price); - max_pred = intceil(1.4 * buy_price); - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); + if (!generate_individual_random_price(given_prices, predicted_prices, + 2 + high_phase_1_len + dec_phase_1_len, high_phase_2_len, 0.9, 1.4)) { + return; } // Dec Phase 2 - var min_rate = 6000; - var max_rate = 8000; - for (var i = 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len; i < 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len; i++) { - min_pred = intceil(min_rate * buy_price / 10000); - max_pred = intceil(max_rate * buy_price / 10000); - - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); - max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - min_rate -= 1000; - max_rate -= 400; + if (!generate_decreasing_random_price( + given_prices, predicted_prices, + 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len, + dec_phase_2_len, 0.6, 0.8, 0.04, 0.1)) { + return; } // High Phase 3 if (2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len + high_phase_3_len != 14) { throw new Error("Phase lengths don't add up"); } - for (var i = 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len; i < 14; i++) { - min_pred = intceil(0.9 * buy_price); - max_pred = intceil(1.4 * buy_price); - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - } - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); + const prev_length = 2 + high_phase_1_len + dec_phase_1_len + + high_phase_2_len + dec_phase_2_len; + if (!generate_individual_random_price( + given_prices, predicted_prices, prev_length, 14 - prev_length, 0.9, + 1.4)) { + return; } + yield { pattern_description: "Fluctuating", pattern_number: 0, @@ -265,8 +358,8 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { } */ - buy_price = given_prices[0]; - var predicted_prices = [ + const buy_price = given_prices[0]; + const predicted_prices = [ { min: buy_price, max: buy_price, @@ -277,54 +370,21 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { }, ]; - var min_rate = 8500; - var max_rate = 9000; - - for (var i = 2; i < peak_start; i++) { - min_pred = intceil(min_rate * buy_price / 10000); - max_pred = intceil(max_rate * buy_price / 10000); - - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); - max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - min_rate -= 500; - max_rate -= 300; + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2, peak_start - 2, 0.85, 0.9, 0.03, + 0.05)) { + return; } // Now each day is independent of next min_randoms = [0.9, 1.4, 2.0, 1.4, 0.9, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4] max_randoms = [1.4, 2.0, 6.0, 2.0, 1.4, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9] - for (var i = peak_start; i < 14; i++) { - min_pred = intceil(min_randoms[i - peak_start] * buy_price); - max_pred = intceil(max_randoms[i - peak_start] * buy_price); - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; + for (let i = peak_start; i < 14; i++) { + if (!generate_individual_random_price( + given_prices, predicted_prices, i, 1, min_randoms[i - peak_start], + max_randoms[i - peak_start])) { + return; } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); } yield { pattern_description: "Large spike", @@ -353,9 +413,8 @@ function* generate_pattern_2(given_prices) { break; */ - - buy_price = given_prices[0]; - var predicted_prices = [ + const buy_price = given_prices[0]; + const predicted_prices = [ { min: buy_price, max: buy_price, @@ -366,32 +425,11 @@ function* generate_pattern_2(given_prices) { }, ]; - var min_rate = 8500; - var max_rate = 9000; - for (var i = 2; i < 14; i++) { - min_pred = intceil(min_rate * buy_price / 10000); - max_pred = intceil(max_rate * buy_price / 10000); - - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); - max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - min_rate -= 500; - max_rate -= 300; + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2, 14 - 2, 0.85, 0.9, 0.03, 0.05)) { + return; } + yield { pattern_description: "Decreasing", pattern_number: 2, @@ -431,8 +469,8 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { } */ - buy_price = given_prices[0]; - var predicted_prices = [ + const buy_price = given_prices[0]; + const predicted_prices = [ { min: buy_price, max: buy_price, @@ -442,130 +480,30 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { max: buy_price, }, ]; + let probability = 1; - var min_rate = 4000; - var max_rate = 9000; - - for (var i = 2; i < peak_start; i++) { - min_pred = intceil(min_rate * buy_price / 10000); - max_pred = intceil(max_rate * buy_price / 10000); - - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); - max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - min_rate -= 500; - max_rate -= 300; + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2, peak_start - 2, 0.4, 0.9, 0.03, + 0.05)) { + return; } // The peak - - for (var i = peak_start; i < peak_start + 2; i++) { - min_pred = intceil(0.9 * buy_price); - max_pred = intceil(1.4 * buy_price); - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); + if (!generate_individual_random_price( + given_prices, predicted_prices, peak_start, 2, 0.9, 1.4)) { + return; } - // Main spike 1 - min_pred = intceil(1.4 * buy_price) - 1; - max_pred = intceil(2.0 * buy_price) - 1; - if (!isNaN(given_prices[peak_start + 2])) { - if (given_prices[peak_start + 2] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 2] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[peak_start + 2]; - max_pred = given_prices[peak_start + 2]; + if (!generate_peak_price( + given_prices, predicted_prices, peak_start + 2, 1.4, 2.0)) { + return; } - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - // Main spike 2 - min_pred = predicted_prices[peak_start + 2].min; - max_pred = intceil(2.0 * buy_price); - if (!isNaN(given_prices[peak_start + 3])) { - if (given_prices[peak_start + 3] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 3] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[peak_start + 3]; - max_pred = given_prices[peak_start + 3]; - } - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - // Main spike 3 - min_pred = intceil(1.4 * buy_price) - 1; - max_pred = predicted_prices[peak_start + 3].max - 1; - if (!isNaN(given_prices[peak_start + 4])) { - if (given_prices[peak_start + 4] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 4] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[peak_start + 4]; - max_pred = given_prices[peak_start + 4]; - } - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); if (peak_start + 5 < 14) { - var min_rate = 4000; - var max_rate = 9000; - - for (var i = peak_start + 5; i < 14; i++) { - min_pred = intceil(min_rate * buy_price / 10000); - max_pred = intceil(max_rate * buy_price / 10000); - - - if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return; - } - min_pred = given_prices[i]; - max_pred = given_prices[i]; - min_rate = Math.max(minimum_rate_from_given_and_base(given_prices[i], buy_price), min_rate); - max_rate = Math.min(maximum_rate_from_given_and_base(given_prices[i], buy_price), max_rate); - } - - predicted_prices.push({ - min: min_pred, - max: max_pred, - }); - - min_rate -= 500; - max_rate -= 300; + if (!generate_decreasing_random_price( + given_prices, predicted_prices, peak_start + 5, + 14 - (peak_start + 5), 0.4, 0.9, 0.03, 0.05)) { + return; } } From 3fa1867340d8d3566d7322d10fe5c7fc8aff5b3e Mon Sep 17 00:00:00 2001 From: Pi-Hsun Shih Date: Wed, 15 Apr 2020 21:57:08 +0800 Subject: [PATCH 21/25] Make probability accurate by condition probability on the value. Tested that the result is same as the "should report" in #95. Fix #48, #95. --- js/predictions.js | 484 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 368 insertions(+), 116 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index 27b5e93..c5da355 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -9,13 +9,6 @@ const PATTERN = { SMALL_SPIKE: 3, }; -const PATTERN_COUNTS = { - [PATTERN.FLUCTUATING]: 56, - [PATTERN.LARGE_SPIKE]: 7, - [PATTERN.DECREASING]: 1, - [PATTERN.SMALL_SPIKE]: 8, -}; - const PROBABILITY_MATRIX = { [PATTERN.FLUCTUATING]: { [PATTERN.FLUCTUATING]: 0.20, @@ -57,10 +50,42 @@ function maximum_rate_from_given_and_base(given_price, buy_price) { return RATE_MULTIPLIER * (given_price + 0.00001) / buy_price; } +function rate_range_from_given_and_base(given_price, buy_price) { + return [ + minimum_rate_from_given_and_base(given_price, buy_price), + maximum_rate_from_given_and_base(given_price, buy_price) + ]; +} + function get_price(rate, basePrice) { return intceil(rate * basePrice / RATE_MULTIPLIER); } +function* multiply_generator_probability(generator, probability) { + for (const it of generator) { + yield {...it, probability: it.probability * probability}; + } +} + +function range_length(range) { + return range[1] - range[0]; +} + +function range_intersect(range1, range2) { + if (range1[0] > range2[1] || range1[1] < range2[0]) { + return null; + } + return [Math.max(range1[0], range2[0]), Math.min(range1[1], range2[1])]; +} + +function range_intersect_length(range1, range2) { + if (range1[0] > range2[1] || range1[1] < range2[0]) { + return 0; + } + return range_length(range_intersect(range1, range2)); +} + + /* * This corresponds to the code: * for (int i = start; i < start + length; i++) @@ -69,8 +94,9 @@ function get_price(rate, basePrice) { * intceil(randfloat(rate_min / RATE_MULTIPLIER, rate_max / RATE_MULTIPLIER) * basePrice); * } * - * Would modify the predicted_prices array. - * If the given_prices won't match, returns false, otherwise returns true + * Would return the conditional probability given the given_prices, and modify + * the predicted_prices array. + * If the given_prices won't match, returns 0. */ function generate_individual_random_price( given_prices, predicted_prices, start, length, rate_min, rate_max) { @@ -78,6 +104,8 @@ function generate_individual_random_price( rate_max *= RATE_MULTIPLIER; const buy_price = given_prices[0]; + const rate_range = [rate_min, rate_max]; + let prob = 1; for (let i = start; i < start + length; i++) { let min_pred = get_price(rate_min, buy_price); @@ -85,7 +113,14 @@ function generate_individual_random_price( if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern - return false; + return 0; + } + if (given_prices[i] >= min_pred || given_prices[i] <= max_pred) { + // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. + const real_rate_range = + rate_range_from_given_and_base(given_prices[i], buy_price); + prob *= range_intersect_length(rate_range, real_rate_range) / + range_length(rate_range); } min_pred = given_prices[i]; max_pred = given_prices[i]; @@ -96,7 +131,144 @@ function generate_individual_random_price( max: max_pred, }); } - return true; + return prob; +} + +/* + * Probability Density Function of rates. + * Since the PDF is continuous*, we approximate it by a discrete probability function: + * the value in range [(x - 0.5), (x + 0.5)) has a uniform probability + * prob[x - value_start]; + * + * Note that we operate all rate on the (* RATE_MULTIPLIER) scale. + * + * (*): Well not really since it only takes values that "float" can represent in some form, but the + * space is too large to compute directly in JS. + */ +class PDF { + /* + * Initialize a PDF in range [a, b], a and b can be non-integer. + * if uniform is true, then initialize the probability to be uniform, else initialize to a + * all-zero (invalid) PDF. + */ + constructor(a, b, uniform = true) { + this.value_start = Math.round(a); + this.value_end = Math.round(b); + const range = [a, b]; + const total_length = range_length(range); + this.prob = Array(this.value_end - this.value_start + 1); + if (uniform) { + for (let i = 0; i < this.prob.length; i++) { + this.prob[i] = + range_intersect_length(this.range_of(i), range) / total_length; + } + } + } + + range_of(idx) { + // TODO: consider doing the "exclusive end" properly. + return [this.value_start + idx - 0.5, this.value_start + idx + 0.5 - 1e-9]; + } + + min_value() { + return this.value_start - 0.5; + } + + max_value() { + return this.value_end + 0.5 - 1e-9; + } + + normalize() { + const total_probability = this.prob.reduce((acc, it) => acc + it, 0); + for (let i = 0; i < this.prob.length; i++) { + this.prob[i] /= total_probability; + } + } + + /* + * Limit the values to be in the range, and return the probability that the value was in this + * range. + */ + range_limit(range) { + let [start, end] = range; + start = Math.max(start, this.min_value()); + end = Math.min(end, this.max_value()); + if (start >= end) { + // Set this to invalid values + this.value_start = this.value_end = 0; + this.prob = []; + return 0; + } + + let prob = 0; + const start_idx = Math.round(start) - this.value_start; + const end_idx = Math.round(end) - this.value_start; + for (let i = start_idx; i <= end_idx; i++) { + const bucket_prob = this.prob[i] * range_intersect_length(this.range_of(i), range); + this.prob[i] = bucket_prob; + prob += bucket_prob; + } + + this.prob = this.prob.slice(start_idx, end_idx + 1); + this.value_start = Math.round(start); + this.value_end = Math.round(end); + this.normalize(); + + return prob; + } + + /* + * Subtract the PDF by a uniform distribution in [rate_decay_min, rate_decay_max] + * + * For simplicity, we assume that rate_decay_min and rate_decay_max are both integers. + */ + decay(rate_decay_min, rate_decay_max) { + const ret = new PDF( + this.min_value() - rate_decay_max, this.max_value() - rate_decay_min, false); + /* + // O(n^2) naive algorithm for reference, which would be too slow. + for (let i = this.value_start; i <= this.value_end; i++) { + const unit_prob = this.prob[i - this.value_start] / (rate_decay_max - rate_decay_min) / 2; + for (let j = rate_decay_min; j < rate_decay_max; j++) { + // ([i - 0.5, i + 0.5] uniform) - ([j, j + 1] uniform) + // -> [i - j - 1.5, i + 0.5 - j] with a triangular PDF + // -> approximate by + // [i - j - 1.5, i - j - 0.5] uniform & + // [i - j - 0.5, i - j + 0.5] uniform + ret.prob[i - j - 1 - ret.value_start] += unit_prob; // Part A + ret.prob[i - j - ret.value_start] += unit_prob; // Part B + } + } + */ + // Transform to "CDF" + for (let i = 1; i < this.prob.length; i++) { + this.prob[i] += this.prob[i - 1]; + } + // Return this.prob[l - this.value_start] + ... + this.prob[r - 1 - this.value_start]; + // This assume that this.prob is already transformed to "CDF". + const sum = (l, r) => { + l -= this.value_start; + r -= this.value_start; + if (l < 0) l = 0; + if (r > this.prob.length) r = this.prob.length; + if (l >= r) return 0; + return this.prob[r - 1] - (l == 0 ? 0 : this.prob[l - 1]); + }; + + for (let x = 0; x < ret.prob.length; x++) { + // i - j - 1 - ret.value_start == x (Part A) + // -> i = x + j + 1 + ret.value_start, j in [rate_decay_min, rate_decay_max) + ret.prob[x] = sum(x + rate_decay_min + 1 + ret.value_start, x + rate_decay_max + 1 + ret.value_start); + + // i - j - ret.value_start == x (Part B) + // -> i = x + j + ret.value_start, j in [rate_decay_min, rate_decay_max) + ret.prob[x] += sum(x + rate_decay_min + ret.value_start, x + rate_decay_max + ret.value_start); + } + this.prob = ret.prob; + this.value_start = ret.value_start; + this.value_end = ret.value_end; + this.normalize(); + } } /* @@ -108,33 +280,38 @@ function generate_individual_random_price( * rate -= randfloat(rate_decay_min, rate_decay_max); * } * - * Would modify the predicted_prices array. - * If the given_prices won't match, returns false, otherwise returns true + * Would return the conditional probability given the given_prices, and modify + * the predicted_prices array. + * If the given_prices won't match, returns 0. */ function generate_decreasing_random_price( - given_prices, predicted_prices, start, length, rate_min, - rate_max, rate_decay_min, rate_decay_max) { - rate_min *= RATE_MULTIPLIER; - rate_max *= RATE_MULTIPLIER; + given_prices, predicted_prices, start, length, start_rate_min, + start_rate_max, rate_decay_min, rate_decay_max) { + start_rate_min *= RATE_MULTIPLIER; + start_rate_max *= RATE_MULTIPLIER; rate_decay_min *= RATE_MULTIPLIER; rate_decay_max *= RATE_MULTIPLIER; const buy_price = given_prices[0]; + let rate_pdf = new PDF(start_rate_min, start_rate_max); + let prob = 1; for (let i = start; i < start + length; i++) { - let min_pred = get_price(rate_min, buy_price); - let max_pred = get_price(rate_max, buy_price); + let min_pred = get_price(rate_pdf.min_value(), buy_price); + let max_pred = get_price(rate_pdf.max_value(), buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern - return false; + return 0; } if (given_prices[i] >= min_pred || given_prices[i] <= max_pred) { - // The value in the FUDGE_FACTOR range is ignored so the rate range would not be empty. - const real_rate_min = minimum_rate_from_given_and_base(given_prices[i], buy_price); - const real_rate_max = maximum_rate_from_given_and_base(given_prices[i], buy_price); - rate_min = Math.max(rate_min, real_rate_min); - rate_max = Math.min(rate_max, real_rate_max); + // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. + const real_rate_range = + rate_range_from_given_and_base(given_prices[i], buy_price); + prob *= rate_pdf.range_limit(real_rate_range); + if (prob == 0) { + return 0; + } } min_pred = given_prices[i]; max_pred = given_prices[i]; @@ -145,10 +322,9 @@ function generate_decreasing_random_price( max: max_pred, }); - rate_min -= rate_decay_max; - rate_max -= rate_decay_min; + rate_pdf.decay(rate_decay_min, rate_decay_max); } - return true; + return prob; } @@ -159,8 +335,9 @@ function generate_decreasing_random_price( * sellPrices[work++] = intceil(rate * basePrice); * sellPrices[work++] = intceil(randfloat(rate_min, rate) * basePrice) - 1; * - * Would modify the predicted_prices array. - * If the given_prices won't match, returns false, otherwise returns true + * Would return the conditional probability given the given_prices, and modify + * the predicted_prices array. + * If the given_prices won't match, returns 0. */ function generate_peak_price( given_prices, predicted_prices, start, rate_min, rate_max) { @@ -168,15 +345,83 @@ function generate_peak_price( rate_max *= RATE_MULTIPLIER; const buy_price = given_prices[0]; + let prob = 1; + let rate_range = [rate_min, rate_max]; + + // * Calculate the probability first. + // Prob(middle_price) + const middle_price = given_prices[start + 1]; + if (!isNaN(middle_price)) { + const min_pred = get_price(rate_min, buy_price); + const max_pred = get_price(rate_max, buy_price); + if (middle_price < min_pred - FUDGE_FACTOR || middle_price > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return 0; + } + if (middle_price >= min_pred || middle_price <= max_pred) { + // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. + const real_rate_range = + rate_range_from_given_and_base(middle_price, buy_price); + prob *= range_intersect_length(rate_range, real_rate_range) / + range_length(rate_range); + if (prob == 0) { + return 0; + } + + rate_range = range_intersect(rate_range, real_rate_range); + } + } + const left_price = given_prices[start]; + const right_price = given_prices[start + 2]; + // Prob(left_price | middle_price), Prob(right_price | middle_price) + // + // A = rate_range[0], B = rate_range[1], C = rate_min, X = rate, Y = randfloat(rate_min, rate) + // rate = randfloat(A, B); sellPrices[work++] = intceil(randfloat(C, rate) * basePrice) - 1; + // + // => X->U(A,B), Y->U(C,X), Y-C->U(0,X-A), Y-C->U(0,1)*(X-A), Y-C->U(0,1)*U(C-A,B-A), + // let Z=Y-C, Z1=C-A, Z2=B-A, Z->U(0,1)*U(Z1,Z2) + // Prob(Z>=t) = integral_{x=0}^{1} [min(t/x,Z2)-min(t/x,Z1)]/ (Z2-Z1) + // let F(t, ZZ) = integral_{x=0}^{1} min(t/x, ZZ) + // 1. if ZZ < t, then min(t/x, ZZ) = ZZ -> F(t, ZZ) = ZZ + // 2. if ZZ >= t, then F(t, ZZ) = integral_{x=0}^{t/ZZ} ZZ + integral_{x=t/ZZ}^{1} t/x + // = t - t/ZZ log(t/ZZ) + // Prob(Z>=t) = (F(t, Z2) - F(t, Z1)) / (Z2 - Z1) + // Prob(Y>=t) = Prob(Z>=t-C) + for (const price of [left_price, right_price]) { + if (isNaN(price)) { + continue; + } + const min_pred = get_price(rate_min, buy_price) - 1; + const max_pred = get_price(rate_range[1], buy_price) - 1; + if (price < min_pred - FUDGE_FACTOR || price > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return 0; + } + if (price >= min_pred || price <= max_pred) { + // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. + const rate2_range = rate_range_from_given_and_base(price + 1, buy_price); + const F = (t, ZZ) => (ZZ < t ? ZZ : t - t / ZZ * Math.log(t / ZZ)); + const [A, B] = rate_range; + const C = rate_min; + const Z1 = C - A; + const Z2 = B - A; + const PY = (t) => (F(t - C, Z2) - F(t - C, Z1)) / (Z2 - Z1); + prob *= PY(rate2_range[1]) - PY(rate2_range[0]); + if (prob == 0) { + return 0; + } + } + } + + // * Then generate the real predicted range. + // We're doing things in different order then how we calculate probability, + // since forward prediction is more useful here. + // // Main spike 1 min_pred = get_price(rate_min, buy_price) - 1; max_pred = get_price(rate_max, buy_price) - 1; if (!isNaN(given_prices[start])) { - if (given_prices[start] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 2] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return false; - } min_pred = given_prices[start]; max_pred = given_prices[start]; } @@ -187,12 +432,8 @@ function generate_peak_price( // Main spike 2 min_pred = predicted_prices[start].min; - max_pred = intceil(2.0 * buy_price); + max_pred = get_price(rate_max, buy_price); if (!isNaN(given_prices[start + 1])) { - if (given_prices[start + 1] < min_pred - FUDGE_FACTOR || given_prices[start + 1] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return false; - } min_pred = given_prices[start + 1]; max_pred = given_prices[start + 1]; } @@ -202,13 +443,9 @@ function generate_peak_price( }); // Main spike 3 - min_pred = intceil(1.4 * buy_price) - 1; + min_pred = get_price(rate_min, buy_price) - 1; max_pred = predicted_prices[start + 1].max - 1; if (!isNaN(given_prices[start + 2])) { - if (given_prices[start + 2] < min_pred - FUDGE_FACTOR || given_prices[start + 2] > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return false; - } min_pred = given_prices[start + 2]; max_pred = given_prices[start + 2]; } @@ -217,7 +454,7 @@ function generate_peak_price( max: max_pred, }); - return true; + return prob; } function* @@ -271,31 +508,36 @@ function* max: buy_price, }, ]; + let probability = 1; // High Phase 1 - if (!generate_individual_random_price( - given_prices, predicted_prices, 2, high_phase_1_len, 0.9, 1.4)) { + probability *= generate_individual_random_price( + given_prices, predicted_prices, 2, high_phase_1_len, 0.9, 1.4); + if (probability == 0) { return; } // Dec Phase 1 - if (!generate_decreasing_random_price( - given_prices, predicted_prices, 2 + high_phase_1_len, dec_phase_1_len, - 0.6, 0.8, 0.04, 0.1)) { + probability *= generate_decreasing_random_price( + given_prices, predicted_prices, 2 + high_phase_1_len, dec_phase_1_len, + 0.6, 0.8, 0.04, 0.1); + if (probability == 0) { return; } // High Phase 2 - if (!generate_individual_random_price(given_prices, predicted_prices, - 2 + high_phase_1_len + dec_phase_1_len, high_phase_2_len, 0.9, 1.4)) { + probability *= generate_individual_random_price(given_prices, predicted_prices, + 2 + high_phase_1_len + dec_phase_1_len, high_phase_2_len, 0.9, 1.4); + if (probability == 0) { return; } // Dec Phase 2 - if (!generate_decreasing_random_price( - given_prices, predicted_prices, - 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len, - dec_phase_2_len, 0.6, 0.8, 0.04, 0.1)) { + probability *= generate_decreasing_random_price( + given_prices, predicted_prices, + 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len, + dec_phase_2_len, 0.6, 0.8, 0.04, 0.1); + if (probability == 0) { return; } @@ -306,16 +548,17 @@ function* const prev_length = 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len; - if (!generate_individual_random_price( - given_prices, predicted_prices, prev_length, 14 - prev_length, 0.9, - 1.4)) { + probability *= generate_individual_random_price( + given_prices, predicted_prices, prev_length, 14 - prev_length, 0.9, 1.4); + if (probability == 0) { return; } yield { pattern_description: "Fluctuating", pattern_number: 0, - prices: predicted_prices + prices: predicted_prices, + probability, }; } @@ -330,7 +573,9 @@ function* generate_pattern_0(given_prices) { for (var dec_phase_1_len = 2; dec_phase_1_len < 4; dec_phase_1_len++) { for (var high_phase_1_len = 0; high_phase_1_len < 7; high_phase_1_len++) { for (var high_phase_3_len = 0; high_phase_3_len < (7 - high_phase_1_len - 1 + 1); high_phase_3_len++) { - yield* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, 7 - high_phase_1_len - high_phase_3_len, 5 - dec_phase_1_len, high_phase_3_len); + yield* multiply_generator_probability( + generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, 7 - high_phase_1_len - high_phase_3_len, 5 - dec_phase_1_len, high_phase_3_len), + 1 / (4 - 2) / 7 / (7 - high_phase_1_len)); } } } @@ -369,10 +614,11 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { max: buy_price, }, ]; + let probability = 1; - if (!generate_decreasing_random_price( - given_prices, predicted_prices, 2, peak_start - 2, 0.85, 0.9, 0.03, - 0.05)) { + probability *= generate_decreasing_random_price( + given_prices, predicted_prices, 2, peak_start - 2, 0.85, 0.9, 0.03, 0.05); + if (probability == 0) { return; } @@ -380,22 +626,24 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { min_randoms = [0.9, 1.4, 2.0, 1.4, 0.9, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4] max_randoms = [1.4, 2.0, 6.0, 2.0, 1.4, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9] for (let i = peak_start; i < 14; i++) { - if (!generate_individual_random_price( - given_prices, predicted_prices, i, 1, min_randoms[i - peak_start], - max_randoms[i - peak_start])) { + probability *= generate_individual_random_price( + given_prices, predicted_prices, i, 1, min_randoms[i - peak_start], + max_randoms[i - peak_start]); + if (probability == 0) { return; } } yield { pattern_description: "Large spike", pattern_number: 1, - prices: predicted_prices + prices: predicted_prices, + probability, }; } function* generate_pattern_1(given_prices) { for (var peak_start = 3; peak_start < 10; peak_start++) { - yield* generate_pattern_1_with_peak(given_prices, peak_start); + yield* multiply_generator_probability(generate_pattern_1_with_peak(given_prices, peak_start), 1 / (10 - 3)); } } @@ -424,16 +672,19 @@ function* generate_pattern_2(given_prices) { max: buy_price, }, ]; + let probability = 1; - if (!generate_decreasing_random_price( - given_prices, predicted_prices, 2, 14 - 2, 0.85, 0.9, 0.03, 0.05)) { + probability *= generate_decreasing_random_price( + given_prices, predicted_prices, 2, 14 - 2, 0.85, 0.9, 0.03, 0.05); + if (probability == 0) { return; } yield { pattern_description: "Decreasing", pattern_number: 2, - prices: predicted_prices + prices: predicted_prices, + probability, }; } @@ -482,27 +733,30 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { ]; let probability = 1; - if (!generate_decreasing_random_price( - given_prices, predicted_prices, 2, peak_start - 2, 0.4, 0.9, 0.03, - 0.05)) { + probability *= generate_decreasing_random_price( + given_prices, predicted_prices, 2, peak_start - 2, 0.4, 0.9, 0.03, 0.05); + if (probability == 0) { return; } // The peak - if (!generate_individual_random_price( - given_prices, predicted_prices, peak_start, 2, 0.9, 1.4)) { + probability *= generate_individual_random_price( + given_prices, predicted_prices, peak_start, 2, 0.9, 1.4); + if (probability == 0) { return; } - if (!generate_peak_price( - given_prices, predicted_prices, peak_start + 2, 1.4, 2.0)) { + probability *= generate_peak_price( + given_prices, predicted_prices, peak_start + 2, 1.4, 2.0); + if (probability == 0) { return; } if (peak_start + 5 < 14) { - if (!generate_decreasing_random_price( - given_prices, predicted_prices, peak_start + 5, - 14 - (peak_start + 5), 0.4, 0.9, 0.03, 0.05)) { + probability *= generate_decreasing_random_price( + given_prices, predicted_prices, peak_start + 5, 14 - (peak_start + 5), + 0.4, 0.9, 0.03, 0.05); + if (probability == 0) { return; } } @@ -510,62 +764,60 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { yield { pattern_description: "Small spike", pattern_number: 3, - prices: predicted_prices + prices: predicted_prices, + probability, }; } function* generate_pattern_3(given_prices) { - for (var peak_start = 2; peak_start < 10; peak_start++) { - yield* generate_pattern_3_with_peak(given_prices, peak_start); + for (let peak_start = 2; peak_start < 10; peak_start++) { + yield* multiply_generator_probability(generate_pattern_3_with_peak(given_prices, peak_start), 1 / (10 - 2)); } } -function* generate_possibilities(sell_prices, first_buy) { +function get_transition_probability(previous_pattern) { + if (typeof previous_pattern === 'undefined' || Number.isNaN(previous_pattern) || previous_pattern === null || previous_pattern < 0 || previous_pattern > 3) { + // TODO: Fill the steady state pattern (https://github.com/mikebryant/ac-nh-turnip-prices/pull/90) here. + return [0.346278, 0.247363, 0.147607, 0.258752]; + } + + return PROBABILITY_MATRIX[previous_pattern]; +} + +function* generate_all_patterns(sell_prices, previous_pattern) { + const generate_pattern_fns = [generate_pattern_0, generate_pattern_1, generate_pattern_2, generate_pattern_3]; + const transition_probability = get_transition_probability(previous_pattern); + + for (let i = 0; i < 4; i++) { + yield* multiply_generator_probability(generate_pattern_fns[i](sell_prices), transition_probability[i]); + } +} + +function* generate_possibilities(sell_prices, first_buy, previous_pattern) { if (first_buy || isNaN(sell_prices[0])) { for (var buy_price = 90; buy_price <= 110; buy_price++) { sell_prices[0] = sell_prices[1] = buy_price; if (first_buy) { yield* generate_pattern_3(sell_prices); } else { - yield* generate_pattern_0(sell_prices); - yield* generate_pattern_1(sell_prices); - yield* generate_pattern_2(sell_prices); - yield* generate_pattern_3(sell_prices); + // All buy prices are equal probability and we're at the outmost layer, + // so don't need to multiply_generator_probability here. + yield* generate_all_patterns(sell_prices, previous_pattern) } } } else { - yield* generate_pattern_0(sell_prices); - yield* generate_pattern_1(sell_prices); - yield* generate_pattern_2(sell_prices); - yield* generate_pattern_3(sell_prices); + yield* generate_all_patterns(sell_prices, previous_pattern) } } -function row_probability(possibility, previous_pattern) { - return PROBABILITY_MATRIX[previous_pattern][possibility.pattern_number] / PATTERN_COUNTS[possibility.pattern_number]; -} +function analyze_possibilities(sell_prices, first_buy, previous_pattern) { + const generated_possibilities = Array.from(generate_possibilities(sell_prices, first_buy, previous_pattern)); -function get_probabilities(possibilities, previous_pattern) { - if (typeof previous_pattern === 'undefined' || Number.isNaN(previous_pattern) || previous_pattern === null || previous_pattern < 0 || previous_pattern > 3) { - return possibilities + const total_probability = generated_possibilities.reduce((acc, it) => acc + it.probability, 0); + for (const it of generated_possibilities) { + it.probability /= total_probability; } - var max_percent = possibilities.map(function (poss) { - return row_probability(poss, previous_pattern); - }).reduce(function (prev, current) { - return prev + current; - }, 0); - - return possibilities.map(function (poss) { - poss.probability = row_probability(poss, previous_pattern) / max_percent; - return poss; - }); -} - -function analyze_possibilities(sell_prices, first_buy, previous_pattern) { - generated_possibilities = Array.from(generate_possibilities(sell_prices, first_buy)); - generated_possibilities = get_probabilities(generated_possibilities, previous_pattern); - for (let poss of generated_possibilities) { var weekMins = []; var weekMaxes = []; From 8c43570c9a9c672b122306cb8e8e5e4b8d2f3b6c Mon Sep 17 00:00:00 2001 From: Pi-Hsun Shih Date: Wed, 15 Apr 2020 22:14:22 +0800 Subject: [PATCH 22/25] Fix a wrong comment. --- js/predictions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index c5da355..257334a 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -381,13 +381,13 @@ function generate_peak_price( // // => X->U(A,B), Y->U(C,X), Y-C->U(0,X-A), Y-C->U(0,1)*(X-A), Y-C->U(0,1)*U(C-A,B-A), // let Z=Y-C, Z1=C-A, Z2=B-A, Z->U(0,1)*U(Z1,Z2) - // Prob(Z>=t) = integral_{x=0}^{1} [min(t/x,Z2)-min(t/x,Z1)]/ (Z2-Z1) + // Prob(Z<=t) = integral_{x=0}^{1} [min(t/x,Z2)-min(t/x,Z1)]/ (Z2-Z1) // let F(t, ZZ) = integral_{x=0}^{1} min(t/x, ZZ) // 1. if ZZ < t, then min(t/x, ZZ) = ZZ -> F(t, ZZ) = ZZ // 2. if ZZ >= t, then F(t, ZZ) = integral_{x=0}^{t/ZZ} ZZ + integral_{x=t/ZZ}^{1} t/x // = t - t/ZZ log(t/ZZ) - // Prob(Z>=t) = (F(t, Z2) - F(t, Z1)) / (Z2 - Z1) - // Prob(Y>=t) = Prob(Z>=t-C) + // Prob(Z<=t) = (F(t, Z2) - F(t, Z1)) / (Z2 - Z1) + // Prob(Y<=t) = Prob(Z>=t-C) for (const price of [left_price, right_price]) { if (isNaN(price)) { continue; From a5194879d02846e7fb077c8cc8acc46bfa0d3195 Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Wed, 15 Apr 2020 15:30:07 +0100 Subject: [PATCH 23/25] Revert "Fix a wrong comment." This reverts commit 8c43570c9a9c672b122306cb8e8e5e4b8d2f3b6c. --- js/predictions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index 257334a..c5da355 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -381,13 +381,13 @@ function generate_peak_price( // // => X->U(A,B), Y->U(C,X), Y-C->U(0,X-A), Y-C->U(0,1)*(X-A), Y-C->U(0,1)*U(C-A,B-A), // let Z=Y-C, Z1=C-A, Z2=B-A, Z->U(0,1)*U(Z1,Z2) - // Prob(Z<=t) = integral_{x=0}^{1} [min(t/x,Z2)-min(t/x,Z1)]/ (Z2-Z1) + // Prob(Z>=t) = integral_{x=0}^{1} [min(t/x,Z2)-min(t/x,Z1)]/ (Z2-Z1) // let F(t, ZZ) = integral_{x=0}^{1} min(t/x, ZZ) // 1. if ZZ < t, then min(t/x, ZZ) = ZZ -> F(t, ZZ) = ZZ // 2. if ZZ >= t, then F(t, ZZ) = integral_{x=0}^{t/ZZ} ZZ + integral_{x=t/ZZ}^{1} t/x // = t - t/ZZ log(t/ZZ) - // Prob(Z<=t) = (F(t, Z2) - F(t, Z1)) / (Z2 - Z1) - // Prob(Y<=t) = Prob(Z>=t-C) + // Prob(Z>=t) = (F(t, Z2) - F(t, Z1)) / (Z2 - Z1) + // Prob(Y>=t) = Prob(Z>=t-C) for (const price of [left_price, right_price]) { if (isNaN(price)) { continue; From 8f4988224596830c5c4526765ebb40bdc214426c Mon Sep 17 00:00:00 2001 From: Mike Bryant Date: Wed, 15 Apr 2020 15:30:10 +0100 Subject: [PATCH 24/25] Revert "Make probability accurate by condition probability on the value." This reverts commit 3fa1867340d8d3566d7322d10fe5c7fc8aff5b3e. --- js/predictions.js | 484 +++++++++++----------------------------------- 1 file changed, 116 insertions(+), 368 deletions(-) diff --git a/js/predictions.js b/js/predictions.js index c5da355..27b5e93 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -9,6 +9,13 @@ const PATTERN = { SMALL_SPIKE: 3, }; +const PATTERN_COUNTS = { + [PATTERN.FLUCTUATING]: 56, + [PATTERN.LARGE_SPIKE]: 7, + [PATTERN.DECREASING]: 1, + [PATTERN.SMALL_SPIKE]: 8, +}; + const PROBABILITY_MATRIX = { [PATTERN.FLUCTUATING]: { [PATTERN.FLUCTUATING]: 0.20, @@ -50,42 +57,10 @@ function maximum_rate_from_given_and_base(given_price, buy_price) { return RATE_MULTIPLIER * (given_price + 0.00001) / buy_price; } -function rate_range_from_given_and_base(given_price, buy_price) { - return [ - minimum_rate_from_given_and_base(given_price, buy_price), - maximum_rate_from_given_and_base(given_price, buy_price) - ]; -} - function get_price(rate, basePrice) { return intceil(rate * basePrice / RATE_MULTIPLIER); } -function* multiply_generator_probability(generator, probability) { - for (const it of generator) { - yield {...it, probability: it.probability * probability}; - } -} - -function range_length(range) { - return range[1] - range[0]; -} - -function range_intersect(range1, range2) { - if (range1[0] > range2[1] || range1[1] < range2[0]) { - return null; - } - return [Math.max(range1[0], range2[0]), Math.min(range1[1], range2[1])]; -} - -function range_intersect_length(range1, range2) { - if (range1[0] > range2[1] || range1[1] < range2[0]) { - return 0; - } - return range_length(range_intersect(range1, range2)); -} - - /* * This corresponds to the code: * for (int i = start; i < start + length; i++) @@ -94,9 +69,8 @@ function range_intersect_length(range1, range2) { * intceil(randfloat(rate_min / RATE_MULTIPLIER, rate_max / RATE_MULTIPLIER) * basePrice); * } * - * Would return the conditional probability given the given_prices, and modify - * the predicted_prices array. - * If the given_prices won't match, returns 0. + * Would modify the predicted_prices array. + * If the given_prices won't match, returns false, otherwise returns true */ function generate_individual_random_price( given_prices, predicted_prices, start, length, rate_min, rate_max) { @@ -104,8 +78,6 @@ function generate_individual_random_price( rate_max *= RATE_MULTIPLIER; const buy_price = given_prices[0]; - const rate_range = [rate_min, rate_max]; - let prob = 1; for (let i = start; i < start + length; i++) { let min_pred = get_price(rate_min, buy_price); @@ -113,14 +85,7 @@ function generate_individual_random_price( if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern - return 0; - } - if (given_prices[i] >= min_pred || given_prices[i] <= max_pred) { - // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. - const real_rate_range = - rate_range_from_given_and_base(given_prices[i], buy_price); - prob *= range_intersect_length(rate_range, real_rate_range) / - range_length(rate_range); + return false; } min_pred = given_prices[i]; max_pred = given_prices[i]; @@ -131,144 +96,7 @@ function generate_individual_random_price( max: max_pred, }); } - return prob; -} - -/* - * Probability Density Function of rates. - * Since the PDF is continuous*, we approximate it by a discrete probability function: - * the value in range [(x - 0.5), (x + 0.5)) has a uniform probability - * prob[x - value_start]; - * - * Note that we operate all rate on the (* RATE_MULTIPLIER) scale. - * - * (*): Well not really since it only takes values that "float" can represent in some form, but the - * space is too large to compute directly in JS. - */ -class PDF { - /* - * Initialize a PDF in range [a, b], a and b can be non-integer. - * if uniform is true, then initialize the probability to be uniform, else initialize to a - * all-zero (invalid) PDF. - */ - constructor(a, b, uniform = true) { - this.value_start = Math.round(a); - this.value_end = Math.round(b); - const range = [a, b]; - const total_length = range_length(range); - this.prob = Array(this.value_end - this.value_start + 1); - if (uniform) { - for (let i = 0; i < this.prob.length; i++) { - this.prob[i] = - range_intersect_length(this.range_of(i), range) / total_length; - } - } - } - - range_of(idx) { - // TODO: consider doing the "exclusive end" properly. - return [this.value_start + idx - 0.5, this.value_start + idx + 0.5 - 1e-9]; - } - - min_value() { - return this.value_start - 0.5; - } - - max_value() { - return this.value_end + 0.5 - 1e-9; - } - - normalize() { - const total_probability = this.prob.reduce((acc, it) => acc + it, 0); - for (let i = 0; i < this.prob.length; i++) { - this.prob[i] /= total_probability; - } - } - - /* - * Limit the values to be in the range, and return the probability that the value was in this - * range. - */ - range_limit(range) { - let [start, end] = range; - start = Math.max(start, this.min_value()); - end = Math.min(end, this.max_value()); - if (start >= end) { - // Set this to invalid values - this.value_start = this.value_end = 0; - this.prob = []; - return 0; - } - - let prob = 0; - const start_idx = Math.round(start) - this.value_start; - const end_idx = Math.round(end) - this.value_start; - for (let i = start_idx; i <= end_idx; i++) { - const bucket_prob = this.prob[i] * range_intersect_length(this.range_of(i), range); - this.prob[i] = bucket_prob; - prob += bucket_prob; - } - - this.prob = this.prob.slice(start_idx, end_idx + 1); - this.value_start = Math.round(start); - this.value_end = Math.round(end); - this.normalize(); - - return prob; - } - - /* - * Subtract the PDF by a uniform distribution in [rate_decay_min, rate_decay_max] - * - * For simplicity, we assume that rate_decay_min and rate_decay_max are both integers. - */ - decay(rate_decay_min, rate_decay_max) { - const ret = new PDF( - this.min_value() - rate_decay_max, this.max_value() - rate_decay_min, false); - /* - // O(n^2) naive algorithm for reference, which would be too slow. - for (let i = this.value_start; i <= this.value_end; i++) { - const unit_prob = this.prob[i - this.value_start] / (rate_decay_max - rate_decay_min) / 2; - for (let j = rate_decay_min; j < rate_decay_max; j++) { - // ([i - 0.5, i + 0.5] uniform) - ([j, j + 1] uniform) - // -> [i - j - 1.5, i + 0.5 - j] with a triangular PDF - // -> approximate by - // [i - j - 1.5, i - j - 0.5] uniform & - // [i - j - 0.5, i - j + 0.5] uniform - ret.prob[i - j - 1 - ret.value_start] += unit_prob; // Part A - ret.prob[i - j - ret.value_start] += unit_prob; // Part B - } - } - */ - // Transform to "CDF" - for (let i = 1; i < this.prob.length; i++) { - this.prob[i] += this.prob[i - 1]; - } - // Return this.prob[l - this.value_start] + ... + this.prob[r - 1 - this.value_start]; - // This assume that this.prob is already transformed to "CDF". - const sum = (l, r) => { - l -= this.value_start; - r -= this.value_start; - if (l < 0) l = 0; - if (r > this.prob.length) r = this.prob.length; - if (l >= r) return 0; - return this.prob[r - 1] - (l == 0 ? 0 : this.prob[l - 1]); - }; - - for (let x = 0; x < ret.prob.length; x++) { - // i - j - 1 - ret.value_start == x (Part A) - // -> i = x + j + 1 + ret.value_start, j in [rate_decay_min, rate_decay_max) - ret.prob[x] = sum(x + rate_decay_min + 1 + ret.value_start, x + rate_decay_max + 1 + ret.value_start); - - // i - j - ret.value_start == x (Part B) - // -> i = x + j + ret.value_start, j in [rate_decay_min, rate_decay_max) - ret.prob[x] += sum(x + rate_decay_min + ret.value_start, x + rate_decay_max + ret.value_start); - } - this.prob = ret.prob; - this.value_start = ret.value_start; - this.value_end = ret.value_end; - this.normalize(); - } + return true; } /* @@ -280,38 +108,33 @@ class PDF { * rate -= randfloat(rate_decay_min, rate_decay_max); * } * - * Would return the conditional probability given the given_prices, and modify - * the predicted_prices array. - * If the given_prices won't match, returns 0. + * Would modify the predicted_prices array. + * If the given_prices won't match, returns false, otherwise returns true */ function generate_decreasing_random_price( - given_prices, predicted_prices, start, length, start_rate_min, - start_rate_max, rate_decay_min, rate_decay_max) { - start_rate_min *= RATE_MULTIPLIER; - start_rate_max *= RATE_MULTIPLIER; + given_prices, predicted_prices, start, length, rate_min, + rate_max, rate_decay_min, rate_decay_max) { + rate_min *= RATE_MULTIPLIER; + rate_max *= RATE_MULTIPLIER; rate_decay_min *= RATE_MULTIPLIER; rate_decay_max *= RATE_MULTIPLIER; const buy_price = given_prices[0]; - let rate_pdf = new PDF(start_rate_min, start_rate_max); - let prob = 1; for (let i = start; i < start + length; i++) { - let min_pred = get_price(rate_pdf.min_value(), buy_price); - let max_pred = get_price(rate_pdf.max_value(), buy_price); + let min_pred = get_price(rate_min, buy_price); + let max_pred = get_price(rate_max, buy_price); if (!isNaN(given_prices[i])) { if (given_prices[i] < min_pred - FUDGE_FACTOR || given_prices[i] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern - return 0; + return false; } if (given_prices[i] >= min_pred || given_prices[i] <= max_pred) { - // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. - const real_rate_range = - rate_range_from_given_and_base(given_prices[i], buy_price); - prob *= rate_pdf.range_limit(real_rate_range); - if (prob == 0) { - return 0; - } + // The value in the FUDGE_FACTOR range is ignored so the rate range would not be empty. + const real_rate_min = minimum_rate_from_given_and_base(given_prices[i], buy_price); + const real_rate_max = maximum_rate_from_given_and_base(given_prices[i], buy_price); + rate_min = Math.max(rate_min, real_rate_min); + rate_max = Math.min(rate_max, real_rate_max); } min_pred = given_prices[i]; max_pred = given_prices[i]; @@ -322,9 +145,10 @@ function generate_decreasing_random_price( max: max_pred, }); - rate_pdf.decay(rate_decay_min, rate_decay_max); + rate_min -= rate_decay_max; + rate_max -= rate_decay_min; } - return prob; + return true; } @@ -335,9 +159,8 @@ function generate_decreasing_random_price( * sellPrices[work++] = intceil(rate * basePrice); * sellPrices[work++] = intceil(randfloat(rate_min, rate) * basePrice) - 1; * - * Would return the conditional probability given the given_prices, and modify - * the predicted_prices array. - * If the given_prices won't match, returns 0. + * Would modify the predicted_prices array. + * If the given_prices won't match, returns false, otherwise returns true */ function generate_peak_price( given_prices, predicted_prices, start, rate_min, rate_max) { @@ -345,83 +168,15 @@ function generate_peak_price( rate_max *= RATE_MULTIPLIER; const buy_price = given_prices[0]; - let prob = 1; - let rate_range = [rate_min, rate_max]; - - // * Calculate the probability first. - // Prob(middle_price) - const middle_price = given_prices[start + 1]; - if (!isNaN(middle_price)) { - const min_pred = get_price(rate_min, buy_price); - const max_pred = get_price(rate_max, buy_price); - if (middle_price < min_pred - FUDGE_FACTOR || middle_price > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return 0; - } - if (middle_price >= min_pred || middle_price <= max_pred) { - // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. - const real_rate_range = - rate_range_from_given_and_base(middle_price, buy_price); - prob *= range_intersect_length(rate_range, real_rate_range) / - range_length(rate_range); - if (prob == 0) { - return 0; - } - - rate_range = range_intersect(rate_range, real_rate_range); - } - } - const left_price = given_prices[start]; - const right_price = given_prices[start + 2]; - // Prob(left_price | middle_price), Prob(right_price | middle_price) - // - // A = rate_range[0], B = rate_range[1], C = rate_min, X = rate, Y = randfloat(rate_min, rate) - // rate = randfloat(A, B); sellPrices[work++] = intceil(randfloat(C, rate) * basePrice) - 1; - // - // => X->U(A,B), Y->U(C,X), Y-C->U(0,X-A), Y-C->U(0,1)*(X-A), Y-C->U(0,1)*U(C-A,B-A), - // let Z=Y-C, Z1=C-A, Z2=B-A, Z->U(0,1)*U(Z1,Z2) - // Prob(Z>=t) = integral_{x=0}^{1} [min(t/x,Z2)-min(t/x,Z1)]/ (Z2-Z1) - // let F(t, ZZ) = integral_{x=0}^{1} min(t/x, ZZ) - // 1. if ZZ < t, then min(t/x, ZZ) = ZZ -> F(t, ZZ) = ZZ - // 2. if ZZ >= t, then F(t, ZZ) = integral_{x=0}^{t/ZZ} ZZ + integral_{x=t/ZZ}^{1} t/x - // = t - t/ZZ log(t/ZZ) - // Prob(Z>=t) = (F(t, Z2) - F(t, Z1)) / (Z2 - Z1) - // Prob(Y>=t) = Prob(Z>=t-C) - for (const price of [left_price, right_price]) { - if (isNaN(price)) { - continue; - } - const min_pred = get_price(rate_min, buy_price) - 1; - const max_pred = get_price(rate_range[1], buy_price) - 1; - if (price < min_pred - FUDGE_FACTOR || price > max_pred + FUDGE_FACTOR) { - // Given price is out of predicted range, so this is the wrong pattern - return 0; - } - if (price >= min_pred || price <= max_pred) { - // The value in the FUDGE_FACTOR range is ignored so that it don't give a probability 0. - const rate2_range = rate_range_from_given_and_base(price + 1, buy_price); - const F = (t, ZZ) => (ZZ < t ? ZZ : t - t / ZZ * Math.log(t / ZZ)); - const [A, B] = rate_range; - const C = rate_min; - const Z1 = C - A; - const Z2 = B - A; - const PY = (t) => (F(t - C, Z2) - F(t - C, Z1)) / (Z2 - Z1); - prob *= PY(rate2_range[1]) - PY(rate2_range[0]); - if (prob == 0) { - return 0; - } - } - } - - // * Then generate the real predicted range. - // We're doing things in different order then how we calculate probability, - // since forward prediction is more useful here. - // // Main spike 1 min_pred = get_price(rate_min, buy_price) - 1; max_pred = get_price(rate_max, buy_price) - 1; if (!isNaN(given_prices[start])) { + if (given_prices[start] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 2] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } min_pred = given_prices[start]; max_pred = given_prices[start]; } @@ -432,8 +187,12 @@ function generate_peak_price( // Main spike 2 min_pred = predicted_prices[start].min; - max_pred = get_price(rate_max, buy_price); + max_pred = intceil(2.0 * buy_price); if (!isNaN(given_prices[start + 1])) { + if (given_prices[start + 1] < min_pred - FUDGE_FACTOR || given_prices[start + 1] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } min_pred = given_prices[start + 1]; max_pred = given_prices[start + 1]; } @@ -443,9 +202,13 @@ function generate_peak_price( }); // Main spike 3 - min_pred = get_price(rate_min, buy_price) - 1; + min_pred = intceil(1.4 * buy_price) - 1; max_pred = predicted_prices[start + 1].max - 1; if (!isNaN(given_prices[start + 2])) { + if (given_prices[start + 2] < min_pred - FUDGE_FACTOR || given_prices[start + 2] > max_pred + FUDGE_FACTOR) { + // Given price is out of predicted range, so this is the wrong pattern + return false; + } min_pred = given_prices[start + 2]; max_pred = given_prices[start + 2]; } @@ -454,7 +217,7 @@ function generate_peak_price( max: max_pred, }); - return prob; + return true; } function* @@ -508,36 +271,31 @@ function* max: buy_price, }, ]; - let probability = 1; // High Phase 1 - probability *= generate_individual_random_price( - given_prices, predicted_prices, 2, high_phase_1_len, 0.9, 1.4); - if (probability == 0) { + if (!generate_individual_random_price( + given_prices, predicted_prices, 2, high_phase_1_len, 0.9, 1.4)) { return; } // Dec Phase 1 - probability *= generate_decreasing_random_price( - given_prices, predicted_prices, 2 + high_phase_1_len, dec_phase_1_len, - 0.6, 0.8, 0.04, 0.1); - if (probability == 0) { + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2 + high_phase_1_len, dec_phase_1_len, + 0.6, 0.8, 0.04, 0.1)) { return; } // High Phase 2 - probability *= generate_individual_random_price(given_prices, predicted_prices, - 2 + high_phase_1_len + dec_phase_1_len, high_phase_2_len, 0.9, 1.4); - if (probability == 0) { + if (!generate_individual_random_price(given_prices, predicted_prices, + 2 + high_phase_1_len + dec_phase_1_len, high_phase_2_len, 0.9, 1.4)) { return; } // Dec Phase 2 - probability *= generate_decreasing_random_price( - given_prices, predicted_prices, - 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len, - dec_phase_2_len, 0.6, 0.8, 0.04, 0.1); - if (probability == 0) { + if (!generate_decreasing_random_price( + given_prices, predicted_prices, + 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len, + dec_phase_2_len, 0.6, 0.8, 0.04, 0.1)) { return; } @@ -548,17 +306,16 @@ function* const prev_length = 2 + high_phase_1_len + dec_phase_1_len + high_phase_2_len + dec_phase_2_len; - probability *= generate_individual_random_price( - given_prices, predicted_prices, prev_length, 14 - prev_length, 0.9, 1.4); - if (probability == 0) { + if (!generate_individual_random_price( + given_prices, predicted_prices, prev_length, 14 - prev_length, 0.9, + 1.4)) { return; } yield { pattern_description: "Fluctuating", pattern_number: 0, - prices: predicted_prices, - probability, + prices: predicted_prices }; } @@ -573,9 +330,7 @@ function* generate_pattern_0(given_prices) { for (var dec_phase_1_len = 2; dec_phase_1_len < 4; dec_phase_1_len++) { for (var high_phase_1_len = 0; high_phase_1_len < 7; high_phase_1_len++) { for (var high_phase_3_len = 0; high_phase_3_len < (7 - high_phase_1_len - 1 + 1); high_phase_3_len++) { - yield* multiply_generator_probability( - generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, 7 - high_phase_1_len - high_phase_3_len, 5 - dec_phase_1_len, high_phase_3_len), - 1 / (4 - 2) / 7 / (7 - high_phase_1_len)); + yield* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, 7 - high_phase_1_len - high_phase_3_len, 5 - dec_phase_1_len, high_phase_3_len); } } } @@ -614,11 +369,10 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { max: buy_price, }, ]; - let probability = 1; - probability *= generate_decreasing_random_price( - given_prices, predicted_prices, 2, peak_start - 2, 0.85, 0.9, 0.03, 0.05); - if (probability == 0) { + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2, peak_start - 2, 0.85, 0.9, 0.03, + 0.05)) { return; } @@ -626,24 +380,22 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { min_randoms = [0.9, 1.4, 2.0, 1.4, 0.9, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4] max_randoms = [1.4, 2.0, 6.0, 2.0, 1.4, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9] for (let i = peak_start; i < 14; i++) { - probability *= generate_individual_random_price( - given_prices, predicted_prices, i, 1, min_randoms[i - peak_start], - max_randoms[i - peak_start]); - if (probability == 0) { + if (!generate_individual_random_price( + given_prices, predicted_prices, i, 1, min_randoms[i - peak_start], + max_randoms[i - peak_start])) { return; } } yield { pattern_description: "Large spike", pattern_number: 1, - prices: predicted_prices, - probability, + prices: predicted_prices }; } function* generate_pattern_1(given_prices) { for (var peak_start = 3; peak_start < 10; peak_start++) { - yield* multiply_generator_probability(generate_pattern_1_with_peak(given_prices, peak_start), 1 / (10 - 3)); + yield* generate_pattern_1_with_peak(given_prices, peak_start); } } @@ -672,19 +424,16 @@ function* generate_pattern_2(given_prices) { max: buy_price, }, ]; - let probability = 1; - probability *= generate_decreasing_random_price( - given_prices, predicted_prices, 2, 14 - 2, 0.85, 0.9, 0.03, 0.05); - if (probability == 0) { + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2, 14 - 2, 0.85, 0.9, 0.03, 0.05)) { return; } yield { pattern_description: "Decreasing", pattern_number: 2, - prices: predicted_prices, - probability, + prices: predicted_prices }; } @@ -733,30 +482,27 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { ]; let probability = 1; - probability *= generate_decreasing_random_price( - given_prices, predicted_prices, 2, peak_start - 2, 0.4, 0.9, 0.03, 0.05); - if (probability == 0) { + if (!generate_decreasing_random_price( + given_prices, predicted_prices, 2, peak_start - 2, 0.4, 0.9, 0.03, + 0.05)) { return; } // The peak - probability *= generate_individual_random_price( - given_prices, predicted_prices, peak_start, 2, 0.9, 1.4); - if (probability == 0) { + if (!generate_individual_random_price( + given_prices, predicted_prices, peak_start, 2, 0.9, 1.4)) { return; } - probability *= generate_peak_price( - given_prices, predicted_prices, peak_start + 2, 1.4, 2.0); - if (probability == 0) { + if (!generate_peak_price( + given_prices, predicted_prices, peak_start + 2, 1.4, 2.0)) { return; } if (peak_start + 5 < 14) { - probability *= generate_decreasing_random_price( - given_prices, predicted_prices, peak_start + 5, 14 - (peak_start + 5), - 0.4, 0.9, 0.03, 0.05); - if (probability == 0) { + if (!generate_decreasing_random_price( + given_prices, predicted_prices, peak_start + 5, + 14 - (peak_start + 5), 0.4, 0.9, 0.03, 0.05)) { return; } } @@ -764,60 +510,62 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { yield { pattern_description: "Small spike", pattern_number: 3, - prices: predicted_prices, - probability, + prices: predicted_prices }; } function* generate_pattern_3(given_prices) { - for (let peak_start = 2; peak_start < 10; peak_start++) { - yield* multiply_generator_probability(generate_pattern_3_with_peak(given_prices, peak_start), 1 / (10 - 2)); + for (var peak_start = 2; peak_start < 10; peak_start++) { + yield* generate_pattern_3_with_peak(given_prices, peak_start); } } -function get_transition_probability(previous_pattern) { - if (typeof previous_pattern === 'undefined' || Number.isNaN(previous_pattern) || previous_pattern === null || previous_pattern < 0 || previous_pattern > 3) { - // TODO: Fill the steady state pattern (https://github.com/mikebryant/ac-nh-turnip-prices/pull/90) here. - return [0.346278, 0.247363, 0.147607, 0.258752]; - } - - return PROBABILITY_MATRIX[previous_pattern]; -} - -function* generate_all_patterns(sell_prices, previous_pattern) { - const generate_pattern_fns = [generate_pattern_0, generate_pattern_1, generate_pattern_2, generate_pattern_3]; - const transition_probability = get_transition_probability(previous_pattern); - - for (let i = 0; i < 4; i++) { - yield* multiply_generator_probability(generate_pattern_fns[i](sell_prices), transition_probability[i]); - } -} - -function* generate_possibilities(sell_prices, first_buy, previous_pattern) { +function* generate_possibilities(sell_prices, first_buy) { if (first_buy || isNaN(sell_prices[0])) { for (var buy_price = 90; buy_price <= 110; buy_price++) { sell_prices[0] = sell_prices[1] = buy_price; if (first_buy) { yield* generate_pattern_3(sell_prices); } else { - // All buy prices are equal probability and we're at the outmost layer, - // so don't need to multiply_generator_probability here. - yield* generate_all_patterns(sell_prices, previous_pattern) + yield* generate_pattern_0(sell_prices); + yield* generate_pattern_1(sell_prices); + yield* generate_pattern_2(sell_prices); + yield* generate_pattern_3(sell_prices); } } } else { - yield* generate_all_patterns(sell_prices, previous_pattern) + yield* generate_pattern_0(sell_prices); + yield* generate_pattern_1(sell_prices); + yield* generate_pattern_2(sell_prices); + yield* generate_pattern_3(sell_prices); } } -function analyze_possibilities(sell_prices, first_buy, previous_pattern) { - const generated_possibilities = Array.from(generate_possibilities(sell_prices, first_buy, previous_pattern)); +function row_probability(possibility, previous_pattern) { + return PROBABILITY_MATRIX[previous_pattern][possibility.pattern_number] / PATTERN_COUNTS[possibility.pattern_number]; +} - const total_probability = generated_possibilities.reduce((acc, it) => acc + it.probability, 0); - for (const it of generated_possibilities) { - it.probability /= total_probability; +function get_probabilities(possibilities, previous_pattern) { + if (typeof previous_pattern === 'undefined' || Number.isNaN(previous_pattern) || previous_pattern === null || previous_pattern < 0 || previous_pattern > 3) { + return possibilities } + var max_percent = possibilities.map(function (poss) { + return row_probability(poss, previous_pattern); + }).reduce(function (prev, current) { + return prev + current; + }, 0); + + return possibilities.map(function (poss) { + poss.probability = row_probability(poss, previous_pattern) / max_percent; + return poss; + }); +} + +function analyze_possibilities(sell_prices, first_buy, previous_pattern) { + generated_possibilities = Array.from(generate_possibilities(sell_prices, first_buy)); + generated_possibilities = get_probabilities(generated_possibilities, previous_pattern); + for (let poss of generated_possibilities) { var weekMins = []; var weekMaxes = []; From b653b3993ce6188f40129fd19a684ef8e815c720 Mon Sep 17 00:00:00 2001 From: Pi-Hsun Shih Date: Wed, 15 Apr 2020 23:30:01 +0800 Subject: [PATCH 25/25] Fix peak_start not defined Fix #110. --- js/predictions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/predictions.js b/js/predictions.js index 27b5e93..f1410a6 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -173,7 +173,7 @@ function generate_peak_price( min_pred = get_price(rate_min, buy_price) - 1; max_pred = get_price(rate_max, buy_price) - 1; if (!isNaN(given_prices[start])) { - if (given_prices[start] < min_pred - FUDGE_FACTOR || given_prices[peak_start + 2] > max_pred + FUDGE_FACTOR) { + if (given_prices[start] < min_pred - FUDGE_FACTOR || given_prices[start] > max_pred + FUDGE_FACTOR) { // Given price is out of predicted range, so this is the wrong pattern return false; }
" + poss.pattern_description + "${Number.isFinite(poss.probability) ? ((poss.probability * 100).toPrecision(3) + '%') : '—'}