diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..237a330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store + +# Go build artifacts +/bin/ +/dist/ +*.test +*.out + +# Local configuration and secrets +.env +config.yaml + +# Editor folders +.idea/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b0550c --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: fmt test run tidy + +fmt: + go fmt ./... + +test: + go test ./... + +run: + go run ./cmd/bot + +tidy: + go mod tidy diff --git a/README.md b/README.md index e69de29..5f7c2c4 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,21 @@ +# Overnight Trading Bot + +Go-проект для overnight-бота по фондам T-Капитала через T-Invest API. + +## Quick Start + +```sh +cp config.example.yaml config.yaml +go test ./... +go run ./cmd/bot -config config.yaml +``` + +## Development + +```sh +make fmt +make test +make run +``` + +`config.yaml` не коммитится: в нем будут локальные настройки, account id и ссылки на переменные окружения с токенами. diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..90ffb8b --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "overnight-trading-bot/internal/app" +) + +func main() { + configPath := flag.String("config", "config.yaml", "path to YAML configuration file") + flag.Parse() + + if err := app.Run(context.Background(), app.Options{ + ConfigPath: *configPath, + Stdout: os.Stdout, + }); err != nil { + fmt.Fprintf(os.Stderr, "bot failed: %v\n", err) + os.Exit(1) + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..6ddde04 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,70 @@ +app: + mode: paper + timezone: Europe/Moscow + +tinkoff: + token_env: T_INVEST_TOKEN + account_id_env: T_INVEST_ACCOUNT_ID + +risk: + max_position_rub: 10000 + min_net_edge_bps: 10 + risk_buffer_bps: 5 + max_spread_bps: 20 + max_tick_bps: 5 + min_adv_rub: 10000000 + +signal: + overnight_window_short: 60 + overnight_window_long: 252 + min_tstat_short: 1.25 + min_win_rate_short: 0.55 + ewma_lambda: 0.08 + +execution: + entry_window: "18:30-18:45" + exit_window: "10:15-10:45" + limit_order_only: true + +instruments: + - ticker: TRUR + enabled: true + expected_commission_bps_per_side: 0 + free_order_limit_per_day: 15 + fund_type: mixed + - ticker: TGLD + enabled: true + expected_commission_bps_per_side: 0 + free_order_limit_per_day: 15 + fund_type: commodity + - ticker: TBRU + enabled: true + expected_commission_bps_per_side: 0 + fund_type: bonds + - ticker: TDIV + enabled: true + expected_commission_bps_per_side: 0 + fund_type: equity_income + - ticker: TMON + enabled: true + expected_commission_bps_per_side: 0 + fund_type: money_market + - ticker: TOFZ + enabled: true + expected_commission_bps_per_side: 0 + fund_type: bonds + - ticker: TLCB + enabled: true + expected_commission_bps_per_side: 0 + fund_type: corporate_bonds + - ticker: TITR + enabled: true + expected_commission_bps_per_side: 0 + fund_type: equity + - ticker: TRND + enabled: true + expected_commission_bps_per_side: 0 + fund_type: equity + - ticker: TMOS + enabled: false + exclude_reason: "Excluded by default due to possible non-zero sell-side fee" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26595bb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module overnight-trading-bot + +go 1.26.2 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..2810d53 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,39 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "os" +) + +type Options struct { + ConfigPath string + Stdout io.Writer +} + +func Run(ctx context.Context, opts Options) error { + if err := ctx.Err(); err != nil { + return err + } + + if opts.ConfigPath == "" { + return errors.New("config path is required") + } + + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + + if _, err := os.Stat(opts.ConfigPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("config file %q does not exist; copy config.example.yaml to config.yaml and fill credentials", opts.ConfigPath) + } + + return fmt.Errorf("check config file %q: %w", opts.ConfigPath, err) + } + + fmt.Fprintf(opts.Stdout, "overnight trading bot initialized with config %q\n", opts.ConfigPath) + return nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..8ec9a2f --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,51 @@ +package app + +import ( + "bytes" + "context" + "os" + "strings" + "testing" +) + +func TestRunRequiresConfigPath(t *testing.T) { + err := Run(context.Background(), Options{}) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "config path is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunReportsMissingConfig(t *testing.T) { + err := Run(context.Background(), Options{ConfigPath: "missing.yaml"}) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunUsesExistingConfig(t *testing.T) { + touch := t.TempDir() + "/config.yaml" + if err := os.WriteFile(touch, []byte("instruments: []\n"), 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + err := Run(context.Background(), Options{ + ConfigPath: touch, + Stdout: &stdout, + }) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(stdout.String(), "initialized") { + t.Fatalf("unexpected stdout: %q", stdout.String()) + } +}