백엔드 전체 구현 완료: 내부 서비스(Auth, Client, Realtime), API 엔드포인트 및 스케줄러 구현
This commit is contained in:
55
backend/app/core/config.py
Normal file
55
backend/app/core/config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
from typing import List, Union
|
||||
from pydantic import AnyHttpUrl, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "BatchuKis Backend"
|
||||
API_V1_STR: str = "/api"
|
||||
|
||||
# Server Config
|
||||
PORT: int = 80
|
||||
HOST: str = "0.0.0.0"
|
||||
|
||||
# Security: CORS & Allowed Hosts
|
||||
# In production, this should be set to ["kis.tindevil.com"]
|
||||
ALLOWED_HOSTS: List[str] = ["localhost", "127.0.0.1", "kis.tindevil.com"]
|
||||
|
||||
# CORS Origins
|
||||
BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
"https://kis.tindevil.com",
|
||||
]
|
||||
|
||||
# Database
|
||||
# Using aiosqlite for async SQLite
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./kis_stock.db"
|
||||
|
||||
# Timezone
|
||||
TIMEZONE: str = "Asia/Seoul"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
@field_validator("ALLOWED_HOSTS", mode="before")
|
||||
def assemble_allowed_hosts(cls, v: Union[str, List[str]]) -> List[str]:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
elif isinstance(v, (list, str)):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
|
||||
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[Union[str, AnyHttpUrl]]:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
elif isinstance(v, (list, str)):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
settings = Settings()
|
||||
19
backend/app/core/crypto.py
Normal file
19
backend/app/core/crypto.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
from base64 import b64decode
|
||||
|
||||
def aes_cbc_base64_dec(key: str, iv: str, cipher_text: str) -> str:
|
||||
"""
|
||||
Decrypts KIS WebSocket data using AES-256-CBC.
|
||||
adapted from KIS official sample.
|
||||
"""
|
||||
if not key or not iv:
|
||||
raise ValueError("Key and IV are required for decryption")
|
||||
|
||||
# Key and IV are assumed to be utf-8 strings
|
||||
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
|
||||
|
||||
# Decrypt and unpad
|
||||
decrypted_bytes = unpad(cipher.decrypt(b64decode(cipher_text)), AES.block_size)
|
||||
|
||||
return bytes.decode(decrypted_bytes, 'utf-8')
|
||||
55
backend/app/core/market_schedule.py
Normal file
55
backend/app/core/market_schedule.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, time
|
||||
import pytz
|
||||
|
||||
KST = pytz.timezone("Asia/Seoul")
|
||||
US_EASTERN = pytz.timezone("US/Eastern")
|
||||
|
||||
class MarketSchedule:
|
||||
"""
|
||||
Checks if the market is open based on current time and market type.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_market_open(market: str) -> bool:
|
||||
"""
|
||||
:param market: 'Domestic' or 'Overseas'
|
||||
"""
|
||||
if market == "Domestic":
|
||||
return MarketSchedule._is_domestic_open()
|
||||
elif market == "Overseas":
|
||||
return MarketSchedule._is_overseas_open()
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_domestic_open() -> bool:
|
||||
now = datetime.now(KST)
|
||||
|
||||
# 1. Weekend Check (0=Mon, 4=Fri, 5=Sat, 6=Sun)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# 2. Time Check (09:00 ~ 15:30)
|
||||
current_time = now.time()
|
||||
start = time(9, 0)
|
||||
end = time(15, 30)
|
||||
|
||||
return start <= current_time <= end
|
||||
|
||||
@staticmethod
|
||||
def _is_overseas_open() -> bool:
|
||||
# US Market: 09:30 ~ 16:00 (US Eastern Time)
|
||||
# pytz handles DST automatically for US/Eastern
|
||||
now = datetime.now(US_EASTERN)
|
||||
|
||||
# 1. Weekend Check
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# 2. Time Check
|
||||
current_time = now.time()
|
||||
start = time(9, 30)
|
||||
end = time(16, 0)
|
||||
|
||||
return start <= current_time <= end
|
||||
|
||||
market_schedule = MarketSchedule()
|
||||
35
backend/app/core/rate_limiter.py
Normal file
35
backend/app/core/rate_limiter.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Centralized Request Queue that enforces a physical delay between API calls.
|
||||
Default delay is 250ms (4 requests per second).
|
||||
"""
|
||||
def __init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_call_time = 0
|
||||
self._delay = 0.25 # seconds (250ms)
|
||||
|
||||
async def wait(self):
|
||||
"""
|
||||
Acquire lock and sleep if necessary to respect the rate limit.
|
||||
"""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_call_time
|
||||
|
||||
if elapsed < self._delay:
|
||||
sleep_time = self._delay - elapsed
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
self._last_call_time = time.monotonic()
|
||||
|
||||
def set_delay(self, ms: int):
|
||||
"""
|
||||
Update the delay interval dynamically from DB settings.
|
||||
"""
|
||||
self._delay = ms / 1000.0
|
||||
|
||||
# Singleton instance
|
||||
global_rate_limiter = RateLimiter()
|
||||
Reference in New Issue
Block a user