Initial public release

This commit is contained in:
sinmb79
2026-03-30 13:19:11 +09:00
commit 92a692b63c
116 changed files with 5822 additions and 0 deletions
View File
+52
View File
@@ -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
+52
View File
@@ -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")
+49
View File
@@ -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]
+19
View File
@@ -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()
+33
View File
@@ -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