aboutsummaryrefslogtreecommitdiff
path: root/internal/game/crypto.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/crypto.go')
-rw-r--r--internal/game/crypto.go681
1 files changed, 681 insertions, 0 deletions
diff --git a/internal/game/crypto.go b/internal/game/crypto.go
new file mode 100644
index 0000000..08a6b64
--- /dev/null
+++ b/internal/game/crypto.go
@@ -0,0 +1,681 @@
+package game
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "thehouseoficarus/internal/cryptomarket"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func cryptoUseTarget(s string) bool {
+ switch s {
+ case "crypto", "cryptocurrency", "crypto exchange":
+ return true
+ }
+ return false
+}
+func (g *Game) atCryptoCounter(p *player.Player) bool {
+ if p == nil || g.World == nil {
+ return false
+ }
+ r, e := g.World.LoadRoom(p.RoomID)
+ return e == nil && r.CryptoExchange
+}
+func (g *Game) openCryptoCounter(s *net.Session) {
+ if !g.atCryptoCounter(s.Player) {
+ s.WriteLine("There is no Crypto Exchange here.")
+ return
+ }
+ if s.CryptoActive {
+ s.CryptoShowHints = true
+ g.executeCrypto(s, "refresh", nil)
+ return
+ }
+ if !g.leaveCasinoGames(s.Player) {
+ s.WriteLine("Finish your casino round first.")
+ return
+ }
+ g.closePredictionCounter(s)
+ g.closeCryptoCounter(s)
+ s.CryptoActive = true
+ s.CryptoShowHints = true
+ g.executeCrypto(s, "markets", nil)
+}
+func (g *Game) closeCryptoCounter(s *net.Session) {
+ if s.CryptoCancel != nil {
+ s.CryptoCancel()
+ s.CryptoCancel = nil
+ }
+ s.CryptoActive = false
+ s.CryptoBusy = false
+ s.CryptoEpoch++
+ s.CryptoSelected = ""
+ s.CryptoScreen = "markets"
+ s.CryptoShowHints = false
+ s.CryptoUnavailable = false
+}
+
+// cryptoVerbs is the counter's own vocabulary. Exact matches win, and unique
+// prefixes are accepted just like every other casino and counter interface.
+// Real world commands keep their meaning unless the counter explicitly owns
+// them (buy, sell, option, close, rules and help are claimed on the counter).
+var cryptoVerbs = []string{
+ "markets", "view", "buy", "sell", "short", "cover", "option", "write",
+ "quote", "contracts", "close", "active", "portfolio", "closed", "fund",
+ "withdraw", "repay", "refresh", "back", "help", "rules", "stop", "leave", "exit",
+}
+
+func cryptoVerb(cmd string) (string, bool) {
+ for _, v := range cryptoVerbs {
+ if cmd == v {
+ return v, true
+ }
+ }
+ if _, exists := commandRegistry[cmd]; exists {
+ return "", false
+ }
+ found := ""
+ for _, v := range cryptoVerbs {
+ if strings.HasPrefix(v, cmd) {
+ if found != "" {
+ return "", true
+ }
+ found = v
+ }
+ }
+ return found, found != ""
+}
+func (g *Game) cryptoInputOwned(s *net.Session, cmd string) bool {
+ if !s.CryptoActive || !g.atCryptoCounter(s.Player) {
+ return false
+ }
+ _, ok := cryptoVerb(cmd)
+ return ok
+}
+func (g *Game) handleCryptoInput(s *net.Session, cmd string, args []string) bool {
+ if !s.CryptoActive {
+ return false
+ }
+ if !g.atCryptoCounter(s.Player) {
+ g.closeCryptoCounter(s)
+ return false
+ }
+ if cmd == "help" && len(args) > 0 {
+ return false
+ }
+ verb, owned := cryptoVerb(cmd)
+ if !owned {
+ if cmd == "use" && cryptoUseTarget(strings.ToLower(strings.Join(args, " "))) {
+ return false
+ }
+ switch cmd {
+ case "use", "play", "bank", "quit", "logout":
+ g.closeCryptoCounter(s)
+ }
+ return false
+ }
+ if verb == "" {
+ s.WriteLine("That shortcut is ambiguous. Choose a command below.")
+ g.cryptoHints(s)
+ return true
+ }
+ // Keep the global option/options settings command reachable: only a
+ // real option trade (option call|put ...) belongs to the counter. The
+ // plural options command is the player's settings command, not ours.
+ if verb == "option" && !(len(args) > 0 && (args[0] == "call" || args[0] == "put")) {
+ return false
+ }
+ switch verb {
+ case "stop", "leave", "exit":
+ g.closeCryptoCounter(s)
+ s.WriteLine("You step away from the Crypto Exchange. Your positions stay open.")
+ case "help", "rules":
+ g.cryptoHelp(s)
+ default:
+ g.executeCrypto(s, verb, args)
+ }
+ return true
+}
+func (g *Game) cryptoHelp(s *net.Session) {
+ g.predictionFrame(s, "HOW TO TRADE", []string{
+ "Fund your account, choose a currency, then buy coins or back a falling price with a short.",
+ "Try: fund 1000, view BTC, buy BTC 0.001. Sell a position with close ID.",
+ "Options let you bet on a price by a future date. Use quote to check the price first.",
+ "Shorts and written options can leave debts. The exchange collects these from your bank and future deposits.",
+ "Your positions are checked when you return, not while you are away. See help crypto for details.",
+ })
+ g.cryptoHints(s, true)
+}
+
+// A transaction snapshots only the fields it owns. Money and ledger always
+// share one atomic save, including bank garnishment; uncertain renames remain
+// applied using the existing casino persistence contract.
+func (g *Game) cryptoTransaction(p *player.Player, mutate func() error) error {
+ before := p.Crypto.Clone()
+ credits, bank := p.Credits, p.BankedCredits
+ rollback := func() { p.Crypto = before; p.Credits = credits; p.BankedCredits = bank }
+ if e := p.Crypto.Validate(); e != nil {
+ return e
+ }
+ if e := mutate(); e != nil {
+ rollback()
+ return e
+ }
+ p.Crypto.SweepBank(&p.BankedCredits)
+ if e := p.Crypto.Validate(); e != nil {
+ rollback()
+ return e
+ }
+ if e := (casinoWallet{g: g, p: p}).save("crypto exchange"); e != nil {
+ rollback()
+ return fmt.Errorf("exchange could not save; balances unchanged: %w", e)
+ }
+ return nil
+}
+func (g *Game) reconcileCrypto(p *player.Player, snap cryptomarket.Snapshot, now time.Time) ([]string, error) {
+ work := p.Crypto.Clone()
+ messages, e := work.Reconcile(snap, now)
+ if e != nil {
+ return nil, e
+ }
+ bank := p.BankedCredits
+ paid := work.SweepBank(&bank)
+ if len(messages) == 0 && paid == 0 {
+ return nil, nil
+ }
+ e = g.cryptoTransaction(p, func() error { p.Crypto = work; p.BankedCredits = bank; return nil })
+ if e != nil {
+ return nil, e
+ }
+ if paid > 0 {
+ messages = append(messages, fmt.Sprintf("The Crypto Exchange collected %d bank credits against debt.", paid))
+ }
+ return messages, nil
+}
+func (g *Game) cryptoFunds(s *net.Session, action string, args []string, verified ...cryptomarket.Snapshot) {
+ if len(args) != 1 {
+ s.WriteLine(action + " CREDITS")
+ return
+ }
+ n, e := strconv.ParseInt(args[0], 10, 64)
+ if e != nil || n <= 0 || n > cryptomarket.MaxMoney {
+ s.WriteLine("Enter a positive whole-credit amount within the economy limit.")
+ return
+ }
+ p := s.Player
+ e = g.cryptoTransaction(p, func() error {
+ p.Crypto.SweepBank(&p.BankedCredits)
+ switch action {
+ case "fund", "repay":
+ if action == "repay" && (p.Crypto.Balance >= 0 || n > -p.Crypto.Balance) {
+ return fmt.Errorf("repay at most the outstanding exchange debt")
+ }
+ if int64(p.Credits) < n {
+ return fmt.Errorf("not enough carried credits")
+ }
+ balance, err := cryptomarket.Sum(p.Crypto.Balance, n)
+ if err != nil {
+ return err
+ }
+ p.Credits -= int(n)
+ p.Crypto.Balance = balance
+ case "withdraw":
+ if p.Crypto.Balance < n {
+ return fmt.Errorf("not enough available exchange cash")
+ }
+ // Only withdraw free cash, never collateral. Derivatives must have
+ // verified nonnegative close equity, even when a positive settlement
+ // is waiting for balance-cap room. Missing data cannot release cash.
+ for _, v := range p.Crypto.Positions {
+ if v.Status != "open" || (!v.Risky() && !v.Option()) {
+ continue
+ }
+ if len(verified) != 1 {
+ return fmt.Errorf("derivatives need current quotes before withdrawing")
+ }
+ expiry, _ := verified[0].Expiry(v.Symbol, v.Expiry)
+ value, err := cryptomarket.Value(v, verified[0].Quotes[v.Symbol], time.Now().UTC(), expiry)
+ if err != nil || value < 0 {
+ return fmt.Errorf("settle derivative liabilities before withdrawing; quote or expiry data may be unavailable")
+ }
+ }
+ v, err := addCreditsChecked(p.Credits, int(n))
+ if err != nil {
+ return err
+ }
+ p.Credits = v
+ p.Crypto.Balance -= n
+ }
+ return nil
+ })
+ if e != nil {
+ s.WriteLine(e.Error())
+ return
+ }
+ g.cryptoBalance(s)
+}
+func (g *Game) cryptoBalance(s *net.Session) {
+ s.WriteLine(g.colorize(s, "casino_credits", fmt.Sprintf("Exchange cash: %d credits | Bank: %d | Carried: %d", s.Player.Crypto.Balance, s.Player.BankedCredits, s.Player.Credits)))
+ if s.Player.Crypto.Balance < 0 {
+ s.WriteLine(g.colorize(s, "casino_result_loss", "DEBT: future bank deposits will be seized until paid. Use repay CREDITS."))
+ }
+}
+
+func (g *Game) executeCrypto(s *net.Session, action string, args []string) {
+ if s.CryptoBusy {
+ s.WriteLine("Crypto quotes are updating; wait or stop.")
+ g.cryptoHints(s)
+ return
+ }
+ hintsHandled := false
+ defer func() {
+ if !hintsHandled {
+ g.cryptoHints(s)
+ }
+ }()
+ if action == "back" {
+ action = "markets"
+ args = nil
+ }
+ if action == "refresh" {
+ action = s.CryptoScreen
+ if action == "" {
+ action = "markets"
+ }
+ if action == "view" {
+ args = []string{s.CryptoSelected}
+ }
+ }
+ switch action {
+ case "fund", "repay":
+ g.cryptoFunds(s, action, args)
+ return
+ case "closed":
+ g.cryptoPortfolio(s, cryptomarket.Snapshot{}, true)
+ return
+ case "contracts":
+ g.cryptoOptions(s)
+ return
+ }
+ // Parse and reject malformed commands BEFORE making any HTTP requests.
+ symbol, kind, quantity, strike := "", "", "", ""
+ days := 0
+ id := int64(0)
+ switch action {
+ case "withdraw":
+ if len(args) != 1 {
+ s.WriteLine("withdraw CREDITS")
+ return
+ }
+ n, err := strconv.ParseInt(args[0], 10, 64)
+ if err != nil || n <= 0 || n > cryptomarket.MaxMoney {
+ s.WriteLine("Enter a positive whole-credit amount within the economy limit.")
+ return
+ }
+ case "markets", "active", "portfolio":
+ if len(args) != 0 {
+ s.WriteLine("This command takes no arguments.")
+ return
+ }
+ case "view":
+ if len(args) != 1 {
+ s.WriteLine("view SYMBOL")
+ return
+ }
+ symbol = strings.ToUpper(args[0])
+ case "buy", "short":
+ if len(args) != 2 {
+ s.WriteLine(action + " SYMBOL QUANTITY")
+ return
+ }
+ symbol = strings.ToUpper(args[0])
+ quantity = args[1]
+ kind = "spot"
+ if action == "short" {
+ kind = "short"
+ }
+ case "option", "write", "quote":
+ validKind := len(args) > 0 && (args[0] == "call" || args[0] == "put" || (action == "quote" && (args[0] == "write-call" || args[0] == "write-put")))
+ if len(args) != 5 || !validKind {
+ s.WriteLine(action + " call|put SYMBOL QUANTITY STRIKE DAYS")
+ return
+ }
+ kind = args[0]
+ if action == "write" {
+ kind = "write-" + kind
+ }
+ symbol = strings.ToUpper(args[1])
+ quantity = args[2]
+ strike = args[3]
+ days, _ = strconv.Atoi(args[4])
+ if days != 1 && days != 7 && days != 30 {
+ s.WriteLine("DAYS must be 1, 7 or 30.")
+ return
+ }
+ if _, e := cryptomarket.Decimal(strike); e != nil {
+ s.WriteLine(e.Error())
+ return
+ }
+ case "sell", "cover", "close":
+ if len(args) != 1 {
+ s.WriteLine(action + " POSITION_ID")
+ return
+ }
+ id, _ = strconv.ParseInt(args[0], 10, 64)
+ found := false
+ for _, p := range s.Player.Crypto.Positions {
+ if p.ID == id && p.Status == "open" {
+ symbol = p.Symbol
+ found = true
+ if action == "cover" && p.Kind != "short" {
+ s.WriteLine("cover is for shorts; use close ID.")
+ return
+ }
+ }
+ }
+ if !found {
+ s.WriteLine("Choose an open position ID from active.")
+ return
+ }
+ default:
+ hintsHandled = true
+ g.cryptoHelp(s)
+ return
+ }
+ if symbol != "" {
+ if _, ok := cryptomarket.Find(symbol); !ok {
+ s.WriteLine("Supported currencies: BTC ETH SOL DOGE SHIB.")
+ return
+ }
+ }
+ if quantity != "" {
+ if _, e := cryptomarket.Decimal(quantity); e != nil {
+ s.WriteLine(e.Error())
+ return
+ }
+ }
+ symbols := map[string]bool{}
+ for _, sym := range cryptomarket.Symbols(s.Player.Crypto) {
+ symbols[sym] = true
+ }
+ if symbol != "" {
+ symbols[symbol] = true
+ }
+ if action == "markets" {
+ for _, i := range cryptomarket.Instruments {
+ symbols[i.Symbol] = true
+ }
+ }
+ list := []string{}
+ for sym := range symbols {
+ list = append(list, sym)
+ }
+ sort.Strings(list)
+ if g.CryptoClient == nil {
+ g.CryptoClient = cryptomarket.NewClient()
+ }
+ client := g.CryptoClient
+ p, room, epoch := s.Player, s.Player.RoomID, s.CryptoEpoch
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
+ s.CryptoCancel = cancel
+ s.CryptoBusy = true
+ hintsHandled = true
+ s.WriteLine("Updating crypto quotes...")
+ work := func() {
+ defer cancel()
+ snap, fetchErr := client.Fetch(ctx, list)
+ if ctx.Err() != nil {
+ fetchErr = ctx.Err()
+ }
+ apply := func() {
+ if !s.CryptoActive || s.CryptoEpoch != epoch {
+ return
+ }
+ s.CryptoBusy = false
+ s.CryptoCancel = nil
+ if g.isShuttingDown() || s.Player != p || s.State() != net.StateGame || s.IsDisconnecting() || p.RoomID != room || !g.atCryptoCounter(p) {
+ return
+ }
+ if g.owner != nil && g.loggedInChars[p.Name] != s {
+ return
+ }
+ if g.owner != nil {
+ defer g.reprompt(s)
+ }
+ defer g.cryptoHints(s)
+ s.CryptoUnavailable = fetchErr != nil
+ if fetchErr != nil {
+ g.predictionFrame(s, "COUNTER CLOSED", []string{"Prices are unavailable. Try refresh in a moment.", "No trade was made. Your positions are unchanged."})
+ g.cryptoBalance(s)
+ return
+ }
+ now := time.Now().UTC()
+ messages, e := g.reconcileCrypto(p, snap, now)
+ if e != nil {
+ s.WriteLine(e.Error())
+ return
+ }
+ for _, m := range messages {
+ s.WriteLine(m)
+ }
+ switch action {
+ case "withdraw":
+ g.cryptoFunds(s, action, args, snap)
+ case "markets":
+ g.cryptoMarkets(s, snap)
+ case "view":
+ s.CryptoSelected = symbol
+ g.cryptoView(s, symbol, snap)
+ case "active", "portfolio":
+ g.cryptoPortfolio(s, snap, false)
+ case "quote":
+ preview := p.Crypto.Clone()
+ preview.Balance = cryptomarket.MaxMoney
+ receipt, err := preview.Open(symbol, kind, quantity, strike, days, snap.Quotes[symbol], now)
+ if err != nil {
+ s.WriteLine(err.Error())
+ return
+ }
+ lines := []string{
+ g.colorize(s, "casino_emphasis", fmt.Sprintf("%s %s x %s", kind, symbol, quantity)),
+ fmt.Sprintf("Debit %s | premium %s | collateral %s credits",
+ g.colorize(s, "casino_credits", strconv.FormatInt(receipt.Cost, 10)),
+ g.colorize(s, "casino_credits", strconv.FormatInt(receipt.Premium, 10)),
+ g.colorize(s, "casino_credits", strconv.FormatInt(receipt.Collateral, 10))),
+ fmt.Sprintf("Strike %s | expires %s UTC", strike, time.Unix(receipt.Expiry, 0).UTC().Format("2006-01-02")),
+ g.colorize(s, "casino_action", "No order placed. Actual execution reprices."),
+ }
+ g.predictionFrame(s, "INDICATIVE QUOTE", lines)
+ default:
+ var receipt cryptomarket.Position
+ e = g.cryptoTransaction(p, func() error {
+ if id > 0 {
+ var err error
+ receipt, err = p.Crypto.Close(id, snap, now, "closed")
+ return err
+ }
+ // No new risk while an expired contract lacks its settlement data.
+ for _, v := range p.Crypto.Positions {
+ if v.Status != "open" {
+ continue
+ }
+ if v.Option() && v.Expiry <= now.Unix() {
+ return fmt.Errorf("settlement data pending; new positions are paused")
+ }
+ if v.Risky() || v.Option() {
+ if _, err := cryptomarket.Value(v, snap.Quotes[v.Symbol], now, ""); err != nil {
+ return fmt.Errorf("existing derivative cannot be valued; new positions are paused")
+ }
+ }
+ }
+ var err error
+ receipt, err = p.Crypto.Open(symbol, kind, quantity, strike, days, snap.Quotes[symbol], now)
+ return err
+ })
+ if e != nil {
+ s.WriteLine(e.Error())
+ return
+ }
+ if id > 0 {
+ category, verb := "casino_result_push", "released"
+ if receipt.Proceeds > 0 {
+ category = "casino_result_win"
+ } else if receipt.Proceeds < 0 {
+ category, verb = "casino_result_loss", "charged"
+ }
+ s.WriteLine(g.colorize(s, category, fmt.Sprintf("Closed #%d: %+d credits %s to exchange cash.", receipt.ID, receipt.Proceeds, verb)))
+ } else {
+ s.WriteLine(fmt.Sprintf("Opened #%d %s %s %s; debited %d credits.", receipt.ID, receipt.Kind, g.colorize(s, "casino_emphasis", receipt.Symbol), receipt.Quantity, receipt.Cost))
+ if receipt.Option() {
+ s.WriteLine(fmt.Sprintf("Strike %s; premium %d; expires %s UTC.", receipt.Strike, receipt.Premium, time.Unix(receipt.Expiry, 0).UTC().Format("2006-01-02 15:04")))
+ }
+ }
+ g.cryptoBalance(s)
+ }
+ }
+ if g.owner == nil {
+ apply()
+ } else {
+ g.run(apply)
+ }
+ }
+ if g.owner == nil {
+ work()
+ } else {
+ go work()
+ }
+}
+
+func (g *Game) cryptoMarkets(s *net.Session, snap cryptomarket.Snapshot) {
+ s.CryptoSelected = ""
+ s.CryptoScreen = "markets"
+ rows := [][]string{}
+ for _, i := range cryptomarket.Instruments {
+ q, ok := snap.Quotes[i.Symbol]
+ if !ok || q.Validate(time.Now()) != nil {
+ rows = append(rows, []string{
+ g.colorize(s, "casino_emphasis", i.Symbol),
+ g.colorize(s, "casino_menu", "--"),
+ g.colorize(s, "casino_menu", "--"),
+ })
+ continue
+ }
+ rows = append(rows, []string{
+ g.colorize(s, "casino_emphasis", i.Symbol),
+ g.colorize(s, "casino_result_loss", q.Ask),
+ g.colorize(s, "casino_result_win", q.Bid),
+ })
+ }
+ headers := []string{
+ g.colorize(s, "casino_menu", "Currency"),
+ g.colorize(s, "casino_menu", "Buy"),
+ g.colorize(s, "casino_menu", "Sell"),
+ }
+ g.predictionTable(s, g.colorize(s, "casino_emphasis", "CRYPTO EXCHANGE"), headers, rows, 0)
+ g.cryptoBalance(s)
+}
+func (g *Game) cryptoView(s *net.Session, symbol string, snap cryptomarket.Snapshot) {
+ s.CryptoScreen = "view"
+ q, ok := snap.Quotes[symbol]
+ if ok {
+ s.WriteLine(fmt.Sprintf("%s / USD: bid %s | ask %s | fetched %s UTC",
+ g.colorize(s, "casino_emphasis", symbol),
+ g.colorize(s, "casino_result_win", q.Bid),
+ g.colorize(s, "casino_result_loss", q.Ask),
+ q.At.UTC().Format("15:04:05")))
+ } else {
+ s.WriteLine(g.colorize(s, "casino_result_loss", symbol+": current quote unavailable"))
+ }
+ lines := cryptomarket.Chart(snap.Candles[symbol], predictionWidth(s)-4, s.Player.OptionBool("unicode"), func(text string, up bool) string {
+ key := "casino_result_loss"
+ if up {
+ key = "casino_result_win"
+ }
+ return g.colorize(s, key, text)
+ })
+ g.predictionFrame(s, symbol+" DAILY CANDLES", lines)
+ g.cryptoBalance(s)
+}
+func (g *Game) cryptoPortfolio(s *net.Session, snap cryptomarket.Snapshot, closed bool) {
+ s.CryptoScreen = "active"
+ if closed {
+ s.CryptoScreen = "closed"
+ }
+ now := time.Now().UTC()
+ rows := [][]string{}
+ for _, p := range s.Player.Crypto.Positions {
+ if (p.Status != "open") != closed {
+ continue
+ }
+ desc := p.Kind + " " + p.Symbol
+ if p.Option() {
+ desc += " " + p.Strike + " " + time.Unix(p.Expiry, 0).UTC().Format("01-02")
+ }
+ if closed {
+ desc += " (" + p.Status + ")"
+ }
+ if p.Risky() {
+ desc += " [coll " + strconv.FormatInt(p.Collateral, 10) + "]"
+ }
+ value, valued := int64(0), false
+ if closed {
+ value, valued = p.Proceeds, true
+ } else {
+ expiry, _ := snap.Expiry(p.Symbol, p.Expiry)
+ if v, e := cryptomarket.Value(p, snap.Quotes[p.Symbol], now, expiry); e == nil {
+ value, valued = v, true
+ }
+ }
+ valueText, pnlText := g.colorize(s, "casino_menu", "n/a"), g.colorize(s, "casino_menu", "n/a")
+ if valued {
+ valueText = strconv.FormatInt(value, 10)
+ pl := value - p.Cost
+ category := "casino_result_push"
+ if pl > 0 {
+ category = "casino_result_win"
+ } else if pl < 0 {
+ category = "casino_result_loss"
+ }
+ pnlText = g.colorize(s, category, fmt.Sprintf("%+d", pl))
+ }
+ rows = append(rows, []string{
+ g.colorize(s, "casino_menu", strconv.FormatInt(p.ID, 10)),
+ g.colorize(s, "casino_emphasis", desc),
+ p.Quantity,
+ strconv.FormatInt(p.Cost, 10),
+ valueText,
+ pnlText,
+ })
+ }
+ if len(rows) == 0 {
+ message := "No open positions. Use view SYMBOL to trade."
+ if closed {
+ message = "No completed trades yet."
+ }
+ g.predictionFrame(s, "CRYPTO PORTFOLIO", []string{message})
+ g.cryptoBalance(s)
+ return
+ }
+ headers := []string{"#", "Position", "Qty", "Cost"}
+ title := "CRYPTO POSITIONS"
+ last := "P/L"
+ if closed {
+ headers = append(headers, "Released")
+ title, last = "CRYPTO TRADE HISTORY", "Net"
+ } else {
+ headers = append(headers, "Value")
+ }
+ headers = append(headers, last)
+ colored := make([]string, len(headers))
+ for i, h := range headers {
+ colored[i] = g.colorize(s, "casino_menu", h)
+ }
+ g.predictionTable(s, g.colorize(s, "casino_emphasis", fmt.Sprintf("%s (%d)", title, len(rows))), colored, rows, 1)
+ g.cryptoBalance(s)
+}