Initial public release
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KeyManager:
|
||||
def __init__(self, master_key_path: str = "~/.hydra/master.key"):
|
||||
self._master_path = Path(master_key_path).expanduser()
|
||||
self._key_dir = self._master_path.parent
|
||||
self._fernet = self._load_or_create_fernet()
|
||||
|
||||
def _load_or_create_fernet(self) -> Fernet:
|
||||
self._key_dir.mkdir(parents=True, exist_ok=True)
|
||||
if self._master_path.exists():
|
||||
raw = self._master_path.read_bytes()
|
||||
else:
|
||||
raw = Fernet.generate_key()
|
||||
self._master_path.write_bytes(raw)
|
||||
self._master_path.chmod(0o600)
|
||||
logger.info("master_key_created", path=str(self._master_path))
|
||||
return Fernet(raw)
|
||||
|
||||
def encrypt(self, plain: str) -> bytes:
|
||||
return self._fernet.encrypt(plain.encode())
|
||||
|
||||
def decrypt(self, token: bytes) -> str:
|
||||
return self._fernet.decrypt(token).decode()
|
||||
|
||||
def store(self, exchange: str, api_key: str, secret: str) -> None:
|
||||
payload = json.dumps({"api_key": api_key, "secret": secret})
|
||||
encrypted = self.encrypt(payload)
|
||||
out = self._key_dir / f"{exchange}.enc"
|
||||
out.write_bytes(encrypted)
|
||||
out.chmod(0o600)
|
||||
logger.info("key_stored", exchange=exchange)
|
||||
|
||||
def load(self, exchange: str) -> tuple[str, str]:
|
||||
path = self._key_dir / f"{exchange}.enc"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"No stored key for exchange '{exchange}'")
|
||||
payload = json.loads(self.decrypt(path.read_bytes()))
|
||||
return payload["api_key"], payload["secret"]
|
||||
|
||||
def check_withdrawal_permission(self, exchange: str) -> bool:
|
||||
"""Placeholder — subclasses override for exchange-specific checks."""
|
||||
return False
|
||||
@@ -0,0 +1,52 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from hydra.logging.setup import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
VALID_MARKETS = {"kr", "us", "upbit", "binance", "hl", "poly"}
|
||||
|
||||
|
||||
class MarketManager:
|
||||
def __init__(self, config_path: str = "config/markets.yaml"):
|
||||
self._path = Path(config_path)
|
||||
self._data: dict = self._load()
|
||||
|
||||
def _load(self) -> dict:
|
||||
if not self._path.exists():
|
||||
return {"markets": {m: {"enabled": False, "mode": "paper"} for m in VALID_MARKETS}}
|
||||
with self._path.open() as f:
|
||||
return yaml.safe_load(f) or {"markets": {}}
|
||||
|
||||
def _save(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._path.open("w") as f:
|
||||
yaml.dump(self._data, f, allow_unicode=True)
|
||||
|
||||
def get_active_markets(self) -> list[str]:
|
||||
return [k for k, v in self._data.get("markets", {}).items() if v.get("enabled")]
|
||||
|
||||
def is_active(self, market_id: str) -> bool:
|
||||
return self._data.get("markets", {}).get(market_id, {}).get("enabled", False)
|
||||
|
||||
def enable(self, market_id: str, mode: str = "paper") -> None:
|
||||
if market_id not in VALID_MARKETS:
|
||||
raise ValueError(f"Unknown market '{market_id}'. Valid: {VALID_MARKETS}")
|
||||
markets = self._data.setdefault("markets", {})
|
||||
markets.setdefault(market_id, {})["enabled"] = True
|
||||
markets[market_id]["mode"] = mode
|
||||
self._save()
|
||||
logger.info("market_enabled", market=market_id, mode=mode)
|
||||
|
||||
def disable(self, market_id: str) -> None:
|
||||
markets = self._data.get("markets", {})
|
||||
if market_id in markets:
|
||||
markets[market_id]["enabled"] = False
|
||||
self._save()
|
||||
logger.info("market_disabled", market=market_id)
|
||||
|
||||
def get_mode(self, market_id: str) -> str:
|
||||
return self._data.get("markets", {}).get(market_id, {}).get("mode", "paper")
|
||||
@@ -0,0 +1,49 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileLimits:
|
||||
name: str
|
||||
core_mem_gb: int
|
||||
redis_mem_gb: int
|
||||
db_mem_gb: int
|
||||
cpus: int
|
||||
ai_enabled: bool
|
||||
db_backend: str # "sqlite" or "timescaledb"
|
||||
|
||||
|
||||
PROFILES: dict[str, ProfileLimits] = {
|
||||
"lite": ProfileLimits(
|
||||
name="lite",
|
||||
core_mem_gb=2,
|
||||
redis_mem_gb=1,
|
||||
db_mem_gb=0,
|
||||
cpus=2,
|
||||
ai_enabled=False,
|
||||
db_backend="sqlite",
|
||||
),
|
||||
"pro": ProfileLimits(
|
||||
name="pro",
|
||||
core_mem_gb=4,
|
||||
redis_mem_gb=2,
|
||||
db_mem_gb=4,
|
||||
cpus=4,
|
||||
ai_enabled=True,
|
||||
db_backend="timescaledb",
|
||||
),
|
||||
"expert": ProfileLimits(
|
||||
name="expert",
|
||||
core_mem_gb=8,
|
||||
redis_mem_gb=4,
|
||||
db_mem_gb=8,
|
||||
cpus=8,
|
||||
ai_enabled=True,
|
||||
db_backend="timescaledb",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_profile(name: str) -> ProfileLimits:
|
||||
if name not in PROFILES:
|
||||
raise ValueError(f"Unknown profile '{name}'. Choose: {list(PROFILES)}")
|
||||
return PROFILES[name]
|
||||
@@ -0,0 +1,19 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
hydra_api_key: str = "change-me"
|
||||
hydra_profile: str = "lite"
|
||||
redis_url: str = "redis://localhost:6379"
|
||||
db_url: str = "sqlite:///data/hydra.db"
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
log_level: str = "INFO"
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,33 @@
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class StrategyConfig(BaseModel):
|
||||
stop_loss_pct: float = 0.02
|
||||
take_profit_pct: float = 0.05
|
||||
position_size_pct: float = 0.10
|
||||
max_positions: int = 5
|
||||
|
||||
@field_validator("stop_loss_pct")
|
||||
@classmethod
|
||||
def validate_stop_loss(cls, v: float) -> float:
|
||||
if v <= 0:
|
||||
raise ValueError("손절은 양수여야 합니다")
|
||||
if v > 0.20:
|
||||
raise ValueError(f"손절 {v * 100:.1f}%는 너무 큽니다 (최대 20%)")
|
||||
return v
|
||||
|
||||
@field_validator("position_size_pct")
|
||||
@classmethod
|
||||
def validate_position_size(cls, v: float) -> float:
|
||||
if v > 0.50:
|
||||
raise ValueError(f"포지션 사이즈 {v * 100:.1f}%는 너무 큽니다 (최대 50%)")
|
||||
return v
|
||||
|
||||
|
||||
class RiskConfig(BaseModel):
|
||||
daily_loss_limit_pct: float = 0.03
|
||||
daily_loss_kill_pct: float = 0.05
|
||||
max_position_per_symbol_pct: float = 0.20
|
||||
max_position_per_strategy_pct: float = 0.30
|
||||
max_position_per_market_pct: float = 0.50
|
||||
consecutive_loss_limit: int = 5
|
||||
Reference in New Issue
Block a user