completed task
This commit is contained in:
parent
d0b7aa142a
commit
88087b3e54
160
README.md
160
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.
|
|
||||||
|
|
||||||
Your code must compile, pass `go vet` and `go test -race`, and run via `docker-compose up`.
|
|
||||||
|
|
||||||
## Purpose of This Task
|
|
||||||
|
|
||||||
We want to assess your ability to write clean HTTP code, work with SQL, and reason about concurrency.
|
|
||||||
|
|
||||||
## What You Should Build
|
|
||||||
|
|
||||||
### Components and Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌──────────────┐ POST /trades ┌───────────────┐
|
|
||||||
│ API Server │ ─────────────────► │ Queue Table │
|
|
||||||
│ cmd/server │ └───────────────┘
|
|
||||||
└──────────────┘ ▲
|
|
||||||
│ SELECT … FOR UPDATE
|
|
||||||
┌──────────────┐ UPDATE stats │
|
|
||||||
│ Worker │ ◄───────────────────────┘
|
|
||||||
│ cmd/worker │
|
|
||||||
└──────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **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
|
## Примеры API запросов (cURL)
|
||||||
|
|
||||||
| Field | Type | Validation Rule |
|
### 1. Отправка (POST /trades)
|
||||||
| - | - | - |
|
|
||||||
| `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
|
```bash
|
||||||
lot := 100000.0
|
curl -X POST -H "Content-Type: application/json" -d '{
|
||||||
profit := (close - open) * volume * lot
|
"account": "ACC001",
|
||||||
if side == "sell" { profit = -profit }
|
"symbol": "EURUSD",
|
||||||
|
"volume": 1.5,
|
||||||
|
"open": 1.0750,
|
||||||
|
"close": 1.0780,
|
||||||
|
"side": "buy"
|
||||||
|
}' http://localhost:8080/trades
|
||||||
|
```
|
||||||
|
Ожидаемый ответ: `HTTP/1.1 200 OK` (без тела ответа, если не изменено).
|
||||||
|
|
||||||
|
* **Запрос с ошибкой валидации (неверный формат symbol):**
|
||||||
|
|
||||||
|
```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` (с телом ошибки, описывающим проблему валидации).
|
||||||
|
|
||||||
|
* **Запрос с отсутствующим обязательным полем (например, 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
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTTP Contracts
|
* **Для несуществующего аккаунта или аккаунта без сделок:**
|
||||||
|
|
||||||
| Method | URL | Request / Response | Expected Behavior |
|
```bash
|
||||||
| - | - | - | - |
|
curl -X GET http://localhost:8080/stats/UNKNOWN_ACC
|
||||||
| 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) |
|
```json
|
||||||
|
{
|
||||||
### How to Run
|
"account": "UNKNOWN_ACC",
|
||||||
|
"trades": 0,
|
||||||
```shell
|
"profit": 0
|
||||||
# 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:
|
### 3. Проверка состояния сервиса (GET /healthz)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8080/healthz
|
||||||
```
|
```
|
||||||
curl -X POST http://localhost:8080/trades \
|
Ожидаемый ответ: `HTTP/1.1 200 OK` (тело ответа может быть пустым или содержать "OK").
|
||||||
-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`.
|
|
||||||
|
|||||||
@ -30,6 +30,8 @@ COPY --from=builder /app/server .
|
|||||||
# Create directory for database
|
# Create directory for database
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
COPY /internal/db/data.db /data/data.db
|
||||||
|
|
||||||
# Expose the port
|
# Expose the port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,99 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "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() {
|
func main() {
|
||||||
// Command line flags
|
// Command line flags
|
||||||
dbPath := flag.String("db", "data.db", "path to SQLite database")
|
dbPath := flag.String("db", "data.db", "path to SQLite database")
|
||||||
@ -28,28 +113,16 @@ func main() {
|
|||||||
log.Fatalf("Failed to ping database: %v", err)
|
log.Fatalf("Failed to ping database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create server instance
|
||||||
|
app := &server{db: db}
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// POST /trades endpoint
|
// Register handlers
|
||||||
mux.HandleFunc("POST /trades", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /trades", app.handlePostTrades())
|
||||||
// TODO: Write code here
|
mux.HandleFunc("GET /stats/{acc}", app.handleGetStats())
|
||||||
w.WriteHeader(http.StatusOK)
|
mux.HandleFunc("GET /healthz", app.handleGetHealthz())
|
||||||
})
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
serverAddr := fmt.Sprintf(":%s", *listenAddr)
|
serverAddr := fmt.Sprintf(":%s", *listenAddr)
|
||||||
|
|||||||
220
cmd/server/main_test.go
Normal file
220
cmd/server/main_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
26
cmd/server/validator/validator.go
Normal file
26
cmd/server/validator/validator.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
159
cmd/server/validator/validator_test.go
Normal file
159
cmd/server/validator/validator_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"flag"
|
"flag"
|
||||||
|
"gitlab.com/digineat/go-broker-test/internal/model"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -32,6 +33,44 @@ func main() {
|
|||||||
// Main worker loop
|
// Main worker loop
|
||||||
for {
|
for {
|
||||||
// TODO: Write code here
|
// 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
|
// Sleep for the specified interval
|
||||||
time.Sleep(*pollInterval)
|
time.Sleep(*pollInterval)
|
||||||
|
|||||||
20
go.mod
20
go.mod
@ -2,4 +2,22 @@ module gitlab.com/digineat/go-broker-test
|
|||||||
|
|
||||||
go 1.24.2
|
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
|
||||||
|
)
|
||||||
|
|||||||
30
go.sum
30
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 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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=
|
||||||
|
|||||||
BIN
internal/db/data.db
Normal file
BIN
internal/db/data.db
Normal file
Binary file not shown.
10
internal/model/account_stats.go
Normal file
10
internal/model/account_stats.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
26
internal/model/trade.go
Normal file
26
internal/model/trade.go
Normal file
@ -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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user