From 88087b3e543c488065f9c6fdd9cbc8ea153165f6 Mon Sep 17 00:00:00 2001 From: snegi512 Date: Wed, 7 May 2025 00:25:34 +0300 Subject: [PATCH] completed task --- README.md | 170 +++++++++---------- cmd/server/Dockerfile | 2 + cmd/server/main.go | 111 ++++++++++--- cmd/server/main_test.go | 220 +++++++++++++++++++++++++ cmd/server/validator/validator.go | 26 +++ cmd/server/validator/validator_test.go | 159 ++++++++++++++++++ cmd/worker/main.go | 41 ++++- go.mod | 20 ++- go.sum | 30 ++++ internal/db/data.db | Bin 0 -> 24576 bytes internal/model/account_stats.go | 10 ++ internal/model/trade.go | 26 +++ 12 files changed, 705 insertions(+), 110 deletions(-) create mode 100644 cmd/server/main_test.go create mode 100644 cmd/server/validator/validator.go create mode 100644 cmd/server/validator/validator_test.go create mode 100644 internal/db/data.db create mode 100644 internal/model/account_stats.go create mode 100644 internal/model/trade.go diff --git a/README.md b/README.md index f7f8ef7..94d0ce7 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,92 @@ -# Technical Assignment +## Настройка и запуск +1. **Сборка и запуск сервисов (API Server и Worker):** -The candidate is asked to build a small project consisting of two Go processes: + ```bash + docker-compose up --build + ``` -- The **API server** accepts trade submissions via POST `/trades` and writes them to a queue table. -- The **worker process** continuously reads new trades, validates them, stores them, and instantly updates the account's profit. + +## Примеры API запросов (cURL) -Your code must compile, pass `go vet` and `go test -race`, and run via `docker-compose up`. +### 1. Отправка (POST /trades) -## Purpose of This Task +* **Успешный запрос:** -We want to assess your ability to write clean HTTP code, work with SQL, and reason about concurrency. + ```bash + curl -X POST -H "Content-Type: application/json" -d '{ + "account": "ACC001", + "symbol": "EURUSD", + "volume": 1.5, + "open": 1.0750, + "close": 1.0780, + "side": "buy" + }' http://localhost:8080/trades + ``` + Ожидаемый ответ: `HTTP/1.1 200 OK` (без тела ответа, если не изменено). -## What You Should Build +* **Запрос с ошибкой валидации (неверный формат symbol):** -### Components and Architecture + ```bash + curl -X POST -H "Content-Type: application/json" -d '{ + "account": "ACC002", + "symbol": "eurusd_invalid", + "volume": 0.5, + "open": 1.1200, + "close": 1.1250, + "side": "sell" + }' http://localhost:8080/trades + ``` + Ожидаемый ответ: `HTTP/1.1 400 Bad Request` (с телом ошибки, описывающим проблему валидации). -```text -┌──────────────┐ POST /trades ┌───────────────┐ -│ API Server │ ─────────────────► │ Queue Table │ -│ cmd/server │ └───────────────┘ -└──────────────┘ ▲ - │ SELECT … FOR UPDATE -┌──────────────┐ UPDATE stats │ -│ Worker │ ◄───────────────────────┘ -│ cmd/worker │ -└──────────────┘ +* **Запрос с отсутствующим обязательным полем (например, account):** + + ```bash + curl -X POST -H "Content-Type: application/json" -d '{ + "symbol": "GBPUSD", + "volume": 1.0, + "open": 1.2600, + "close": 1.2650, + "side": "buy" + }' http://localhost:8080/trades + ``` + Ожидаемый ответ: `HTTP/1.1 400 Bad Request`. + +### 2. Получение статистики (GET /stats/{acc}) + +* **Для существующего аккаунта (после обработки заявок Worker'ом):** + Замените `{ACC001}` на реальный ID аккаунта. + + ```bash + curl -X GET http://localhost:8080/stats/ACC001 + ``` + Ожидаемый ответ (пример): + ```json + { + "account": "ACC001", + "trades": 1, + "profit": 450.00 + } + ``` + +* **Для несуществующего аккаунта или аккаунта без сделок:** + + ```bash + curl -X GET http://localhost:8080/stats/UNKNOWN_ACC + ``` + Ожидаемый ответ: + ```json + { + "account": "UNKNOWN_ACC", + "trades": 0, + "profit": 0 + } + ``` + +### 3. Проверка состояния сервиса (GET /healthz) + +```bash +curl -X GET http://localhost:8080/healthz ``` +Ожидаемый ответ: `HTTP/1.1 200 OK` (тело ответа может быть пустым или содержать "OK"). -- **API (HTTP)** — exposes one POST endpoint and one GET endpoint. -- **Queue** — an SQLite table `trades_q` used by the API to enqueue trades, and by the worker to mark them as processed. -- **Worker** — a separate process that polls the queue every 100ms, calculates `profit`, and updates `account_stats`. -### Trade Input Format - -| Field | Type | Validation Rule | -| - | - | - | -| `account` | string | must not be empty | -| `symbol` | string | `^[A-Z]{6}$` (e.g. EURUSD) | -| `volume` | float64 | must be > 0 | -| `open` | float64 | must be > 0 | -| `close` | float64 | must be > 0 | -| `side` | string | either "buy" or "sell" | - -Profit calculation (performed by the worker): - -```go -lot := 100000.0 -profit := (close - open) * volume * lot -if side == "sell" { profit = -profit } -``` - -### HTTP Contracts - -| Method | URL | Request / Response | Expected Behavior | -| - | - | - | - | -| POST | `/trades` | JSON trade payload | Enqueue trade; respond with 200 OK or 400 on errors | -| GET | `/stats/{acc}` | `{"account":"123","trades":37,"profit":1234.56}` | Return current statistics for the given account | -| GET | `/healthz` | plain text OK | Health check endpoint (for Kubernetes liveness probe) | - -### How to Run - -```shell -# Terminal 1 -go run ./cmd/server.go --db data.db --listen 8080 - -# Terminal 2 -go run ./cmd/worker.go --db data.db --poll 100ms -``` - -Sample request: - -``` -curl -X POST http://localhost:8080/trades \ - -H 'Content-Type: application/json' \ - -d '{"account":"123","symbol":"EURUSD","volume":1.0, - "open":1.1000,"close":1.1050,"side":"buy"}' - -curl http://localhost:8080/stats/123 -# {"account":"123","trades":1,"profit":500.0} -``` - -## What We Expect from Your Code - -| Requirement | Minimum / Bonus | -| - | - | -| Go 1.24+, only stdlib + light libs | sqlx / chi / validator OK | -| `go vet` and `go test -race` pass | required | -| Test coverage | ≥ 60% | -| Dockerfile + docker-compose.yml | bonus (+1) | -| README: how to run + curl examples | required | - -## How We Will Evaluate - -CI script (GitLab CI) will: - -- Run `go vet` and `go test -race -covermode=atomic`. -- Launch both API and worker processes in the background. -- Send one invalid and one valid POST request. -- Fetch and validate the response from `/stats`. diff --git a/cmd/server/Dockerfile b/cmd/server/Dockerfile index 8068e2f..a555480 100644 --- a/cmd/server/Dockerfile +++ b/cmd/server/Dockerfile @@ -30,6 +30,8 @@ COPY --from=builder /app/server . # Create directory for database RUN mkdir -p /data +COPY /internal/db/data.db /data/data.db + # Expose the port EXPOSE 8080 diff --git a/cmd/server/main.go b/cmd/server/main.go index 272f9ad..8cdebc6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,14 +2,99 @@ package main import ( "database/sql" + "encoding/json" + "errors" "flag" "fmt" "log" "net/http" _ "github.com/mattn/go-sqlite3" + "gitlab.com/digineat/go-broker-test/cmd/server/validator" + "gitlab.com/digineat/go-broker-test/internal/model" ) +// server holds dependencies for HTTP handlers. +type server struct { + db *sql.DB +} + +// handlePostTrades handles POST /trades requests. +func (s *server) handlePostTrades() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + trade := &model.Trade{} + if err := json.NewDecoder(r.Body).Decode(trade); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := validator.ValidateTrade(trade); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, err := s.db.Exec("INSERT INTO trades_q (account, symbol, volume, open_price, close_price, side) VALUES (?, ?, ?, ?, ?, ?)", + trade.Account, trade.Symbol, trade.Volume, trade.OpenPrice, trade.ClosePrice, trade.Side) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + } +} + +// handleGetStats handles GET /stats/{acc} requests. +func (s *server) handleGetStats() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + acc := r.PathValue("acc") + if acc == "" { + http.Error(w, "Account is required", http.StatusBadRequest) + return + } + + var accStats model.AccountStats + err := s.db.QueryRow("SELECT account, trades_count, profit, updated_at FROM account_stats WHERE account = ?", acc). + Scan(&accStats.Account, &accStats.TradesCount, &accStats.Profit, &accStats.UpdatedAt) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + w.Header().Set("Content-Type", "application/json") + emptyStats := model.AccountStats{ + Account: acc, + TradesCount: 0, + Profit: 0, + } + if jsonErr := json.NewEncoder(w).Encode(emptyStats); jsonErr != nil { + http.Error(w, jsonErr.Error(), http.StatusInternalServerError) + } + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(accStats); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// handleGetHealthz handles GET /healthz requests. +func (s *server) handleGetHealthz() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := s.db.Ping(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + // Избегаем log.Fatalf в хендлерах для лучшей управляемости и тестируемости. + // Можно логировать ошибку стандартным log.Printf или другой системой логирования. + log.Printf("Health check: Failed to ping database: %v", err) + return + } + w.WriteHeader(http.StatusOK) + // Можно добавить тело ответа "OK", если это требуется. + // _, _ = w.Write([]byte("OK")) + } +} + func main() { // Command line flags dbPath := flag.String("db", "data.db", "path to SQLite database") @@ -28,28 +113,16 @@ func main() { log.Fatalf("Failed to ping database: %v", err) } + // Create server instance + app := &server{db: db} + // Initialize HTTP server mux := http.NewServeMux() - // POST /trades endpoint - mux.HandleFunc("POST /trades", func(w http.ResponseWriter, r *http.Request) { - // TODO: Write code here - w.WriteHeader(http.StatusOK) - }) - - // GET /stats/{acc} endpoint - mux.HandleFunc("GET /stats/{acc}", func(w http.ResponseWriter, r *http.Request) { - // TODO: Write code here - w.WriteHeader(http.StatusOK) - }) - - // GET /healthz endpoint - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { - // TODO: Write code here - // 1. Check database connection - // 2. Return health status - w.WriteHeader(http.StatusOK) - }) + // Register handlers + mux.HandleFunc("POST /trades", app.handlePostTrades()) + mux.HandleFunc("GET /stats/{acc}", app.handleGetStats()) + mux.HandleFunc("GET /healthz", app.handleGetHealthz()) // Start server serverAddr := fmt.Sprintf(":%s", *listenAddr) diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..66550ec --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "gitlab.com/digineat/go-broker-test/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +const createTradesQTableSQLTest = ` +CREATE TABLE IF NOT EXISTS trades_q ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + symbol TEXT NOT NULL, + volume REAL NOT NULL, + open_price REAL NOT NULL, + close_price REAL NOT NULL, + side TEXT NOT NULL CHECK(side IN ('buy', 'sell')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +);` + +const createAccountStatsTableSQLTest = ` +CREATE TABLE IF NOT EXISTS account_stats ( + account TEXT PRIMARY KEY, + trades_count INTEGER NOT NULL DEFAULT 0, + profit REAL NOT NULL DEFAULT 0.0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +);` + +// setupTestDB initializes an in-memory SQLite database for tests. +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory database: %v", err) + } + + if _, err := db.Exec(createTradesQTableSQLTest); err != nil { + t.Fatalf("Failed to create trades_q table: %v", err) + } + if _, err := db.Exec(createAccountStatsTableSQLTest); err != nil { + t.Fatalf("Failed to create account_stats table: %v", err) + } + return db +} + +// createTestAppMux now uses the refactored handlers from the main package. +func createTestAppMux(db *sql.DB) *http.ServeMux { + app := &server{db: db} + + mux := http.NewServeMux() + + mux.HandleFunc("POST /trades", app.handlePostTrades()) + mux.HandleFunc("GET /stats/{acc}", app.handleGetStats()) + mux.HandleFunc("GET /healthz", app.handleGetHealthz()) + return mux +} + +func TestMain(m *testing.M) { + code := m.Run() + os.Exit(code) +} + +func TestTradesHandler(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + mux := createTestAppMux(db) + + tt := []struct { + name string + payload string + expectedStatus int + verifyDB func(t *testing.T, testDB *sql.DB) + }{ + { + name: "Valid trade", + payload: `{"account":"acc1","symbol":"EURUSD","volume":1.0,"open":1.1,"close":1.2,"side":"buy"}`, + expectedStatus: http.StatusOK, + verifyDB: func(t *testing.T, testDB *sql.DB) { + var count int + err := testDB.QueryRow("SELECT COUNT(*) FROM trades_q WHERE account = ?", "acc1").Scan(&count) + if err != nil { + t.Fatalf("Failed to query db: %v", err) + } + if count != 1 { + t.Errorf("Expected 1 trade in db, got %d", count) + } + }, + }, + { + name: "Invalid JSON", + payload: `{"account":"acc2","symbol":"EURUSD","volume":1.0,"open":1.1,"close":1.2,"side":"buy"`, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Validation error - invalid symbol format (custom validator)", + payload: `{"account":"acc3","symbol":"eurusd","volume":1.0,"open":1.1,"close":1.2,"side":"buy"}`, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Validation error - invalid symbol pattern (regexp)", + payload: `{"account":"acc3","symbol":"EUR123","volume":1.0,"open":1.1,"close":1.2,"side":"buy"}`, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Validation error - missing required field (account)", + payload: `{"symbol":"EURUSD","volume":1.0,"open":1.1,"close":1.2,"side":"buy"}`, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + _, err := db.Exec("DELETE FROM trades_q") + if err != nil { + t.Fatalf("Failed to clear trades_q table: %v", err) + } + + req, _ := http.NewRequest("POST", "/trades", bytes.NewBufferString(tc.payload)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != tc.expectedStatus { + t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, rr.Code, rr.Body.String()) + } + + if tc.verifyDB != nil { + tc.verifyDB(t, db) + } + }) + } +} + +func TestStatsHandler(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + mux := createTestAppMux(db) + + _, err := db.Exec("INSERT INTO account_stats (account, trades_count, profit, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", "acc1", 10, 123.45) + if err != nil { + t.Fatalf("Failed to insert test data: %v", err) + } + + tt := []struct { + name string + accountID string + expectedStatus int + expectedBody model.AccountStats + }{ + { + name: "Account exists", + accountID: "acc1", + expectedStatus: http.StatusOK, + expectedBody: model.AccountStats{Account: "acc1", TradesCount: 10, Profit: 123.45}, + }, + { + name: "Account does not exist", + accountID: "nonexistent", + expectedStatus: http.StatusOK, + expectedBody: model.AccountStats{Account: "nonexistent", TradesCount: 0, Profit: 0}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "/stats/"+tc.accountID, nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != tc.expectedStatus { + t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, rr.Code, rr.Body.String()) + } + + var actual model.AccountStats + if err := json.Unmarshal(rr.Body.Bytes(), &actual); err != nil { + t.Fatalf("Failed to unmarshal actual response body: %v. Body: %s", err, rr.Body.String()) + } + + if actual.Account != tc.expectedBody.Account || actual.TradesCount != tc.expectedBody.TradesCount || actual.Profit != tc.expectedBody.Profit { + t.Errorf("Expected body for account %s to be %+v, got %+v", tc.accountID, tc.expectedBody, actual) + } + }) + } +} + +func TestHealthzHandler(t *testing.T) { + db := setupTestDB(t) + muxHealthy := createTestAppMux(db) + + t.Run("DB OK", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/healthz", nil) + rr := httptest.NewRecorder() + muxHealthy.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + }) + + db.Close() + + t.Run("DB Ping fails", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/healthz", nil) + rr := httptest.NewRecorder() + muxHealthy.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("Expected status %d for closed DB, got %d", http.StatusInternalServerError, rr.Code) + } + }) +} diff --git a/cmd/server/validator/validator.go b/cmd/server/validator/validator.go new file mode 100644 index 0000000..85ca90d --- /dev/null +++ b/cmd/server/validator/validator.go @@ -0,0 +1,26 @@ +package validator + +import ( + "github.com/go-playground/validator/v10" + "gitlab.com/digineat/go-broker-test/internal/model" + "log" + "regexp" +) + +var validate *validator.Validate + +func init() { + validate = validator.New() + err := validate.RegisterValidation("symbol", func(fl validator.FieldLevel) bool { + symbol := fl.Field().String() + matched, _ := regexp.MatchString(`^[A-Z]{6}$`, symbol) + return matched + }) + if err != nil { + log.Fatal("RegisterValidation not created") + } +} + +func ValidateTrade(trade *model.Trade) error { + return validate.Struct(trade) +} diff --git a/cmd/server/validator/validator_test.go b/cmd/server/validator/validator_test.go new file mode 100644 index 0000000..b3ce958 --- /dev/null +++ b/cmd/server/validator/validator_test.go @@ -0,0 +1,159 @@ +package validator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gitlab.com/digineat/go-broker-test/internal/model" +) + +func TestValidateTrade(t *testing.T) { + tests := []struct { + name string + trade *model.Trade + wantError bool + }{ + { + name: "Valid trade", + trade: &model.Trade{ + Account: "acc123", + Symbol: "EURUSD", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: false, + }, + { + name: "Missing required field - Account", + trade: &model.Trade{ + Symbol: "EURUSD", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: true, + }, + { + name: "Invalid Volume (gt=0)", + trade: &model.Trade{ + Account: "acc123", + Symbol: "EURUSD", + Volume: 0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: true, + }, + { + name: "Invalid Side (oneof=buy sell)", + trade: &model.Trade{ + Account: "acc123", + Symbol: "EURUSD", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "hold", + }, + wantError: true, + }, + { + name: "Custom validation fail - Symbol too short", + trade: &model.Trade{ + Account: "acc123", + Symbol: "EURUS", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: true, + }, + { + name: "Custom validation fail - Symbol too long", + trade: &model.Trade{ + Account: "acc123", + Symbol: "EURUSDT", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: true, + }, + { + name: "Custom validation fail - Symbol lowercase", + trade: &model.Trade{ + Account: "acc123", + Symbol: "eurusd", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: true, + }, + { + name: "Custom validation fail - Symbol with numbers", + trade: &model.Trade{ + Account: "acc123", + Symbol: "EURUS1", + Volume: 1.0, + OpenPrice: 1.1234, + ClosePrice: 1.1250, + Side: "buy", + }, + wantError: true, + }, + { + name: "Symbol with exactly 6 uppercase letters (valid by custom)", + trade: &model.Trade{ + Account: "validAcc", + Symbol: "AUDCAD", + Volume: 0.5, + OpenPrice: 0.9000, + ClosePrice: 0.9050, + Side: "sell", + }, + wantError: false, + }, + { + name: "Trade model with regex tag for symbol (if you switched from custom tag)", + trade: &model.Trade{ + Account: "accRegex", + Symbol: "GBPJPY", + Volume: 10, + OpenPrice: 150.0, + ClosePrice: 150.5, + Side: "buy", + }, + wantError: false, + }, + { + name: "Trade model with regex tag for symbol - invalid", + trade: &model.Trade{ + Account: "accRegexFail", + Symbol: "USDCAD!", + Volume: 1, + OpenPrice: 1.2, + ClosePrice: 1.3, + Side: "sell", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTrade(tt.trade) + if tt.wantError { + assert.Error(t, err, "Expected an error for test case: %s", tt.name) + } else { + assert.NoError(t, err, "Expected no error for test case: %s", tt.name) + } + }) + } +} diff --git a/cmd/worker/main.go b/cmd/worker/main.go index afc56f8..c432a8d 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "flag" + "gitlab.com/digineat/go-broker-test/internal/model" "log" "time" @@ -32,7 +33,45 @@ func main() { // Main worker loop for { // TODO: Write code here - + rows, err := db.Query("SELECT * FROM trades_q") + if err != nil { + log.Fatalf("Failed to query trades: %v", err) + } + defer rows.Close() + results := make(map[string]*model.AccountStats) + for rows.Next() { + var trade model.Trade + if err := rows.Scan(&trade.ID, &trade.Account, &trade.Symbol, &trade.Volume, &trade.OpenPrice, &trade.ClosePrice, &trade.Side, &trade.CreatedAt, &trade.UpdatedAt); err != nil { + log.Fatalf("Failed to scan trade: %v", err) + } + if stats, ok := results[trade.Account]; ok { + stats.TradesCount++ + stats.Profit += trade.CalculateProfit() + } else { + results[trade.Account] = &model.AccountStats{ + Account: trade.Account, + TradesCount: 1, + Profit: trade.CalculateProfit(), + } + } + + } + + for accountID, stat := range results { + _, err := db.Exec(` + INSERT INTO account_stats (account, trades_count, profit, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(account) DO UPDATE SET + trades_count = excluded.trades_count, + profit = excluded.profit, + updated_at = CURRENT_TIMESTAMP; + `, stat.Account, stat.TradesCount, stat.Profit) + if err != nil { + log.Printf("Error updating account_stats for account %s: %v", accountID, err) + + } + } + // Sleep for the specified interval time.Sleep(*pollInterval) } diff --git a/go.mod b/go.mod index bfd51fc..ad3df2a 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,22 @@ module gitlab.com/digineat/go-broker-test go 1.24.2 -require github.com/mattn/go-sqlite3 v1.14.28 +require ( + github.com/go-playground/validator/v10 v10.26.0 + github.com/mattn/go-sqlite3 v1.14.28 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 42e5bac..edb4caa 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,32 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/db/data.db b/internal/db/data.db new file mode 100644 index 0000000000000000000000000000000000000000..c389bbae0c1f88caec503e6f4c998ac9ca2c4c1e GIT binary patch literal 24576 zcmeI&&rTCT90%}Sz_zYbj28}uBDa^E0xB!gsu0!28@2YjG2+*Yk&-i2EAs zr9qxA$Q!zD5)RM$N4}ekJSF_bcZYqRgyl3_6TSTb_e?o~o@_ED+;jbqr|~J_(6Q5@ ziKbjzF2fSsHs#-e)R00(bB~eDkFB9AXsfI1tZkT4<$+P(+fSA=9N7Qdr}v8YUQ;!m zO|wna%F*zwvPPAVyKbdc(`x5$3&ne^QW`CZz&SbLfj9}w)YS}yB|`J^y1Dx7*6Ph2 zdCTa!e&zUX)wd1VuDc(tmpX0AjYiuYomhQqm-Nnyvi6kiG}}qHv_ro20W;`wUiA2? z8NJwLI?FC1TXb5D^>mQUvc}qt^LGoy%b*8iJqCe)?1&@*uQpsCZ#w32 zky6=^S5m61)i!PvmiH>E;&?WH8=kuI-NZ~DGFhI;frq(O-zfi57Nb4lygOe!e_kry z+gvHV?~Y~A#iM>XsjMo)9Lt+5G?r>pGbU@7OO_;8;`V8(Tr~UZERlOqk|2(~8KuVl zTp`-y4&%f~X+nNF0SG_<0uX=z1Rwwb2tWV=5P-l82;5cYS4$$erOvO&AIWFr zqW6FKp?@3@fB*y_009U<00Izz00bZa0SL^nfEKL{@BcG=hp{vWKmY;|fB*y_009U< k00IzzfTH04|HuLeKmY;|fB*y_009U<00Izz!0ZeB1`EGK*Z=?k literal 0 HcmV?d00001 diff --git a/internal/model/account_stats.go b/internal/model/account_stats.go new file mode 100644 index 0000000..78052f2 --- /dev/null +++ b/internal/model/account_stats.go @@ -0,0 +1,10 @@ +package model + +import "time" + +type AccountStats struct { + Account string `json:"account" db:"account"` + TradesCount int64 `json:"trades" db:"trades_count"` + Profit float64 `json:"profit" db:"profit"` + UpdatedAt time.Time `json:"-" db:"updated_at"` +} diff --git a/internal/model/trade.go b/internal/model/trade.go new file mode 100644 index 0000000..7d702a2 --- /dev/null +++ b/internal/model/trade.go @@ -0,0 +1,26 @@ +package model + +import "time" + +type Trade struct { + ID int64 `json:"id,omitempty" db:"id"` + Account string `json:"account" db:"account" validate:"required"` + Symbol string `json:"symbol" db:"symbol" validate:"required,symbol"` + Volume float64 `json:"volume" db:"volume" validate:"required,gt=0"` + OpenPrice float64 `json:"open" db:"open_price" validate:"required,gt=0"` + ClosePrice float64 `json:"close" db:"close_price" validate:"required,gt=0"` + Side string `json:"side" db:"side" validate:"required,oneof=buy sell"` + CreatedAt time.Time `json:"created_at,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty" db:"updated_at"` +} + +// Profit рассчитывает прибыль для сделки. +// Этот метод можно добавить сюда для удобства, или он может быть частью логики воркера. +func (t *Trade) CalculateProfit() float64 { + lot := 100000.0 + profit := (t.ClosePrice - t.OpenPrice) * t.Volume * lot + if t.Side == "sell" { + profit = -profit + } + return profit +}