From 7240595478fedec02e9e47c704976cf56a66d3e8 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Wed, 4 Jun 2025 12:08:19 +0400 Subject: [PATCH] First version --- README.md | 47 ++++++++++++++ examples/info/README.md | 53 ++++++++++++++++ examples/info/main.go | 25 ++++++++ go.mod | 4 +- go.sum | 2 + internal/.gitkeep | 0 internal/conn/conn.go | 50 +++++++++++++++ internal/proto/proto.go | 65 ++++++++++++++++++++ internal/proto/proto_test.go | 39 ++++++++++++ mt4/client.go | 115 +++++++++++++++++++++++++++++++++++ mt4/client_test.go | 45 ++++++++++++++ 11 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 examples/info/README.md create mode 100644 examples/info/main.go create mode 100644 go.sum delete mode 100644 internal/.gitkeep create mode 100644 internal/conn/conn.go create mode 100644 internal/proto/proto.go create mode 100644 internal/proto/proto_test.go create mode 100644 mt4/client.go create mode 100644 mt4/client_test.go diff --git a/README.md b/README.md index e69de29..d047a8e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,47 @@ +# MT4 Client Library + +A lightweight Go client library for interacting with a MetaTrader 4 (MT4) trading server over TCP. + +## Example Usage + +```go +import "go.popov.link/metatrader4/mt4" + +client := mt4.NewClient("127.0.0.1", 443, + mt4.WithDialTimeout(3*time.Second), + mt4.WithAutoClose(true), +) +ctx := context.Background() +params := map[string]string{ + "login": "55555", + "password": "_some_password_", +} +res, err := client.Execute(ctx, "WWAPUSER", params) +``` + +The `Execute` method sends a raw MT4 command. Parameters are encoded using base64 and Windows-1251. +Use `WithAutoClose(false)` if you want to reuse the connection manually via `client.Close()`. + +## Options + +- `WithDialTimeout(d time.Duration)`: Sets the timeout for establishing a TCP connection. Default: 5s. +- `WithReadTimeout(d time.Duration)`: Sets the maximum time to wait for a server response. Default: 5s. +- `WithWriteTimeout(d time.Duration)`: Sets the maximum time to complete sending a request. Default: 5s. +- `WithAutoClose(enabled bool)`: If `true`, closes the connection after each `Execute` (default). Use `false` to reuse the session manually via `client.Close()`. + +## Requirements + +- Go 1.24 or later +- MetaTrader 4 server with TCP access + +## Maintainer & Project Info + +- Vanity import path: `go.popov.link/metatrader4` +- Source mirror (read-only): [code.popov.link](https://code.popov.link/valentineus/go-metatrader4) +- Issues and contributions: [GitHub](https://github.com/valentineus/go-metatrader4/issues) + +Maintained by [Valentin Popov](mailto:valentin@popov.link). + +## License + +This project is licensed under the [MIT License](LICENSE.txt). \ No newline at end of file diff --git a/examples/info/README.md b/examples/info/README.md new file mode 100644 index 0000000..e6ac4d4 --- /dev/null +++ b/examples/info/README.md @@ -0,0 +1,53 @@ +# Example: INFO Command + +This example demonstrates how to use the [`go-metatrader4`](https://github.com/valentineus/go-metatrader4) library to send the `INFO` command to a MetaTrader 4 (MT4) server and retrieve server information. + +The `INFO` command requests basic server details such as build version and company name. + +## Usage + +To run this example: + +```bash +go run main.go +``` + +Make sure you are connected to an MT4 server that accepts TCP connections on the configured host and port. + +## Code Overview + +```go +client := mt4.NewClient("127.0.0.1", 443, + mt4.WithDialTimeout(3*time.Second), + mt4.WithReadTimeout(5*time.Second), + mt4.WithWriteTimeout(5*time.Second), +) +ctx := context.Background() +resp, err := client.Execute(ctx, "INFO", nil) + +``` + +This code creates an MT4 client, sends the INFO command without parameters, and prints the response to stdout. + +## Expected Response Format + +The response typically looks like this: + +```text +MetaTrader 4 Server 4.00 build 1380 +Some Broker Company Name +``` + +Where: + +- `build 1380` — current server build number +- `Some Broker Company Name` — name of the White Label owner of the server + +## Requirements + +- Go 1.24 or later +- Access to a running MetaTrader 4 server + +## License + +This example is provided under the MIT License. See the [main project license](../../LICENSE.txt) for details. \ No newline at end of file diff --git a/examples/info/main.go b/examples/info/main.go new file mode 100644 index 0000000..260ecba --- /dev/null +++ b/examples/info/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "go.popov.link/metatrader4/mt4" +) + +func main() { + client := mt4.NewClient("127.0.0.1", 443, + mt4.WithDialTimeout(3*time.Second), + mt4.WithReadTimeout(5*time.Second), + mt4.WithWriteTimeout(5*time.Second), + ) + ctx := context.Background() + // INFO does not require parameters + resp, err := client.Execute(ctx, "INFO", nil) + if err != nil { + log.Fatal(err) + } + fmt.Println(resp) +} diff --git a/go.mod b/go.mod index 6ee1f59..e2803ab 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ -module github.com/valentineus/go-metatrader4 +module go.popov.link/metatrader4 go 1.24.2 + +require golang.org/x/text v0.25.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3470e4e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= diff --git a/internal/.gitkeep b/internal/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/conn/conn.go b/internal/conn/conn.go new file mode 100644 index 0000000..a1da6f1 --- /dev/null +++ b/internal/conn/conn.go @@ -0,0 +1,50 @@ +package conn + +import ( + "context" + "io" + "net" + "time" +) + +type Conn struct { + netConn net.Conn +} + +// FromNetConn wraps an existing net.Conn. Useful for tests. +func FromNetConn(n net.Conn) *Conn { return &Conn{netConn: n} } + +func Dial(ctx context.Context, addr string, timeout time.Duration) (*Conn, error) { + d := net.Dialer{Timeout: timeout} + c, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + return &Conn{netConn: c}, nil +} + +func (c *Conn) Close() error { + if c.netConn == nil { + return nil + } + return c.netConn.Close() +} + +func (c *Conn) Send(ctx context.Context, data []byte, timeout time.Duration) error { + if dl, ok := ctx.Deadline(); ok { + c.netConn.SetWriteDeadline(dl) + } else { + c.netConn.SetWriteDeadline(time.Now().Add(timeout)) + } + _, err := c.netConn.Write(data) + return err +} + +func (c *Conn) Receive(ctx context.Context, timeout time.Duration) ([]byte, error) { + if dl, ok := ctx.Deadline(); ok { + c.netConn.SetReadDeadline(dl) + } else { + c.netConn.SetReadDeadline(time.Now().Add(timeout)) + } + return io.ReadAll(c.netConn) +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go new file mode 100644 index 0000000..d86ce75 --- /dev/null +++ b/internal/proto/proto.go @@ -0,0 +1,65 @@ +package proto + +import ( + "encoding/base64" + "fmt" + "sort" + "strings" + "unicode" + + "golang.org/x/text/encoding/charmap" +) + +// EncodeParams converts params map into a sorted base64-encoded string using Windows-1251 encoding. +func EncodeParams(params map[string]string) (string, error) { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + for i, k := range keys { + if i > 0 { + sb.WriteByte('|') + } + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(params[k]) + } + sb.WriteByte('|') + + enc := charmap.Windows1251.NewEncoder() + encoded, err := enc.String(sb.String()) + if err != nil { + return "", fmt.Errorf("encode params: %w", err) + } + return base64.StdEncoding.EncodeToString([]byte(encoded)), nil +} + +// DecodeResponse decodes base64-encoded Windows-1251 text to UTF-8 and removes control characters. +func DecodeResponse(data string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data)) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + decoded, err := charmap.Windows1251.NewDecoder().Bytes(raw) + if err != nil { + return "", fmt.Errorf("decode charset: %w", err) + } + cleaned := strings.Map(func(r rune) rune { + if unicode.IsPrint(r) || r == '\n' || r == '\r' || r == '\t' { + return r + } + return -1 + }, string(decoded)) + return cleaned, nil +} + +// BuildRequest returns byte slice representing the command and parameters. +func BuildRequest(command, encodedParams string, quit bool) []byte { + if quit { + return []byte(fmt.Sprintf("%s %s\nQUIT\n", command, encodedParams)) + } + return []byte(fmt.Sprintf("%s %s\n", command, encodedParams)) +} diff --git a/internal/proto/proto_test.go b/internal/proto/proto_test.go new file mode 100644 index 0000000..0e7e644 --- /dev/null +++ b/internal/proto/proto_test.go @@ -0,0 +1,39 @@ +package proto + +import ( + "strings" + "testing" +) + +func TestEncodeParamsOrder(t *testing.T) { + params := map[string]string{"B": "2", "A": "1"} + encoded1, err := EncodeParams(params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // encode again with different map order + encoded2, err := EncodeParams(map[string]string{"A": "1", "B": "2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if encoded1 != encoded2 { + t.Fatalf("expected deterministic encode, got %s vs %s", encoded1, encoded2) + } +} + +func TestDecodeResponse(t *testing.T) { + // "привет" in Cyrillic + original := "привет" + params := map[string]string{"MSG": original} + enc, err := EncodeParams(params) + if err != nil { + t.Fatalf("encode params: %v", err) + } + dec, err := DecodeResponse(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !strings.Contains(dec, original) { + t.Fatalf("expected to contain %q, got %q", original, dec) + } +} diff --git a/mt4/client.go b/mt4/client.go new file mode 100644 index 0000000..8a5f388 --- /dev/null +++ b/mt4/client.go @@ -0,0 +1,115 @@ +package mt4 + +import ( + "context" + "fmt" + "net" + "time" + + "go.popov.link/metatrader4/internal/conn" + "go.popov.link/metatrader4/internal/proto" +) + +// Client provides access to a MetaTrader4 server. +type Client struct { + addr string + port int + dialTimeout time.Duration + readTimeout time.Duration + writeTimeout time.Duration + autoClose bool + dialer net.Dialer + c *conn.Conn +} + +// Option configures the Client. +type Option func(*Client) + +// WithDialTimeout sets timeout for establishing connections. +func WithDialTimeout(d time.Duration) Option { return func(c *Client) { c.dialTimeout = d } } + +// WithReadTimeout sets timeout for reading responses. +func WithReadTimeout(d time.Duration) Option { return func(c *Client) { c.readTimeout = d } } + +// WithWriteTimeout sets timeout for writing requests. +func WithWriteTimeout(d time.Duration) Option { return func(c *Client) { c.writeTimeout = d } } + +// WithAutoClose enables or disables automatic connection close after Execute. +func WithAutoClose(b bool) Option { return func(c *Client) { c.autoClose = b } } + +// NewClient creates a new Client with optional configuration. +func NewClient(addr string, port int, opts ...Option) *Client { + cl := &Client{ + addr: addr, + port: port, + dialTimeout: 5 * time.Second, + readTimeout: 5 * time.Second, + writeTimeout: 5 * time.Second, + autoClose: true, + } + for _, o := range opts { + o(cl) + } + return cl +} + +// Connect establishes connection to the MT4 server if not already connected. +func (c *Client) Connect(ctx context.Context) error { + if c.c != nil { + return nil + } + address := fmt.Sprintf("%s:%d", c.addr, c.port) + cn, err := conn.Dial(ctx, address, c.dialTimeout) + if err != nil { + return err + } + c.c = cn + return nil +} + +// Close closes underlying connection. +func (c *Client) Close() error { + if c.c == nil { + return nil + } + err := c.c.Close() + c.c = nil + return err +} + +// Execute sends command with params to the server and returns decoded response. +func (c *Client) Execute(ctx context.Context, command string, params map[string]string) (string, error) { + if err := c.Connect(ctx); err != nil { + return "", fmt.Errorf("connect: %w", err) + } + + encoded, err := proto.EncodeParams(params) + if err != nil { + if c.autoClose { + c.Close() + } + return "", err + } + req := proto.BuildRequest(command, encoded, c.autoClose) + + if err := c.c.Send(ctx, req, c.writeTimeout); err != nil { + if c.autoClose { + c.Close() + } + return "", fmt.Errorf("send: %w", err) + } + + respBytes, err := c.c.Receive(ctx, c.readTimeout) + if c.autoClose { + c.Close() + } + if err != nil { + return "", fmt.Errorf("receive: %w", err) + } + + resp, err := proto.DecodeResponse(string(respBytes)) + if err != nil { + return "", err + } + return resp, nil +} diff --git a/mt4/client_test.go b/mt4/client_test.go new file mode 100644 index 0000000..9644c9a --- /dev/null +++ b/mt4/client_test.go @@ -0,0 +1,45 @@ +package mt4 + +import ( + "context" + "net" + "strings" + "testing" + "time" + + ic "go.popov.link/metatrader4/internal/conn" + "go.popov.link/metatrader4/internal/proto" +) + +// mockServer returns net.Pipe connections with server writing resp to client. +func mockServer(response string) (net.Conn, net.Conn) { + server, client := net.Pipe() + go func() { + defer server.Close() + buf := make([]byte, 1024) + server.Read(buf) // read request ignoring + server.Write([]byte(response)) + }() + return client, server +} + +func TestClientExecute(t *testing.T) { + reqParams := map[string]string{"A": "1"} + encoded, err := proto.EncodeParams(reqParams) + if err != nil { + t.Fatalf("encode params: %v", err) + } + resp := encoded + clientConn, _ := mockServer(resp) + + c := &Client{addr: "", port: 0, autoClose: true, readTimeout: time.Second, writeTimeout: time.Second, dialTimeout: time.Second} + c.c = ic.FromNetConn(clientConn) + + res, err := c.Execute(context.Background(), "CMD", reqParams) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(res, "1") { + t.Fatalf("unexpected response %q", res) + } +}