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
4 changes: 0 additions & 4 deletions .github/workflows/staging-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,17 @@ env:
jobs:
full-stack-deploy:
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 20

steps:
- name: Connect to Tailscale
continue-on-error: true
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TAILSCALE_CLIENT_ID }}
oauth-secret: ${{ secrets.TAILSCALE_CLIENT_SECRET }}
tags: tag:ci

- name: Install SSH key
continue-on-error: true
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
Expand All @@ -34,7 +31,6 @@ jobs:
ssh-keyscan -p 2222 100.64.83.67 >> ~/.ssh/known_hosts

- name: Deploy stack
continue-on-error: true
run: |
ssh -i ~/.ssh/home_server_key \
-p 2222 \
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ This repository favors direct, explicit code over thin indirection.

## Trading Logic

- Treat futures sizing carefully: `fiat_order_size` is the configured risk budget unless surrounding code explicitly documents a margin-spend interpretation.
- For KuCoin futures bots, `fiat_order_size` is the **initial margin** the bot commits (margin-spend interpretation), not the risk-at-stop. `KucoinPositionDeal.calculate_contracts(balance, price)` is `balance * symbol_info.futures_leverage / (price * multiplier)`; `contracts_to_fiat_order_size` is its inverse. `notional = fiat_order_size * futures_leverage`.
- Leverage lives on the symbol row (`SymbolTable.futures_leverage`, bounded `[1, 3]`). It is **per-symbol**, not a global constant. New symbols default to `1x`; dial individual symbols up via the symbols API only as an explicit product decision.
- `base_order` clamps via `min(margin_sized_contracts, max_contracts_for_margin(available_balance, price))` and re-validates with `required_margin_for_contracts` before sending the order to KuCoin. Anything that places futures orders must keep going through `required_margin_for_contracts` so the affordability check stays exchange-truthful.
- Percent fields stored as values like `6.5` must be converted to ratios with `/ 100` before arithmetic.
- Do not apply leverage to PnL or risk-at-stop calculations. Leverage affects margin requirements, not the price move loss for a position.
- Use `1x` leverage for KuCoin futures orders unless a later product decision explicitly changes this.
- Keep exchange-specific assumptions near the exchange implementation and cover them with focused tests.

## Model Fields
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Add generated_at index for signal listing

Revision ID: f1a2b3c4d5e6
Revises: e9f0a1b2c3d4
Create Date: 2026-05-10 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


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


def upgrade() -> None:
"""Upgrade schema."""
op.create_index(
"ix_signals_generated_at",
"signals",
[sa.text("generated_at DESC")],
unique=False,
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_signals_generated_at", table_name="signals")
43 changes: 0 additions & 43 deletions api/charts/routes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from user.models.user import UserTokenData
from user.services.auth import get_current_user
from databases.utils import get_session
from tools.handle_error import (
json_response,
Expand All @@ -13,47 +11,6 @@
charts_blueprint = APIRouter()


@charts_blueprint.get("/top-gainers", tags=["charts"])
def top_gainers(session: Session = Depends(get_session)):
try:
gainers, losers = MarketDominationController().gainers_losers()
if gainers:
return json_response(
{
"data": gainers,
"message": "Successfully retrieved top gainers data.",
"error": 0,
}
)
else:
raise HTTPException(404, detail="No data found")

except Exception as error:
return json_response_error(f"Failed to retrieve top gainers data: {error}")


@charts_blueprint.get("/top-losers", tags=["charts"])
def top_losers(
session: Session = Depends(get_session),
_: UserTokenData = Depends(get_current_user),
):
try:
gainers, losers = MarketDominationController().gainers_losers()
if losers:
return json_response(
{
"data": losers,
"message": "Successfully retrieved top losers data.",
"error": 0,
}
)
else:
raise HTTPException(404, detail="No data found")

except Exception as error:
return json_response_error(f"Failed to retrieve top gainers data: {error}")


@charts_blueprint.get(
"/adr-series",
tags=["charts"],
Expand Down
94 changes: 82 additions & 12 deletions api/databases/crud/signals_crud.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime
from typing import Any, Sequence
from typing import Any, Sequence, cast

from sqlalchemy.orm import load_only
from sqlmodel import Session, col, select

from databases.tables.signals_table import SignalsTable
Expand Down Expand Up @@ -58,6 +59,85 @@ def query(
limit: int = 100,
offset: int = 0,
) -> Sequence[SignalsTable]:
stmt = self._filtered_query(
algorithm_name=algorithm_name,
symbol=symbol,
current_regime=current_regime,
autotrade=autotrade,
since=since,
until=until,
)
stmt = (
stmt.order_by(col(SignalsTable.generated_at).desc())
.offset(offset)
.limit(limit)
)
with get_db_session(self._external_session) as session:
rows = session.exec(stmt).all()
if self._external_session is None:
for row in rows:
session.expunge(row)
return rows

def query_summary(
self,
algorithm_name: str | None = None,
symbol: str | None = None,
current_regime: str | None = None,
autotrade: bool | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
stmt = self._filtered_query(
algorithm_name=algorithm_name,
symbol=symbol,
current_regime=current_regime,
autotrade=autotrade,
since=since,
until=until,
)
stmt = (
stmt.options(
load_only(
cast(Any, SignalsTable.id),
cast(Any, SignalsTable.algorithm_name),
cast(Any, SignalsTable.symbol),
cast(Any, SignalsTable.generated_at),
cast(Any, SignalsTable.direction),
cast(Any, SignalsTable.autotrade),
cast(Any, SignalsTable.current_regime),
)
)
.order_by(col(SignalsTable.generated_at).desc())
.offset(offset)
.limit(limit)
)
with get_db_session(self._external_session) as session:
rows = session.exec(stmt).all()
return [
{
"id": row.id,
"algorithm_name": row.algorithm_name,
"symbol": row.symbol,
"generated_at": row.generated_at,
"direction": row.direction,
"autotrade": row.autotrade,
"current_regime": row.current_regime,
}
for row in rows
]

def _filtered_query(
self,
algorithm_name: str | None = None,
symbol: str | None = None,
current_regime: str | None = None,
autotrade: bool | None = None,
since: datetime | None = None,
until: datetime | None = None,
) -> Any:
stmt = select(SignalsTable)
if algorithm_name is not None:
stmt = stmt.where(SignalsTable.algorithm_name == algorithm_name)
Expand All @@ -71,14 +151,4 @@ def query(
stmt = stmt.where(SignalsTable.generated_at >= since)
if until is not None:
stmt = stmt.where(SignalsTable.generated_at <= until)
stmt = (
stmt.order_by(col(SignalsTable.generated_at).desc())
.offset(offset)
.limit(limit)
)
with get_db_session(self._external_session) as session:
rows = session.exec(stmt).all()
if self._external_session is None:
for row in rows:
session.expunge(row)
return rows
return stmt
47 changes: 21 additions & 26 deletions api/exchange_apis/kucoin/futures/futures_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,27 +87,24 @@ def create_controller(self) -> PaperTradingTableCrud | BotTableCrud:

def calculate_contracts(self, balance: float, price: float) -> int:
"""
Size futures positions from a fiat risk budget.
Size futures positions from initial margin (margin-spend interpretation).

For futures bots, ``fiat_order_size`` is the max fiat loss budget at
the configured stop, not the margin to spend. KuCoin PnL is determined
by notional move, so leverage does not change the loss at stop.
``fiat_order_size`` is the initial margin the bot commits, not the
risk-at-stop. ``notional = balance * symbol_info.futures_leverage`` and
``contracts = notional / (price * multiplier)``. Per-symbol leverage is
sourced from the symbol table, capped at ``le=3``.
"""
if balance <= 0 or price <= 0:
return 0

stop_loss_ratio = self.active_bot.stop_loss / 100
if stop_loss_ratio <= 0:
return 0

symbol_data = getattr(self, "kucoin_symbol_data", None)
multiplier = float(
getattr(symbol_data, "multiplier", 0)
or getattr(self.kucoin_futures_api, "DEFAULT_MULTIPLIER", 1)
or 1
)

contracts = balance / (stop_loss_ratio * price * multiplier)
contracts = balance * self.symbol_info.futures_leverage / (price * multiplier)
return int(round_numbers(contracts, self.symbol_info.qty_precision))

def _is_reversal_possible(
Expand Down Expand Up @@ -170,24 +167,20 @@ def estimate_reversal_possible_for_new_bot(self) -> bool:

def contracts_to_fiat_order_size(self, contracts: float, price: float) -> float:
"""
Invert calculate_contracts() so fiat_order_size reflects the actual
risk budget used to open an existing futures position.
Invert calculate_contracts() so fiat_order_size reflects the initial
margin actually committed by an open futures position.
"""
if contracts <= 0 or price <= 0:
return 0.0

stop_loss_ratio = self.active_bot.stop_loss / 100
if stop_loss_ratio <= 0:
return 0.0

symbol_data = getattr(self, "kucoin_symbol_data", None)
multiplier = float(
getattr(symbol_data, "multiplier", 0)
or getattr(self.kucoin_futures_api, "DEFAULT_MULTIPLIER", 1)
)

return round_numbers(
contracts * price * multiplier * stop_loss_ratio,
contracts * price * multiplier / self.symbol_info.futures_leverage,
8,
)

Expand Down Expand Up @@ -237,8 +230,10 @@ def required_margin_for_contracts(self, contracts: float, price: float) -> float
"""
Estimate the margin needed for a futures order before submitting it.

`fiat_order_size` is the planned stop-loss risk, so it can be far
smaller than the margin required to carry the calculated position.
Under margin-spend sizing the required margin for a freshly calculated
position should equal ``fiat_order_size`` (modulo rounding from
integer contracts), but we recompute it from the contracts actually
placed so the affordability check uses the exchange-truth notional.
"""
if contracts <= 0 or price <= 0:
return 0.0
Expand Down Expand Up @@ -579,17 +574,17 @@ def base_order(self) -> BotModel:
size=available_balance,
)

risk_sized_contracts = self.calculate_contracts(
margin_sized_contracts = self.calculate_contracts(
self.active_bot.fiat_order_size, price
)

if risk_sized_contracts <= 0:
if margin_sized_contracts <= 0:
raise BinbotErrors(
"Calculated contracts is 0. Check if the order size, stop loss, and risk settings are correct."
)

affordable_contracts = self.max_contracts_for_margin(available_balance, price)
contracts = min(risk_sized_contracts, affordable_contracts)
contracts = min(margin_sized_contracts, affordable_contracts)

if contracts <= 0:
min_contract_margin = self.required_margin_for_contracts(
Expand All @@ -607,20 +602,20 @@ def base_order(self) -> BotModel:
f"exceeds available balance {available_balance} {self.fiat}."
)

actual_risk = self.contracts_to_fiat_order_size(contracts, price)
actual_margin = self.contracts_to_fiat_order_size(contracts, price)
notional = round_numbers(self.notional_for_contracts(contracts, price), 8)

if contracts < risk_sized_contracts:
if contracts < margin_sized_contracts:
self.active_bot.add_log(
f"Futures order downsized from {risk_sized_contracts} to {contracts} contracts "
f"Futures order downsized from {margin_sized_contracts} to {contracts} contracts "
f"because required margin exceeded available balance."
)

self.active_bot.add_log(
f"Futures activation sizing: contracts={contracts}, notional={notional} {self.fiat}, "
f"leverage={self.symbol_info.futures_leverage}x, required_margin={required_margin} {self.fiat}, "
f"available_balance={available_balance} {self.fiat}, planned_risk={self.active_bot.fiat_order_size} {self.fiat}, "
f"actual_risk={actual_risk} {self.fiat}."
f"available_balance={available_balance} {self.fiat}, planned_margin={self.active_bot.fiat_order_size} {self.fiat}, "
f"actual_margin={actual_margin} {self.fiat}."
)

if self.active_bot.position == Position.short:
Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies = [
"alembic>=1.18.4",
"alembic-postgresql-enum",
"pydantic-settings>=2.10.1",
"pybinbot>=1.8.4",
"pybinbot>=1.8.8",
]

[project.urls]
Expand Down
15 changes: 14 additions & 1 deletion api/signals/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,22 @@ class SignalRecord(SignalCreate):
id: int


class SignalListRecord(BaseModel):
id: int
algorithm_name: str = Field(..., max_length=128)
symbol: str = Field(..., max_length=64)
generated_at: datetime
direction: str = Field(..., max_length=16)
autotrade: bool = False
current_regime: str | None = Field(default=None, max_length=32)
context: dict[str, Any] | None = None
bot_params: dict[str, Any] | None = None
indicators: dict[str, Any] | None = None


class SignalResponse(StandardResponse):
data: SignalRecord


class SignalListResponse(StandardResponse):
data: list[SignalRecord]
data: list[SignalListRecord]
Loading
Loading