aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2025-06-04 11:08:19 +0300
committerGitHub <noreply@github.com>2025-06-04 11:08:19 +0300
commit7240595478fedec02e9e47c704976cf56a66d3e8 (patch)
tree300165d4ecf00fed03d33b36863370384cbdc7ce
parent76dc648f33a9f96e68d5c9000032c2b986bd5a3d (diff)
downloadgo-metatrader4-7240595478fedec02e9e47c704976cf56a66d3e8.tar.xz
go-metatrader4-7240595478fedec02e9e47c704976cf56a66d3e8.zip
First version
-rw-r--r--README.md47
-rw-r--r--examples/info/README.md53
-rw-r--r--examples/info/main.go25
-rw-r--r--go.mod4
-rw-r--r--go.sum2
-rw-r--r--internal/.gitkeep0
-rw-r--r--internal/conn/conn.go50
-rw-r--r--internal/proto/proto.go65
-rw-r--r--internal/proto/proto_test.go39
-rw-r--r--mt4/client.go115
-rw-r--r--mt4/client_test.go45
11 files changed, 444 insertions, 1 deletions
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
--- a/internal/.gitkeep
+++ /dev/null
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)
+ }
+}