Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 13 additions & 54 deletions cmd/recomma/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"context"
"database/sql"
"errors"
"log"
"log/slog"
Expand All @@ -17,12 +16,12 @@ import (

"github.com/go-webauthn/webauthn/webauthn"
"github.com/rs/cors"
"github.com/sonirico/go-hyperliquid"

tc "github.com/terwey/3commas-sdk-go/threecommas"
"github.com/terwey/recomma/cmd/recomma/internal/config"
"github.com/terwey/recomma/emitter"
"github.com/terwey/recomma/engine"
"github.com/terwey/recomma/filltracker"
"github.com/terwey/recomma/hl"
"github.com/terwey/recomma/hl/ws"
"github.com/terwey/recomma/internal/api"
Expand Down Expand Up @@ -69,7 +68,7 @@ func main() {

appCtx = rlog.ContextWithLogger(appCtx, logger)

store, err := storage.New(cfg.StoragePath, nil)
store, err := storage.New(cfg.StoragePath)
if err != nil {
fatal("storage init failed", err)
}
Expand Down Expand Up @@ -200,7 +199,12 @@ func main() {
fatal("Could not create Hyperliquid Exchange", err)
}

ws, err := ws.New(appCtx, store, secrets.Secrets.HYPERLIQUIDWALLET, secrets.Secrets.HYPERLIQUIDURL)
fillTracker := filltracker.New(store, logger)
if err := fillTracker.Rebuild(appCtx); err != nil {
logger.Warn("fill tracker rebuild failed", slog.String("error", err.Error()))
}

ws, err := ws.New(appCtx, store, fillTracker, secrets.Secrets.HYPERLIQUIDWALLET, secrets.Secrets.HYPERLIQUIDURL)
if err != nil {
fatal("Could not create Hyperliquid websocket conn", err)
}
Expand Down Expand Up @@ -230,6 +234,7 @@ func main() {
e := engine.NewEngine(client,
engine.WithStorage(store),
engine.WithEmitter(engineEmitter),
engine.WithFillTracker(fillTracker),
)

submitter := emitter.NewHyperLiquidEmitter(exchange, ws, store)
Expand All @@ -253,57 +258,11 @@ func main() {
}
produceOnce(appCtx)

cancelTakeProfit := func(ctx context.Context) {
deals, err := store.ListDealIDs(ctx)
if err != nil {
slog.Debug("cancelTakeProfit ListDeals returned an error", slog.String("error", err.Error()))
}

logger.Debug("checking deals if completed", slog.Any("deals", deals))

for _, d := range deals {
dealLogger := logger.With("deal_id", d)
filled, err := store.DealSafetiesFilled(uint32(d))
if err != nil {
dealLogger.Debug("cancelTakeProfit DealSafetiesFilled returned an error", slog.String("error", err.Error()))
}
if !filled {
continue
}

md, event, err := store.LoadTakeProfitForDeal(uint32(d))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
dealLogger.Debug("deal has no TakeProfit")
continue
}
dealLogger.Warn("cancelTakeProfit LoadTakeProfitForDeal returned an error", slog.String("error", err.Error()))
continue
}

cancelCtx, cancel := context.WithTimeout(ctx, 30*time.Second)

err = submitter.Emit(cancelCtx, recomma.OrderWork{
MD: *md,
Action: recomma.Action{
Type: recomma.ActionCancel,
Cancel: &hyperliquid.CancelOrderRequestByCloid{
Coin: event.Coin,
Cloid: md.Hex(),
},
Reason: "safeties filled for deal",
},
})
cancel()
if err != nil {
dealLogger.Warn("could not cancel TP", slog.String("error", err.Error()))
} else {
dealLogger.Info("Safeties filled for Deal, cancelled TP")
}
}
reconcileTakeProfits := func(ctx context.Context) {
fillTracker.CancelCompletedTakeProfits(ctx, submitter)
}

cancelTakeProfit(appCtx)
reconcileTakeProfits(appCtx)

// Periodic resync; stops automatically when ctx is cancelled
resync := cfg.ResyncInterval
Expand All @@ -322,7 +281,7 @@ func main() {
return
}
produceOnce(appCtx)
cancelTakeProfit(appCtx)
reconcileTakeProfits(appCtx)
}
}
}()
Expand Down
118 changes: 118 additions & 0 deletions engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"time"

tc "github.com/terwey/3commas-sdk-go/threecommas"
"github.com/terwey/recomma/adapter"
"github.com/terwey/recomma/filltracker"
"github.com/terwey/recomma/metadata"
"github.com/terwey/recomma/recomma"
"github.com/terwey/recomma/storage"
Expand Down Expand Up @@ -38,6 +40,7 @@ type Engine struct {
store *storage.Storage
emitter recomma.Emitter
logger *slog.Logger
tracker *filltracker.Service
}

type EngineOption func(*Engine)
Expand All @@ -54,6 +57,12 @@ func WithEmitter(emitter recomma.Emitter) EngineOption {
}
}

func WithFillTracker(tracker *filltracker.Service) EngineOption {
return func(h *Engine) {
h.tracker = tracker
}
}

func NewEngine(client ThreeCommasAPI, opts ...EngineOption) *Engine {
e := &Engine{
client: client,
Expand Down Expand Up @@ -182,6 +191,22 @@ func (e *Engine) processDeal(ctx context.Context, wi WorkKey, currency string, e
logger := e.logger.With("deal-id", wi.DealID).With("bot-id", wi.BotID)
seen := make(map[uint32]metadata.Metadata)

var fillSnapshot *filltracker.DealSnapshot
if e.tracker != nil {
if snapshot, ok := e.tracker.Snapshot(wi.DealID); ok {
fillSnapshot = &snapshot
logger.Debug("fill snapshot",
slog.Float64("net_qty", snapshot.Position.NetQty),
slog.Float64("avg_entry", snapshot.Position.AverageEntry),
slog.Bool("all_buys_filled", snapshot.AllBuysFilled),
slog.Float64("outstanding_buys", snapshot.OutstandingBuyQty),
slog.Float64("outstanding_sells", snapshot.OutstandingSellQty),
)
} else {
logger.Debug("fill snapshot unavailable")
}
}

for _, event := range events {
md := metadata.Metadata{
BotID: wi.BotID,
Expand All @@ -199,6 +224,7 @@ func (e *Engine) processDeal(ctx context.Context, wi WorkKey, currency string, e
if event.OrderType == tc.MarketOrderDealOrderTypeTakeProfit {
if event.Action == tc.BotEventActionCancel || event.Action == tc.BotEventActionCancelled {
// we ignore Take Profit cancellations, we cancel TP's ourselves based on the combined orders for the deal
// TODO: figure out that the Take Profit SHOULD be cancelled because the price changed!!
continue
}
}
Expand Down Expand Up @@ -227,6 +253,12 @@ func (e *Engine) processDeal(ctx context.Context, wi WorkKey, currency string, e
if !shouldEmit {
continue
}
if fillSnapshot != nil && latestEvent != nil {
action, shouldEmit = e.adjustActionWithTracker(currency, md, *latestEvent, action, fillSnapshot, logger.With("botevent-id", md.BotEventID))
if !shouldEmit {
continue
}
}
work := recomma.OrderWork{MD: md, Action: action}
if latestEvent != nil {
work.BotEvent = *latestEvent
Expand Down Expand Up @@ -348,3 +380,89 @@ func sameSnapshot(a, b *recomma.BotEvent) bool {
a.Type == b.Type &&
a.IsMarket == b.IsMarket
}

const qtyTolerance = 1e-6

func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyTolerance
}

func (e *Engine) adjustActionWithTracker(
currency string,
md metadata.Metadata,
latest recomma.BotEvent,
action recomma.Action,
snapshot *filltracker.DealSnapshot,
logger *slog.Logger,
) (recomma.Action, bool) {
if snapshot == nil {
return action, true
}
if latest.OrderType != tc.MarketOrderDealOrderTypeTakeProfit {
return action, true
}

desiredQty := snapshot.Position.NetQty
if desiredQty <= qtyTolerance {
logger.Info("skipping take profit placement: position flat",
slog.Float64("net_qty", desiredQty),
slog.Any("action_type", action.Type),
)
return recomma.Action{Type: recomma.ActionNone, Reason: "skip take-profit: position flat"}, false
}

active := snapshot.ActiveTakeProfit
if active != nil && active.ReduceOnly && nearlyEqual(active.RemainingQty, desiredQty) {
logger.Debug("take profit already matches position",
slog.Float64("desired_qty", desiredQty),
slog.Float64("existing_qty", active.RemainingQty),
)
return recomma.Action{Type: recomma.ActionNone, Reason: "take-profit already matches position"}, false
}

switch action.Type {
case recomma.ActionNone:
req := adapter.ToCreateOrderRequest(currency, latest, md)
req.Size = desiredQty
req.ReduceOnly = true
logger.Info("placing take profit to match position",
slog.Float64("desired_qty", desiredQty),
slog.Float64("price", req.Price),
)
return recomma.Action{Type: recomma.ActionCreate, Create: &req}, true
case recomma.ActionCreate:
if action.Create == nil {
req := adapter.ToCreateOrderRequest(currency, latest, md)
action.Create = &req
}
action.Create.Size = desiredQty
action.Create.ReduceOnly = true
logger.Info("creating take profit with tracked size",
slog.Float64("desired_qty", desiredQty),
slog.Float64("price", action.Create.Price),
)
return action, true
case recomma.ActionModify:
if action.Modify == nil {
req := adapter.ToCreateOrderRequest(currency, latest, md)
req.Size = desiredQty
req.ReduceOnly = true
logger.Warn("modify without prior request; emitting create instead",
slog.Float64("desired_qty", desiredQty),
)
return recomma.Action{Type: recomma.ActionCreate, Create: &req}, true
}
action.Modify.Order.Size = desiredQty
action.Modify.Order.ReduceOnly = true
logger.Info("modifying take profit to tracked size",
slog.Float64("desired_qty", desiredQty),
slog.Float64("price", action.Modify.Order.Price),
)
return action, true
case recomma.ActionCancel:
logger.Debug("take profit cancel requested", slog.Float64("net_qty", desiredQty))
return action, true
default:
return action, true
}
}
Loading