Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Add strategy_threshold_override table

Revision ID: f7b8c9d0e1f2
Revises: e4a3a439c266
Create Date: 2026-04-25 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "f7b8c9d0e1f2"
down_revision: Union[str, Sequence[str], None] = "e4a3a439c266"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"strategy_threshold_override",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("strategy_name", sa.String(), nullable=False),
sa.Column("symbol", sa.String(), nullable=False),
sa.Column("buy_trigger_pct", sa.Float(), nullable=False),
sa.Column("sell_trigger_pct", sa.Float(), nullable=False),
sa.Column("profile", sa.String(), nullable=False),
sa.Column("source_provider", sa.String(), nullable=False),
sa.Column("market_context_timestamp", sa.Float(), nullable=False),
sa.Column("expires_at", sa.Float(), nullable=False),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("reason", sa.String(), nullable=False),
sa.Column("raw_model_response", sa.JSON(), nullable=False),
sa.Column("created_at", sa.Float(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_strategy_threshold_override_id",
"strategy_threshold_override",
["id"],
unique=True,
)
op.create_index(
"ix_strategy_threshold_override_strategy_name",
"strategy_threshold_override",
["strategy_name"],
unique=False,
)
op.create_index(
"ix_strategy_threshold_override_symbol",
"strategy_threshold_override",
["symbol"],
unique=False,
)
op.create_index(
"ix_strategy_threshold_override_source_provider",
"strategy_threshold_override",
["source_provider"],
unique=False,
)
op.create_index(
"ix_strategy_threshold_override_expires_at",
"strategy_threshold_override",
["expires_at"],
unique=False,
)


def downgrade() -> None:
op.drop_index("ix_strategy_threshold_override_expires_at", table_name="strategy_threshold_override")
op.drop_index("ix_strategy_threshold_override_source_provider", table_name="strategy_threshold_override")
op.drop_index("ix_strategy_threshold_override_symbol", table_name="strategy_threshold_override")
op.drop_index("ix_strategy_threshold_override_strategy_name", table_name="strategy_threshold_override")
op.drop_index("ix_strategy_threshold_override_id", table_name="strategy_threshold_override")
op.drop_table("strategy_threshold_override")
54 changes: 54 additions & 0 deletions api/databases/crud/strategy_threshold_override_crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from sqlmodel import Session, select, desc
from databases.tables.strategy_threshold_override_table import (
StrategyThresholdOverrideTable,
)


class StrategyThresholdOverrideCrud:
def __init__(self, session: Session):
self.session = session

def create(
self,
row: StrategyThresholdOverrideTable,
) -> StrategyThresholdOverrideTable:
self.session.add(row)
self.session.commit()
self.session.refresh(row)
return row

def get_active(
self,
strategy_name: str,
symbol: str,
now_ts: float,
) -> StrategyThresholdOverrideTable | None:
statement = (
select(StrategyThresholdOverrideTable)
.where(
StrategyThresholdOverrideTable.strategy_name == strategy_name,
StrategyThresholdOverrideTable.symbol == symbol,
StrategyThresholdOverrideTable.expires_at > now_ts,
)
.order_by(desc(StrategyThresholdOverrideTable.created_at))
)
return self.session.exec(statement).first()

def list_for_symbol(
self,
strategy_name: str,
symbol: str,
limit: int = 20,
offset: int = 0,
) -> list[StrategyThresholdOverrideTable]:
statement = (
select(StrategyThresholdOverrideTable)
.where(
StrategyThresholdOverrideTable.strategy_name == strategy_name,
StrategyThresholdOverrideTable.symbol == symbol,
)
.order_by(desc(StrategyThresholdOverrideTable.created_at))
.limit(limit)
.offset(offset)
)
return list(self.session.exec(statement).all())
1 change: 1 addition & 0 deletions api/databases/tables/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
from .symbol_table import * # noqa
from .asset_index_table import * # noqa
from .symbol_exchange_table import * # noqa
from .strategy_threshold_override_table import * # noqa
29 changes: 29 additions & 0 deletions api/databases/tables/strategy_threshold_override_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from uuid import UUID, uuid4
from typing import Optional
from sqlalchemy import Column, JSON
from sqlmodel import SQLModel, Field
from pybinbot import timestamp


class StrategyThresholdOverrideTable(SQLModel, table=True):
__tablename__ = "strategy_threshold_override"

id: Optional[UUID] = Field(
default_factory=uuid4,
primary_key=True,
nullable=False,
unique=True,
index=True,
)
strategy_name: str = Field(index=True)
symbol: str = Field(index=True)
buy_trigger_pct: float = Field(ge=0)
sell_trigger_pct: float = Field(ge=0)
profile: str = Field(default="")
source_provider: str = Field(default="manual", index=True)
market_context_timestamp: float = Field(default_factory=timestamp)
expires_at: float = Field(index=True)
confidence: float = Field(default=0, ge=0, le=1)
reason: str = Field(default="")
raw_model_response: dict = Field(default_factory=dict, sa_column=Column(JSON))
created_at: float = Field(default_factory=timestamp)
2 changes: 2 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from asset_index.routes import asset_index_blueprint
from inquiries.routes import inquiries_router
from portfolio.routes import portfolio_blueprint
from strategy_thresholds.routes import strategy_threshold_blueprint
from pybinbot import configure_logging
from databases.tables import * # noqa

Expand Down Expand Up @@ -67,6 +68,7 @@ async def root():
app.include_router(asset_index_blueprint, prefix="/asset-index")
app.include_router(inquiries_router)
app.include_router(portfolio_blueprint)
app.include_router(strategy_threshold_blueprint)


@app.exception_handler(RequestValidationError)
Expand Down
61 changes: 61 additions & 0 deletions api/openai_api/threshold_advisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import json
from openai import OpenAI

from tools.config import Config
from strategy_thresholds.models import MarketContextPayload, ThresholdRecommendation


class OpenAiThresholdError(Exception):
pass


class OpenAiThresholdAdvisor:
def __init__(self, model: str | None = None):
self.config = Config()
self.model = model or self.config.grid_threshold_llm_model
self.client = OpenAI(api_key=self.config.openai_api_key)
if not self.client.api_key:
raise OpenAiThresholdError("OPENAI_API_KEY is not configured")

def _build_prompt(
self,
strategy: str,
symbol: str,
context: MarketContextPayload,
) -> str:
return (
"Return ONLY valid JSON for a threshold recommendation. "
"Use keys: strategy, symbol, profile, buy_trigger_pct, sell_trigger_pct, ttl_minutes, confidence, reason. "
"Apply conservative thresholds for range markets and reject unstable trend states.\n"
f"Strategy: {strategy}\n"
f"Symbol: {symbol}\n"
f"Market context: {context.model_dump_json()}"
)

def suggest(
self,
strategy: str,
symbol: str,
context: MarketContextPayload,
) -> tuple[ThresholdRecommendation, dict]:
prompt = self._build_prompt(strategy=strategy, symbol=symbol, context=context)
response = self.client.responses.create(
model=self.model,
input=prompt,
text={"format": {"type": "json_object"}},
)

output_text = response.output_text
if not output_text:
raise OpenAiThresholdError("OpenAI response did not include output_text")

try:
payload = json.loads(output_text)
except json.JSONDecodeError as exc:
raise OpenAiThresholdError("OpenAI response was not valid JSON") from exc

payload.setdefault("strategy", strategy)
payload.setdefault("symbol", symbol)

recommendation = ThresholdRecommendation.model_validate(payload)
return recommendation, response.model_dump()
3 changes: 2 additions & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ dependencies = [
"pydantic-settings>=2.10.1",
"confluent-kafka>=2.11.1",
"aiokafka>=0.12.0",
"pybinbot>=1.3.2",
"pybinbot>=1.3.6",
"openai>=2.6.1",
]

[project.urls]
Expand Down
121 changes: 121 additions & 0 deletions api/strategy_thresholds/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from pydantic import BaseModel, Field, model_validator
from typing import Any
from tools.handle_error import IResponseBase


MIN_TRIGGER = 0.008
MAX_TRIGGER = 0.05
MAX_STEP_DELTA = 0.005
MIN_TTL_MINUTES = 30
MAX_TTL_MINUTES = 240


class ThresholdRecommendation(BaseModel):
strategy: str
symbol: str
buy_trigger_pct: float = Field(ge=MIN_TRIGGER, le=MAX_TRIGGER)
sell_trigger_pct: float = Field(ge=MIN_TRIGGER, le=MAX_TRIGGER)
ttl_minutes: int = Field(ge=MIN_TTL_MINUTES, le=MAX_TTL_MINUTES)
confidence: float = Field(ge=0, le=1)
reason: str = Field(min_length=3)
profile: str = Field(default="")


class MarketContextPayload(BaseModel):
market_regime: str
coin_regime: str
market_stress_score: float = Field(ge=0)
atr_pct: float | None = None
bb_width: float | None = None
extras: dict[str, Any] = Field(default_factory=dict)


class SuggestThresholdRequest(BaseModel):
strategy: str
symbol: str
market_context: MarketContextPayload
model: str | None = None
apply: bool = True


class ApplyThresholdRequest(BaseModel):
recommendation: ThresholdRecommendation
source_provider: str = "manual"
market_context_timestamp: float
raw_model_response: dict[str, Any] = Field(default_factory=dict)


class ThresholdOverrideModel(BaseModel):
id: str
strategy_name: str
symbol: str
buy_trigger_pct: float
sell_trigger_pct: float
profile: str
source_provider: str
market_context_timestamp: float
expires_at: float
confidence: float
reason: str
raw_model_response: dict[str, Any]
created_at: float


class ThresholdOverrideResponse(IResponseBase):
data: ThresholdOverrideModel | None = None


class ThresholdOverrideListResponse(IResponseBase):
data: list[ThresholdOverrideModel] = Field(default_factory=list)


class SuggestThresholdResponse(IResponseBase):
data: ThresholdRecommendation | None = None


class GuardrailInput(BaseModel):
recommendation: ThresholdRecommendation
current_buy_trigger_pct: float | None = None
current_sell_trigger_pct: float | None = None
market_stress_score: float
market_regime: str
coin_regime: str

@model_validator(mode="after")
def validate_step_delta(self):
if self.current_buy_trigger_pct is not None:
if (
abs(
self.recommendation.buy_trigger_pct
- self.current_buy_trigger_pct
)
> MAX_STEP_DELTA
):
raise ValueError("buy_trigger_pct exceeds max step delta")

if self.current_sell_trigger_pct is not None:
if (
abs(
self.recommendation.sell_trigger_pct
- self.current_sell_trigger_pct
)
> MAX_STEP_DELTA
):
raise ValueError("sell_trigger_pct exceeds max step delta")

if self.market_stress_score >= 0.35:
raise ValueError("market stress too high")

blocked_coin_regimes = {
"VOLATILE",
"TREND_UP",
"TREND_DOWN",
"BREAKOUT_UP",
"BREAKDOWN",
}
if self.market_regime.upper() not in {"RANGE", "TRANSITIONAL"}:
raise ValueError("market regime does not support threshold updates")
if self.coin_regime.upper() in blocked_coin_regimes:
raise ValueError("coin regime does not support threshold updates")

return self
Loading
Loading