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
13 changes: 10 additions & 3 deletions .agents/skills/write-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
name: write-pr
description: Push the current branch, generate a PR title/body from commits since the base branch following this project's convention (no prefix, Korean title), attach best-matching labels if any exist, and open the GitHub PR.
compatibility: Requires git and gh (GitHub CLI)
---

## Step 1 — Determine Base Branch
Expand Down Expand Up @@ -38,7 +37,15 @@ If there are no commits ahead of base, report that and exit.

예: `JWT 인증 필터 추가`

**PR 본문**: 커밋 목록을 바탕으로 변경 사항을 bullet로 정리하고, 관련 이슈가 있으면 `Closes #<번호>` 형식으로 연결.
**PR 본문**: 저장소에 PR 템플릿이 있으면 반드시 먼저 읽고 그 형식을 채운다.

```bash
find .github -maxdepth 3 -type f \( -iname '*pull_request_template*' -o -path './.github/PULL_REQUEST_TEMPLATE/*' \)
```

- 템플릿이 있으면 섹션명과 체크리스트를 유지하고, 커밋 목록을 바탕으로 내용을 채운다.
- 관련 이슈가 있으면 템플릿의 관련 이슈 섹션에 `Closes #<번호>` 형식으로 연결한다.
- 템플릿이 없을 때만 자유 형식 bullet 본문을 작성한다.

**어트리뷰션 주의**: 본문에 "Generated by Codex" 같은 서명이나 트레일러를 추가하지 않는다.

Expand All @@ -60,4 +67,4 @@ gh pr create --base develop --title "<한글 제목>" --body "<본문>"

## Step 7 — Report

생성된 PR URL을 출력한다.
생성된 PR URL을 출력한다.
38 changes: 38 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://mlflow:mlflow@localhost:5432/arena_ai

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
49 changes: 49 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from app.mlops import models as _mlops_models # noqa: F401
from app.shared.db import Base, get_database_url
from app.trading_ai import models as _trading_ai_models # noqa: F401

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = Base.metadata

config.set_main_option("sqlalchemy.url", get_database_url())


def run_migrations_offline() -> None:
context.configure(
url=get_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)

with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
78 changes: 78 additions & 0 deletions alembic/versions/20260708_0001_create_ai_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""create ai tables

Revision ID: 20260708_0001
Revises:
Create Date: 2026-07-08
"""

from typing import Sequence

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = "20260708_0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"decision_logs",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("challenge_id", sa.BigInteger(), nullable=False),
sa.Column("order_id", sa.BigInteger(), nullable=True),
sa.Column("symbol_code", sa.String(length=20), nullable=False),
sa.Column("market", sa.String(length=10), nullable=False),
sa.Column("feature_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("model_output_probability", sa.Numeric(precision=5, scale=4), nullable=False),
sa.Column("action", sa.String(length=10), nullable=False),
sa.Column("model_version", sa.String(length=50), nullable=False),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("action IN ('BUY', 'SELL', 'HOLD')", name="ck_decision_logs_action"),
sa.CheckConstraint("market IN ('KR', 'US', 'COIN')", name="ck_decision_logs_market"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_decision_logs_challenge_decided", "decision_logs", ["challenge_id", "decided_at"])
op.create_index("idx_decision_logs_model_version", "decision_logs", ["model_version"])

op.create_table(
"model_versions",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("version_tag", sa.String(length=50), nullable=False),
sa.Column("trained_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("performance_metrics", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("status", sa.String(length=20), server_default="CHALLENGER", nullable=False),
sa.CheckConstraint("status IN ('CHAMPION', 'CHALLENGER', 'RETIRED')", name="ck_model_versions_status"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("version_tag", name="uq_model_versions_version_tag"),
)
op.create_index(
"uq_model_versions_champion",
"model_versions",
["status"],
unique=True,
postgresql_where=sa.text("status = 'CHAMPION'"),
)
op.create_table(
"shap_values",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("decision_id", sa.BigInteger(), nullable=False),
sa.Column("feature_name", sa.String(length=50), nullable=False),
sa.Column("contribution", sa.Numeric(precision=8, scale=5), nullable=False),
sa.Column("base_value", sa.Numeric(precision=5, scale=4), nullable=False),
sa.ForeignKeyConstraint(["decision_id"], ["decision_logs.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_shap_values_decision_id", "shap_values", ["decision_id"])


def downgrade() -> None:
op.drop_index("idx_shap_values_decision_id", table_name="shap_values")
op.drop_table("shap_values")
op.drop_index("uq_model_versions_champion", table_name="model_versions")
op.drop_table("model_versions")
op.drop_index("idx_decision_logs_model_version", table_name="decision_logs")
op.drop_index("idx_decision_logs_challenge_decided", table_name="decision_logs")
op.drop_table("decision_logs")
1 change: 1 addition & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

8 changes: 8 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import FastAPI

app = FastAPI()


@app.get("/health")
def health():
return {"status": "ok"}
1 change: 1 addition & 0 deletions app/mlops/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

33 changes: 33 additions & 0 deletions app/mlops/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from datetime import datetime
from typing import Any

from sqlalchemy import BigInteger, DateTime, Enum, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from app.shared.db import Base
from app.shared.enums import ModelStatus


class ModelVersion(Base):
__tablename__ = "model_versions"
__table_args__ = (
UniqueConstraint("version_tag", name="uq_model_versions_version_tag"),
Index(
"uq_model_versions_champion",
"status",
unique=True,
postgresql_where="status = 'CHAMPION'",
),
)
Comment thread
cfcromn marked this conversation as resolved.

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
version_tag: Mapped[str] = mapped_column(String(50), nullable=False)
trained_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
performance_metrics: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[ModelStatus] = mapped_column(
Enum(ModelStatus, native_enum=False, name="ck_model_versions_status"),
nullable=False,
default=ModelStatus.CHALLENGER,
server_default=ModelStatus.CHALLENGER.value,
)
Comment thread
cfcromn marked this conversation as resolved.
23 changes: 23 additions & 0 deletions app/mlops/repositories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.mlops.models import ModelVersion
from app.shared.enums import ModelStatus


class ModelVersionRepository:
def __init__(self, db: Session):
self.db = db

def add(self, model_version: ModelVersion) -> ModelVersion:
self.db.add(model_version)
self.db.flush()
return model_version

def get_by_version_tag(self, version_tag: str) -> ModelVersion | None:
stmt = select(ModelVersion).where(ModelVersion.version_tag == version_tag)
return self.db.scalar(stmt)

def get_champion(self) -> ModelVersion | None:
stmt = select(ModelVersion).where(ModelVersion.status == ModelStatus.CHAMPION)
return self.db.scalar(stmt)
1 change: 1 addition & 0 deletions app/shared/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

35 changes: 35 additions & 0 deletions app/shared/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from functools import lru_cache
import os
from typing import Generator

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker


DEFAULT_DATABASE_URL = "postgresql+psycopg://mlflow:mlflow@localhost:5432/arena_ai"


class Base(DeclarativeBase):
pass


def get_database_url() -> str:
return os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)


@lru_cache
def get_engine():
return create_engine(get_database_url(), pool_pre_ping=True)


@lru_cache
def get_session_local():
return sessionmaker(autocommit=False, autoflush=False, bind=get_engine())


def get_db() -> Generator[Session, None, None]:
db = get_session_local()()
try:
yield db
finally:
db.close()
19 changes: 19 additions & 0 deletions app/shared/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from enum import Enum


class Market(str, Enum):
KR = "KR"
US = "US"
COIN = "COIN"


class TradingAction(str, Enum):
BUY = "BUY"
SELL = "SELL"
HOLD = "HOLD"


class ModelStatus(str, Enum):
CHAMPION = "CHAMPION"
CHALLENGER = "CHALLENGER"
RETIRED = "RETIRED"
1 change: 1 addition & 0 deletions app/trading_ai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

50 changes: 50 additions & 0 deletions app/trading_ai/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from datetime import datetime
from decimal import Decimal
from typing import Any

from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, Numeric, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.shared.db import Base
from app.shared.enums import Market, TradingAction


class DecisionLog(Base):
__tablename__ = "decision_logs"
__table_args__ = (
Index("idx_decision_logs_challenge_decided", "challenge_id", "decided_at"),
Index("idx_decision_logs_model_version", "model_version"),
)

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
challenge_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
order_id: Mapped[int | None] = mapped_column(BigInteger)
symbol_code: Mapped[str] = mapped_column(String(20), nullable=False)
market: Mapped[Market] = mapped_column(
Enum(Market, native_enum=False, name="ck_decision_logs_market"),
nullable=False,
)
feature_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
model_output_probability: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)
action: Mapped[TradingAction] = mapped_column(
Enum(TradingAction, native_enum=False, name="ck_decision_logs_action"),
nullable=False,
)
model_version: Mapped[str] = mapped_column(String(50), nullable=False)
decided_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

shap_values: Mapped[list["ShapValue"]] = relationship(back_populates="decision")


class ShapValue(Base):
__tablename__ = "shap_values"
__table_args__ = (Index("idx_shap_values_decision_id", "decision_id"),)

id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
decision_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("decision_logs.id"), nullable=False)
feature_name: Mapped[str] = mapped_column(String(50), nullable=False)
contribution: Mapped[Decimal] = mapped_column(Numeric(8, 5), nullable=False)
base_value: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)

decision: Mapped[DecisionLog] = relationship(back_populates="shap_values")
Loading