// ==UserScript==
// @name Robinhood Watchlist Monitor
// @namespace http://tampermonkey.net/
// @version 0.3
// @description Adds additional columns to robinhood watchlist table
// to specify price targets and monitor price limits.
// The price limit cells changes color when stock prices
// hit entry, price targets and stop loss limits.
// @author Ramsundar K G <[email protected]>
// @match https://robinhood.com/lists/*
// @grant none
// @require https://code.jquery.com/jquery-3.5.1.min.js
// ==/UserScript==
(function () {
'use strict';
var $ = window.jQuery;
const DEBUG_MODE = false; // Set this flag to enable debugding
const LIMIT_TYPE = {
MAX: 'max',
MIN: 'min'
};
const LIMIT_INDICATOR_STYLES = {
GREEN: 'background-color: green; color: black',
RED: 'background-color: red; color: black',
YELLOW: 'background-color: yellow; color: black'
}
const LIMITS_CONFIG = [
{
tag: 'entry',
header: 'Entry',
limitType: LIMIT_TYPE.MAX,
indicatorStyle: LIMIT_INDICATOR_STYLES.YELLOW
},
{
tag: 'priceTarget1',
header: 'PT 1',
limitType: LIMIT_TYPE.MIN,
indicatorStyle: LIMIT_INDICATOR_STYLES.GREEN
},
{
tag: 'priceTarget2',
header: 'PT 2',
limitType: LIMIT_TYPE.MIN,
indicatorStyle: LIMIT_INDICATOR_STYLES.GREEN
},
{
tag: 'priceTarget3',
header: 'PT 3',
limitType: LIMIT_TYPE.MIN,
indicatorStyle: LIMIT_INDICATOR_STYLES.GREEN
},
{
tag: 'stopLoss',
header: 'Stop Loss',
limitType: LIMIT_TYPE.MAX,
indicatorStyle: LIMIT_INDICATOR_STYLES.RED
}
];
var stockList = {};
/*--- waitForKeyElements(): A utility function, for Greasemonkey scripts,
that detects and handles AJAXed content.
Usage example:
waitForKeyElements (
"div.comments"
, commentCallbackFunction
);
//--- Page-specific function to do what we want when the node is found.
function commentCallbackFunction (jNode) {
jNode.text ("This comment changed by waitForKeyElements().");
}
IMPORTANT: This function requires your script to have loaded jQuery.
*/
function waitForKeyElements(
selectorTxt,
actionFunction,
bWaitOnce,
iframeSelector
) {
var targetNodes, btargetsFound;
if (typeof iframeSelector == "undefined") {
targetNodes = $(selectorTxt);
} else {
targetNodes = $(iframeSelector).contents()
.find(selectorTxt);
}
if (targetNodes && targetNodes.length > 0) {
btargetsFound = true;
/*--- Found target node(s). Go through each and act if they are new. */
targetNodes.each(function () {
var jThis = $(this);
var alreadyFound = jThis.data('alreadyFound') || false;
if (!alreadyFound) {
//--- Call the payload function.
var cancelFound = actionFunction(jThis);
if (cancelFound) {
btargetsFound = false;
} else {
jThis.data('alreadyFound', true);
}
}
});
} else {
btargetsFound = false;
}
//--- Get the timer-control variable for this selector.
var controlObj = waitForKeyElements.controlObj || {};
var controlKey = selectorTxt.replace(/[^\w]/g, "_");
var timeControl = controlObj[controlKey];
//--- Now set or clear the timer as appropriate.
if (btargetsFound && bWaitOnce && timeControl) {
//--- The only condition where we need to clear the timer.
clearInterval(timeControl);
delete controlObj[controlKey]
} else {
//--- Set a timer, if needed.
if (!timeControl) {
timeControl = setInterval(function () {
waitForKeyElements(selectorTxt,
actionFunction,
bWaitOnce,
iframeSelector
);
},
300
);
controlObj[controlKey] = timeControl;
}
}
waitForKeyElements.controlObj = controlObj;
}
// Local storage for limit prices so that they persist on refresh
var storage = {
setLimitPrice: function (symbol, limitTag, price) {
let key = symbol + '.' + limitTag;
localStorage.setItem(key, price);
},
getLimitPrice: function (symbol, limitTag) {
let key = symbol + '.' + limitTag;
return localStorage.getItem(key);
}
}
class LimitPrice {
constructor(el, type, indicatorStyle, tag, price = null) {
this.el = el;
this.type = type;
this.tag = tag;
this.indicatorStyle = indicatorStyle
this.price = price;
this.el.html(
'<input type="limit-price-textbox" placeholder="$0.00" autocomplete="off" type="text" value=""></input>'
);
this.elTextbox = $(this.el.find('input'));
this.elTextbox.attr('tag', this.tag);
this.elTextbox.val(this.price);
}
getPrice() { return this.price; }
getPriceStr() { return (this.price != null) ? this.price : ''; }
setPrice(price) {
this.price = price;
this.elTextbox.val(price);
}
setIndicator() {
this.elTextbox.attr('style', this.indicatorStyle);
}
clearIndicator() {
this.elTextbox.removeAttr('style');
}
update(currentPrice) {
let priceStr = this.elTextbox.val().toString().replace(/[^0-9\.]+/g, "");
let price = parseFloat(priceStr);
this.price = isNaN(price) ? null : price;
if (this.price != null && currentPrice != null) {
//DEBUG_MODE && console.log("curr: " + currentPrice + " vs limit: " + this.price);
if (this.type == LIMIT_TYPE.MAX) {
(currentPrice <= this.price)
? this.setIndicator()
: this.clearIndicator();
} else { // (this.type == LIMIT_TYPE.MIN)
(currentPrice >= this.price)
? this.setIndicator()
: this.clearIndicator();
}
} else {
this.clearIndicator();
}
}
}
class Stock {
constructor(elTableRow) {
this.elTableRow = elTableRow;
this.elSymbol = $(elTableRow.find('div[role="cell"]')[1]);
this.elPrice = $(elTableRow.find('div[role="cell"]')[2]);
this.symbol = this.elSymbol.text().trim();
this.currentPrice = this.parsePrice();
// Create limit table cells
this.limits = [];
LIMITS_CONFIG.forEach(config => {
let limitPrice = storage.getLimitPrice(this.symbol, config.tag);
DEBUG_MODE && console.log("Init: " + this.symbol + "-" + config.tag + ": " + limitPrice);
this.limits.push(
this.initLimitCell(config.limitType, config.indicatorStyle, config.tag, limitPrice)
);
});
this.initListeners();
// Update once to refresh indicators
this.update();
// debug
debugElement(this.elTableRow, 'green');
}
getPrice() { return this.currentPrice; }
getSymbol() { return this.symbol; }
getCSV() {
return this.symbol + ',' + this.limits.map(limit => limit.getPriceStr()).join(",");
}
setLimitValues(limitValues) {
for (let i = 0; i < limitValues.length; i++)
this.limits[i].setPrice(limitValues[i]);
this.update();
}
parsePrice() {
let priceStr = this.elPrice.text().trim().replace(/[^0-9\.]+/g, "");
return parseFloat(priceStr);
}
initLimitCell(type, indicatorStyle, tag, price = null) {
// Use symbol cell as ref
let cellRef = $(this.elTableRow.find('div[role="cell"]')[1]);
let cell = $(cellRef.clone());
let limitPrice = new LimitPrice(cell.find('span'), type, indicatorStyle, tag, price);
cell.on("click", e => e.preventDefault()); // disable click; otherise redirects to stock page
this.elTableRow.children().first().append(cell);
// debug
debugElement(cell, 'red');
return limitPrice;
}
initListeners() {
let stock = this;
this.elTableRow.on('change DOMSubtreeModified', function (event) {
//console.log("=========")
//console.log(event)
//console.log("---------")
stock.update();
// If the limit price has changed, then update local stograge
if ($(event.target).attr('type') === "limit-price-textbox") {
let tag = $(event.target).attr('tag');
let limitPrice = event.target.value;
storage.setLimitPrice(stock.getSymbol(), tag, limitPrice);
}
});
}
update() {
this.currentPrice = this.parsePrice();
//DEBUG_MODE && console.log(this.symbol + ": " + this.currentPrice);
this.limits.forEach(limit => {
limit.update(this.currentPrice);
});
}
}
function highlightDOM(el, color = 'blue') {
el.css("border", '3px solid ' + color);
}
function debugElement(el, color = 'blue') {
if (DEBUG_MODE) {
console.log("DEGUB ELEMENT START")
console.log(el);
highlightDOM(el, color);
console.log("DEGUB ELEMENT END")
}
}
function initTableHeader(elTableHeader) {
// Use symbol cell as ref
let elCellRef = $(elTableHeader.find('div[role="columnheader"]')[1]);
LIMITS_CONFIG.forEach(limit => {
elTableHeader.append(elCellRef.clone().text(limit.header));
});
// debug
debugElement(elTableHeader, 'blue');
debugElement(elCellRef, 'yellow');
}
function addGetLimitsCsvButton(el) {
el.prepend(
'<div> ' +
' <button id="get-limits-csv" type="button">Get Limits CSV</button>' +
'</div>'
);
let elBtn = $('#get-limits-csv');
elBtn.attr('style',
'background-color: Transparent;' +
' padding: 0.4em 1.2em;' +
' border: 0.125em solid;' +
' border-radius: 0.25em;' +
' margin: 0 0.3em 0 0.3em;' +
' font-weight: 300;' +
' color: var(--rh__text-color);' +
' cursor: pointer; '
),
elBtn.click(function () {
let csvStr = "";
Object.values(stockList).forEach(stock => {
csvStr += stock.getCSV() + '\n';
});
DEBUG_MODE && console.log("Limits CSV:\n" + csvStr);
prompt("Watchlist CSV: (Ctrl+C to copy to clipboard)", csvStr);
});
}
function addSetLimitsCsvButton(el) {
el.prepend(
'<div> ' +
' <button id="set-limits-csv" type="button">Set Limits CSV</button>' +
'</div>'
);
let elBtn = $('#set-limits-csv');
elBtn.attr('style',
'background-color: Transparent;' +
' padding: 0.4em 1.2em;' +
' border: 0.125em solid;' +
' border-radius: 0.25em;' +
' margin: 0 0.3em 0 0.3em;' +
' font-weight: 300;' +
' color: var(--rh__text-color);' +
' cursor: pointer; '
),
elBtn.click(function () {
let csvStrList = prompt("Enter Limits CSV:");
DEBUG_MODE && console.log("Entered Limts CSV: ");
csvStrList.split('\n').forEach(csvStr => {
if (csvStr) {
DEBUG_MODE && console.log(csvStr);
let csvArray = csvStr.split(',');
let symbol = csvArray[0];
let limitValues = csvArray.slice(1);
if (symbol in stockList)
stockList[symbol].setLimitValues(limitValues);
}
});
});
}
/* Start Here! */
// Hide sidebar to create more space for watchlist table
waitForKeyElements('.sidebar-content', function (el) {
el.hide();
}, true);
// Expand table width
waitForKeyElements('.main-container > .row > .col-12', function (el) {
el.removeClass('col-12');
el.addClass('col-18');
}, true);
// Wait for table header and init with desired metrics
waitForKeyElements('.main-container div[role="table"] > div[role="rowgroup"]', function (el) {
let elTableHeader = el.children().first();
initTableHeader(elTableHeader);
}, true);
// Wait for table rows and init stocks
waitForKeyElements('.main-container a[data-testid^="ListTableRow"]', function (el) {
let elTableRow = el;
// init Row
let stock = new Stock(elTableRow);
stockList[stock.getSymbol()] = stock;
}, true);
waitForKeyElements('.main-container button[data-testid="ListDetailHeaderOverflowMenu"]', function (el) {
let elTopRight = el.parent().parent();
addSetLimitsCsvButton(elTopRight);
addGetLimitsCsvButton(elTopRight);
debugElement(elTopRight, 'purple');
}, true);
})();