- RiskManager halts + flattens on max daily loss / drawdown, caps position size and trades per day; adds a manual kill switch (POST /api/bot/kill). - Telegram and webhook notifications on trades, errors and halts. - Optional API bearer token on /api, injected server-side by the frontend nginx so the browser never sees it. - Kraken and Coinbase market data (public); Coinbase live broker. Live on an unsupported exchange now errors instead of silently falling back to paper. - Strategy presets backed by the StrategyConfig table (CRUD API + UI). - Idempotent additive-column migration so existing SQLite databases upgrade in place (fixes the new strategy_configs.name column).
199 lines
6.5 KiB
Python
199 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.db import SessionLocal
|
|
from app.models import (
|
|
EquitySnapshot,
|
|
Event,
|
|
Order,
|
|
OrderSide,
|
|
Position,
|
|
PositionSide,
|
|
PositionStatus,
|
|
StrategyConfig,
|
|
)
|
|
|
|
|
|
class Store:
|
|
"""Thin persistence layer around the trading tables."""
|
|
|
|
def __init__(self, session_factory: sessionmaker = SessionLocal) -> None:
|
|
self._session_factory: sessionmaker = session_factory
|
|
|
|
def create_position(
|
|
self,
|
|
*,
|
|
strategy_key: str,
|
|
symbol: str,
|
|
side: PositionSide,
|
|
qty: float,
|
|
entry_price: float,
|
|
entry_fee: float,
|
|
stop_loss: float | None = None,
|
|
take_profit: float | None = None,
|
|
) -> Position:
|
|
with self._session_factory() as session:
|
|
position = Position(
|
|
strategy_key=strategy_key,
|
|
symbol=symbol,
|
|
side=side,
|
|
qty=qty,
|
|
entry_price=entry_price,
|
|
entry_fee=entry_fee,
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
status=PositionStatus.OPEN,
|
|
)
|
|
session.add(position)
|
|
session.commit()
|
|
return position
|
|
|
|
def close_position(
|
|
self, position: Position, *, exit_price: float, exit_fee: float, realized_pnl: float
|
|
) -> Position:
|
|
from app.models import utcnow
|
|
|
|
with self._session_factory() as session:
|
|
merged = session.merge(position)
|
|
merged.status = PositionStatus.CLOSED
|
|
merged.exit_price = exit_price
|
|
merged.exit_fee = exit_fee
|
|
merged.realized_pnl = realized_pnl
|
|
merged.closed_at = utcnow()
|
|
session.commit()
|
|
return merged
|
|
|
|
def abandon_open_positions(self, strategy_key: str, reason: str = "reset") -> int:
|
|
"""Mark lingering open positions closed at entry price (used on restart)."""
|
|
from app.models import utcnow
|
|
|
|
with self._session_factory() as session:
|
|
positions = list(
|
|
session.scalars(
|
|
select(Position).where(
|
|
Position.strategy_key == strategy_key,
|
|
Position.status == PositionStatus.OPEN,
|
|
)
|
|
).all()
|
|
)
|
|
for position in positions:
|
|
position.status = PositionStatus.CLOSED
|
|
position.exit_price = position.entry_price
|
|
position.exit_fee = 0.0
|
|
position.realized_pnl = -position.entry_fee
|
|
position.closed_at = utcnow()
|
|
session.add_all(positions)
|
|
session.commit()
|
|
return len(positions)
|
|
|
|
def open_position_for(self, strategy_key: str) -> Position | None:
|
|
with self._session_factory() as session:
|
|
return session.scalars(
|
|
select(Position)
|
|
.where(
|
|
Position.strategy_key == strategy_key,
|
|
Position.status == PositionStatus.OPEN,
|
|
)
|
|
.order_by(Position.id.desc())
|
|
).first()
|
|
|
|
def record_order(
|
|
self,
|
|
*,
|
|
position_id: int | None,
|
|
strategy_key: str,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
qty: float,
|
|
price: float,
|
|
fee: float,
|
|
reason: str,
|
|
) -> Order:
|
|
with self._session_factory() as session:
|
|
order = Order(
|
|
position_id=position_id,
|
|
strategy_key=strategy_key,
|
|
symbol=symbol,
|
|
side=side,
|
|
qty=qty,
|
|
price=price,
|
|
fee=fee,
|
|
reason=reason,
|
|
)
|
|
session.add(order)
|
|
session.commit()
|
|
return order
|
|
|
|
def record_event(self, level: str, message: str, strategy_key: str | None = None) -> Event:
|
|
with self._session_factory() as session:
|
|
event = Event(level=level, message=message, strategy_key=strategy_key)
|
|
session.add(event)
|
|
session.commit()
|
|
return event
|
|
|
|
def record_equity(self, equity: float, cash: float, price: float) -> None:
|
|
with self._session_factory() as session:
|
|
session.add(EquitySnapshot(equity=equity, cash=cash, price=price))
|
|
session.commit()
|
|
|
|
def list_positions(self, limit: int = 50) -> list[Position]:
|
|
with self._session_factory() as session:
|
|
return list(
|
|
session.scalars(select(Position).order_by(Position.id.desc()).limit(limit)).all()
|
|
)
|
|
|
|
def list_orders(self, limit: int = 50) -> list[Order]:
|
|
with self._session_factory() as session:
|
|
return list(session.scalars(select(Order).order_by(Order.id.desc()).limit(limit)).all())
|
|
|
|
def list_events(self, limit: int = 50) -> list[Event]:
|
|
with self._session_factory() as session:
|
|
return list(session.scalars(select(Event).order_by(Event.id.desc()).limit(limit)).all())
|
|
|
|
def list_equity(self, limit: int = 500) -> list[EquitySnapshot]:
|
|
with self._session_factory() as session:
|
|
rows = list(
|
|
session.scalars(
|
|
select(EquitySnapshot).order_by(EquitySnapshot.id.desc()).limit(limit)
|
|
).all()
|
|
)
|
|
return list(reversed(rows))
|
|
|
|
def save_strategy_config(
|
|
self,
|
|
*,
|
|
name: str,
|
|
strategy_key: str,
|
|
symbol: str,
|
|
timeframe: str,
|
|
params: dict,
|
|
) -> StrategyConfig:
|
|
with self._session_factory() as session:
|
|
config = StrategyConfig(
|
|
name=name,
|
|
strategy_key=strategy_key,
|
|
symbol=symbol,
|
|
timeframe=timeframe,
|
|
params=params,
|
|
)
|
|
session.add(config)
|
|
session.commit()
|
|
return config
|
|
|
|
def list_strategy_configs(self) -> list[StrategyConfig]:
|
|
with self._session_factory() as session:
|
|
return list(
|
|
session.scalars(select(StrategyConfig).order_by(StrategyConfig.id.desc())).all()
|
|
)
|
|
|
|
def delete_strategy_config(self, config_id: int) -> bool:
|
|
with self._session_factory() as session:
|
|
config = session.get(StrategyConfig, config_id)
|
|
if config is None:
|
|
return False
|
|
session.delete(config)
|
|
session.commit()
|
|
return True
|