def test_apply_dividends_to_games(self): user_id = 1 date = datetime_to_posix(dt.now().replace(hour=0, minute=0, second=0, microsecond=0)) amzn_dividend = 10 tsla_dividend = 20 envidia_dividend = 15 fake_dividends = ([['AMZN', 'Amazon INC', amzn_dividend, date], ['TSLA', 'Tesla Motors', tsla_dividend, date], ['NVDA', 'Envidia SA', envidia_dividend, date]]) fake_dividends = pd.DataFrame( fake_dividends, columns=['symbol', 'company', 'amount', 'exec_date']) insert_dividends_to_db(fake_dividends) user_1_game_8_balance = get_current_game_cash_balance(user_id, 8) user_1_game_3_balance = get_current_game_cash_balance(user_id, 3) should_remain_1_000_000 = get_current_game_cash_balance(user_id, 6) apply_dividends_to_stocks() # user 1, game 8 is holding NVDA. we should see their holding * the dividend in their updated cash balance envidia_holding = get_current_stock_holding(user_id, 8, "NVDA") self.assertEqual( get_current_game_cash_balance(user_id, 8) - user_1_game_8_balance, envidia_holding * envidia_dividend) # user 1, game 3 is holding AMZN, TSLA, and NVDA. Their change in cash balance should be equal to the sum of # each position * its corresponding dividend active_balances = get_active_balances(3, user_id) merged_table = fake_dividends.merge(active_balances, how="left", on="symbol") merged_table[ "payout"] = merged_table["amount"] * merged_table["balance"] total_dividend = merged_table["payout"].sum() self.assertEqual( get_current_game_cash_balance(user_id, 3) - user_1_game_3_balance, total_dividend) # user 1 isn't holding any dividend-paying shares in game 6. they should have no cash balance change self.assertEqual(get_current_game_cash_balance(user_id, 6), should_remain_1_000_000)
def api_place_order(): """Placing an order involves several layers of conditional logic: is this is a buy or sell order? Stop, limit, or market? Do we either have the adequate cash on hand, or enough of a position in the stock for this order to be valid? Here an order_ticket from the frontend--along with the user_id tacked on during the API call--gets decoded, checked for validity, and booked. Market orders are fulfilled in the same step. Stop/limit orders are monitored on an ongoing basis by the celery schedule and book as their requirements are satisfies""" user_id = decode_token(request) order_ticket = request.json game_id = order_ticket["game_id"] stop_limit_price = order_ticket.get("stop_limit_price") if stop_limit_price: stop_limit_price = float(stop_limit_price) try: symbol = order_ticket["symbol"].upper() # ensure upper casing market_price, last_updated = fetch_price(symbol) async_cache_price.delay(symbol, market_price, last_updated) cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, symbol) order_id = place_order( user_id, game_id, symbol, order_ticket["buy_or_sell"], cash_balance, current_holding, order_ticket["order_type"], order_ticket["quantity_type"], market_price, float(order_ticket["amount"]), order_ticket["time_in_force"], stop_limit_price) except Exception as e: return make_response(str(e), 400) serialize_and_pack_pending_orders(game_id, user_id) add_fulfilled_order_entry(game_id, user_id, order_id) serialize_and_pack_portfolio_details(game_id, user_id) return make_response(ORDER_PLACED_MESSAGE, 200)
def test_async_process_all_open_orders(self, base_time_mock): base_time_mock.time.return_value = 1596738863.111858 user_id = 1 game_id = 3 _user_ids = get_active_game_user_ids(game_id) for _user_id in _user_ids: no_pending_orders_table(game_id, _user_id) no_fulfilled_orders_table(game_id, _user_id) # Place a guaranteed-to-clear order buy_stock = "MSFT" mock_buy_order = {"amount": 1, "buy_or_sell": "buy", "game_id": 3, "order_type": "stop", "stop_limit_price": 1, "market_price": 0.5, "quantity_type": "Shares", "symbol": buy_stock, "time_in_force": "until_cancelled" } current_cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, buy_stock) place_order(user_id, game_id, mock_buy_order["symbol"], mock_buy_order["buy_or_sell"], current_cash_balance, current_holding, mock_buy_order["order_type"], mock_buy_order["quantity_type"], mock_buy_order["market_price"], mock_buy_order["amount"], mock_buy_order["time_in_force"], mock_buy_order["stop_limit_price"]) # Place a guaranteed-to-clear order buy_stock = "AAPL" mock_buy_order = {"amount": 1, "buy_or_sell": "buy", "game_id": 3, "order_type": "stop", "stop_limit_price": 1, "market_price": 0.5, "quantity_type": "Shares", "symbol": buy_stock, "time_in_force": "until_cancelled" } current_cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, buy_stock) place_order(user_id, game_id, mock_buy_order["symbol"], mock_buy_order["buy_or_sell"], current_cash_balance, current_holding, mock_buy_order["order_type"], mock_buy_order["quantity_type"], mock_buy_order["market_price"], mock_buy_order["amount"], mock_buy_order["time_in_force"], mock_buy_order["stop_limit_price"]) open_orders = get_all_open_orders(game_id) starting_open_orders = len(open_orders) self.assertEqual(starting_open_orders, 4) for order_id, _ in open_orders.items(): process_order(order_id) new_open_orders = get_all_open_orders(game_id) self.assertLessEqual(starting_open_orders - len(new_open_orders), 4)
def test_play_game_tasks(self): start_time = time.time() game_title = "lucky few" creator_id = 1 mock_game = { "creator_id": creator_id, "title": game_title, "game_mode": "multi_player", "duration": 180, "buy_in": 100, "benchmark": "return_ratio", "side_bets_perc": 50, "side_bets_period": "weekly", "invitees": ["miguel", "murcitdev", "toofast"], "invite_window": DEFAULT_INVITE_OPEN_WINDOW, "stakes": "monopoly" } game_id = add_game( mock_game["creator_id"], mock_game["title"], mock_game["game_mode"], mock_game["duration"], mock_game["benchmark"], mock_game["stakes"], mock_game["buy_in"], mock_game["side_bets_perc"], mock_game["side_bets_period"], mock_game["invitees"], mock_game["invite_window"] ) game_entry = query_to_dict("SELECT * FROM games WHERE title = %s", game_title)[0] # Check the game entry table # OK for these results to shift with the test fixtures self.assertEqual(game_entry["id"], game_id) for k, v in mock_game.items(): if k == "invitees": continue if k == "duration": continue if k == "invite_window": self.assertAlmostEqual(game_entry[k], start_time + DEFAULT_INVITE_OPEN_WINDOW * SECONDS_IN_A_DAY, 3) continue self.assertAlmostEqual(game_entry[k], v, 1) # Confirm that game status was updated as expected # ------------------------------------------------ game_status_entry = query_to_dict("SELECT * FROM game_status WHERE game_id = %s", game_id)[0] self.assertEqual(game_status_entry["id"], 17) self.assertEqual(game_status_entry["game_id"], game_id) self.assertEqual(game_status_entry["status"], "pending") users_from_db = json.loads(game_status_entry["users"]) self.assertEqual(set(users_from_db), {3, 4, 5, 1}) # and that the game invites table is working as well # -------------------------------------------------- with self.engine.connect() as conn: game_invites_df = pd.read_sql("SELECT * FROM game_invites WHERE game_id = %s", conn, params=[game_id]) self.assertEqual(game_invites_df.shape, (4, 5)) for _, row in game_invites_df.iterrows(): self.assertIn(row["user_id"], users_from_db) status = "invited" if row["user_id"] == creator_id: status = "joined" self.assertEqual(row["status"], status) # less than a two-second difference between when we sent the data and when it was logged. If the local # celery worked is gummed up and not working properly this can fail self.assertTrue(row["timestamp"] - start_time < 2) # we'll mock in a time value for the current game in a moment, but first check that async_service_open_games is # working as expected with self.engine.connect() as conn: gi_count_pre = conn.execute("SELECT COUNT(*) FROM game_invites;").fetchone()[0] open_game_ids = get_open_game_ids_past_window() for _id in open_game_ids: service_open_game(_id) with self.engine.connect() as conn: gi_count_post = conn.execute("SELECT COUNT(*) FROM game_invites;").fetchone()[0] self.assertEqual(gi_count_post - gi_count_pre, 4) with self.engine.connect() as conn: df = pd.read_sql("SELECT game_id, user_id, status FROM game_invites WHERE game_id in (1, 2)", conn) self.assertEqual(df[df["user_id"] == 5]["status"].to_list(), ["invited", "expired"]) self.assertEqual(df[(df["user_id"] == 3) & (df["game_id"] == 2)]["status"].to_list(), ["joined"]) # murcitdev is going to decline to play, toofast and miguel will play and receive their virtual cash balances # ----------------------------------------------------------------------------------------------------------- for user_id in [3, 4]: respond_to_game_invite(game_id, user_id, "joined", time.time()) respond_to_game_invite(game_id, 5, "declined", time.time()) # So far so good. Pretend that we're now past the invite open window and it's time to play # ---------------------------------------------------------------------------------------- game_start_time = time.time() + DEFAULT_INVITE_OPEN_WINDOW * SECONDS_IN_A_DAY + 1 with patch("backend.logic.games.time") as mock_time: # users have joined, and we're past the invite window mock_time.time.return_value = game_start_time open_game_ids = get_open_game_ids_past_window() for _id in open_game_ids: service_open_game(_id) with self.engine.connect() as conn: # Verify game updated to active status and active players game_status = conn.execute( "SELECT status, users FROM game_status WHERE game_id = %s ORDER BY id DESC LIMIT 0, 1", game_id).fetchone() self.assertEqual(game_status[0], "active") self.assertEqual(len(set(json.loads(game_status[1])) - {1, 3, 4}), 0) # Verify that we have three plays for game 5 with $1,000,000 virtual cash balances res = conn.execute( "SELECT balance FROM game_balances WHERE game_id = %s AND balance_type = 'virtual_cash';", game_id).fetchall() balances = [x[0] for x in res] self.assertIs(len(balances), 3) self.assertTrue(all([x == STARTING_VIRTUAL_CASH for x in balances])) # For now I've tried to keep things simple and divorce the ordering part of the integration test from game # startup. May need to close the loop on this later when expanding the test to cover payouts game_start_time = 1590508896 # Place two market orders and a buy limit order with patch("backend.logic.games.time") as mock_game_time, patch( "backend.logic.base.time") as mock_data_time: mock_game_time.time.return_value = mock_data_time.time.return_value = game_start_time + 300 # Everything working as expected. Place a couple buy orders to get things started stock_pick = "AMZN" user_id = 1 order_quantity = 500_000 amzn_price, _ = fetch_price(stock_pick) cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, stock_pick) place_order( user_id=user_id, game_id=game_id, symbol=stock_pick, buy_or_sell="buy", cash_balance=cash_balance, current_holding=current_holding, order_type="market", quantity_type="USD", market_price=amzn_price, amount=order_quantity, time_in_force="day" ) original_amzn_holding = get_current_stock_holding(user_id, game_id, stock_pick) updated_cash = get_current_game_cash_balance(user_id, game_id) expected_quantity = order_quantity // amzn_price expected_cost = expected_quantity * amzn_price self.assertEqual(original_amzn_holding, expected_quantity) test_user_original_cash = STARTING_VIRTUAL_CASH - expected_cost self.assertAlmostEqual(updated_cash, test_user_original_cash, 2) stock_pick = "MELI" user_id = 4 order_quantity = 600 meli_price = 600 cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, stock_pick) place_order( user_id=user_id, game_id=game_id, symbol=stock_pick, buy_or_sell="buy", cash_balance=cash_balance, current_holding=current_holding, order_type="market", quantity_type="Shares", market_price=meli_price, amount=order_quantity, time_in_force="day" ) original_meli_holding = get_current_stock_holding(user_id, game_id, stock_pick) original_miguel_cash = get_current_game_cash_balance(user_id, game_id) self.assertEqual(original_meli_holding, order_quantity) miguel_cash = STARTING_VIRTUAL_CASH - order_quantity * meli_price self.assertAlmostEqual(original_miguel_cash, miguel_cash, 2) stock_pick = "NVDA" user_id = 3 order_quantity = 1420 nvda_limit_ratio = 0.95 nvda_price = 350 stop_limit_price = nvda_price * nvda_limit_ratio cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, stock_pick) place_order( user_id=user_id, game_id=game_id, symbol=stock_pick, buy_or_sell="buy", cash_balance=cash_balance, current_holding=current_holding, order_type="limit", quantity_type="Shares", market_price=nvda_price, amount=order_quantity, time_in_force="until_cancelled", stop_limit_price=stop_limit_price ) updated_holding = get_current_stock_holding(user_id, game_id, stock_pick) updated_cash = get_current_game_cash_balance(user_id, game_id) self.assertEqual(updated_holding, 0) self.assertEqual(updated_cash, STARTING_VIRTUAL_CASH) with patch("backend.logic.games.fetch_price") as mock_price_fetch, patch( "backend.logic.base.time") as mock_base_time, patch("backend.logic.games.time") as mock_game_time: order_clear_price = stop_limit_price - 5 amzn_stop_ratio = 0.9 meli_limit_ratio = 1.1 mock_price_fetch.side_effect = [ (order_clear_price, None), (amzn_stop_ratio * amzn_price - 1, None), (meli_limit_ratio * meli_price + 1, None), ] mock_game_time.time.side_effect = [ game_start_time + 24 * 60 * 60, # NVDA order from above game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60 + 1000, # AMZN order needs to clear on the same day game_start_time + 48 * 60 * 60, # MELI order is open until being cancelled game_start_time + 48 * 60 * 60, game_start_time + 48 * 60 * 60, game_start_time + 48 * 60 * 60, game_start_time + 48 * 60 * 60, ] mock_base_time.time.side_effect = [ game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60, game_start_time + 24 * 60 * 60 + 1000, game_start_time + 24 * 60 * 60 + 1000, game_start_time + 24 * 60 * 60 + 1000, game_start_time + 48 * 60 * 60, game_start_time + 48 * 60 * 60, game_start_time + 48 * 60 * 60, game_start_time + 48 * 60 * 60, ] # First let's go ahead and clear that last transaction that we had above with self.engine.connect() as conn: open_order_id = conn.execute(""" SELECT id FROM orders WHERE user_id = %s AND game_id = %s AND symbol = %s;""", user_id, game_id, stock_pick).fetchone()[0] process_order(open_order_id) updated_holding = get_current_stock_holding(user_id, game_id, stock_pick) updated_cash = get_current_game_cash_balance(user_id, game_id) self.assertEqual(updated_holding, order_quantity) self.assertAlmostEqual(updated_cash, STARTING_VIRTUAL_CASH - order_clear_price * order_quantity, 3) # Now let's go ahead and place stop-loss and stop-limit orders against existing positions stock_pick = "AMZN" user_id = 1 order_quantity = 250_000 cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, stock_pick) stop_limit_price = amzn_stop_ratio * amzn_price place_order( user_id=user_id, game_id=game_id, symbol=stock_pick, buy_or_sell="sell", cash_balance=cash_balance, current_holding=current_holding, order_type="stop", quantity_type="USD", market_price=stop_limit_price + 10, amount=order_quantity, time_in_force="day", stop_limit_price=stop_limit_price ) amzn_sales_entry = query_to_dict(""" SELECT id, price, quantity FROM orders WHERE user_id = %s AND game_id = %s AND symbol = %s ORDER BY id DESC LIMIT 0, 1; """, user_id, game_id, stock_pick)[0] stock_pick = "MELI" user_id = 4 miguel_order_quantity = 300 cash_balance = get_current_game_cash_balance(user_id, game_id) current_holding = get_current_stock_holding(user_id, game_id, stock_pick) place_order( user_id=user_id, game_id=game_id, symbol=stock_pick, buy_or_sell="sell", cash_balance=cash_balance, current_holding=current_holding, order_type="limit", quantity_type="Shares", market_price=meli_limit_ratio * meli_price - 10, amount=miguel_order_quantity, time_in_force="until_cancelled", stop_limit_price=meli_limit_ratio * meli_price ) with self.engine.connect() as conn: meli_open_order_id = conn.execute(""" SELECT id FROM orders WHERE user_id = %s AND game_id = %s AND symbol = %s ORDER BY id DESC LIMIT 0, 1;""", user_id, game_id, stock_pick).fetchone()[0] process_order(amzn_sales_entry["id"]) process_order(meli_open_order_id) with self.engine.connect() as conn: query = """ SELECT o.user_id, o.id, o.symbol, os.clear_price FROM orders o INNER JOIN order_status os ON o.id = os.order_id WHERE os.status = 'fulfilled' AND game_id = %s; """ df = pd.read_sql(query, conn, params=[game_id]) test_user_id = 1 test_user_stock = "AMZN" updated_holding = get_current_stock_holding(test_user_id, game_id, test_user_stock) updated_cash = get_current_game_cash_balance(test_user_id, game_id) amzn_clear_price = df[df["id"] == amzn_sales_entry["id"]].iloc[0]["clear_price"] # This test is a little bit awkward because of how we are handling floating point for prices and # doing integer round here. This may need to become more precise in the future self.assertEqual(updated_holding, original_amzn_holding - amzn_sales_entry["quantity"]) self.assertAlmostEqual(updated_cash, test_user_original_cash + amzn_sales_entry["quantity"] * amzn_clear_price, 2) test_user_id = 4 test_user_stock = "MELI" updated_holding = get_current_stock_holding(test_user_id, game_id, test_user_stock) updated_cash = get_current_game_cash_balance(test_user_id, game_id) meli_clear_price = df[df["id"] == meli_open_order_id].iloc[0]["clear_price"] shares_sold = miguel_order_quantity self.assertEqual(updated_holding, original_meli_holding - shares_sold) self.assertAlmostEqual(updated_cash, original_miguel_cash + shares_sold * meli_clear_price, 2) # if all users leave at the end of a game, that game should shut down leave_game(game_id, 1) leave_game(game_id, 3) leave_game(game_id, 4) game_status_entry = query_to_dict( "SELECT * FROM game_status WHERE game_id = %s ORDER BY id DESC LIMIT 0, 1;", game_id)[0] self.assertEqual(game_status_entry["status"], "cancelled") self.assertEqual(json.loads(game_status_entry["users"]), []) game_invites_entries = query_to_dict( "SELECT * FROM game_invites WHERE game_id = %s ORDER BY id DESC LIMIT 0, 3;", game_id) for entry in game_invites_entries: self.assertEqual(entry["status"], "left")
def _start_game_runner(self, start_time, game_id): s3_cache.flushall() user_statuses = get_user_invite_statuses_for_pending_game(game_id) pending_user_usernames = [ x["username"] for x in user_statuses if x["status"] == "invited" ] pending_user_ids = get_user_ids(pending_user_usernames) # get all user IDs for the game. For this test case everyone is going or accept with self.engine.connect() as conn: result = conn.execute( "SELECT DISTINCT user_id FROM game_invites WHERE game_id = %s", game_id).fetchall() all_ids = [x[0] for x in result] self.user_id = all_ids[0] # all users accept their game invite with patch("backend.logic.games.time") as game_time_mock, patch( "backend.logic.base.time") as base_time_mock: game_time_mock.time.return_value = start_time time = Mock() time.time.return_value = base_time_mock.time.return_value = start_time for user_id in pending_user_ids: respond_to_game_invite(game_id, user_id, "joined", time.time()) # check that we have the balances that we expect sql = "SELECT balance, user_id from game_balances WHERE game_id = %s;" with self.engine.connect() as conn: df = pd.read_sql(sql, conn, params=[game_id]) self.assertTrue(df.shape, (0, 2)) sql = "SELECT balance, user_id from game_balances WHERE game_id = %s;" with self.engine.connect() as conn: df = pd.read_sql(sql, conn, params=[game_id]) self.assertTrue(df.shape, (4, 2)) # a couple things should have just happened here. We expect to have the following assets available to us # now in our redis cache: (1) an empty open orders table for each user, (2) an empty current balances table for # each user, (3) an empty field chart for each user, (4) an empty field chart, and (5) an initial game stats # list cache_keys = s3_cache.keys() self.assertIn(f"{game_id}/{LEADERBOARD_PREFIX}", cache_keys) self.assertIn(f"{game_id}/{FIELD_CHART_PREFIX}", cache_keys) for user_id in all_ids: self.assertIn(f"{game_id}/{user_id}/{CURRENT_BALANCES_PREFIX}", cache_keys) self.assertIn(f"{game_id}/{user_id}/{PENDING_ORDERS_PREFIX}", cache_keys) self.assertIn(f"{game_id}/{user_id}/{FULFILLED_ORDER_PREFIX}", cache_keys) self.assertIn(f"{game_id}/{user_id}/{BALANCES_CHART_PREFIX}", cache_keys) self.assertIn(f"{game_id}/{user_id}/{ORDER_PERF_CHART_PREFIX}", cache_keys) # quickly verify the structure of the chart assets. They should be blank, with transparent colors field_chart = s3_cache.unpack_s3_json( f"{game_id}/{FIELD_CHART_PREFIX}") self.assertEqual(len(field_chart["datasets"]), len(all_ids)) chart_colors = [x["backgroundColor"] for x in field_chart["datasets"]] self.assertTrue(all([x == NULL_RGBA for x in chart_colors])) leaderboard = s3_cache.unpack_s3_json( f"{game_id}/{LEADERBOARD_PREFIX}") self.assertEqual(len(leaderboard["records"]), len(all_ids)) self.assertTrue( all([ x["cash_balance"] == STARTING_VIRTUAL_CASH for x in leaderboard["records"] ])) a_current_balance_table = s3_cache.unpack_s3_json( f"{game_id}/{self.user_id}/{CURRENT_BALANCES_PREFIX}") self.assertEqual(a_current_balance_table["data"], []) an_open_orders_table = s3_cache.unpack_s3_json( f"{game_id}/{self.user_id}/{PENDING_ORDERS_PREFIX}") self.assertEqual(an_open_orders_table["data"], []) a_fulfilled_orders_table = s3_cache.unpack_s3_json( f"{game_id}/{self.user_id}/{FULFILLED_ORDER_PREFIX}") self.assertEqual(a_fulfilled_orders_table["data"], []) a_balances_chart = s3_cache.unpack_s3_json( f"{game_id}/{self.user_id}/{BALANCES_CHART_PREFIX}") self.assertEqual(len(a_balances_chart["datasets"]), 1) self.assertEqual(a_balances_chart["datasets"][0]["label"], "Cash") self.assertEqual(a_balances_chart["datasets"][0]["backgroundColor"], NULL_RGBA) # now have a user put an order. It should go straight to the queue and be reflected in the open orders table, # but they should not have any impact on the user's balances if the order is placed outside of trading day self.stock_pick = "TSLA" self.market_price = 1_000 with patch("backend.logic.games.time") as game_time_mock, patch( "backend.logic.base.time") as base_time_mock: game_time_mock.time.side_effect = [start_time + 1] * 2 base_time_mock.time.return_value = start_time + 1 stock_pick = self.stock_pick cash_balance = get_current_game_cash_balance(self.user_id, game_id) current_holding = get_current_stock_holding( self.user_id, game_id, stock_pick) order_id = place_order(user_id=self.user_id, game_id=game_id, symbol=self.stock_pick, buy_or_sell="buy", cash_balance=cash_balance, current_holding=current_holding, order_type="market", quantity_type="Shares", market_price=self.market_price, amount=1, time_in_force="day") serialize_and_pack_pending_orders(game_id, self.user_id) add_fulfilled_order_entry(game_id, self.user_id, order_id) serialize_and_pack_portfolio_details(game_id, self.user_id)