feat: route nofxos data requests through claw402 x402 payment

When CLAW402_WALLET_KEY env var is set, all nofxos.ai data API calls
(AI500, OI rankings, NetFlow, price rankings, etc.) are automatically
routed through claw402.ai with x402 USDC payment instead of direct
free access.

- New Claw402DataClient for GET requests with x402 payment flow
- Endpoint mapping: /api/xxx -> /api/v1/nofx/xxx
- MakeClaw402SignFunc helper for reusable payment signing
- Auto-detection: if CLAW402_WALLET_KEY is set, claw402 mode activates
- Fallback: without wallet key, direct nofxos.ai access (backward compatible)

Env vars:
  CLAW402_WALLET_KEY=0x...  # wallet private key for payment
  CLAW402_URL=https://claw402.ai  # optional, defaults to claw402.ai
This commit is contained in:
shinchan-zhai
2026-03-25 01:05:31 +08:00
parent cd6fe62e16
commit f1a0725130
12 changed files with 1812 additions and 48 deletions

View File

@@ -390,39 +390,45 @@ func (t *AlpacaTrader) GetMarketPrice(symbol string) (float64, error) {
}
// SetStopLoss places a stop order
// SetStopLoss places a stop order. Uses "nofxi-sl-" client_order_id prefix
// so CancelStopLossOrders can distinguish SL orders from user-placed stop orders.
func (t *AlpacaTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
side := "sell" // stop loss for long = sell when price drops
if positionSide == "SHORT" {
side = "buy"
}
order := AlpacaOrderRequest{
Symbol: symbol,
Qty: fmt.Sprintf("%.4f", quantity),
Side: side,
Type: "stop",
TimeInForce: "gtc",
StopPrice: fmt.Sprintf("%.2f", stopPrice),
order := map[string]string{
"symbol": symbol,
"qty": fmt.Sprintf("%.4f", quantity),
"side": side,
"type": "stop",
"time_in_force": "gtc",
"stop_price": fmt.Sprintf("%.2f", stopPrice),
"client_order_id": fmt.Sprintf("nofxi-sl-%s-%d", symbol, time.Now().UnixNano()),
}
_, err := t.doPost("/v2/orders", order)
return err
}
// SetTakeProfit places a limit order as take-profit
// SetTakeProfit places a limit order as take-profit.
// Uses a "nofxi-tp-" client_order_id prefix so CancelTakeProfitOrders can
// distinguish TP orders from user-placed limit orders.
func (t *AlpacaTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
side := "sell" // take profit for long = sell when price rises
if positionSide == "SHORT" {
side = "buy"
}
order := AlpacaOrderRequest{
Symbol: symbol,
Qty: fmt.Sprintf("%.4f", quantity),
Side: side,
Type: "limit",
TimeInForce: "gtc",
LimitPrice: fmt.Sprintf("%.2f", takeProfitPrice),
order := map[string]string{
"symbol": symbol,
"qty": fmt.Sprintf("%.4f", quantity),
"side": side,
"type": "limit",
"time_in_force": "gtc",
"limit_price": fmt.Sprintf("%.2f", takeProfitPrice),
"client_order_id": fmt.Sprintf("nofxi-tp-%s-%d", symbol, time.Now().UnixNano()),
}
_, err := t.doPost("/v2/orders", order)
@@ -430,13 +436,39 @@ func (t *AlpacaTrader) SetTakeProfit(symbol string, positionSide string, quantit
}
// CancelStopLossOrders cancels stop orders for a symbol
// CancelStopLossOrders cancels only NOFXi-created stop-loss orders
// (identified by "nofxi-sl-" client_order_id prefix).
func (t *AlpacaTrader) CancelStopLossOrders(symbol string) error {
return t.cancelOrdersByType(symbol, "stop")
orders, err := t.getOpenOrdersRaw(symbol)
if err != nil {
return err
}
for _, o := range orders {
if strings.EqualFold(o.Type, "stop") && strings.HasPrefix(o.ClientOrderID, "nofxi-sl-") {
if _, err := t.doDelete("/v2/orders/" + o.ID); err != nil {
logger.Warnf("[Alpaca] cancel SL order %s: %v", o.ID, err)
}
}
}
return nil
}
// CancelTakeProfitOrders cancels limit orders (used as take-profit) for a symbol
// CancelTakeProfitOrders cancels only NOFXi-created take-profit limit orders
// for a symbol (identified by "nofxi-tp-" client_order_id prefix).
// This avoids accidentally canceling user-placed limit buy/sell orders.
func (t *AlpacaTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelOrdersByType(symbol, "limit")
orders, err := t.getOpenOrdersRaw(symbol)
if err != nil {
return err
}
for _, o := range orders {
if strings.EqualFold(o.Type, "limit") && strings.HasPrefix(o.ClientOrderID, "nofxi-tp-") {
if _, err := t.doDelete("/v2/orders/" + o.ID); err != nil {
logger.Warnf("[Alpaca] cancel TP order %s: %v", o.ID, err)
}
}
}
return nil
}
// CancelAllOrders cancels all pending orders for a symbol.
@@ -604,6 +636,23 @@ func (t *AlpacaTrader) IsMarketOpen() (bool, string, error) {
// --- Helper: cancel orders by type ---
// getOpenOrdersRaw returns raw AlpacaOrder structs (with ClientOrderID) for filtering.
func (t *AlpacaTrader) getOpenOrdersRaw(symbol string) ([]AlpacaOrder, error) {
path := "/v2/orders?status=open"
if symbol != "" {
path += "&symbols=" + symbol
}
data, err := t.doGet(path)
if err != nil {
return nil, fmt.Errorf("get open orders: %w", err)
}
var orders []AlpacaOrder
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("parse orders: %w", err)
}
return orders, nil
}
func (t *AlpacaTrader) cancelOrdersByType(symbol, orderType string) error {
orders, err := t.GetOpenOrders(symbol)
if err != nil {

View File

@@ -116,26 +116,33 @@ func newMockAlpacaServer() *mockAlpacaServer {
}
json.NewEncoder(w).Encode(result)
case "POST":
var req AlpacaOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Parse as generic map to capture all fields including client_order_id
var raw map[string]string
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
http.Error(w, `{"message":"invalid body"}`, 400)
return
}
reqType := raw["type"]
reqSide := raw["side"]
reqSymbol := raw["symbol"]
reqQty := raw["qty"]
// Simulate order fill for market orders
fillStatus := "accepted"
if req.Type == "market" {
if reqType == "market" {
fillStatus = "filled"
// If buy, add position
if req.Side == "buy" {
m.addPosition(req.Symbol, req.Qty, "150.00")
if reqSide == "buy" {
m.addPosition(reqSymbol, reqQty, "150.00")
}
}
order := m.createOrder(req.Symbol, req.Side, req.Qty, req.Type, fillStatus)
if req.StopPrice != "" {
order.StopPrice = req.StopPrice
order := m.createOrder(reqSymbol, reqSide, reqQty, reqType, fillStatus)
if v := raw["stop_price"]; v != "" {
order.StopPrice = v
}
if req.LimitPrice != "" {
order.LimitPrice = req.LimitPrice
if v := raw["limit_price"]; v != "" {
order.LimitPrice = v
}
if v := raw["client_order_id"]; v != "" {
order.ClientOrderID = v
}
if fillStatus != "filled" {
order.Status = "new" // stop/limit orders stay open