Baseline: Ausgangszustand vor Modularisierung
Erster Commit des bestehenden monolithischen WinForms-Copytraders, inklusive der Alt-Backups (*.bak), damit diese dauerhaft in der Historie rekonstruierbar bleiben. Threema-Lib unter libs/ wurde vendored (nested .git entfernt). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Nethereum.Signer;
|
||||
using Nethereum.Signer.EIP712;
|
||||
using Nethereum.ABI.FunctionEncoding.Attributes;
|
||||
using Nethereum.ABI.EIP712;
|
||||
using Nethereum.Util;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
[Struct("EIP712Domain")]
|
||||
public class ClobDomain
|
||||
{
|
||||
[Parameter("string", "name", 1)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Parameter("string", "version", 2)]
|
||||
public string Version { get; set; } = "";
|
||||
|
||||
[Parameter("uint256", "chainId", 3)]
|
||||
public System.Numerics.BigInteger ChainId { get; set; }
|
||||
}
|
||||
|
||||
[Struct("EIP712Domain")]
|
||||
public class CtfDomain
|
||||
{
|
||||
[Parameter("string", "name", 1)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Parameter("string", "version", 2)]
|
||||
public string Version { get; set; }
|
||||
|
||||
[Parameter("uint256", "chainId", 3)]
|
||||
public ulong ChainId { get; set; }
|
||||
|
||||
[Parameter("address", "verifyingContract", 4)]
|
||||
public string VerifyingContract { get; set; }
|
||||
}
|
||||
|
||||
[Struct("ClobAuth")]
|
||||
public class ClobAuth
|
||||
{
|
||||
[Parameter("address", "address", 1)]
|
||||
public string Address { get; set; }
|
||||
|
||||
[Parameter("string", "timestamp", 2)]
|
||||
public string Timestamp { get; set; } = "";
|
||||
|
||||
[Parameter("uint256", "nonce", 3)]
|
||||
public System.Numerics.BigInteger Nonce { get; set; }
|
||||
|
||||
[Parameter("string", "message", 4)]
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
[Struct("Order")]
|
||||
public class CtfOrder
|
||||
{
|
||||
[Parameter("uint256", "salt", 1)]
|
||||
public System.Numerics.BigInteger Salt { get; set; }
|
||||
|
||||
[Parameter("address", "maker", 2)]
|
||||
public string Maker { get; set; }
|
||||
|
||||
[Parameter("address", "signer", 3)]
|
||||
public string Signer { get; set; }
|
||||
|
||||
[Parameter("address", "taker", 4)]
|
||||
public string Taker { get; set; }
|
||||
|
||||
[Parameter("uint256", "tokenId", 5)]
|
||||
public System.Numerics.BigInteger TokenId { get; set; }
|
||||
|
||||
[Parameter("uint256", "makerAmount", 6)]
|
||||
public System.Numerics.BigInteger MakerAmount { get; set; }
|
||||
|
||||
[Parameter("uint256", "takerAmount", 7)]
|
||||
public System.Numerics.BigInteger TakerAmount { get; set; }
|
||||
|
||||
[Parameter("uint256", "expiration", 8)]
|
||||
public System.Numerics.BigInteger Expiration { get; set; }
|
||||
|
||||
[Parameter("uint256", "nonce", 9)]
|
||||
public System.Numerics.BigInteger Nonce { get; set; }
|
||||
|
||||
[Parameter("uint256", "feeRateBps", 10)]
|
||||
public System.Numerics.BigInteger FeeRateBps { get; set; }
|
||||
|
||||
[Parameter("uint8", "side", 11)]
|
||||
public byte Side { get; set; }
|
||||
|
||||
[Parameter("uint8", "signatureType", 12)]
|
||||
public byte SignatureType { get; set; }
|
||||
}
|
||||
|
||||
public class PolymarketClobClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TerminalLogger _logger;
|
||||
private const string ClobHost = "https://clob.polymarket.com";
|
||||
private const int ChainId = 137;
|
||||
|
||||
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
|
||||
/// </summary>
|
||||
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
|
||||
{
|
||||
string payload = timestamp + method + requestPath + body;
|
||||
|
||||
// Convert URL-Safe Base64 back to Standard Base64
|
||||
string b64 = secret.Replace('-', '+').Replace('_', '/');
|
||||
switch (b64.Length % 4)
|
||||
{
|
||||
case 2: b64 += "=="; break;
|
||||
case 3: b64 += "="; break;
|
||||
}
|
||||
|
||||
byte[] secretBytes = Convert.FromBase64String(b64);
|
||||
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
byte[] hash = hmac.ComputeHash(payloadBytes);
|
||||
|
||||
string signature = Convert.ToBase64String(hash);
|
||||
return signature.Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
|
||||
/// </summary>
|
||||
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signer = new Eip712TypedDataSigner();
|
||||
var key = new EthECKey(privateKey);
|
||||
string computedAddress = key.GetPublicAddress();
|
||||
|
||||
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
|
||||
var typedData = new TypedData<ClobDomain>
|
||||
{
|
||||
Domain = new ClobDomain
|
||||
{
|
||||
Name = "ClobAuthDomain",
|
||||
Version = "1",
|
||||
ChainId = new System.Numerics.BigInteger(ChainId)
|
||||
},
|
||||
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
|
||||
PrimaryType = "ClobAuth"
|
||||
};
|
||||
|
||||
var clobAuth = new ClobAuth
|
||||
{
|
||||
Address = computedAddress,
|
||||
Timestamp = timestamp,
|
||||
Nonce = new System.Numerics.BigInteger(0),
|
||||
Message = "This message attests that I control the given wallet"
|
||||
};
|
||||
|
||||
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
|
||||
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
|
||||
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
|
||||
|
||||
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
|
||||
_logger.Warning($"DEBUG_CS_SIG: {signature}");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
|
||||
request.Headers.Add("POLY_ADDRESS", computedAddress);
|
||||
request.Headers.Add("POLY_SIGNATURE", signature);
|
||||
request.Headers.Add("POLY_TIMESTAMP", timestamp);
|
||||
request.Headers.Add("POLY_NONCE", "0");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
// If the key has not been created yet on Polymarket, derive might fail. We then try to create it.
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
|
||||
request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key");
|
||||
request.Headers.Add("POLY_ADDRESS", computedAddress);
|
||||
request.Headers.Add("POLY_SIGNATURE", signature);
|
||||
request.Headers.Add("POLY_TIMESTAMP", timestamp);
|
||||
request.Headers.Add("POLY_NONCE", "0");
|
||||
response = await _httpClient.SendAsync(request);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
|
||||
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
|
||||
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
|
||||
|
||||
return (apiKey, secret, passphrase);
|
||||
}
|
||||
else
|
||||
{
|
||||
string err = await response.Content.ReadAsStringAsync();
|
||||
_logger.Error($"Failed to execute L1 Auth: {response.StatusCode} {err}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
|
||||
}
|
||||
|
||||
return (string.Empty, string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
|
||||
{
|
||||
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string endpoint = "/balance-allowance";
|
||||
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
|
||||
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
|
||||
// Python SDK signs ONLY the base path, not the query params
|
||||
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
|
||||
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
|
||||
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
|
||||
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
|
||||
request.Headers.Add("POLY_SIGNATURE", signature);
|
||||
request.Headers.Add("POLY_TIMESTAMP", timestamp);
|
||||
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
|
||||
{
|
||||
var balanceStr = balProp.GetString();
|
||||
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
|
||||
{
|
||||
decimal finalBal = balRaw / 1_000_000m;
|
||||
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
|
||||
return finalBal;
|
||||
}
|
||||
}
|
||||
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
|
||||
}
|
||||
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
string errStr = await response.Content.ReadAsStringAsync();
|
||||
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
|
||||
|
||||
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
|
||||
{
|
||||
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
|
||||
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
|
||||
if (!string.IsNullOrEmpty(newKeys.ApiKey))
|
||||
{
|
||||
acc.ApiKey = newKeys.ApiKey;
|
||||
acc.ApiSecret = newKeys.ApiSecret;
|
||||
acc.ApiPassphrase = newKeys.ApiPassphrase;
|
||||
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
|
||||
|
||||
// Await propagation of new keys inside Polymarket's Gamma backend
|
||||
await Task.Delay(2500);
|
||||
|
||||
// Retry recursively strictly once
|
||||
return await GetUsdcBalanceAsync(acc, true);
|
||||
}
|
||||
}
|
||||
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
|
||||
}
|
||||
else
|
||||
{
|
||||
string errStr = await response.Content.ReadAsStringAsync();
|
||||
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static System.Numerics.BigInteger GenerateSalt()
|
||||
{
|
||||
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
|
||||
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
|
||||
return new System.Numerics.BigInteger(t * 10000 + r);
|
||||
}
|
||||
|
||||
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
|
||||
{
|
||||
decimal tick = overrideTickSize ?? 0.001m;
|
||||
decimal priceRounded = Math.Round(rawPrice / tick) * tick;
|
||||
if (priceRounded < tick) priceRounded = tick;
|
||||
|
||||
long priceTicks = (long)Math.Round(priceRounded * 1000m);
|
||||
|
||||
long makerDecimals = overrideMakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 2 : 4);
|
||||
long takerDecimals = overrideTakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 4 : 2);
|
||||
|
||||
long makerStepRaw = (long)Math.Pow(10, 6 - makerDecimals);
|
||||
long takerStepRaw = (long)Math.Pow(10, 6 - takerDecimals);
|
||||
|
||||
long numerator = 1000L * takerStepRaw;
|
||||
long denominator = makerStepRaw * priceTicks;
|
||||
|
||||
long a = numerator, b = denominator;
|
||||
while (a != 0 && b != 0) { if (a > b) a %= b; else b %= a; }
|
||||
long gcd = a | b;
|
||||
|
||||
long N = numerator / gcd;
|
||||
long baseMakerRaw = N * makerStepRaw;
|
||||
|
||||
decimal quantumShares;
|
||||
if (sideStr.ToUpper() == "BUY")
|
||||
{
|
||||
long baseTakerRaw = baseMakerRaw * priceTicks / 1000L;
|
||||
quantumShares = baseTakerRaw / 1000000m;
|
||||
}
|
||||
else
|
||||
{
|
||||
quantumShares = baseMakerRaw / 1000000m;
|
||||
}
|
||||
|
||||
decimal executedShares = 0;
|
||||
decimal executedUsdc = 0;
|
||||
decimal finalMakerAmountRaw = 0;
|
||||
decimal finalTakerAmountRaw = 0;
|
||||
|
||||
if (sideStr.ToUpper() == "BUY")
|
||||
{
|
||||
decimal sharesRaw = investAmountUsd / priceRounded;
|
||||
decimal takerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
|
||||
if (takerShares < quantumShares) takerShares = quantumShares;
|
||||
|
||||
while (takerShares * priceRounded < 1.0m || (orderType.ToUpper() != "MARKET" && takerShares < 5.0m))
|
||||
{
|
||||
takerShares += quantumShares;
|
||||
}
|
||||
|
||||
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
|
||||
finalMakerAmountRaw = Math.Round(finalTakerAmountRaw * priceRounded);
|
||||
|
||||
executedShares = takerShares;
|
||||
executedUsdc = finalMakerAmountRaw / 1_000_000m;
|
||||
}
|
||||
else
|
||||
{
|
||||
decimal sharesRaw = investAmountUsd / limitPrice;
|
||||
decimal makerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
|
||||
|
||||
if (makerShares <= 0) return (-1, -1, 0, 0);
|
||||
|
||||
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
|
||||
finalTakerAmountRaw = Math.Round(finalMakerAmountRaw * priceRounded);
|
||||
|
||||
executedShares = makerShares;
|
||||
executedUsdc = finalTakerAmountRaw / 1_000_000m;
|
||||
}
|
||||
|
||||
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
|
||||
/// </summary>
|
||||
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
|
||||
return "Error: Missing API or Private Keys";
|
||||
|
||||
try
|
||||
{
|
||||
var signer = new Eip712TypedDataSigner();
|
||||
var key = new EthECKey(account.PrivateKey);
|
||||
|
||||
var typedData = new TypedData<CtfDomain>
|
||||
{
|
||||
Domain = new CtfDomain
|
||||
{
|
||||
Name = "Polymarket CTF Exchange",
|
||||
Version = "1",
|
||||
ChainId = ChainId,
|
||||
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
},
|
||||
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
|
||||
PrimaryType = "Order"
|
||||
};
|
||||
|
||||
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
|
||||
|
||||
if (amounts.shares <= 0)
|
||||
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
|
||||
|
||||
decimal makerAmountRaw = amounts.makerRaw;
|
||||
decimal takerAmountRaw = amounts.takerRaw;
|
||||
|
||||
System.Numerics.BigInteger parsedTokenId;
|
||||
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
|
||||
{
|
||||
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
|
||||
}
|
||||
|
||||
var ctfOrder = new CtfOrder
|
||||
{
|
||||
Salt = GenerateSalt(),
|
||||
Maker = account.WalletAddress,
|
||||
Signer = key.GetPublicAddress(),
|
||||
Taker = "0x0000000000000000000000000000000000000000",
|
||||
TokenId = parsedTokenId,
|
||||
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
|
||||
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
|
||||
Expiration = 0,
|
||||
Nonce = 0,
|
||||
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
|
||||
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
|
||||
SignatureType = 2
|
||||
};
|
||||
|
||||
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
|
||||
|
||||
var reqBody = new
|
||||
{
|
||||
order = new
|
||||
{
|
||||
salt = (long)ctfOrder.Salt,
|
||||
maker = ctfOrder.Maker.ToLower(),
|
||||
signer = ctfOrder.Signer.ToLower(),
|
||||
taker = ctfOrder.Taker.ToLower(),
|
||||
tokenId = ctfOrder.TokenId.ToString(),
|
||||
makerAmount = ctfOrder.MakerAmount.ToString(),
|
||||
takerAmount = ctfOrder.TakerAmount.ToString(),
|
||||
expiration = ctfOrder.Expiration.ToString(),
|
||||
nonce = ctfOrder.Nonce.ToString(),
|
||||
feeRateBps = ctfOrder.FeeRateBps.ToString(),
|
||||
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
|
||||
signatureType = ctfOrder.SignatureType,
|
||||
signature = signature
|
||||
},
|
||||
owner = account.ApiKey,
|
||||
orderType = orderType
|
||||
};
|
||||
|
||||
string jsonBody = JsonSerializer.Serialize(reqBody);
|
||||
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
string requestPath = "/order";
|
||||
|
||||
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
|
||||
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
|
||||
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
|
||||
request.Headers.Add("POLY_API_KEY", account.ApiKey);
|
||||
request.Headers.Add("POLY_TIMESTAMP", timestamp);
|
||||
request.Headers.Add("POLY_SIGNATURE", hmacSig);
|
||||
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
|
||||
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
||||
|
||||
if (debugPayloadLog)
|
||||
{
|
||||
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
|
||||
|
||||
if (isFokFail && sideStr == "BUY")
|
||||
{
|
||||
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
|
||||
// We skip it silently.
|
||||
return "SKIPPED_LIQUIDITY";
|
||||
}
|
||||
|
||||
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
|
||||
|
||||
if (isFokFail && sideStr == "SELL")
|
||||
{
|
||||
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
|
||||
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
|
||||
}
|
||||
else
|
||||
{
|
||||
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
|
||||
if (tickMatch.Success && overrideTickSize == null)
|
||||
{
|
||||
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
|
||||
{
|
||||
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
|
||||
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
|
||||
}
|
||||
}
|
||||
|
||||
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
|
||||
if (decMatch.Success && overrideMakerDecimals == null)
|
||||
{
|
||||
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
|
||||
{
|
||||
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
|
||||
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
|
||||
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's taker fee: (\d+)");
|
||||
if (match.Success && actualFeeBps == 0) // Only retry once
|
||||
{
|
||||
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
|
||||
{
|
||||
_logger.Info($"🔄 Automatische Anpassung an Taker Fee ({newFeeBps} bps). Order wird erneut platziert...");
|
||||
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
|
||||
}
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
|
||||
return "OK";
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
|
||||
return responseContent;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user