0
mirror of https://github.com/valentineus/go-metatrader4.git synced 2025-06-08 02:03:34 +03:00

First version

This commit is contained in:
Valentin Popov 2025-06-04 12:08:19 +04:00 committed by GitHub
parent 76dc648f33
commit 7240595478
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 444 additions and 1 deletions

View File

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

53
examples/info/README.md Normal file
View File

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

25
examples/info/main.go Normal file
View File

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

4
go.mod
View File

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

2
go.sum Normal file
View File

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

View File

50
internal/conn/conn.go Normal file
View File

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

65
internal/proto/proto.go Normal file
View File

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

View File

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

115
mt4/client.go Normal file
View File

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

45
mt4/client_test.go Normal file
View File

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