Skip to content

Commit 4be4c77

Browse files
authored
Merge pull request #24 from talkincode/cname
Cname
2 parents 4cc8fb0 + e16765b commit 4be4c77

File tree

8 files changed

+874
-2
lines changed

8 files changed

+874
-2
lines changed

CNAME

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hypmcp.talkincode.net

main.py

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66

77
from dotenv import load_dotenv
88
from fastmcp import FastMCP
9-
from pydantic import BaseModel, Field
9+
from pydantic import BaseModel, Field, model_validator
10+
from pydantic import ValidationError as PydanticValidationError
1011

1112
from services.hyperliquid_services import HyperliquidServices
12-
from services.validators import ValidationError, validate_order_inputs
13+
from services.validators import ValidationError, validate_coin, validate_order_inputs
1314

1415
# Load environment variables
1516
load_dotenv()
@@ -84,6 +85,53 @@ def initialize_service():
8485
logger.info(f"Service initialized for account: {account_info}")
8586

8687

88+
class CandlesSnapshotParams(BaseModel):
89+
"""Bulk candles snapshot request parameters"""
90+
91+
coins: list[str] = Field(..., min_length=1, description="List of trading pairs")
92+
interval: str = Field(
93+
..., description="Candlestick interval supported by HyperLiquid"
94+
)
95+
start_time: int | None = Field(
96+
default=None,
97+
description="Start timestamp in milliseconds",
98+
)
99+
end_time: int | None = Field(
100+
default=None,
101+
description="End timestamp in milliseconds",
102+
)
103+
days: int | None = Field(
104+
default=None,
105+
gt=0,
106+
description="Fetch recent N days (mutually exclusive with start/end)",
107+
)
108+
limit: int | None = Field(
109+
default=None,
110+
gt=0,
111+
le=5000,
112+
description="Maximum number of candles per coin (latest N records)",
113+
)
114+
115+
@model_validator(mode="after")
116+
def validate_time_params(self):
117+
if self.days is not None and (
118+
self.start_time is not None or self.end_time is not None
119+
):
120+
raise ValueError("days cannot be used together with start_time or end_time")
121+
122+
if self.days is None and self.start_time is None:
123+
raise ValueError("start_time is required when days is not provided")
124+
125+
if (
126+
self.start_time is not None
127+
and self.end_time is not None
128+
and self.start_time >= self.end_time
129+
):
130+
raise ValueError("start_time must be less than end_time")
131+
132+
return self
133+
134+
87135
# Account Management Tools
88136

89137

@@ -369,6 +417,103 @@ async def get_orderbook(coin: str, depth: int = 20) -> dict[str, Any]:
369417
return await hyperliquid_service.get_orderbook(coin, depth)
370418

371419

420+
@mcp.tool
421+
async def get_candles_snapshot(
422+
coins: list[str],
423+
interval: str,
424+
start_time: int | None = None,
425+
end_time: int | None = None,
426+
days: int | None = None,
427+
limit: int | None = None,
428+
) -> dict[str, Any]:
429+
"""
430+
Fetch candlestick (OHLCV) data for multiple coins in one request
431+
432+
Args:
433+
coins: List of trading pairs (e.g., ["BTC", "ETH"])
434+
interval: Candlestick interval supported by HyperLiquid (e.g., "1m", "1h")
435+
start_time: Start timestamp in milliseconds (required when days not provided)
436+
end_time: End timestamp in milliseconds (defaults to now when omitted)
437+
days: Number of recent days to fetch (mutually exclusive with start/end)
438+
limit: Optional max number of candles per coin (latest N samples)
439+
"""
440+
441+
initialize_service()
442+
443+
try:
444+
params = CandlesSnapshotParams(
445+
coins=coins,
446+
interval=interval,
447+
start_time=start_time,
448+
end_time=end_time,
449+
days=days,
450+
limit=limit,
451+
)
452+
except PydanticValidationError as validation_error:
453+
return {
454+
"success": False,
455+
"error": f"Invalid input: {validation_error.errors()}",
456+
"error_code": "VALIDATION_ERROR",
457+
}
458+
except ValueError as validation_error:
459+
return {
460+
"success": False,
461+
"error": f"Invalid input: {str(validation_error)}",
462+
"error_code": "VALIDATION_ERROR",
463+
}
464+
465+
# Validate each coin using existing validator for consistency
466+
for coin in params.coins:
467+
try:
468+
validate_coin(coin)
469+
except ValidationError as validation_error:
470+
return {
471+
"success": False,
472+
"error": f"Invalid input: {str(validation_error)}",
473+
"error_code": "VALIDATION_ERROR",
474+
}
475+
476+
service_result = await hyperliquid_service.get_candles_snapshot_bulk(
477+
coins=params.coins,
478+
interval=params.interval,
479+
start_time=params.start_time,
480+
end_time=params.end_time,
481+
days=params.days,
482+
)
483+
484+
if not service_result.get("success"):
485+
return service_result
486+
487+
candles_data = service_result.get("data", {})
488+
applied_limit = params.limit or None
489+
490+
if applied_limit is not None:
491+
limited_data = {}
492+
for coin, candles in candles_data.items():
493+
if not isinstance(candles, list):
494+
limited_data[coin] = candles
495+
continue
496+
limited_data[coin] = candles[-applied_limit:]
497+
candles_data = limited_data
498+
499+
response: dict[str, Any] = {
500+
"success": True,
501+
"data": candles_data,
502+
"interval": service_result.get("interval"),
503+
"start_time": service_result.get("start_time"),
504+
"end_time": service_result.get("end_time"),
505+
"requested_coins": params.coins,
506+
}
507+
508+
if applied_limit is not None:
509+
response["limit_per_coin"] = applied_limit
510+
511+
if service_result.get("coin_errors"):
512+
response["coin_errors"] = service_result["coin_errors"]
513+
514+
return response
515+
516+
372517
@mcp.tool
373518
async def get_funding_history(coin: str, days: int = 7) -> dict[str, Any]:
374519
"""
@@ -636,6 +781,23 @@ def start_server():
636781
)
637782
logger.info(f"Logs will be written to: {log_path}")
638783

784+
# Log all registered tools BEFORE starting server
785+
if hasattr(mcp, "_tool_manager") and hasattr(mcp._tool_manager, "_tools"):
786+
tools_dict = mcp._tool_manager._tools
787+
tool_names = sorted(tools_dict.keys())
788+
789+
print("\n" + "=" * 60)
790+
print(f"✅ {len(tool_names)} MCP Tools Registered:")
791+
print("=" * 60)
792+
793+
for i, tool_name in enumerate(tool_names, 1):
794+
marker = "🆕" if tool_name == "get_candles_snapshot" else " "
795+
print(f"{marker} {i:2d}. {tool_name}")
796+
797+
print("=" * 60 + "\n")
798+
else:
799+
print("\n⚠️ Cannot verify tool registration\n")
800+
639801
asyncio.run(run_as_server())
640802
except Exception as e:
641803
logger.error(f"Failed to start server: {e}")

services/hyperliquid_services.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,137 @@ async def get_orderbook(self, coin: str, depth: int = 20) -> dict[str, Any]:
603603
)
604604
return {"success": False, "error": str(e)}
605605

606+
async def get_candles_snapshot_bulk(
607+
self,
608+
coins: list[str],
609+
interval: str,
610+
start_time: int | None = None,
611+
end_time: int | None = None,
612+
days: int | None = None,
613+
) -> dict[str, Any]:
614+
"""
615+
Retrieve candlestick data for multiple coins in a single call
616+
617+
Args:
618+
coins: List of trading pairs (e.g., ["BTC", "ETH"])
619+
interval: Candlestick interval string accepted by HyperLiquid
620+
start_time: Optional start timestamp (ms). Required when days is None.
621+
end_time: Optional end timestamp (ms). Defaults to current time when omitted.
622+
days: Optional number of recent days to fetch. Mutually exclusive with start/end.
623+
"""
624+
625+
try:
626+
if not isinstance(coins, list) or not coins:
627+
raise ValueError("coins must be a non-empty list of strings")
628+
629+
normalized_coins: list[str] = []
630+
for coin in coins:
631+
if not coin or not isinstance(coin, str):
632+
raise ValueError("each coin must be a non-empty string")
633+
coin_clean = coin.strip()
634+
if not coin_clean:
635+
raise ValueError("each coin must be a non-empty string")
636+
if coin_clean not in normalized_coins:
637+
normalized_coins.append(coin_clean)
638+
639+
if not interval or not isinstance(interval, str):
640+
raise ValueError("interval must be a non-empty string")
641+
interval = interval.strip()
642+
if not interval:
643+
raise ValueError("interval must be a non-empty string")
644+
645+
if days is not None and (start_time is not None or end_time is not None):
646+
raise ValueError(
647+
"days cannot be used together with start_time or end_time"
648+
)
649+
650+
current_time_ms = int(time.time() * 1000)
651+
652+
if days is not None:
653+
if not isinstance(days, int) or days <= 0:
654+
raise ValueError("days must be a positive integer")
655+
effective_end = current_time_ms if end_time is None else int(end_time)
656+
effective_start = effective_end - (days * 24 * 60 * 60 * 1000)
657+
else:
658+
if start_time is None:
659+
raise ValueError("start_time is required when days is not provided")
660+
effective_start = int(start_time)
661+
effective_end = (
662+
int(end_time) if end_time is not None else current_time_ms
663+
)
664+
665+
if effective_start >= effective_end:
666+
raise ValueError("start_time must be less than end_time")
667+
668+
candles_by_coin: dict[str, list[dict[str, Any]]] = {}
669+
coin_errors: dict[str, str] = {}
670+
671+
for coin in normalized_coins:
672+
try:
673+
raw_candles = self.info.candles_snapshot(
674+
coin,
675+
interval,
676+
effective_start,
677+
effective_end,
678+
)
679+
680+
formatted_candles: list[dict[str, Any]] = []
681+
for candle in raw_candles or []:
682+
timestamp = candle.get("t") or candle.get("T")
683+
if timestamp is None:
684+
# Skip malformed entries without timestamp
685+
continue
686+
687+
try:
688+
formatted_candles.append(
689+
{
690+
"timestamp": int(timestamp),
691+
"open": float(candle["o"]),
692+
"high": float(candle["h"]),
693+
"low": float(candle["l"]),
694+
"close": float(candle["c"]),
695+
"volume": float(candle["v"]),
696+
"trade_count": int(candle.get("n", 0)),
697+
}
698+
)
699+
except (KeyError, TypeError, ValueError) as format_error:
700+
self.logger.warning(
701+
"Skipping malformed candle for %s: %s",
702+
coin,
703+
format_error,
704+
)
705+
706+
formatted_candles.sort(key=lambda item: item["timestamp"])
707+
candles_by_coin[coin] = formatted_candles
708+
except Exception as coin_error:
709+
coin_errors[coin] = str(coin_error)
710+
self.logger.error(
711+
"Failed to fetch candles for %s: %s", coin, coin_error
712+
)
713+
714+
if not candles_by_coin:
715+
return {
716+
"success": False,
717+
"error": "Failed to fetch candle data for requested coins",
718+
"coin_errors": coin_errors,
719+
}
720+
721+
response: dict[str, Any] = {
722+
"success": True,
723+
"data": candles_by_coin,
724+
"interval": interval,
725+
"start_time": effective_start,
726+
"end_time": effective_end,
727+
}
728+
729+
if coin_errors:
730+
response["coin_errors"] = coin_errors
731+
732+
return response
733+
except Exception as e:
734+
self.logger.error("Failed to get candles snapshot bulk: %s", e)
735+
return {"success": False, "error": str(e)}
736+
606737
async def update_leverage(
607738
self, coin: str, leverage: int, is_cross: bool = True
608739
) -> dict[str, Any]:

0 commit comments

Comments
 (0)