diff --git a/css/stockmarket.scss b/css/stockmarket.scss index 677111f65..cda0c65c2 100644 --- a/css/stockmarket.scss +++ b/css/stockmarket.scss @@ -7,13 +7,27 @@ p { font-size: $defaultFontSize * 0.8125; } + a { font-size: $defaultFontSize * 0.875; } - h2 { +} + +.stock-market-info-and-purchases { + > h2 { + display: block; margin-top: 10px; margin-left: 10px; + } + + > p { display: block; + margin-left: 10px; + width: 70%; + } + + > a, > button { + margin: 10px; } } @@ -27,19 +41,10 @@ } } -#stock-market-container p { - padding: 6px; - margin: 6px; - width: 70%; -} - -#stock-market-container a { - margin: 10px; -} - #stock-market-watchlist-filter { + margin: 5px 5px 5px 10px; + padding: 4px; width: 50%; - margin-left: 10px; } .stock-market-input { @@ -51,13 +56,25 @@ color: var(--my-font-color); } +.stock-market-price-movement-warning { + border: 1px solid white; + color: red; + margin: 2px; + padding: 2px; +} + .stock-market-position-text { color: #fff; - display: inline-block; + display: block; p { color: #fff; - display: block; + display: inline-block; + margin: 4px; + } + + h3 { + margin: 4px; } } diff --git a/src/StockMarket/StockMarket.jsx b/src/StockMarket/StockMarket.jsx index af88f19ce..38d100929 100644 --- a/src/StockMarket/StockMarket.jsx +++ b/src/StockMarket/StockMarket.jsx @@ -1,14 +1,12 @@ -import { - Order, - OrderTypes, - PositionTypes -} from "./Order"; +import { Order } from "./Order"; import { Stock } from "./Stock"; import { getStockMarket4SDataCost, getStockMarket4STixApiCost } from "./StockMarketCosts"; import { InitStockMetadata } from "./data/InitStockMetadata"; +import { OrderTypes } from "./data/OrderTypes"; +import { PositionTypes } from "./data/PositionTypes"; import { StockSymbols } from "./data/StockSymbols"; import { StockMarketRoot } from "./ui/Root"; @@ -23,7 +21,7 @@ import { dialogBoxCreate } from "../../utils/DialogBox"; import { Reviver } from "../../utils/JSONReviver"; import React from "react"; -import ReactDOm from "react-dom"; +import ReactDOM from "react-dom"; export function placeOrder(stock, shares, price, type, position, workerScript=null) { var tixApi = (workerScript instanceof WorkerScript); @@ -60,7 +58,7 @@ export function cancelOrder(params, workerScript=null) { if (StockMarket["Orders"] == null) {return false;} if (params.order && params.order instanceof Order) { var order = params.order; - //An 'Order' object is passed in + // An 'Order' object is passed in var stockOrders = StockMarket["Orders"][order.stock.symbol]; for (var i = 0; i < stockOrders.length; ++i) { if (order == stockOrders[i]) { @@ -72,7 +70,7 @@ export function cancelOrder(params, workerScript=null) { return false; } else if (params.stock && params.shares && params.price && params.type && params.pos && params.stock instanceof Stock) { - //Order properties are passed in. Need to look for the order + // Order properties are passed in. Need to look for the order var stockOrders = StockMarket["Orders"][params.stock.symbol]; var orderTxt = params.stock.symbol + " - " + params.shares + " @ " + numeralWrapper.format(params.price, '$0.000a'); @@ -124,7 +122,7 @@ function executeOrder(order) { break; } if (res) { - //Remove order from order book + // Remove order from order book for (var i = 0; i < stockOrders.length; ++i) { if (order == stockOrders[i]) { stockOrders.splice(i, 1); @@ -509,14 +507,12 @@ export function processStockPrices(numCycles=1) { processOrders(stock, OrderTypes.LimitSell, PositionTypes.Long); processOrders(stock, OrderTypes.StopBuy, PositionTypes.Long); processOrders(stock, OrderTypes.StopSell, PositionTypes.Short); - displayStockMarketContent(); } else { stock.price /= (1 + av); processOrders(stock, OrderTypes.LimitBuy, PositionTypes.Long); processOrders(stock, OrderTypes.LimitSell, PositionTypes.Short); processOrders(stock, OrderTypes.StopBuy, PositionTypes.Short); processOrders(stock, OrderTypes.StopSell, PositionTypes.Long); - displayStockMarketContent(); } var otlkMagChange = stock.otlkMag * av; @@ -533,9 +529,10 @@ export function processStockPrices(numCycles=1) { stock.otlkMag *= -1; stock.b = !stock.b; } - } } + + displayStockMarketContent(); } //Checks and triggers any orders for the specified stock diff --git a/src/StockMarket/StockMarketHelpers.ts b/src/StockMarket/StockMarketHelpers.ts index ec985fa46..a1ab5af5c 100644 --- a/src/StockMarket/StockMarketHelpers.ts +++ b/src/StockMarket/StockMarketHelpers.ts @@ -13,6 +13,10 @@ import { CONSTANTS } from "../Constants"; export function getBuyTransactionCost(stock: Stock, shares: number, posType: PositionTypes): number | null { if (isNaN(shares) || shares <= 0 || !(stock instanceof Stock)) { return null; } + // Cap the 'shares' arg at the stock's maximum shares. This'll prevent + // hanging in the case when a really big number is passed in + shares = Math.min(shares, stock.maxShares); + const isLong = (posType === PositionTypes.Long); // If the number of shares doesn't trigger a price movement, its a simple calculation @@ -58,6 +62,10 @@ export function getBuyTransactionCost(stock: Stock, shares: number, posType: Pos export function getSellTransactionGain(stock: Stock, shares: number, posType: PositionTypes): number | null { if (isNaN(shares) || shares <= 0 || !(stock instanceof Stock)) { return null; } + // Cap the 'shares' arg at the stock's maximum shares. This'll prevent + // hanging in the case when a really big number is passed in + shares = Math.min(shares, stock.maxShares); + const isLong = (posType === PositionTypes.Long); // If the number of shares doesn't trigger a price mvoement, its a simple calculation diff --git a/src/StockMarket/ui/InfoAndPurchases.tsx b/src/StockMarket/ui/InfoAndPurchases.tsx index 9158eb638..11b33cad5 100644 --- a/src/StockMarket/ui/InfoAndPurchases.tsx +++ b/src/StockMarket/ui/InfoAndPurchases.tsx @@ -39,6 +39,16 @@ export class InfoAndPurchases extends React.Component { this.purchase4SMarketDataTixApiAccess = this.purchase4SMarketDataTixApiAccess.bind(this); } + shouldComponentUpdate(nextProps: IProps) { + // This only need to rerender if the player has purchased something new + if (this.props.p.hasWseAccount !== nextProps.p.hasWseAccount) { return true; } + if (this.props.p.hasTixApiAccess !== nextProps.p.hasTixApiAccess) { return true; } + if (this.props.p.has4SData !== nextProps.p.has4SData) { return true; } + if (this.props.p.has4SDataTixApi !== nextProps.p.has4SDataTixApi) { return true; } + + return false; + } + handleClick4SMarketDataHelpTip() { dialogBoxCreate( "Access to the 4S Market Data feed will display two additional pieces " + @@ -179,15 +189,19 @@ export class InfoAndPurchases extends React.Component { render() { const documentationLink = "https://bitburner.readthedocs.io/en/latest/basicgameplay/stockmarket.html"; return ( -
-

Welcome to the World Stock Exchange (WSE)!



+
+

Welcome to the World Stock Exchange (WSE)!

+ +

- To begin trading, you must first purchase an account. + To begin trading, you must first purchase an account:

{this.renderPurchaseWseAccountButton()} - - Investopedia - +

Trade Information eXchange (TIX) API

TIX, short for Trade Information eXchange, is the communications protocol @@ -208,7 +222,7 @@ export class InfoAndPurchases extends React.Component {

Commission Fees: Every transaction you make has a {numeralWrapper.formatMoney(CONSTANTS.StockMarketCommission)} commission fee. -

+


WARNING: When you reset after installing Augmentations, the Stock Market is reset. You will retain your WSE Account, access to the diff --git a/src/StockMarket/ui/StockTicker.tsx b/src/StockMarket/ui/StockTicker.tsx index f474cc3ee..8be9c3b06 100644 --- a/src/StockMarket/ui/StockTicker.tsx +++ b/src/StockMarket/ui/StockTicker.tsx @@ -10,12 +10,18 @@ import { StockTickerTxButton } from "./StockTickerTxButton"; import { Order } from "../Order"; import { Stock } from "../Stock"; +import { + getBuyTransactionCost, + getSellTransactionGain, +} from "../StockMarketHelpers"; import { OrderTypes } from "../data/OrderTypes"; import { PositionTypes } from "../data/PositionTypes"; import { CONSTANTS } from "../../Constants"; import { IPlayer } from "../../PersonObjects/IPlayer"; import { SourceFileFlags } from "../../SourceFile/SourceFileFlags"; +import { numeralWrapper } from "../../ui/numeralFormat"; +import { Accordion } from "../../ui/React/Accordion"; import { dialogBoxCreate } from "../../../utils/DialogBox"; import { @@ -63,8 +69,11 @@ export class StockTicker extends React.Component { qty: "", } + this.getBuyTransactionCostText = this.getBuyTransactionCostText.bind(this); + this.getSellTransactionCostText = this.getSellTransactionCostText.bind(this); this.handleBuyButtonClick = this.handleBuyButtonClick.bind(this); this.handleBuyMaxButtonClick = this.handleBuyMaxButtonClick.bind(this); + this.handleHeaderClick = this.handleHeaderClick.bind(this); this.handleOrderTypeChange = this.handleOrderTypeChange.bind(this); this.handlePositionTypeChange = this.handlePositionTypeChange.bind(this); this.handleQuantityChange = this.handleQuantityChange.bind(this); @@ -96,8 +105,46 @@ export class StockTicker extends React.Component { yesNoTxtInpBoxCreate(popupTxt); } + getBuyTransactionCostText(): string { + const stock = this.props.stock; + const qty: number = this.getQuantity(); + if (isNaN(qty)) { return ""; } + const cost = getBuyTransactionCost(this.props.stock, qty, this.state.position); + if (cost == null) { return ""; } + + let costTxt = `Purchasing ${numeralWrapper.formatBigNumber(qty)} shares will cost ${numeralWrapper.formatMoney(cost)}. `; + + const causesMovement = qty > stock.shareTxUntilMovement; + if (causesMovement) { + costTxt += `WARNING: Purchasing this many shares will influence the stock price`; + } + + return costTxt; + } + + getQuantity(): number { + return Math.round(parseFloat(this.state.qty)); + } + + getSellTransactionCostText(): string { + const stock = this.props.stock; + const qty: number = this.getQuantity(); + if (isNaN(qty)) { return ""; } + const cost = getSellTransactionGain(this.props.stock, qty, this.state.position); + if (cost == null) { return ""; } + + let costTxt = `Selling ${numeralWrapper.formatBigNumber(qty)} shares will result in a gain of ${numeralWrapper.formatMoney(cost)}. `; + + const causesMovement = qty > stock.shareTxUntilMovement; + if (causesMovement) { + costTxt += `WARNING: Selling this many shares will influence the stock price`; + } + + return costTxt; + } + handleBuyButtonClick() { - const shares = parseInt(this.state.qty); + const shares = this.getQuantity(); if (isNaN(shares)) { dialogBoxCreate(`Invalid input for quantity (number of shares): ${this.state.qty}`); return; @@ -178,6 +225,18 @@ export class StockTicker extends React.Component { } } + handleHeaderClick(e: React.MouseEvent) { + const elem = e.currentTarget; + elem.classList.toggle("active"); + + const panel: HTMLElement = elem.nextElementSibling as HTMLElement; + if (panel!.style.display === "block") { + panel!.style.display = "none"; + } else { + panel.style.display = "block"; + } + } + handleOrderTypeChange(e: React.ChangeEvent) { const val = e.target.value; @@ -224,7 +283,7 @@ export class StockTicker extends React.Component { } handleSellButtonClick() { - const shares = parseInt(this.state.qty); + const shares = this.getQuantity(); if (isNaN(shares)) { dialogBoxCreate(`Invalid input for quantity (number of shares): ${this.state.qty}`); return; @@ -294,49 +353,68 @@ export class StockTicker extends React.Component { } render() { + // Determine if the player's intended transaction will cause a price movement + let causesMovement: boolean = false; + const qty = this.getQuantity(); + if (!isNaN(qty)) { + causesMovement = qty > this.props.stock.shareTxUntilMovement; + } + return (

  • - -
    - - - + + } + panelContent={ +
    + + + - - - - - - -
    + + + + + { + causesMovement && +

    + WARNING: Buying/Selling {numeralWrapper.formatBigNumber(qty)} shares will affect + the stock's price. This applies during the transaction itself as well. See Investopedia + for more details. +

    + } + + +
    + } + />
  • ) } diff --git a/src/StockMarket/ui/StockTickerPositionText.tsx b/src/StockMarket/ui/StockTickerPositionText.tsx index 36107cb00..362709259 100644 --- a/src/StockMarket/ui/StockTickerPositionText.tsx +++ b/src/StockMarket/ui/StockTickerPositionText.tsx @@ -37,18 +37,16 @@ export class StockTickerPositionText extends React.Component { Shares in the long position will increase in value if the price of the corresponding stock increases - +

    Shares: {numeralWrapper.format(stock.playerShares, "0,0")} -

    +


    - Average Price: {numeralWrapper.formatMoney(stock.playerAvgPx)} - (Total Cost: {numeralWrapper.formatMoney(totalCost)}) -

    + Average Price: {numeralWrapper.formatMoney(stock.playerAvgPx)} (Total Cost: {numeralWrapper.formatMoney(totalCost)}) +


    - Profit: {numeralWrapper.formatMoney(gains)} - ({numeralWrapper.formatPercentage(percentageGains)}) -

    + Profit: {numeralWrapper.formatMoney(gains)} ({numeralWrapper.formatPercentage(percentageGains)}) +


    ) } @@ -71,18 +69,16 @@ export class StockTickerPositionText extends React.Component { Shares in the short position will increase in value if the price of the corresponding stock decreases - +

    Shares: {numeralWrapper.format(stock.playerShortShares, "0,0")} -

    +


    - Average Price: {numeralWrapper.formatMoney(stock.playerAvgShortPx)} - (Total Cost: {numeralWrapper.formatMoney(totalCost)}) -

    + Average Price: {numeralWrapper.formatMoney(stock.playerAvgShortPx)} (Total Cost: {numeralWrapper.formatMoney(totalCost)}) +


    - Profit: {numeralWrapper.formatMoney(gains)} - ({numeralWrapper.formatPercentage(percentageGains)}) -

    + Profit: {numeralWrapper.formatMoney(gains)} ({numeralWrapper.formatPercentage(percentageGains)}) +


    ) } else { @@ -96,15 +92,16 @@ export class StockTickerPositionText extends React.Component { return (

    - Max Shares: ${numeralWrapper.formatMoney(stock.maxShares)} + Max Shares: {numeralWrapper.formatBigNumber(stock.maxShares)}

    Ask Price: {numeralWrapper.formatMoney(stock.getAskPrice())} See Investopedia for details on what this is -

    +


    + Bid Price: {numeralWrapper.formatMoney(stock.getBidPrice())} See Investopedia for details on what this is diff --git a/src/StockMarket/ui/StockTickerTxButton.tsx b/src/StockMarket/ui/StockTickerTxButton.tsx index e15a730b1..bc656d2d9 100644 --- a/src/StockMarket/ui/StockTickerTxButton.tsx +++ b/src/StockMarket/ui/StockTickerTxButton.tsx @@ -7,12 +7,35 @@ import * as React from "react"; type IProps = { onClick: () => void; text: string; + tooltip?: string; +} + +type IInnerHTMLMarkup = { + __html: string; } export function StockTickerTxButton(props: IProps): React.ReactElement { + let className = "stock-market-input std-button"; + + const hasTooltip = (typeof props.tooltip === "string" && props.tooltip !== ""); + if (hasTooltip) { + className += " tooltip"; + } + + let tooltipMarkup: IInnerHTMLMarkup | null; + if (hasTooltip) { + tooltipMarkup = { + __html: props.tooltip! + } + } + return ( - ) } diff --git a/src/StockMarket/ui/StockTickers.tsx b/src/StockMarket/ui/StockTickers.tsx index 98bea19cd..83f943127 100644 --- a/src/StockMarket/ui/StockTickers.tsx +++ b/src/StockMarket/ui/StockTickers.tsx @@ -75,6 +75,10 @@ export class StockTickers extends React.Component { this.setState({ watchlistSymbols: sanitizedWatchlist.split(","), }); + } else { + this.setState({ + watchlistSymbols: [], + }); } } @@ -91,8 +95,9 @@ export class StockTickers extends React.Component { for (const stockMarketProp in this.props.stockMarket) { const val = this.props.stockMarket[stockMarketProp]; if (val instanceof Stock) { + // Skip if there's a filter and the stock isnt in that filter if (this.state.watchlistSymbols.length > 0 && !this.state.watchlistSymbols.includes(val.symbol)) { - continue; // Not in watchlist + continue; } let orders = this.props.stockMarket.Orders[val.symbol]; @@ -100,11 +105,19 @@ export class StockTickers extends React.Component { orders = []; } + // Skip if we're in portfolio mode and the player doesnt own this or have any active orders + if (this.state.tickerDisplayMode === TickerDisplayMode.Portfolio) { + if (val.playerShares === 0 && val.playerShortShares === 0 && orders.length === 0) { + continue; + } + } + tickers.push( { + constructor(props: IProps) { + super(props); + + this.handleHeaderClick = this.handleHeaderClick.bind(this); + + this.state = { + panelOpened: false, + } + } + + handleHeaderClick(e: React.MouseEvent) { + const elem = e.currentTarget; + elem.classList.toggle("active"); + + const panel: HTMLElement = elem.nextElementSibling as HTMLElement; + if (panel!.style.display === "block") { + panel!.style.display = "none"; + this.setState({ + panelOpened: false, + }); + } else { + panel!.style.display = "block"; + this.setState({ + panelOpened: true, + }); + } + } + + render() { + return ( +

    + + +
    + ) + } +} + +type IPanelProps = { + opened: boolean; + panelContent: React.ReactElement; +} + +class AccordionPanel extends React.Component { + shouldComponentUpdate(nextProps: IPanelProps) { + return this.props.opened || nextProps.opened; + } + + render() { + return ( +
    + {this.props.panelContent} +
    + ) + } +}