completed task

This commit is contained in:
snegi512 2025-05-07 00:25:34 +03:00
parent d0b7aa142a
commit 88087b3e54
12 changed files with 705 additions and 110 deletions

170
README.md
View File

@ -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`.
## Примеры API запросов (cURL)
## Purpose of This Task
### 1. Отправка (POST /trades)
We want to assess your ability to write clean HTTP code, work with SQL, and reason about concurrency.
* **Успешный запрос:**
## What You Should Build
```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` (без тела ответа, если не изменено).
### Components and Architecture
* **Запрос с ошибкой валидации (неверный формат symbol):**
```text
┌──────────────┐ POST /trades ┌───────────────┐
│ API Server │ ─────────────────► │ Queue Table │
│ cmd/server │ └───────────────┘
└──────────────┘ ▲
│ SELECT … FOR UPDATE
┌──────────────┐ UPDATE stats │
│ Worker │ ◄───────────────────────┘
│ cmd/worker │
└──────────────┘
```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
}
```
* **Для несуществующего аккаунта или аккаунта без сделок:**
```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`.

View File

@ -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

View File

@ -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
View 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)
}
})
}

View 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)
}

View 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)
}
})
}
}

View File

@ -3,6 +3,7 @@ package main
import (
"database/sql"
"flag"
"gitlab.com/digineat/go-broker-test/internal/model"
"log"
"time"
@ -32,6 +33,44 @@ 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
View File

@ -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
View File

@ -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

Binary file not shown.

View 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
View 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
}