completed task
This commit is contained in:
parent
d0b7aa142a
commit
88087b3e54
170
README.md
170
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`.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
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 (
|
||||
"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)
|
||||
}
|
||||
|
||||
20
go.mod
20
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
|
||||
)
|
||||
|
||||
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/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