feat: enhance fee management with API integration and audit trail support
This commit is contained in:
@@ -49,6 +49,36 @@ Key features include:
|
|||||||
- Backtesting parameter configuration
|
- Backtesting parameter configuration
|
||||||
- Fee configuration by pairing and market type
|
- Fee configuration by pairing and market type
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
Full page templates (`src/arbitrade/web/templates/`):
|
||||||
|
|
||||||
|
| Template | Route | Purpose |
|
||||||
|
| ------------------ | ------------------------ | ------------------------------------------------------- |
|
||||||
|
| `base.html` | — (root layout) | Dark theme, `.shell` container, HTMX, CSS variables |
|
||||||
|
| `dashboard.html` | `/`, `/dashboard` | Main dashboard: metrics, overview, controls, charts |
|
||||||
|
| `config.html` | `/dashboard/config` | Full configuration: fees, runtime, alerts, Kraken, risk |
|
||||||
|
| `audit.html` | `/dashboard/audit` | Audit trail with auto-refresh via HTMX |
|
||||||
|
| `backtesting.html` | `/dashboard/backtesting` | Backtesting panel with replay/sweep forms |
|
||||||
|
| `health.html` | `/health` | System health check |
|
||||||
|
|
||||||
|
Dashboard partials (`src/arbitrade/web/templates/partials/`):
|
||||||
|
|
||||||
|
| Partial | In page | Content |
|
||||||
|
| ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| `metrics.html` | Dashboard | 6 KPI cards: P&L, win rate, avg duration, trade count, success %, profit factor |
|
||||||
|
| `overview.html` | Dashboard | Status, balances, fee tier, open trades list, opportunity feed |
|
||||||
|
| `controls.html` | Dashboard | Runtime status, kill switch, config snapshot, alerting status, execution controls (Start/Stop/Kill) |
|
||||||
|
| `charts.html` | Dashboard | Opportunity trend chart (Chart.js, Alpine toggle) |
|
||||||
|
| `config.html` | Config page | Config form: Runtime, Alerts, Kraken, Risk, Strategy sections |
|
||||||
|
| `config_fees.html` | Config page | Pair fee table + add/edit form |
|
||||||
|
| `backtesting_panel.html` | Backtesting page | Run status, replay/sweep forms, recent runs |
|
||||||
|
| `audit.html` | Audit page | Audit trail table: time, actor, event, decision, payload |
|
||||||
|
|
||||||
|
Legacy templates (`src/arbitrade/web/templates/dashboard/`):
|
||||||
|
|
||||||
|
- `config_settings.html`, `config_pairs.html`, `config_fees.html` — superseded by config page; retained for reference
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
|
|||||||
@@ -6,10 +6,31 @@ from collections.abc import Mapping
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
||||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_fee_rate(fee_rate: float | None, db_path: str | None = None) -> float:
|
||||||
|
"""Resolve fee rate from arg or DB snapshot. Falls back to 0.0026."""
|
||||||
|
if fee_rate is not None:
|
||||||
|
return fee_rate
|
||||||
|
if db_path is not None:
|
||||||
|
try:
|
||||||
|
conn = duckdb.connect(db_path)
|
||||||
|
row = conn.execute("""
|
||||||
|
SELECT maker_fee FROM kraken_account_snapshots
|
||||||
|
ORDER BY snapshot_at DESC LIMIT 1
|
||||||
|
""").fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row is not None and row[0] is not None:
|
||||||
|
return float(row[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0.0026 # ultimate fallback
|
||||||
|
|
||||||
|
|
||||||
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
|
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
|
||||||
graph = CurrencyGraph()
|
graph = CurrencyGraph()
|
||||||
graph.add_pair("USD", "BTC", "BTC/USD")
|
graph.add_pair("USD", "BTC", "BTC/USD")
|
||||||
@@ -30,19 +51,23 @@ def _parse_balances(raw: str) -> Mapping[str, float]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Run a deterministic replay backtest.")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run a deterministic replay backtest.")
|
||||||
parser.add_argument("--events", type=Path, required=True)
|
parser.add_argument("--events", type=Path, required=True)
|
||||||
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
||||||
parser.add_argument("--trade-capital", type=float, default=100.0)
|
parser.add_argument("--trade-capital", type=float, default=100.0)
|
||||||
parser.add_argument("--fee-rate", type=float, default=0.0026)
|
parser.add_argument("--fee-rate", type=float, default=None)
|
||||||
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
||||||
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
||||||
|
parser.add_argument("--db-path", type=str, default=None,
|
||||||
|
help="DuckDB path for fee lookup")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cycles_by_pair, available_pairs = _build_graph()
|
cycles_by_pair, available_pairs = _build_graph()
|
||||||
events = load_replay_events(args.events)
|
events = load_replay_events(args.events)
|
||||||
|
fee_rate = _resolve_fee_rate(args.fee_rate, args.db_path)
|
||||||
config = BacktestConfig(
|
config = BacktestConfig(
|
||||||
fee_rate=args.fee_rate,
|
fee_rate=fee_rate,
|
||||||
trade_capital=args.trade_capital,
|
trade_capital=args.trade_capital,
|
||||||
slippage_bps=args.slippage_bps,
|
slippage_bps=args.slippage_bps,
|
||||||
execution_latency_ms=args.execution_latency_ms,
|
execution_latency_ms=args.execution_latency_ms,
|
||||||
@@ -55,15 +80,18 @@ def main() -> int:
|
|||||||
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
||||||
)
|
)
|
||||||
report = asyncio.run(
|
report = asyncio.run(
|
||||||
engine.run(events, starting_balances=_parse_balances(args.starting_balances))
|
engine.run(events, starting_balances=_parse_balances(
|
||||||
|
args.starting_balances))
|
||||||
)
|
)
|
||||||
|
|
||||||
print("Backtest report:")
|
print("Backtest report:")
|
||||||
print(f"- processed_events: {report.processed_events}")
|
print(f"- processed_events: {report.processed_events}")
|
||||||
print(f"- opportunities_seen: {report.opportunities_seen}")
|
print(f"- opportunities_seen: {report.opportunities_seen}")
|
||||||
print(f"- trades_executed: {report.trades_executed}")
|
print(f"- trades_executed: {report.trades_executed}")
|
||||||
print(f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
|
print(
|
||||||
print(f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
|
f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
|
||||||
|
print(
|
||||||
|
f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
|
||||||
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
|
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
|
||||||
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
|
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
|
||||||
print(f"- miss_reasons: {dict(report.miss_reasons)}")
|
print(f"- miss_reasons: {dict(report.miss_reasons)}")
|
||||||
|
|||||||
+63
-15
@@ -19,7 +19,7 @@ from arbitrade.api.auth import require_dashboard_auth
|
|||||||
from arbitrade.api.control_state import DashboardControlState
|
from arbitrade.api.control_state import DashboardControlState
|
||||||
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
||||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||||
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
from arbitrade.storage.repositories import AuditRecord, AuditRepository, KrakenAccountSnapshotRepository
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
|
router = APIRouter(dependencies=[Depends(require_dashboard_auth)])
|
||||||
public_router = APIRouter()
|
public_router = APIRouter()
|
||||||
@@ -185,6 +185,7 @@ def _dashboard_overview(request: Request) -> dict[str, object]:
|
|||||||
"maker_fee": maker_fee,
|
"maker_fee": maker_fee,
|
||||||
"taker_fee": taker_fee,
|
"taker_fee": taker_fee,
|
||||||
"thirty_day_volume": thirty_day_volume,
|
"thirty_day_volume": thirty_day_volume,
|
||||||
|
"fee_source": "API" if fee_tier != "—" else "—",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -569,23 +570,49 @@ def _normalize_fee_profile(profile: str) -> str:
|
|||||||
return profile.strip().lower().replace("-", "_")
|
return profile.strip().lower().replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
def _fee_rate_for_profile(profile: str, custom_fee_rate: float | None) -> float:
|
def _fee_rate_for_profile(
|
||||||
|
profile: str,
|
||||||
|
custom_fee_rate: float | None,
|
||||||
|
request: Request | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Resolve fee rate from profile name.
|
||||||
|
|
||||||
|
- 'api': fetches latest maker_fee from kraken_account_snapshots (requires request)
|
||||||
|
- 'custom': uses custom_fee_rate
|
||||||
|
- legacy 'standard'/'maker_heavy'/'taker_heavy': still supported via hardcoded
|
||||||
|
fallback, logged at warning level
|
||||||
|
"""
|
||||||
normalized = _normalize_fee_profile(profile)
|
normalized = _normalize_fee_profile(profile)
|
||||||
profile_map = {
|
|
||||||
"standard": 0.0026,
|
if normalized == "api":
|
||||||
"maker_heavy": 0.0016,
|
if request is None:
|
||||||
"taker_heavy": 0.0035,
|
raise ValueError("api fee profile requires request context")
|
||||||
}
|
store = request.app.state.store
|
||||||
|
repo = KrakenAccountSnapshotRepository(store)
|
||||||
|
latest = repo.latest_snapshot()
|
||||||
|
if latest is not None and latest.maker_fee is not None:
|
||||||
|
return latest.maker_fee
|
||||||
|
# Fallback to standard if no snapshot yet
|
||||||
|
return 0.0026
|
||||||
|
|
||||||
if normalized == "custom":
|
if normalized == "custom":
|
||||||
if custom_fee_rate is None:
|
if custom_fee_rate is None:
|
||||||
raise ValueError("custom fee profile requires custom_fee_rate")
|
raise ValueError("custom fee profile requires custom_fee_rate")
|
||||||
if custom_fee_rate < 0.0:
|
if custom_fee_rate < 0.0:
|
||||||
raise ValueError("custom_fee_rate must be >= 0")
|
raise ValueError("custom_fee_rate must be >= 0")
|
||||||
return custom_fee_rate
|
return custom_fee_rate
|
||||||
if normalized not in profile_map:
|
|
||||||
valid = ", ".join(sorted(list(profile_map.keys()) + ["custom"]))
|
# Legacy hardcoded profiles (kept for backward compat, but soft-deprecated)
|
||||||
raise ValueError(f"fee_profile must be one of: {valid}")
|
profile_map = {
|
||||||
return profile_map[normalized]
|
"standard": 0.0026,
|
||||||
|
"maker_heavy": 0.0016,
|
||||||
|
"taker_heavy": 0.0035,
|
||||||
|
}
|
||||||
|
if normalized in profile_map:
|
||||||
|
return profile_map[normalized]
|
||||||
|
|
||||||
|
valid = ", ".join(sorted(list(profile_map.keys()) + ["api", "custom"]))
|
||||||
|
raise ValueError(f"fee_profile must be one of: {valid}")
|
||||||
|
|
||||||
|
|
||||||
def _parse_balances(raw: str) -> dict[str, float]:
|
def _parse_balances(raw: str) -> dict[str, float]:
|
||||||
@@ -652,7 +679,7 @@ def _backtesting_panel_context(
|
|||||||
"starting_balances": "USD=1000.0",
|
"starting_balances": "USD=1000.0",
|
||||||
"trade_capital": "100.0",
|
"trade_capital": "100.0",
|
||||||
"min_profit_threshold": "0.0005",
|
"min_profit_threshold": "0.0005",
|
||||||
"fee_profile": "standard",
|
"fee_profile": "api",
|
||||||
"custom_fee_rate": "",
|
"custom_fee_rate": "",
|
||||||
"slippage_bps": "4.0",
|
"slippage_bps": "4.0",
|
||||||
"execution_latency_ms": "20.0",
|
"execution_latency_ms": "20.0",
|
||||||
@@ -687,7 +714,6 @@ async def _dashboard_response(
|
|||||||
"overview_endpoint": "/dashboard/fragment/overview",
|
"overview_endpoint": "/dashboard/fragment/overview",
|
||||||
"controls_endpoint": "/dashboard/fragment/controls",
|
"controls_endpoint": "/dashboard/fragment/controls",
|
||||||
"charts_endpoint": "/dashboard/fragment/charts",
|
"charts_endpoint": "/dashboard/fragment/charts",
|
||||||
"audit_endpoint": "/dashboard/fragment/audit",
|
|
||||||
"stream_endpoint": "/dashboard/stream/metrics",
|
"stream_endpoint": "/dashboard/stream/metrics",
|
||||||
"overview_stream_endpoint": "/dashboard/stream/overview",
|
"overview_stream_endpoint": "/dashboard/stream/overview",
|
||||||
},
|
},
|
||||||
@@ -763,6 +789,28 @@ async def dashboard_charts(request: Request) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/audit", response_class=HTMLResponse)
|
||||||
|
async def dashboard_audit_page(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="audit.html",
|
||||||
|
context={
|
||||||
|
"title": "Arbitrade Audit Trail",
|
||||||
|
"request": request,
|
||||||
|
**_dashboard_audit(request),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/audit/fragment", response_class=HTMLResponse)
|
||||||
|
async def dashboard_audit_fragment(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/audit.html",
|
||||||
|
context={"request": request, **_dashboard_audit(request)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/fragment/audit", response_class=HTMLResponse)
|
@router.get("/dashboard/fragment/audit", response_class=HTMLResponse)
|
||||||
async def dashboard_audit(request: Request) -> HTMLResponse:
|
async def dashboard_audit(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -903,7 +951,7 @@ async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
|
|||||||
"starting_balances": form.get("starting_balances", "USD=1000.0"),
|
"starting_balances": form.get("starting_balances", "USD=1000.0"),
|
||||||
"trade_capital": form.get("trade_capital", "100.0"),
|
"trade_capital": form.get("trade_capital", "100.0"),
|
||||||
"min_profit_threshold": form.get("min_profit_threshold", "0.0005"),
|
"min_profit_threshold": form.get("min_profit_threshold", "0.0005"),
|
||||||
"fee_profile": _normalize_fee_profile(form.get("fee_profile", "standard")),
|
"fee_profile": _normalize_fee_profile(form.get("fee_profile", "api")),
|
||||||
"custom_fee_rate": form.get("custom_fee_rate", ""),
|
"custom_fee_rate": form.get("custom_fee_rate", ""),
|
||||||
"slippage_bps": form.get("slippage_bps", "4.0"),
|
"slippage_bps": form.get("slippage_bps", "4.0"),
|
||||||
"execution_latency_ms": form.get("execution_latency_ms", "20.0"),
|
"execution_latency_ms": form.get("execution_latency_ms", "20.0"),
|
||||||
@@ -924,7 +972,7 @@ async def dashboard_backtesting_run(request: Request) -> HTMLResponse:
|
|||||||
) if defaults["custom_fee_rate"].strip() else None
|
) if defaults["custom_fee_rate"].strip() else None
|
||||||
)
|
)
|
||||||
fee_rate = _fee_rate_for_profile(
|
fee_rate = _fee_rate_for_profile(
|
||||||
defaults["fee_profile"], custom_fee_rate)
|
defaults["fee_profile"], custom_fee_rate, request=request)
|
||||||
starting_balances = _parse_balances(defaults["starting_balances"])
|
starting_balances = _parse_balances(defaults["starting_balances"])
|
||||||
|
|
||||||
trade_capital = float(defaults["trade_capital"])
|
trade_capital = float(defaults["trade_capital"])
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class ReplayBookEvent:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class BacktestConfig:
|
class BacktestConfig:
|
||||||
fee_rate: float = 0.0026
|
fee_rate: float = 0.0 # 0.0 means "use API-sourced fee from kraken_account_snapshots"
|
||||||
min_profit_threshold: float = 0.0005
|
min_profit_threshold: float = 0.0005
|
||||||
trade_capital: float = 100.0
|
trade_capital: float = 100.0
|
||||||
quote_asset: str = "USD"
|
quote_asset: str = "USD"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class ConfigPairFee(BaseModel):
|
|||||||
market_type: str # 'crypto_crypto' or 'crypto_fiat'
|
market_type: str # 'crypto_crypto' or 'crypto_fiat'
|
||||||
maker_fee_rate: float
|
maker_fee_rate: float
|
||||||
taker_fee_rate: float
|
taker_fee_rate: float
|
||||||
|
source: str = "manual" # 'manual' or 'kraken_api'
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +248,7 @@ class ConfigurationService:
|
|||||||
"market_type": fee.market_type,
|
"market_type": fee.market_type,
|
||||||
"maker_fee_rate": fee.maker_fee_rate,
|
"maker_fee_rate": fee.maker_fee_rate,
|
||||||
"taker_fee_rate": fee.taker_fee_rate,
|
"taker_fee_rate": fee.taker_fee_rate,
|
||||||
|
"source": fee.source,
|
||||||
"updated_at": fee.updated_at.isoformat() if fee.updated_at else None,
|
"updated_at": fee.updated_at.isoformat() if fee.updated_at else None,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
@@ -282,6 +284,7 @@ class ConfigurationService:
|
|||||||
"market_type": updated.market_type,
|
"market_type": updated.market_type,
|
||||||
"maker_fee_rate": updated.maker_fee_rate,
|
"maker_fee_rate": updated.maker_fee_rate,
|
||||||
"taker_fee_rate": updated.taker_fee_rate,
|
"taker_fee_rate": updated.taker_fee_rate,
|
||||||
|
"source": updated.source,
|
||||||
"updated_at": updated.updated_at.isoformat() if updated.updated_at else None,
|
"updated_at": updated.updated_at.isoformat() if updated.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, Ord
|
|||||||
|
|
||||||
detector = IncrementalCycleDetector(
|
detector = IncrementalCycleDetector(
|
||||||
index,
|
index,
|
||||||
fee_rate=0.001,
|
fee_rate=0.001, # synthetic benchmark: uses fixed rate, not API-sourced
|
||||||
min_profit_threshold=0.001,
|
min_profit_threshold=0.001,
|
||||||
max_depth_levels=5,
|
max_depth_levels=5,
|
||||||
max_book_age_seconds=10.0,
|
max_book_age_seconds=10.0,
|
||||||
@@ -92,7 +92,8 @@ def run_incremental_detection_benchmark(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Benchmark incremental detection latency")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Benchmark incremental detection latency")
|
||||||
parser.add_argument("--iterations", type=int, default=50_000)
|
parser.add_argument("--iterations", type=int, default=50_000)
|
||||||
parser.add_argument("--target-ms", type=float, default=1.0)
|
parser.add_argument("--target-ms", type=float, default=1.0)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
|
|||||||
trade_capital DOUBLE,
|
trade_capital DOUBLE,
|
||||||
min_profit_threshold DOUBLE,
|
min_profit_threshold DOUBLE,
|
||||||
slippage_bps INTEGER,
|
slippage_bps INTEGER,
|
||||||
execution_latency_ms INTEGER
|
execution_latency_ms INTEGER,
|
||||||
|
fee_source VARCHAR DEFAULT 'api'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS opportunities (
|
CREATE TABLE IF NOT EXISTS opportunities (
|
||||||
@@ -159,7 +160,7 @@ CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
|
|||||||
|
|
||||||
|
|
||||||
class DuckDBStore:
|
class DuckDBStore:
|
||||||
SCHEMA_VERSION = 3
|
SCHEMA_VERSION = 4
|
||||||
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self._db_path = Path(settings.duckdb_path)
|
self._db_path = Path(settings.duckdb_path)
|
||||||
@@ -274,6 +275,14 @@ class DuckDBStore:
|
|||||||
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
|
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (3)")
|
||||||
_LOG.info("migration_applied", version=3)
|
_LOG.info("migration_applied", version=3)
|
||||||
|
|
||||||
|
if current_version < 4:
|
||||||
|
# Migration v4: Add fee_source to backtesting defaults
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE config_backtesting_defaults ADD COLUMN IF NOT EXISTS fee_source VARCHAR DEFAULT 'api'")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO schema_migrations (version) VALUES (4)")
|
||||||
|
_LOG.info("migration_applied", version=4)
|
||||||
|
|
||||||
# Update version to current
|
# Update version to current
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"INSERT OR REPLACE INTO schema_migrations (version, applied_at) "
|
f"INSERT OR REPLACE INTO schema_migrations (version, applied_at) "
|
||||||
|
|||||||
@@ -738,15 +738,16 @@ class ConfigPairFeeRepository:
|
|||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO config_pair_fees (pairing_id, market_type, maker_fee_rate, taker_fee_rate)
|
INSERT INTO config_pair_fees (pairing_id, market_type, maker_fee_rate, taker_fee_rate, source)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
|
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, source
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
pair_fee.pairing_id,
|
pair_fee.pairing_id,
|
||||||
pair_fee.market_type,
|
pair_fee.market_type,
|
||||||
pair_fee.maker_fee_rate,
|
pair_fee.maker_fee_rate,
|
||||||
pair_fee.taker_fee_rate,
|
pair_fee.taker_fee_rate,
|
||||||
|
pair_fee.source,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@@ -756,7 +757,8 @@ class ConfigPairFeeRepository:
|
|||||||
market_type=row[1],
|
market_type=row[1],
|
||||||
maker_fee_rate=row[2],
|
maker_fee_rate=row[2],
|
||||||
taker_fee_rate=row[3],
|
taker_fee_rate=row[3],
|
||||||
updated_at=row[4]
|
updated_at=row[4],
|
||||||
|
source=row[5],
|
||||||
)
|
)
|
||||||
raise ValueError("Failed to create pair fee")
|
raise ValueError("Failed to create pair fee")
|
||||||
|
|
||||||
@@ -765,7 +767,7 @@ class ConfigPairFeeRepository:
|
|||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
|
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, COALESCE(source, 'manual') AS source
|
||||||
FROM config_pair_fees
|
FROM config_pair_fees
|
||||||
WHERE pairing_id = ? AND market_type = ?
|
WHERE pairing_id = ? AND market_type = ?
|
||||||
""",
|
""",
|
||||||
@@ -778,7 +780,8 @@ class ConfigPairFeeRepository:
|
|||||||
market_type=row[1],
|
market_type=row[1],
|
||||||
maker_fee_rate=row[2],
|
maker_fee_rate=row[2],
|
||||||
taker_fee_rate=row[3],
|
taker_fee_rate=row[3],
|
||||||
updated_at=row[4]
|
updated_at=row[4],
|
||||||
|
source=row[5],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -788,13 +791,14 @@ class ConfigPairFeeRepository:
|
|||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE config_pair_fees
|
UPDATE config_pair_fees
|
||||||
SET maker_fee_rate = ?, taker_fee_rate = ?
|
SET maker_fee_rate = ?, taker_fee_rate = ?, source = ?
|
||||||
WHERE pairing_id = ? AND market_type = ?
|
WHERE pairing_id = ? AND market_type = ?
|
||||||
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
|
RETURNING pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, source
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
pair_fee.maker_fee_rate,
|
pair_fee.maker_fee_rate,
|
||||||
pair_fee.taker_fee_rate,
|
pair_fee.taker_fee_rate,
|
||||||
|
pair_fee.source,
|
||||||
pairing_id,
|
pairing_id,
|
||||||
market_type,
|
market_type,
|
||||||
),
|
),
|
||||||
@@ -806,7 +810,8 @@ class ConfigPairFeeRepository:
|
|||||||
market_type=row[1],
|
market_type=row[1],
|
||||||
maker_fee_rate=row[2],
|
maker_fee_rate=row[2],
|
||||||
taker_fee_rate=row[3],
|
taker_fee_rate=row[3],
|
||||||
updated_at=row[4]
|
updated_at=row[4],
|
||||||
|
source=row[5],
|
||||||
)
|
)
|
||||||
raise ValueError("Failed to update pair fee")
|
raise ValueError("Failed to update pair fee")
|
||||||
|
|
||||||
@@ -827,7 +832,7 @@ class ConfigPairFeeRepository:
|
|||||||
with self._store.connect() as conn:
|
with self._store.connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at
|
SELECT pairing_id, market_type, maker_fee_rate, taker_fee_rate, updated_at, COALESCE(source, 'manual') AS source
|
||||||
FROM config_pair_fees
|
FROM config_pair_fees
|
||||||
WHERE pairing_id = ?
|
WHERE pairing_id = ?
|
||||||
ORDER BY market_type
|
ORDER BY market_type
|
||||||
@@ -840,7 +845,8 @@ class ConfigPairFeeRepository:
|
|||||||
market_type=row[1],
|
market_type=row[1],
|
||||||
maker_fee_rate=row[2],
|
maker_fee_rate=row[2],
|
||||||
taker_fee_rate=row[3],
|
taker_fee_rate=row[3],
|
||||||
updated_at=row[4]
|
updated_at=row[4],
|
||||||
|
source=row[5],
|
||||||
)
|
)
|
||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}Audit Trail{% endblock %} {% block
|
||||||
|
main_class %}shell{% endblock %} {% block content %}
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">Audit Trail</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
System activity, configuration changes, and execution decisions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<a class="button secondary" href="/dashboard">Dashboard</a>
|
||||||
|
<a class="button secondary" href="/dashboard/config">Config</a>
|
||||||
|
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
|
||||||
|
<a class="button secondary" href="/health">Health</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="audit-shell"
|
||||||
|
hx-get="/dashboard/audit/fragment"
|
||||||
|
hx-target="this"
|
||||||
|
hx-trigger="load, every 20s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{% include "partials/audit.html" %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,9 +14,8 @@
|
|||||||
color: #e5eefb;
|
color: #e5eefb;
|
||||||
}
|
}
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 1120px;
|
max-width: none;
|
||||||
margin: 0 auto;
|
padding: 24px 32px 48px;
|
||||||
padding: 32px 20px 48px;
|
|
||||||
}
|
}
|
||||||
.hero {
|
.hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ main_class %}shell{% endblock %} {% block content %}
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a class="button secondary" href="/dashboard">Dashboard</a>
|
<a class="button secondary" href="/dashboard">Dashboard</a>
|
||||||
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
|
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
|
||||||
|
<a class="button secondary" href="/dashboard/audit">Audit</a>
|
||||||
<a class="button secondary" href="/health">Health</a>
|
<a class="button secondary" href="/health">Health</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ head_scripts %}
|
|||||||
<a class="button secondary" href="/health">Health</a>
|
<a class="button secondary" href="/health">Health</a>
|
||||||
<a class="button secondary" href="/dashboard/config">Config</a>
|
<a class="button secondary" href="/dashboard/config">Config</a>
|
||||||
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
|
<a class="button secondary" href="/dashboard/backtesting">Backtesting</a>
|
||||||
|
<a class="button secondary" href="/dashboard/audit">Audit</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -63,15 +64,6 @@ head_scripts %}
|
|||||||
{% include "partials/charts.html" %}
|
{% include "partials/charts.html" %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
id="audit-shell"
|
|
||||||
hx-get="{{ audit_endpoint }}"
|
|
||||||
hx-target="this"
|
|
||||||
hx-trigger="load, every 20s"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
{% include "partials/audit.html" %}
|
|
||||||
</section>
|
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.arbitradeRenderCharts = (payload) => {
|
window.arbitradeRenderCharts = (payload) => {
|
||||||
|
|||||||
@@ -83,6 +83,8 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Fee profile</span>
|
<span>Fee profile</span>
|
||||||
<select name="fee_profile">
|
<select name="fee_profile">
|
||||||
|
{% set sel = "selected" if fee_profile == "api" else "" %}
|
||||||
|
<option value="api" {{ sel }}>api (from Kraken)</option>
|
||||||
{% set sel = "selected" if fee_profile == "standard" else "" %}
|
{% set sel = "selected" if fee_profile == "standard" else "" %}
|
||||||
<option value="standard" {{ sel }}>standard</option>
|
<option value="standard" {{ sel }}>standard</option>
|
||||||
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
|
{% set sel = "selected" if fee_profile == "maker_heavy" else "" %}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@
|
|||||||
<th style="text-align: left; padding: 10px; color: #9fb2d0">
|
<th style="text-align: left; padding: 10px; color: #9fb2d0">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
|
<th style="text-align: left; padding: 10px; color: #9fb2d0">
|
||||||
|
Source
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -143,6 +146,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
<td style="padding: 10px; color: #7f95b7">
|
||||||
|
{{ fee.source if fee.source else "manual" }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -191,7 +197,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
placeholder="0.0016"
|
placeholder="API default"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -201,7 +207,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
placeholder="0.0026"
|
placeholder="API default"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
@@ -75,97 +75,5 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
|
||||||
<div class="label">Edit Config</div>
|
|
||||||
<form
|
|
||||||
class="form-grid"
|
|
||||||
hx-post="{{ config_endpoint }}"
|
|
||||||
hx-target="#controls-panel"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
<label class="field">
|
|
||||||
<span>Trade capital USD</span>
|
|
||||||
<input
|
|
||||||
name="trade_capital_usd"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value="{{ trade_capital_usd_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max trade capital USD</span>
|
|
||||||
<input
|
|
||||||
name="max_trade_capital_usd"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value="{{ max_trade_capital_usd_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max concurrent trades</span>
|
|
||||||
<input
|
|
||||||
name="max_concurrent_trades"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value="{{ max_concurrent_trades_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Tradable pairs</span>
|
|
||||||
<input
|
|
||||||
name="tradable_pairs"
|
|
||||||
type="text"
|
|
||||||
placeholder="BTC/USD, ETH/BTC"
|
|
||||||
value="{{ tradable_pairs_value }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Strategy mode</span>
|
|
||||||
<select name="strategy_mode">
|
|
||||||
{% set sel = "selected" if strategy_mode == "incremental" else "" %}
|
|
||||||
<option value="incremental" {{ sel }}>incremental</option>
|
|
||||||
{% set sel = "selected" if strategy_mode == "paper" else "" %}
|
|
||||||
<option value="paper" {{ sel }}>paper</option>
|
|
||||||
{% set sel = "selected" if strategy_mode == "live" else "" %}
|
|
||||||
<option value="live" {{ sel }}>live</option>
|
|
||||||
{% if strategy_stat_arb_enabled %} {% set sel = "selected" if
|
|
||||||
strategy_mode == "stat_arb_experiment" else "" %}
|
|
||||||
<option value="stat_arb_experiment" {{ sel }}>
|
|
||||||
stat_arb_experiment
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Strategy profit threshold</span>
|
|
||||||
<input
|
|
||||||
name="strategy_profit_threshold"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.0001"
|
|
||||||
value="{{ strategy_profit_threshold }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Max depth levels</span>
|
|
||||||
<input
|
|
||||||
name="strategy_max_depth_levels"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value="{{ strategy_max_depth_levels }}"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="field checkbox">
|
|
||||||
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
|
|
||||||
<input name="paper_trading_mode" type="checkbox" {{ check }} />
|
|
||||||
<span>Paper trading mode</span>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="button">Save config</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="label">Fee Tier</div>
|
<div class="label">Fee Tier</div>
|
||||||
<div class="value">{{ fee_tier }}</div>
|
<div class="value">{{ fee_tier }}</div>
|
||||||
<div class="meta">Maker {{ maker_fee }} / Taker {{ taker_fee }}</div>
|
<div class="meta">
|
||||||
|
Maker {{ maker_fee }} / Taker {{ taker_fee }} · {{ fee_source }}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user