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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,32 @@ The bot handles this automatically at startup:
| 6 | `breakout` | Support/resistance breakout with volume and ATR confirmation |
| 7 | `market_making` | Symmetric buy/sell limits around mid price for spread capture |

### JSON config layer

Both `--config <path.json>` and `$BOT_CONFIG=<path.json>` (env-var fallback) are accepted. Repeat `--config` to layer multiple files (later files override earlier). Layering precedence is **CLI > env > JSON > dataclass defaults** — JSON simply slots in as a more readable surface format on top of the existing flat key namespace.

JSON files may use either flat or nested form (or mix). Nested form is auto-flattened by underscore-joining the path under known namespaces (`market_making`, `risk`):

```json
{
"market_making": {
"spread_bps": 10,
"refresh": { "tolerance_bp": 1, "max_age_seconds": 240 },
"forager": { "enabled": true, "score_threshold": 30.0 }
},
"risk": { "daily_loss_limit": 200 }
}
```

becomes:

```python
{"spread_bps": 10, "refresh_tolerance_bp": 1, "refresh_max_age_seconds": 240,
"forager_enabled": True, "forager_score_threshold": 30.0, "daily_loss_limit": 200}
```

Unknown keys produce a warning (typo detection) but are still passed through to the validator, which decides whether to abort. Missing files are warned and skipped (so a typo in `--config /missing.json` does not block startup); malformed JSON aborts with exit code 2. See `examples/config.example.json` for a full template.

The `market_making` strategy uses **progressive close pricing**: as a position ages, the take-profit price is tightened from full spread → breakeven (at 50% of max age) → small loss (at 75%), reducing costly taker force-closes. The loss tolerance is configurable via `--aggressive-loss-bps` (default: 1 bps). During the force-close phase, `--force-close-max-loss-bps` enables progressive loss acceptance that scales from `aggressive-loss-bps` to the configured maximum as the position approaches the taker deadline. **Unrealized loss early close** (`--unrealized-loss-close-bps`): When a position's unrealized loss exceeds this threshold (in bps), it is immediately closed via taker order regardless of position age. This caps large adverse moves before the age-based close triggers. Default: 0 (disabled).

**BBO mode** (`--bbo-mode`): Places orders at the best bid/ask instead of `mid ± spread_bps`. On Hyperliquid, market spreads are typically 0.1–2 bps, so even `SPREAD_BPS=5` places orders 4–5 bps away from BBO, resulting in low fill rates. BBO mode improves fill rates by tracking the current best prices. Use `--bbo-offset-bps N` to place orders N bps behind BBO (default: 0 = at BBO). Falls back to `mid ± spread_bps` when BBO is unavailable.
Expand Down
50 changes: 48 additions & 2 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from order_manager import OrderManager # noqa: E402
from risk_manager import RiskManager # noqa: E402
from validation import MarginValidator, validate_strategy_config # noqa: E402
from validation.strategy_validator import known_market_making_keys # noqa: E402
from json_config_loader import ConfigError, load_json_configs # noqa: E402
from hip3 import DEXRegistry, MultiDexMarketData, MultiDexOrderManager # noqa: E402
from position_closer import close_position_market # noqa: E402
from rate_limiter import API_ERRORS # noqa: E402
Expand Down Expand Up @@ -42,6 +44,7 @@
class HyperliquidBot:
def __init__(self, strategy_name: str = "simple_ma", coins: Optional[List[str]] = None,
strategy_config: Optional[Dict] = None,
json_overrides: Optional[Dict] = None,
main_loop_interval: float = 10, market_order_slippage: float = 0.01,
enable_ws: bool = False) -> None:
Config.validate()
Expand Down Expand Up @@ -208,8 +211,15 @@ def __init__(self, strategy_name: str = "simple_ma", coins: Optional[List[str]]
}
}

# Merge: default config as base, CLI overrides on top
config = {**default_configs.get(strategy_name, {}), **(strategy_config or {})}
# Merge layers (lowest precedence first):
# dataclass defaults < JSON overrides < CLI / env (strategy_config).
# JSON is opt-in via --config / $BOT_CONFIG; when both unset,
# ``json_overrides`` is None and the layering matches prior releases.
config = {
**default_configs.get(strategy_name, {}),
**(json_overrides or {}),
**(strategy_config or {}),
}

# Validate strategy parameters early
validation_error = validate_strategy_config(strategy_name, config)
Expand Down Expand Up @@ -931,6 +941,11 @@ def stop(self):
parser.add_argument('--main-loop-interval', type=float, help='Main loop sleep interval in seconds (default: 10)')
parser.add_argument('--account-cap-pct', type=float,
help='Max position as %% of account for sizing (grid_trading/market_making)')
parser.add_argument('--config', dest='config_paths', action='append', default=None,
help='Path to a JSON config file. Repeat for layered configs '
'(later files override earlier). Also reads $BOT_CONFIG '
'if no --config flag is supplied. Layering precedence: '
'CLI > env > JSON > dataclass defaults.')

# Simple MA strategy parameters
parser.add_argument('--fast-ma-period', type=int, help='Fast MA period (simple_ma)')
Expand Down Expand Up @@ -1376,10 +1391,41 @@ def _collect_params(params, source, dest):
if strategy_config:
logger.info(f"Custom parameters: {json.dumps(strategy_config, indent=2)}")

# Load JSON config layer (opt-in). Layering precedence is enforced
# in HyperliquidBot.__init__:
# CLI / env (strategy_config) > JSON > dataclass defaults.
# Sources: --config flag (repeatable, later wins), then $BOT_CONFIG
# (only consulted when --config is absent so CLI > env still holds).
config_paths: List[str] = list(args.config_paths or [])
if not config_paths:
bot_config_env = os.environ.get('BOT_CONFIG')
if bot_config_env:
config_paths.append(bot_config_env)
json_overrides: Optional[Dict] = None
if config_paths:
try:
known_keys = (
known_market_making_keys()
if args.strategy == 'market_making' else None
)
json_overrides = load_json_configs(
config_paths,
strategy_name=args.strategy,
known_keys=known_keys,
)
logger.info(
f"[config] JSON layer loaded: {len(json_overrides)} key(s) from "
f"{len(config_paths)} file(s)"
)
except ConfigError as e:
logger.error(f"{e}")
raise SystemExit(2)

bot = HyperliquidBot(
strategy_name=args.strategy,
coins=args.coins if Config.ENABLE_STANDARD_HL else [],
strategy_config=strategy_config if strategy_config else None,
json_overrides=json_overrides,
main_loop_interval=args.main_loop_interval if args.main_loop_interval is not None else 10,
market_order_slippage=args.market_order_slippage if args.market_order_slippage is not None else 0.01,
enable_ws=getattr(args, 'enable_ws', False),
Expand Down
99 changes: 99 additions & 0 deletions examples/config.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
"$schema": "(reserved for future schema validation)",
"$comment": "Example nested JSON config for HyperliquidBot. Pass via --config /path/to/this.json or $BOT_CONFIG. Layering precedence: CLI > env > JSON > dataclass defaults.",
"market_making": {
"spread_bps": 10,
"order_size_usd": 100,
"max_open_orders": 4,
"max_positions": 8,
"maker_only": true,
"bbo_mode": true,
"bbo_offset_bps": 1,
"inventory_skew_bps": 2,
"refresh_interval_seconds": 60,
"refresh_tolerance_bp": 1,
"refresh_max_age_seconds": 240,
"max_position_age_seconds": 120,
"taker_fallback_age_seconds": 120,
"imbalance_threshold": 0.5,
"imbalance_guard_threshold": 0.4,
"imbalance_guard_depth": 5,
"bbo_guard_threshold_bps": 2.0,
"loss_streak_limit": 3,
"loss_streak_cooldown": 300,
"close_breakeven_pct": 0.33,
"close_aggressive_pct": 0.50,
"close_refresh_threshold_bps": 0.5,
"unrealized_loss_close_bps": 15,
"force_close_max_loss_bps": 5.0,
"spread_schedule": "0:1.5,12:1.3,13:1.5,20:1.5,21:1.5,22:1.5",
"quiet_hours_utc": "17",
"quiet_hours_spread_multiplier": 0,
"microprice_skew_enabled": true,
"microprice_skew_multiplier": 1.0,
"microprice_max_skew_bps": 2.0,
"velocity_guard_enabled": true,
"velocity_consecutive": 3,
"velocity_min_move_bps": 1.0,
"dynamic_offset_enabled": true,
"dynamic_offset_sensitivity": 0.5,
"dynamic_offset_tighten_rate": 0.25,
"dynamic_offset_max_addition": 3.0,
"dynamic_offset_max_reduction": 1.0,
"dynamic_offset_floor": 0.5,
"dynamic_offset_min_fills": 3,
"dynamic_age_enabled": true,
"dynamic_age_baseline_vol": 1.0,
"dynamic_age_min": 90,
"dynamic_age_max": 300,
"auto_exclude_enabled": true,
"auto_exclude_threshold_bps": -3.0,
"auto_exclude_consecutive": 3,
"auto_exclude_min_fills": 3,
"auto_exclude_cooldown": 1800,
"auto_exclude_window_label": "60s",
"forager_enabled": false,
"forager_score_threshold": 30.0,
"forager_consecutive": 3,
"forager_cooldown_seconds": 1800,
"forager_weight_activity": 0.3,
"forager_weight_quality": 0.4,
"forager_weight_cost": 0.3,
"forager_window_seconds": 1800.0,
"forager_check_interval_seconds": 300.0,
"forager_activity_idle_min_seconds": 300.0,
"forager_cost_max_per_1k": 0.6,
"forager_min_closes_for_quality": 5,
"coin_offset_overrides": "SP500:0.5,XYZ100:2,US500:3,USTECH:2,NVDA:1.5,GOOGL:2,AMZN:2,COPPER:3,PLATINUM:2,META:3",
"coin_unrealized_loss_overrides": "INTC:25,SP500:25,OIL:10,AAPL:10",
"enable_adverse_selection_log": true,
"close_immediately": false
},
"$comment_nested_form": "The same parameters can be expressed in nested form. The loader auto-flattens by underscore-concatenating the path. Example below — replace the flat block above to use nested.",
"$market_making_nested_example": {
"spread_bps": 10,
"order_size_usd": 100,
"refresh": {
"interval_seconds": 60,
"tolerance_bp": 1,
"max_age_seconds": 240
},
"auto_exclude": {
"enabled": true,
"threshold_bps": -3.0,
"consecutive": 3,
"min_fills": 3,
"cooldown_seconds": 1800,
"window_label": "60s"
},
"forager": {
"enabled": false,
"score_threshold": 30.0,
"consecutive": 3,
"cooldown_seconds": 1800,
"weight_activity": 0.3,
"weight_quality": 0.4,
"weight_cost": 0.3
}
}
}
162 changes: 162 additions & 0 deletions json_config_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""JSON config file loader with flat / nested auto-detection.

The bot's per-strategy ``strategy_config`` is a flat ``Dict[str, Any]``
historically populated by argparse + env vars + hardcoded defaults.
This module adds JSON files as a parallel input layer, supporting:

* **flat** form: keys match the existing CLI / env names directly.
* **nested** form: hierarchical, mirrors the ``MMConfig`` dataclass tree.

A nested file is auto-flattened by underscore-concatenating the path
under the strategy namespace, so:

{"market_making": {"refresh": {"tolerance_bp": 1}}}

becomes:

{"refresh_tolerance_bp": 1}

— exactly the key downstream code (`bot.py`, `MMConfig.from_legacy_dict`)
already understands. The loader does not introduce a new schema; it
simply lifts a more-readable surface format on top of the existing
flat key namespace.

Layering precedence (highest wins):

CLI args > env vars > JSON files > dataclass defaults

JSON is opt-in: with no ``--config`` flag and no ``BOT_CONFIG`` env
var, this module is never invoked and behaviour is unchanged from
prior releases.
"""

import json
import logging
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple

logger = logging.getLogger(__name__)


# Top-level JSON keys that are recognised as strategy / risk namespaces.
# Values inside these sections are flattened with their key prefix
# stripped (the strategy name) and the rest joined by underscores.
_KNOWN_NAMESPACES: Tuple[str, ...] = ("market_making", "risk")


class ConfigError(Exception):
"""Raised when a JSON config is structurally invalid (e.g., parse error)."""


# --------------------------------------------------------------------------- #
# Public API
# --------------------------------------------------------------------------- #


def load_json_configs(
paths: List[str],
strategy_name: str = "market_making",
known_keys: Optional[Set[str]] = None,
) -> Dict[str, Any]:
"""Read and merge JSON config files in declaration order.

Later paths override earlier ones (``dict.update`` semantics). Missing
files emit a warning and are skipped (fail-safe so bot still starts on
typo-ed paths). Parse errors raise :class:`ConfigError` — the caller
decides whether to abort or fall back to CLI/env-only.

Returns the merged flat dict ready to slot in between
``default_configs`` and ``strategy_config`` (env/CLI) in
``bot.py``'s layering chain.

``known_keys`` enables typo detection: when supplied, any flat key
not present in the set produces a warning. Pass
``validation.strategy_validator.known_market_making_keys()`` here.
"""
merged: Dict[str, Any] = {}
for path in paths:
p = Path(path)
if not p.exists():
logger.warning(f"[config] JSON file not found, skipping: {path}")
continue
try:
data = json.loads(p.read_text())
except json.JSONDecodeError as e:
raise ConfigError(f"[config] Invalid JSON in {path}: {e}") from e
if not isinstance(data, dict):
raise ConfigError(
f"[config] Top-level value in {path} must be an object, "
f"got {type(data).__name__}"
)
flat = _to_flat(data, strategy_name)
if known_keys is not None:
unknown = sorted(set(flat) - known_keys)
if unknown:
logger.warning(
f"[config] Unknown keys in {path}: {unknown} "
f"(typo? unsupported feature?)"
)
merged.update(flat)
logger.info(f"[config] Loaded {len(flat)} key(s) from {path}")
return merged


# --------------------------------------------------------------------------- #
# Internals
# --------------------------------------------------------------------------- #


def _to_flat(data: Dict[str, Any], strategy_name: str) -> Dict[str, Any]:
"""Normalise either flat or nested form into flat keys.

Detection rule:

* If the top level contains a known namespace key (e.g.
``market_making``) whose value is a dict, treat as nested and
recursively flatten under that namespace.
* Otherwise, treat the top-level dict as already flat.

Nested form may also be partial: keys not under a known namespace
are still emitted (with their nested path joined). This lets users
use top-level convenience keys (e.g. ``"forager_enabled": true``)
side-by-side with ``"market_making": {...}``.
"""
result: Dict[str, Any] = {}

for top_key, top_value in data.items():
if top_key.startswith("$"):
# Reserved for $schema and similar metadata — silently skip.
continue
if top_key in _KNOWN_NAMESPACES and isinstance(top_value, dict):
for flat_key, value in _walk_nested(top_value):
result[flat_key] = value
elif isinstance(top_value, dict):
# Unknown nested namespace — still flatten under the top_key
# so the validator can warn the user. Avoids silently dropping
# plausibly-intended config.
for flat_key, value in _walk_nested(top_value, prefix=[top_key]):
result[flat_key] = value
else:
# Top-level scalar — assume flat form.
result[top_key] = top_value

return result


def _walk_nested(
data: Dict[str, Any],
prefix: Optional[List[str]] = None,
) -> Iterator[Tuple[str, Any]]:
"""Yield ``(flat_key, value)`` pairs from a nested dict.

Path components are joined with underscores so
``{"forager": {"enabled": true}}`` → ``("forager_enabled", True)``.
Inner dicts recurse; lists / scalars are emitted as-is.
"""
prefix = prefix or []
for key, value in data.items():
new_path = prefix + [key]
if isinstance(value, dict):
yield from _walk_nested(value, new_path)
else:
yield ("_".join(new_path), value)
Loading
Loading