Skip to content

Commit 7240595

Browse files
authored
First version
1 parent 76dc648 commit 7240595

File tree

11 files changed

+444
-1
lines changed

11 files changed

+444
-1
lines changed

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# MT4 Client Library
2+
3+
A lightweight Go client library for interacting with a MetaTrader 4 (MT4) trading server over TCP.
4+
5+
## Example Usage
6+
7+
```go
8+
import "go.popov.link/metatrader4/mt4"
9+
10+
client := mt4.NewClient("127.0.0.1", 443,
11+
mt4.WithDialTimeout(3*time.Second),
12+
mt4.WithAutoClose(true),
13+
)
14+
ctx := context.Background()
15+
params := map[string]string{
16+
"login": "55555",
17+
"password": "_some_password_",
18+
}
19+
res, err := client.Execute(ctx, "WWAPUSER", params)
20+
```
21+
22+
The `Execute` method sends a raw MT4 command. Parameters are encoded using base64 and Windows-1251.
23+
Use `WithAutoClose(false)` if you want to reuse the connection manually via `client.Close()`.
24+
25+
## Options
26+
27+
- `WithDialTimeout(d time.Duration)`: Sets the timeout for establishing a TCP connection. Default: 5s.
28+
- `WithReadTimeout(d time.Duration)`: Sets the maximum time to wait for a server response. Default: 5s.
29+
- `WithWriteTimeout(d time.Duration)`: Sets the maximum time to complete sending a request. Default: 5s.
30+
- `WithAutoClose(enabled bool)`: If `true`, closes the connection after each `Execute` (default). Use `false` to reuse the session manually via `client.Close()`.
31+
32+
## Requirements
33+
34+
- Go 1.24 or later
35+
- MetaTrader 4 server with TCP access
36+
37+
## Maintainer & Project Info
38+
39+
- Vanity import path: `go.popov.link/metatrader4`
40+
- Source mirror (read-only): [code.popov.link](https://code.popov.link/valentineus/go-metatrader4)
41+
- Issues and contributions: [GitHub](https://github.com/valentineus/go-metatrader4/issues)
42+
43+
Maintained by [Valentin Popov](mailto:[email protected]).
44+
45+
## License
46+
47+
This project is licensed under the [MIT License](LICENSE.txt).

examples/info/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Example: INFO Command
2+
3+
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.
4+
5+
The `INFO` command requests basic server details such as build version and company name.
6+
7+
## Usage
8+
9+
To run this example:
10+
11+
```bash
12+
go run main.go
13+
```
14+
15+
Make sure you are connected to an MT4 server that accepts TCP connections on the configured host and port.
16+
17+
## Code Overview
18+
19+
```go
20+
client := mt4.NewClient("127.0.0.1", 443,
21+
mt4.WithDialTimeout(3*time.Second),
22+
mt4.WithReadTimeout(5*time.Second),
23+
mt4.WithWriteTimeout(5*time.Second),
24+
)
25+
ctx := context.Background()
26+
resp, err := client.Execute(ctx, "INFO", nil)
27+
28+
```
29+
30+
This code creates an MT4 client, sends the INFO command without parameters, and prints the response to stdout.
31+
32+
## Expected Response Format
33+
34+
The response typically looks like this:
35+
36+
```text
37+
MetaTrader 4 Server 4.00 build 1380
38+
Some Broker Company Name
39+
```
40+
41+
Where:
42+
43+
- `build 1380` — current server build number
44+
- `Some Broker Company Name` — name of the White Label owner of the server
45+
46+
## Requirements
47+
48+
- Go 1.24 or later
49+
- Access to a running MetaTrader 4 server
50+
51+
## License
52+
53+
This example is provided under the MIT License. See the [main project license](../../LICENSE.txt) for details.

examples/info/main.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"time"
8+
9+
"go.popov.link/metatrader4/mt4"
10+
)
11+
12+
func main() {
13+
client := mt4.NewClient("127.0.0.1", 443,
14+
mt4.WithDialTimeout(3*time.Second),
15+
mt4.WithReadTimeout(5*time.Second),
16+
mt4.WithWriteTimeout(5*time.Second),
17+
)
18+
ctx := context.Background()
19+
// INFO does not require parameters
20+
resp, err := client.Execute(ctx, "INFO", nil)
21+
if err != nil {
22+
log.Fatal(err)
23+
}
24+
fmt.Println(resp)
25+
}

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
module github.com/valentineus/go-metatrader4
1+
module go.popov.link/metatrader4
22

33
go 1.24.2
4+
5+
require golang.org/x/text v0.25.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
2+
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=

internal/.gitkeep

Whitespace-only changes.

internal/conn/conn.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package conn
2+
3+
import (
4+
"context"
5+
"io"
6+
"net"
7+
"time"
8+
)
9+
10+
type Conn struct {
11+
netConn net.Conn
12+
}
13+
14+
// FromNetConn wraps an existing net.Conn. Useful for tests.
15+
func FromNetConn(n net.Conn) *Conn { return &Conn{netConn: n} }
16+
17+
func Dial(ctx context.Context, addr string, timeout time.Duration) (*Conn, error) {
18+
d := net.Dialer{Timeout: timeout}
19+
c, err := d.DialContext(ctx, "tcp", addr)
20+
if err != nil {
21+
return nil, err
22+
}
23+
return &Conn{netConn: c}, nil
24+
}
25+
26+
func (c *Conn) Close() error {
27+
if c.netConn == nil {
28+
return nil
29+
}
30+
return c.netConn.Close()
31+
}
32+
33+
func (c *Conn) Send(ctx context.Context, data []byte, timeout time.Duration) error {
34+
if dl, ok := ctx.Deadline(); ok {
35+
c.netConn.SetWriteDeadline(dl)
36+
} else {
37+
c.netConn.SetWriteDeadline(time.Now().Add(timeout))
38+
}
39+
_, err := c.netConn.Write(data)
40+
return err
41+
}
42+
43+
func (c *Conn) Receive(ctx context.Context, timeout time.Duration) ([]byte, error) {
44+
if dl, ok := ctx.Deadline(); ok {
45+
c.netConn.SetReadDeadline(dl)
46+
} else {
47+
c.netConn.SetReadDeadline(time.Now().Add(timeout))
48+
}
49+
return io.ReadAll(c.netConn)
50+
}

internal/proto/proto.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package proto
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"sort"
7+
"strings"
8+
"unicode"
9+
10+
"golang.org/x/text/encoding/charmap"
11+
)
12+
13+
// EncodeParams converts params map into a sorted base64-encoded string using Windows-1251 encoding.
14+
func EncodeParams(params map[string]string) (string, error) {
15+
keys := make([]string, 0, len(params))
16+
for k := range params {
17+
keys = append(keys, k)
18+
}
19+
sort.Strings(keys)
20+
21+
var sb strings.Builder
22+
for i, k := range keys {
23+
if i > 0 {
24+
sb.WriteByte('|')
25+
}
26+
sb.WriteString(k)
27+
sb.WriteByte('=')
28+
sb.WriteString(params[k])
29+
}
30+
sb.WriteByte('|')
31+
32+
enc := charmap.Windows1251.NewEncoder()
33+
encoded, err := enc.String(sb.String())
34+
if err != nil {
35+
return "", fmt.Errorf("encode params: %w", err)
36+
}
37+
return base64.StdEncoding.EncodeToString([]byte(encoded)), nil
38+
}
39+
40+
// DecodeResponse decodes base64-encoded Windows-1251 text to UTF-8 and removes control characters.
41+
func DecodeResponse(data string) (string, error) {
42+
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data))
43+
if err != nil {
44+
return "", fmt.Errorf("base64 decode: %w", err)
45+
}
46+
decoded, err := charmap.Windows1251.NewDecoder().Bytes(raw)
47+
if err != nil {
48+
return "", fmt.Errorf("decode charset: %w", err)
49+
}
50+
cleaned := strings.Map(func(r rune) rune {
51+
if unicode.IsPrint(r) || r == '\n' || r == '\r' || r == '\t' {
52+
return r
53+
}
54+
return -1
55+
}, string(decoded))
56+
return cleaned, nil
57+
}
58+
59+
// BuildRequest returns byte slice representing the command and parameters.
60+
func BuildRequest(command, encodedParams string, quit bool) []byte {
61+
if quit {
62+
return []byte(fmt.Sprintf("%s %s\nQUIT\n", command, encodedParams))
63+
}
64+
return []byte(fmt.Sprintf("%s %s\n", command, encodedParams))
65+
}

internal/proto/proto_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package proto
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestEncodeParamsOrder(t *testing.T) {
9+
params := map[string]string{"B": "2", "A": "1"}
10+
encoded1, err := EncodeParams(params)
11+
if err != nil {
12+
t.Fatalf("unexpected error: %v", err)
13+
}
14+
// encode again with different map order
15+
encoded2, err := EncodeParams(map[string]string{"A": "1", "B": "2"})
16+
if err != nil {
17+
t.Fatalf("unexpected error: %v", err)
18+
}
19+
if encoded1 != encoded2 {
20+
t.Fatalf("expected deterministic encode, got %s vs %s", encoded1, encoded2)
21+
}
22+
}
23+
24+
func TestDecodeResponse(t *testing.T) {
25+
// "привет" in Cyrillic
26+
original := "привет"
27+
params := map[string]string{"MSG": original}
28+
enc, err := EncodeParams(params)
29+
if err != nil {
30+
t.Fatalf("encode params: %v", err)
31+
}
32+
dec, err := DecodeResponse(enc)
33+
if err != nil {
34+
t.Fatalf("decode: %v", err)
35+
}
36+
if !strings.Contains(dec, original) {
37+
t.Fatalf("expected to contain %q, got %q", original, dec)
38+
}
39+
}

0 commit comments

Comments
 (0)