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:
parent
76dc648f33
commit
7240595478
47
README.md
47
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).
|
53
examples/info/README.md
Normal file
53
examples/info/README.md
Normal 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
25
examples/info/main.go
Normal 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
4
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
|
||||
|
2
go.sum
Normal file
2
go.sum
Normal 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=
|
50
internal/conn/conn.go
Normal file
50
internal/conn/conn.go
Normal 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
65
internal/proto/proto.go
Normal 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))
|
||||
}
|
39
internal/proto/proto_test.go
Normal file
39
internal/proto/proto_test.go
Normal 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
115
mt4/client.go
Normal 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
45
mt4/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user