| | 11 | `bots/main.go`: |
| | 12 | |
| | 13 | {{{ |
| | 14 | // EduBerza market simulation bot. |
| | 15 | // |
| | 16 | // Walks a small price for every active market, inserts rows into |
| | 17 | // market_trades once per tick, and upserts the current 1m candle. |
| | 18 | // |
| | 19 | // Run from the repo root so the relative .env path resolves: |
| | 20 | // |
| | 21 | // go run ./bots/... |
| | 22 | package main |
| | 23 | |
| | 24 | import ( |
| | 25 | "bufio" |
| | 26 | "database/sql" |
| | 27 | "flag" |
| | 28 | "fmt" |
| | 29 | "log" |
| | 30 | "math/rand" |
| | 31 | "os" |
| | 32 | "strings" |
| | 33 | "time" |
| | 34 | |
| | 35 | _ "github.com/lib/pq" |
| | 36 | ) |
| | 37 | |
| | 38 | type market struct { |
| | 39 | id string |
| | 40 | symbol string |
| | 41 | price float64 |
| | 42 | } |
| | 43 | |
| | 44 | func main() { |
| | 45 | interval := flag.Duration("interval", 3*time.Second, "seconds between price ticks") |
| | 46 | flag.Parse() |
| | 47 | |
| | 48 | loadEnv(".env") |
| | 49 | dsn := fmt.Sprintf( |
| | 50 | "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable options='--search_path=project,public'", |
| | 51 | env("DBHOST", "localhost"), |
| | 52 | env("DBPORT", "5432"), |
| | 53 | env("DBUSER", "postgres"), |
| | 54 | env("DBPASSWORD", ""), |
| | 55 | env("DBNAME", "postgres"), |
| | 56 | ) |
| | 57 | db, err := sql.Open("postgres", dsn) |
| | 58 | if err != nil { |
| | 59 | log.Fatalf("open db: %v", err) |
| | 60 | } |
| | 61 | defer db.Close() |
| | 62 | if err := db.Ping(); err != nil { |
| | 63 | log.Fatalf("ping: %v", err) |
| | 64 | } |
| | 65 | |
| | 66 | markets, err := loadMarkets(db) |
| | 67 | if err != nil { |
| | 68 | log.Fatalf("load markets: %v", err) |
| | 69 | } |
| | 70 | if len(markets) == 0 { |
| | 71 | log.Fatal("no active markets found - run `go run ./server -init` first") |
| | 72 | } |
| | 73 | |
| | 74 | log.Printf("bot started. simulating %d markets every %s", len(markets), *interval) |
| | 75 | rnd := rand.New(rand.NewSource(time.Now().UnixNano())) |
| | 76 | |
| | 77 | for { |
| | 78 | for i := range markets { |
| | 79 | m := &markets[i] |
| | 80 | // random walk: ±0.3% per tick |
| | 81 | drift := (rnd.Float64() - 0.5) * 0.006 |
| | 82 | m.price = m.price * (1 + drift) |
| | 83 | if m.price <= 0 { |
| | 84 | m.price = 0.000001 |
| | 85 | } |
| | 86 | qty := rnd.Float64()*0.5 + 0.01 |
| | 87 | |
| | 88 | side := "buy" |
| | 89 | if rnd.Float64() < 0.5 { |
| | 90 | side = "sell" |
| | 91 | } |
| | 92 | |
| | 93 | if err := insertTick(db, m.id, m.price, qty, side); err != nil { |
| | 94 | log.Printf("insert tick %s: %v", m.symbol, err) |
| | 95 | continue |
| | 96 | } |
| | 97 | log.Printf(" %-8s %.6f qty=%.4f side=%s", m.symbol, m.price, qty, side) |
| | 98 | } |
| | 99 | time.Sleep(*interval) |
| | 100 | } |
| | 101 | } |
| | 102 | |
| | 103 | func loadMarkets(db *sql.DB) ([]market, error) { |
| | 104 | rows, err := db.Query(` |
| | 105 | SELECT m.id, c.symbol, COALESCE(lp.price, 100) |
| | 106 | FROM markets m |
| | 107 | JOIN crypto c ON c.id = m.crypto_id |
| | 108 | LEFT JOIN v_latest_prices lp ON lp.market_id = m.id |
| | 109 | WHERE m.is_active = true |
| | 110 | ORDER BY c.symbol`) |
| | 111 | if err != nil { |
| | 112 | return nil, err |
| | 113 | } |
| | 114 | defer rows.Close() |
| | 115 | var out []market |
| | 116 | for rows.Next() { |
| | 117 | var m market |
| | 118 | if err := rows.Scan(&m.id, &m.symbol, &m.price); err != nil { |
| | 119 | return nil, err |
| | 120 | } |
| | 121 | out = append(out, m) |
| | 122 | } |
| | 123 | return out, nil |
| | 124 | } |
| | 125 | |
| | 126 | func insertTick(db *sql.DB, marketID string, price, qty float64, side string) error { |
| | 127 | tx, err := db.Begin() |
| | 128 | if err != nil { |
| | 129 | return err |
| | 130 | } |
| | 131 | defer tx.Rollback() |
| | 132 | |
| | 133 | if _, err := tx.Exec( |
| | 134 | `INSERT INTO market_trades (market_id, executed_at, price, quantity, side, source) |
| | 135 | VALUES ($1, now(), $2, $3, $4, 'simulation')`, |
| | 136 | marketID, price, qty, side, |
| | 137 | ); err != nil { |
| | 138 | return err |
| | 139 | } |
| | 140 | |
| | 141 | // upsert the current 1m candle |
| | 142 | if _, err := tx.Exec(` |
| | 143 | INSERT INTO market_candles (market_id, timeframe, open, high, low, close, volume, candle_time) |
| | 144 | VALUES ($1, '1m', $2, $2, $2, $2, $3, date_trunc('minute', now())) |
| | 145 | ON CONFLICT (market_id, timeframe, candle_time) DO UPDATE |
| | 146 | SET high = GREATEST(market_candles.high, EXCLUDED.close), |
| | 147 | low = LEAST( market_candles.low, EXCLUDED.close), |
| | 148 | close = EXCLUDED.close, |
| | 149 | volume = market_candles.volume + EXCLUDED.volume`, |
| | 150 | marketID, price, qty, |
| | 151 | ); err != nil { |
| | 152 | return err |
| | 153 | } |
| | 154 | return tx.Commit() |
| | 155 | } |
| | 156 | |
| | 157 | func loadEnv(path string) { |
| | 158 | f, err := os.Open(path) |
| | 159 | if err != nil { |
| | 160 | return |
| | 161 | } |
| | 162 | defer f.Close() |
| | 163 | s := bufio.NewScanner(f) |
| | 164 | for s.Scan() { |
| | 165 | line := strings.TrimSpace(s.Text()) |
| | 166 | if line == "" || strings.HasPrefix(line, "#") { |
| | 167 | continue |
| | 168 | } |
| | 169 | parts := strings.SplitN(line, "=", 2) |
| | 170 | if len(parts) == 2 { |
| | 171 | os.Setenv(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) |
| | 172 | } |
| | 173 | } |
| | 174 | } |
| | 175 | |
| | 176 | func env(k, def string) string { |
| | 177 | if v := os.Getenv(k); v != "" { |
| | 178 | return v |
| | 179 | } |
| | 180 | return def |
| | 181 | } |
| | 182 | }}} |
| | 183 | |
| 21 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0001Implementation.md UseCase0001Implementation]||Register a new account||[https://github.com/StefanTrsunov/bp/blob/main/server/auth.go server/auth.go]|| |
| 22 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0002Implementation.md UseCase0002Implementation]||Log in||[https://github.com/StefanTrsunov/bp/blob/main/server/auth.go server/auth.go]|| |
| 23 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0003Implementation.md UseCase0003Implementation]||Deposit virtual funds||[https://github.com/StefanTrsunov/bp/blob/main/server/account.go server/account.go]|| |
| 24 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0004Implementation.md UseCase0004Implementation]||Place market BUY order||[https://github.com/StefanTrsunov/bp/blob/main/server/trade.go server/trade.go]|| |
| 25 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0005Implementation.md UseCase0005Implementation]||Place market SELL order||[https://github.com/StefanTrsunov/bp/blob/main/server/trade.go server/trade.go]|| |
| 26 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0006Implementation.md UseCase0006Implementation]||View portfolio and history||[https://github.com/StefanTrsunov/bp/blob/main/server/portfolio.go server/portfolio.go]|| |
| 27 | | ||[https://github.com/StefanTrsunov/bp/blob/main/docs/P4-Prototype/UseCase0007Implementation.md UseCase0007Implementation]||Manage watchlist||[https://github.com/StefanTrsunov/bp/blob/main/server/watchlist.go server/watchlist.go]|| |
| | 194 | ||UseCase0001Implementation||Register a new account||`server/auth.go`|| |
| | 195 | ||UseCase0002Implementation||Log in||`server/auth.go`|| |
| | 196 | ||UseCase0003Implementation||Deposit virtual funds||`server/account.go`|| |
| | 197 | ||UseCase0004Implementation||Place market BUY order||`server/trade.go`|| |
| | 198 | ||UseCase0005Implementation||Place market SELL order||`server/trade.go`|| |
| | 199 | ||UseCase0006Implementation||View portfolio and history||`server/portfolio.go`|| |
| | 200 | ||UseCase0007Implementation||Manage watchlist||`server/watchlist.go`|| |