feat: GridBot als zweite Paper-Engine — No-Stop-XRP-Grid live
processGridCycle (Paritätstest gegen runGridBacktest), GridEngine mit DB-Recovery (grid_state/grid_lots, bot_state id=2), bot-Spalte in paper_trades/equity_snapshots, /api/grid, Dashboard-Panel. Bewusster Paper-Probelauf trotz Gate-Fail (User-Entscheidung 2026-06-10). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
# trade-kuns
|
# trade-kuns
|
||||||
|
|
||||||
Multi-Pair-Trendfolge-Paper-Bot (BTC/ETH/SOL/XRP_USDT): Donchian-20-Breakout auf 4h, EMA-200 + ADX-20-Filter, Chandelier-Trailing-Stop (3×ATR), long-only, fixe Parameter. **Paper-only — keine Order-Ausführung.**
|
Zwei Paper-Engines in einem Prozess (**paper-only — keine Order-Ausführung**):
|
||||||
|
1. **Trend-Bot** (BTC/ETH/SOL/XRP_USDT): Donchian-20-Breakout auf 4h, EMA-200 + ADX-20-Filter, Chandelier-Trailing-Stop (3×ATR), long-only, fixe Parameter. State: `bot_state` id=1.
|
||||||
|
2. **GridBot** (nur XRP): No-Stop-ATR-Grid — 8 Levels, Spacing 3×ATR(4h), nie Verlust-Verkäufe, verlustfreies Re-Center bei leerem Grid außerhalb der Range. State: `bot_state` id=2, `grid_state`/`grid_lots`. Spec/Validierung: `docs/walkforward-grid-2026-06-10.md`.
|
||||||
|
|
||||||
## Stack & Befehle
|
## Stack & Befehle
|
||||||
- Bun 1.3 + TypeScript, Drizzle (Postgres), Zod. Tests collocated (`*.test.ts`).
|
- Bun 1.3 + TypeScript, Drizzle (Postgres), Zod. Tests collocated (`*.test.ts`).
|
||||||
|
|||||||
25
drizzle/0002_burly_joystick.sql
Normal file
25
drizzle/0002_burly_joystick.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE "grid_lots" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"pair" varchar(16) NOT NULL,
|
||||||
|
"level_idx" integer NOT NULL,
|
||||||
|
"qty" double precision NOT NULL,
|
||||||
|
"entry_ts" timestamp with time zone NOT NULL,
|
||||||
|
"entry_price" double precision NOT NULL,
|
||||||
|
"entry_cost" double precision NOT NULL,
|
||||||
|
"risk_amount" double precision NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "grid_state" (
|
||||||
|
"pair" varchar(16) PRIMARY KEY NOT NULL,
|
||||||
|
"center" double precision NOT NULL,
|
||||||
|
"spacing" double precision NOT NULL,
|
||||||
|
"lower_bound" double precision NOT NULL,
|
||||||
|
"upper_bound" double precision NOT NULL,
|
||||||
|
"budget_per_level" double precision NOT NULL,
|
||||||
|
"activated_ts" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "equity_snapshots" ADD COLUMN "bot" text DEFAULT 'trend' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "equity_snapshots" DROP CONSTRAINT "equity_snapshots_pkey";--> statement-breakpoint
|
||||||
|
ALTER TABLE "equity_snapshots" ADD CONSTRAINT "equity_snapshots_bot_ts_pk" PRIMARY KEY("bot","ts");--> statement-breakpoint
|
||||||
|
ALTER TABLE "paper_trades" ADD COLUMN "bot" text DEFAULT 'trend' NOT NULL;
|
||||||
604
drizzle/meta/0002_snapshot.json
Normal file
604
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
{
|
||||||
|
"id": "2e351b16-12b9-4c9a-a777-29cd5cf06dd4",
|
||||||
|
"prevId": "38fbc5fc-4ef1-4dae-b408-21bcafa513b7",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.backtest_runs": {
|
||||||
|
"name": "backtest_runs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"name": "result",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.bot_state": {
|
||||||
|
"name": "bot_state",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"cash": {
|
||||||
|
"name": "cash",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"start_capital": {
|
||||||
|
"name": "start_capital",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"cursor_ts": {
|
||||||
|
"name": "cursor_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.candles": {
|
||||||
|
"name": "candles",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"pair": {
|
||||||
|
"name": "pair",
|
||||||
|
"type": "varchar(16)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ts": {
|
||||||
|
"name": "ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"open": {
|
||||||
|
"name": "open",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"high": {
|
||||||
|
"name": "high",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"low": {
|
||||||
|
"name": "low",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"close": {
|
||||||
|
"name": "close",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"name": "volume",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"candles_pair_ts_pk": {
|
||||||
|
"name": "candles_pair_ts_pk",
|
||||||
|
"columns": [
|
||||||
|
"pair",
|
||||||
|
"ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.decision_logs": {
|
||||||
|
"name": "decision_logs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pair": {
|
||||||
|
"name": "pair",
|
||||||
|
"type": "varchar(16)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"bar_ts": {
|
||||||
|
"name": "bar_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"signal": {
|
||||||
|
"name": "signal",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"blocked_by": {
|
||||||
|
"name": "blocked_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"close": {
|
||||||
|
"name": "close",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"atr": {
|
||||||
|
"name": "atr",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"adx": {
|
||||||
|
"name": "adx",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"donchian_high": {
|
||||||
|
"name": "donchian_high",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"trend_ema": {
|
||||||
|
"name": "trend_ema",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price_after_4h": {
|
||||||
|
"name": "price_after_4h",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price_after_24h": {
|
||||||
|
"name": "price_after_24h",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price_after_72h": {
|
||||||
|
"name": "price_after_72h",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"decision_logs_pair_bar_ts": {
|
||||||
|
"name": "decision_logs_pair_bar_ts",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "pair",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "bar_ts",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.equity_snapshots": {
|
||||||
|
"name": "equity_snapshots",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"bot": {
|
||||||
|
"name": "bot",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'trend'"
|
||||||
|
},
|
||||||
|
"ts": {
|
||||||
|
"name": "ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"equity": {
|
||||||
|
"name": "equity",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"cash": {
|
||||||
|
"name": "cash",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"equity_snapshots_bot_ts_pk": {
|
||||||
|
"name": "equity_snapshots_bot_ts_pk",
|
||||||
|
"columns": [
|
||||||
|
"bot",
|
||||||
|
"ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.grid_lots": {
|
||||||
|
"name": "grid_lots",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pair": {
|
||||||
|
"name": "pair",
|
||||||
|
"type": "varchar(16)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"level_idx": {
|
||||||
|
"name": "level_idx",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"qty": {
|
||||||
|
"name": "qty",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_ts": {
|
||||||
|
"name": "entry_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_price": {
|
||||||
|
"name": "entry_price",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_cost": {
|
||||||
|
"name": "entry_cost",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"risk_amount": {
|
||||||
|
"name": "risk_amount",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.grid_state": {
|
||||||
|
"name": "grid_state",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"pair": {
|
||||||
|
"name": "pair",
|
||||||
|
"type": "varchar(16)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"center": {
|
||||||
|
"name": "center",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"name": "spacing",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"lower_bound": {
|
||||||
|
"name": "lower_bound",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"upper_bound": {
|
||||||
|
"name": "upper_bound",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"budget_per_level": {
|
||||||
|
"name": "budget_per_level",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"activated_ts": {
|
||||||
|
"name": "activated_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.paper_trades": {
|
||||||
|
"name": "paper_trades",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"bot": {
|
||||||
|
"name": "bot",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'trend'"
|
||||||
|
},
|
||||||
|
"pair": {
|
||||||
|
"name": "pair",
|
||||||
|
"type": "varchar(16)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"side": {
|
||||||
|
"name": "side",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_ts": {
|
||||||
|
"name": "entry_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_price": {
|
||||||
|
"name": "entry_price",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"exit_ts": {
|
||||||
|
"name": "exit_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"exit_price": {
|
||||||
|
"name": "exit_price",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"qty": {
|
||||||
|
"name": "qty",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pnl": {
|
||||||
|
"name": "pnl",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"name": "r",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"exit_reason": {
|
||||||
|
"name": "exit_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.positions": {
|
||||||
|
"name": "positions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"pair": {
|
||||||
|
"name": "pair",
|
||||||
|
"type": "varchar(16)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"side": {
|
||||||
|
"name": "side",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"qty": {
|
||||||
|
"name": "qty",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_ts": {
|
||||||
|
"name": "entry_ts",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_price": {
|
||||||
|
"name": "entry_price",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"entry_cost": {
|
||||||
|
"name": "entry_cost",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"initial_stop": {
|
||||||
|
"name": "initial_stop",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"stop": {
|
||||||
|
"name": "stop",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"trail_extreme": {
|
||||||
|
"name": "trail_extreme",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"risk_amount": {
|
||||||
|
"name": "risk_amount",
|
||||||
|
"type": "double precision",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1781071452889,
|
"when": 1781071452889,
|
||||||
"tag": "0001_certain_omega_red",
|
"tag": "0001_certain_omega_red",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781076227862,
|
||||||
|
"tag": "0002_burly_joystick",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,14 @@
|
|||||||
|
|
||||||
<div class="panel"><h2>Letzte Entscheidungen (4h-Bars)</h2><div id="decisions"></div></div>
|
<div class="panel"><h2>Letzte Entscheidungen (4h-Bars)</h2><div id="decisions"></div></div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>GridBot XRP <span class="tag">No-Stop · 3×ATR · 8 Levels · Paper</span></h2>
|
||||||
|
<div class="kpis" id="grid-kpis" style="margin-bottom:12px"></div>
|
||||||
|
<div id="grid-info" style="color:var(--muted);font-size:12.5px;margin-bottom:10px"></div>
|
||||||
|
<h2>Offene Lots</h2><div id="grid-lots"></div>
|
||||||
|
<h2 style="margin-top:14px">Grid-Trades</h2><div id="grid-trades"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const fmt = (n, d = 2) => n == null || Number.isNaN(n) ? '–' : n.toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d });
|
const fmt = (n, d = 2) => n == null || Number.isNaN(n) ? '–' : n.toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
const fmtTs = (ts) => ts == null ? '–' : new Date(ts).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
const fmtTs = (ts) => ts == null ? '–' : new Date(ts).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
@@ -109,11 +117,12 @@ function drawChart(curve, startCapital) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [pf, stats, trades, decisions] = await Promise.all([
|
const [pf, stats, trades, decisions, grid] = await Promise.all([
|
||||||
fetch('/api/portfolio').then(r => r.json()),
|
fetch('/api/portfolio').then(r => r.json()),
|
||||||
fetch('/api/stats').then(r => r.json()),
|
fetch('/api/stats').then(r => r.json()),
|
||||||
fetch('/api/trades?limit=30').then(r => r.json()),
|
fetch('/api/trades?limit=30').then(r => r.json()),
|
||||||
fetch('/api/decisions?limit=12').then(r => r.json()),
|
fetch('/api/decisions?limit=12').then(r => r.json()),
|
||||||
|
fetch('/api/grid').then(r => r.json()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pnl = pf.equity - pf.startCapital;
|
const pnl = pf.equity - pf.startCapital;
|
||||||
@@ -150,6 +159,29 @@ async function refresh() {
|
|||||||
}),
|
}),
|
||||||
'Noch keine Entscheidungen — die erste 4h-Bar schließt demnächst.');
|
'Noch keine Entscheidungen — die erste 4h-Bar schließt demnächst.');
|
||||||
|
|
||||||
|
const gPnl = grid.equity - grid.startCapital;
|
||||||
|
document.getElementById('grid-kpis').innerHTML =
|
||||||
|
kpi('Equity', fmt(grid.equity) + ' $') +
|
||||||
|
kpi('PnL', sign(gPnl) + ' $', cls(gPnl)) +
|
||||||
|
kpi('Cash', fmt(grid.cash) + ' $') +
|
||||||
|
kpi('Trades', grid.stats.trades) +
|
||||||
|
kpi('Win Rate', grid.stats.trades ? fmt(grid.stats.winRate * 100, 0) + ' %' : '–');
|
||||||
|
|
||||||
|
const gs = grid.grids[0];
|
||||||
|
document.getElementById('grid-info').textContent = gs
|
||||||
|
? `Aktives Grid: Center ${fmt(gs.center, 4)} · Spacing ${fmt(gs.spacing, 4)} · Range ${fmt(gs.lowerBound, 4)}–${fmt(gs.upperBound, 4)} · Budget/Level ${fmt(gs.budgetPerLevel)} $ · aktiviert ${fmtTs(gs.activatedTs)} · XRP jetzt ${fmt(gs.lastPrice, 4)}`
|
||||||
|
: 'Kein aktives Grid — Aktivierung beim nächsten 4h-Close.';
|
||||||
|
|
||||||
|
document.getElementById('grid-lots').innerHTML = table(
|
||||||
|
['Level', 'Entry', 'Entry-Preis', 'Letzter', 'Wert $', 'PnL $'],
|
||||||
|
grid.lots.map(l => `<tr><td>L${l.levelIdx + 1}</td><td>${fmtTs(l.entryTs)}</td><td>${fmt(l.entryPrice, 4)}</td><td>${fmt(l.lastPrice, 4)}</td><td>${fmt(l.value)}</td><td class="${cls(l.unrealizedPnl)}">${sign(l.unrealizedPnl)}</td></tr>`),
|
||||||
|
'Keine offenen Lots — warten auf den ersten Dip unter ein Level.');
|
||||||
|
|
||||||
|
document.getElementById('grid-trades').innerHTML = table(
|
||||||
|
['Entry', 'Exit', 'Entry-Preis', 'Exit-Preis', 'PnL $', 'Grund'],
|
||||||
|
grid.trades.map(t => `<tr><td>${fmtTs(new Date(t.entryTs).getTime())}</td><td>${fmtTs(new Date(t.exitTs).getTime())}</td><td>${fmt(t.entryPrice, 4)}</td><td>${fmt(t.exitPrice, 4)}</td><td class="${cls(t.pnl)}">${sign(t.pnl)}</td><td>${t.exitReason}</td></tr>`),
|
||||||
|
'Noch keine Grid-Trades.');
|
||||||
|
|
||||||
const eng = pf.engine || {};
|
const eng = pf.engine || {};
|
||||||
const ok = eng.lastCycleOk !== false;
|
const ok = eng.lastCycleOk !== false;
|
||||||
document.getElementById('status').innerHTML =
|
document.getElementById('status').innerHTML =
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { and, desc, eq, gte } from 'drizzle-orm';
|
import { and, desc, eq, gte } from 'drizzle-orm';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { botState, candles, decisionLogs, equitySnapshots, paperTrades, positions } from '../db/schema';
|
import { botState, candles, decisionLogs, equitySnapshots, gridLots, gridState, paperTrades, positions } from '../db/schema';
|
||||||
import { aggregate4h } from '../market/aggregate';
|
import { aggregate4h } from '../market/aggregate';
|
||||||
import { computeMetrics, type EquityPoint } from '../backtest/metrics';
|
import { computeMetrics, type EquityPoint } from '../backtest/metrics';
|
||||||
import type { ClosedTrade } from '../engine/portfolio';
|
import type { ClosedTrade } from '../engine/portfolio';
|
||||||
import type { Pair } from '../types';
|
import type { Pair } from '../types';
|
||||||
import { PAIRS } from '../types';
|
import { PAIRS } from '../types';
|
||||||
import type { LiveEngine } from '../live/engine';
|
import type { LiveEngine } from '../live/engine';
|
||||||
|
import type { GridEngine } from '../live/grid-engine';
|
||||||
|
import { GRID_CYCLE_CONFIG } from '../live/grid-engine';
|
||||||
|
|
||||||
function json(data: unknown, status = 200): Response {
|
function json(data: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
||||||
@@ -66,7 +68,7 @@ async function getPortfolio(engine: LiveEngine) {
|
|||||||
|
|
||||||
async function getStats() {
|
async function getStats() {
|
||||||
const [state] = await db.select().from(botState).where(eq(botState.id, 1));
|
const [state] = await db.select().from(botState).where(eq(botState.id, 1));
|
||||||
const tradeRows = await db.select().from(paperTrades);
|
const tradeRows = await db.select().from(paperTrades).where(eq(paperTrades.bot, 'trend'));
|
||||||
const trades: ClosedTrade[] = tradeRows.map((t) => ({
|
const trades: ClosedTrade[] = tradeRows.map((t) => ({
|
||||||
pair: t.pair as Pair,
|
pair: t.pair as Pair,
|
||||||
entryTs: t.entryTs.getTime(),
|
entryTs: t.entryTs.getTime(),
|
||||||
@@ -79,7 +81,11 @@ async function getStats() {
|
|||||||
exitReason: t.exitReason as ClosedTrade['exitReason'],
|
exitReason: t.exitReason as ClosedTrade['exitReason'],
|
||||||
side: t.side as 'long' | 'short',
|
side: t.side as 'long' | 'short',
|
||||||
}));
|
}));
|
||||||
const curveRows = await db.select().from(equitySnapshots).orderBy(equitySnapshots.ts);
|
const curveRows = await db
|
||||||
|
.select()
|
||||||
|
.from(equitySnapshots)
|
||||||
|
.where(eq(equitySnapshots.bot, 'trend'))
|
||||||
|
.orderBy(equitySnapshots.ts);
|
||||||
const curve: EquityPoint[] = curveRows.map((r) => ({ ts: r.ts.getTime(), equity: r.equity }));
|
const curve: EquityPoint[] = curveRows.map((r) => ({ ts: r.ts.getTime(), equity: r.equity }));
|
||||||
const start = state?.startCapital ?? 1000;
|
const start = state?.startCapital ?? 1000;
|
||||||
const metrics = computeMetrics(trades, curve, start);
|
const metrics = computeMetrics(trades, curve, start);
|
||||||
@@ -104,7 +110,78 @@ async function getStats() {
|
|||||||
return { ...metrics, startCapital: start, equityCurve: curve, btcBuyHoldPct };
|
return { ...metrics, startCapital: start, equityCurve: curve, btcBuyHoldPct };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(engine: LiveEngine, port: number) {
|
async function getGrid(gridEngine: GridEngine) {
|
||||||
|
const [state] = await db.select().from(botState).where(eq(botState.id, 2));
|
||||||
|
const stateRows = await db.select().from(gridState);
|
||||||
|
const lotRows = await db.select().from(gridLots);
|
||||||
|
const tradeRows = await db
|
||||||
|
.select()
|
||||||
|
.from(paperTrades)
|
||||||
|
.where(eq(paperTrades.bot, 'grid'))
|
||||||
|
.orderBy(desc(paperTrades.exitTs))
|
||||||
|
.limit(30);
|
||||||
|
const closes = await latestCloses();
|
||||||
|
|
||||||
|
let equity = state?.cash ?? 0;
|
||||||
|
const lots = lotRows.map((l) => {
|
||||||
|
const last = closes.get(l.pair as Pair) ?? l.entryPrice;
|
||||||
|
const value = l.qty * last;
|
||||||
|
equity += value;
|
||||||
|
return {
|
||||||
|
pair: l.pair,
|
||||||
|
levelIdx: l.levelIdx,
|
||||||
|
qty: l.qty,
|
||||||
|
entryTs: l.entryTs.getTime(),
|
||||||
|
entryPrice: l.entryPrice,
|
||||||
|
lastPrice: last,
|
||||||
|
value,
|
||||||
|
unrealizedPnl: value - l.entryCost,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const wins = tradeRows.filter((t) => t.pnl > 0);
|
||||||
|
const grossWin = wins.reduce((s, t) => s + t.pnl, 0);
|
||||||
|
const grossLoss = tradeRows.filter((t) => t.pnl < 0).reduce((s, t) => s - t.pnl, 0);
|
||||||
|
const curveRows = await db
|
||||||
|
.select()
|
||||||
|
.from(equitySnapshots)
|
||||||
|
.where(eq(equitySnapshots.bot, 'grid'))
|
||||||
|
.orderBy(equitySnapshots.ts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
equity,
|
||||||
|
cash: state?.cash ?? 0,
|
||||||
|
startCapital: state?.startCapital ?? 0,
|
||||||
|
cursorTs: state?.cursorTs.getTime() ?? null,
|
||||||
|
config: {
|
||||||
|
pairs: GRID_CYCLE_CONFIG.pairs,
|
||||||
|
spacingAtrMult: GRID_CYCLE_CONFIG.params.spacingAtrMult,
|
||||||
|
gridLevels: GRID_CYCLE_CONFIG.params.gridLevels,
|
||||||
|
noStop: !GRID_CYCLE_CONFIG.params.hardStop,
|
||||||
|
},
|
||||||
|
grids: stateRows.map((s) => ({
|
||||||
|
pair: s.pair,
|
||||||
|
center: s.center,
|
||||||
|
spacing: s.spacing,
|
||||||
|
lowerBound: s.lowerBound,
|
||||||
|
upperBound: s.upperBound,
|
||||||
|
budgetPerLevel: s.budgetPerLevel,
|
||||||
|
activatedTs: s.activatedTs.getTime(),
|
||||||
|
lastPrice: closes.get(s.pair as Pair) ?? null,
|
||||||
|
})),
|
||||||
|
lots,
|
||||||
|
trades: tradeRows,
|
||||||
|
stats: {
|
||||||
|
trades: tradeRows.length,
|
||||||
|
winRate: tradeRows.length ? wins.length / tradeRows.length : 0,
|
||||||
|
profitFactor: grossLoss > 0 ? grossWin / grossLoss : tradeRows.length ? Infinity : 0,
|
||||||
|
totalPnl: tradeRows.reduce((s, t) => s + t.pnl, 0),
|
||||||
|
},
|
||||||
|
equityCurve: curveRows.map((r) => ({ ts: r.ts.getTime(), equity: r.equity })),
|
||||||
|
engine: gridEngine.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(engine: LiveEngine, gridEngine: GridEngine, port: number) {
|
||||||
const indexHtml = Bun.file(new URL('../../../public/index.html', import.meta.url));
|
const indexHtml = Bun.file(new URL('../../../public/index.html', import.meta.url));
|
||||||
|
|
||||||
return Bun.serve({
|
return Bun.serve({
|
||||||
@@ -124,7 +201,13 @@ export function createServer(engine: LiveEngine, port: number) {
|
|||||||
return json(await getPortfolio(engine));
|
return json(await getPortfolio(engine));
|
||||||
case '/api/trades': {
|
case '/api/trades': {
|
||||||
const limit = clampLimit(url, 100, 500);
|
const limit = clampLimit(url, 100, 500);
|
||||||
const rows = await db.select().from(paperTrades).orderBy(desc(paperTrades.exitTs)).limit(limit);
|
const bot = url.searchParams.get('bot') ?? 'trend';
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(paperTrades)
|
||||||
|
.where(eq(paperTrades.bot, bot))
|
||||||
|
.orderBy(desc(paperTrades.exitTs))
|
||||||
|
.limit(limit);
|
||||||
return json(rows);
|
return json(rows);
|
||||||
}
|
}
|
||||||
case '/api/decisions': {
|
case '/api/decisions': {
|
||||||
@@ -136,6 +219,8 @@ export function createServer(engine: LiveEngine, port: number) {
|
|||||||
}
|
}
|
||||||
case '/api/stats':
|
case '/api/stats':
|
||||||
return json(await getStats());
|
return json(await getStats());
|
||||||
|
case '/api/grid':
|
||||||
|
return json(await getGrid(gridEngine));
|
||||||
case '/api/candles': {
|
case '/api/candles': {
|
||||||
const pair = url.searchParams.get('pair') ?? 'BTC_USDT';
|
const pair = url.searchParams.get('pair') ?? 'BTC_USDT';
|
||||||
if (!(PAIRS as readonly string[]).includes(pair)) return json({ error: 'unbekanntes Pair' }, 400);
|
if (!(PAIRS as readonly string[]).includes(pair)) return json({ error: 'unbekanntes Pair' }, 400);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const positions = pgTable('positions', {
|
|||||||
|
|
||||||
export const paperTrades = pgTable('paper_trades', {
|
export const paperTrades = pgTable('paper_trades', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
|
bot: text('bot').notNull().default('trend'), // 'trend' | 'grid'
|
||||||
pair: varchar('pair', { length: 16 }).notNull(),
|
pair: varchar('pair', { length: 16 }).notNull(),
|
||||||
side: text('side').notNull(),
|
side: text('side').notNull(),
|
||||||
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
||||||
@@ -69,10 +70,38 @@ export const botState = pgTable('bot_state', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const equitySnapshots = pgTable('equity_snapshots', {
|
export const equitySnapshots = pgTable(
|
||||||
ts: timestamp('ts', { withTimezone: true }).primaryKey(), // 4h-Bucket
|
'equity_snapshots',
|
||||||
equity: doublePrecision('equity').notNull(),
|
{
|
||||||
cash: doublePrecision('cash').notNull(),
|
bot: text('bot').notNull().default('trend'),
|
||||||
|
ts: timestamp('ts', { withTimezone: true }).notNull(), // 4h-Bucket
|
||||||
|
equity: doublePrecision('equity').notNull(),
|
||||||
|
cash: doublePrecision('cash').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.bot, t.ts] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Aktives Grid je Pair (No-Stop-GridBot). */
|
||||||
|
export const gridState = pgTable('grid_state', {
|
||||||
|
pair: varchar('pair', { length: 16 }).primaryKey(),
|
||||||
|
center: doublePrecision('center').notNull(),
|
||||||
|
spacing: doublePrecision('spacing').notNull(),
|
||||||
|
lowerBound: doublePrecision('lower_bound').notNull(),
|
||||||
|
upperBound: doublePrecision('upper_bound').notNull(),
|
||||||
|
budgetPerLevel: doublePrecision('budget_per_level').notNull(),
|
||||||
|
activatedTs: timestamp('activated_ts', { withTimezone: true }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Offene Grid-Lots (Inventar). */
|
||||||
|
export const gridLots = pgTable('grid_lots', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
pair: varchar('pair', { length: 16 }).notNull(),
|
||||||
|
levelIdx: integer('level_idx').notNull(),
|
||||||
|
qty: doublePrecision('qty').notNull(),
|
||||||
|
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
||||||
|
entryPrice: doublePrecision('entry_price').notNull(),
|
||||||
|
entryCost: doublePrecision('entry_cost').notNull(),
|
||||||
|
riskAmount: doublePrecision('risk_amount').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backtestRuns = pgTable('backtest_runs', {
|
export const backtestRuns = pgTable('backtest_runs', {
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { env } from './config';
|
import { env } from './config';
|
||||||
import { LiveEngine } from './live/engine';
|
import { LiveEngine } from './live/engine';
|
||||||
|
import { GridEngine } from './live/grid-engine';
|
||||||
import { createServer } from './api/server';
|
import { createServer } from './api/server';
|
||||||
|
|
||||||
const CYCLE_MS = 5 * 60 * 1000;
|
const CYCLE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const engine = new LiveEngine();
|
const engine = new LiveEngine();
|
||||||
|
const gridEngine = new GridEngine();
|
||||||
await engine.init();
|
await engine.init();
|
||||||
createServer(engine, env.PORT);
|
await gridEngine.init();
|
||||||
console.log(`trade-kuns Live-Paper-Engine läuft auf :${env.PORT}`);
|
createServer(engine, gridEngine, env.PORT);
|
||||||
|
console.log(`trade-kuns Live-Paper-Engines (trend + grid) laufen auf :${env.PORT}`);
|
||||||
|
|
||||||
void engine.runCycle();
|
// Grid läuft nach der Trend-Engine — deren Gap-Fetch füllt die Candle-DB für beide.
|
||||||
setInterval(() => void engine.runCycle(), CYCLE_MS);
|
const cycle = async () => {
|
||||||
|
await engine.runCycle();
|
||||||
|
await gridEngine.runCycle();
|
||||||
|
};
|
||||||
|
void cycle();
|
||||||
|
setInterval(() => void cycle(), CYCLE_MS);
|
||||||
|
|||||||
@@ -186,8 +186,11 @@ export class LiveEngine {
|
|||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
for (const s of result.equitySnapshots) {
|
for (const s of result.equitySnapshots) {
|
||||||
const row = { ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
const row = { bot: 'trend', ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
||||||
await tx.insert(equitySnapshots).values(row).onConflictDoUpdate({ target: equitySnapshots.ts, set: row });
|
await tx
|
||||||
|
.insert(equitySnapshots)
|
||||||
|
.values(row)
|
||||||
|
.onConflictDoUpdate({ target: [equitySnapshots.bot, equitySnapshots.ts], set: row });
|
||||||
}
|
}
|
||||||
await tx
|
await tx
|
||||||
.update(botState)
|
.update(botState)
|
||||||
|
|||||||
85
src/server/live/grid-cycle.test.ts
Normal file
85
src/server/live/grid-cycle.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Candle, Pair } from '../types';
|
||||||
|
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||||
|
import { runGridBacktest, DEFAULT_GRID_PARAMS } from '../backtest/grid';
|
||||||
|
import { processGridCycle, type GridCycleConfig, type GridLiveState } from './grid-cycle';
|
||||||
|
|
||||||
|
const M15 = 15 * 60 * 1000;
|
||||||
|
const PAIR: Pair = 'XRP_USDT';
|
||||||
|
const T0 = Date.UTC(2025, 0, 1);
|
||||||
|
|
||||||
|
// Live-Spec: No-Stop, ADX-Filter aus, 3×ATR, 8 Levels
|
||||||
|
const PARAMS = { ...DEFAULT_GRID_PARAMS, spacingAtrMult: 3, gridLevels: 8, adxMax: 100, hardStop: false };
|
||||||
|
const CFG: GridCycleConfig = { exec: DEFAULT_EXEC, params: PARAMS, minNotionalUsdt: 10, pairs: [PAIR] };
|
||||||
|
|
||||||
|
/** Volatile Serie: Trend + zwei Crash-Erholungs-Zyklen — füllt Levels und löst TPs aus. */
|
||||||
|
function synthetic(): Candle[] {
|
||||||
|
const out: Candle[] = [];
|
||||||
|
let price = 1.0;
|
||||||
|
for (let k = 0; k < 16 * 400; k++) {
|
||||||
|
const phase = Math.floor(k / 16);
|
||||||
|
let drift = 0.0002;
|
||||||
|
if ((phase > 120 && phase < 150) || (phase > 260 && phase < 290)) drift = -0.004; // Crashes
|
||||||
|
if ((phase >= 150 && phase < 220) || (phase >= 290 && phase < 360)) drift = 0.0025; // Erholungen
|
||||||
|
const open = price;
|
||||||
|
price = Math.max(0.1, price + drift + 0.01 * Math.sin(k / 5));
|
||||||
|
out.push({
|
||||||
|
ts: T0 + k * M15, open, high: Math.max(open, price) + 0.005,
|
||||||
|
low: Math.min(open, price) - 0.005, close: price, volume: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshState(c15: Candle[]): GridLiveState {
|
||||||
|
return { cash: 1000, grids: new Map(), cursorTs: c15[0].ts - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processGridCycle', () => {
|
||||||
|
test('Parität mit runGridBacktest (identische grid_tp-Trades)', () => {
|
||||||
|
const c15 = synthetic();
|
||||||
|
const map = new Map([[PAIR, c15]]);
|
||||||
|
const live = processGridCycle(map, freshState(c15), CFG);
|
||||||
|
const bt = runGridBacktest(map, {
|
||||||
|
startCapital: 1000, exec: DEFAULT_EXEC, params: PARAMS, minNotionalUsdt: 10,
|
||||||
|
tradeFrom: 0, tradeTo: c15[c15.length - 1].ts + M15,
|
||||||
|
});
|
||||||
|
const btTps = bt.trades.filter((t) => t.exitReason === 'grid_tp');
|
||||||
|
expect(live.closedTrades.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(live.closedTrades).toEqual(btTps);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Split-Äquivalenz: ein Lauf ≡ zwei Läufe mit Cut', () => {
|
||||||
|
const c15 = synthetic();
|
||||||
|
const map = new Map([[PAIR, c15]]);
|
||||||
|
const full = processGridCycle(map, freshState(c15), CFG);
|
||||||
|
|
||||||
|
const cut = c15[Math.floor(c15.length * 0.6)].ts;
|
||||||
|
const r1 = processGridCycle(new Map([[PAIR, c15.filter((c) => c.ts <= cut)]]), freshState(c15), CFG);
|
||||||
|
const r2 = processGridCycle(map, { cash: r1.cash, grids: r1.grids, cursorTs: r1.cursorTs }, CFG);
|
||||||
|
|
||||||
|
expect(r2.cursorTs).toBe(full.cursorTs);
|
||||||
|
expect(r2.cash).toBeCloseTo(full.cash, 8);
|
||||||
|
expect([...r1.closedTrades, ...r2.closedTrades]).toEqual(full.closedTrades);
|
||||||
|
expect(JSON.stringify([...r2.grids.entries()])).toBe(JSON.stringify([...full.grids.entries()]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Idempotenz: zweiter Lauf ohne neue Candles ist No-op', () => {
|
||||||
|
const c15 = synthetic();
|
||||||
|
const map = new Map([[PAIR, c15]]);
|
||||||
|
const r1 = processGridCycle(map, freshState(c15), CFG);
|
||||||
|
const r2 = processGridCycle(map, { cash: r1.cash, grids: r1.grids, cursorTs: r1.cursorTs }, CFG);
|
||||||
|
expect(r2.closedTrades).toEqual([]);
|
||||||
|
expect(r2.cash).toBe(r1.cash);
|
||||||
|
expect(r2.cursorTs).toBe(r1.cursorTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('No-Stop-Invariante: kein Trade mit Verlust außer end_of_data', () => {
|
||||||
|
const c15 = synthetic();
|
||||||
|
const res = processGridCycle(new Map([[PAIR, c15]]), freshState(c15), CFG);
|
||||||
|
for (const t of res.closedTrades) {
|
||||||
|
expect(t.exitReason).toBe('grid_tp');
|
||||||
|
expect(t.pnl).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
189
src/server/live/grid-cycle.ts
Normal file
189
src/server/live/grid-cycle.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Candle, Pair } from '../types';
|
||||||
|
import { PAIRS } from '../types';
|
||||||
|
import { aggregateTf } from '../market/aggregate';
|
||||||
|
import { atr } from '../indicators/atr';
|
||||||
|
import { adx } from '../indicators/adx';
|
||||||
|
import type { ClosedTrade, ExecConfig } from '../engine/portfolio';
|
||||||
|
import type { GridParams } from '../backtest/grid';
|
||||||
|
import type { EquitySnapshot } from './process-cycle';
|
||||||
|
|
||||||
|
export interface GridLot {
|
||||||
|
levelIdx: number;
|
||||||
|
qty: number;
|
||||||
|
entryTs: number;
|
||||||
|
entryPrice: number;
|
||||||
|
entryCost: number;
|
||||||
|
riskAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridStateForPair {
|
||||||
|
center: number;
|
||||||
|
spacing: number;
|
||||||
|
lowerBound: number; // center − (N+1)·spacing
|
||||||
|
upperBound: number; // center + (N+1)·spacing
|
||||||
|
budgetPerLevel: number;
|
||||||
|
activatedTs: number;
|
||||||
|
lots: (GridLot | null)[]; // Index = Level
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridLiveState {
|
||||||
|
cash: number;
|
||||||
|
grids: Map<Pair, GridStateForPair>;
|
||||||
|
cursorTs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridCycleConfig {
|
||||||
|
exec: ExecConfig;
|
||||||
|
params: GridParams; // hardStop muss false sein (No-Stop-Design)
|
||||||
|
minNotionalUsdt: number;
|
||||||
|
pairs: Pair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridCycleResult {
|
||||||
|
cash: number;
|
||||||
|
grids: Map<Pair, GridStateForPair>;
|
||||||
|
cursorTs: number;
|
||||||
|
closedTrades: ClosedTrade[];
|
||||||
|
equitySnapshots: EquitySnapshot[];
|
||||||
|
equity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor-inkrementelle Variante von runGridBacktest (No-Stop-Semantik):
|
||||||
|
* Tf-Close: Aktivierung / verlustfreies Re-Center bei leerem Grid außerhalb der Range;
|
||||||
|
* 15m: Sells (nur Lots von vor diesem Bar) vor Buys. Lots werden nie mit Verlust
|
||||||
|
* verkauft. Pure Funktion — Paritätstest gegen runGridBacktest erzwingt Gleichheit.
|
||||||
|
*/
|
||||||
|
export function processGridCycle(
|
||||||
|
candles15ByPair: Map<Pair, Candle[]>,
|
||||||
|
state: GridLiveState,
|
||||||
|
cfg: GridCycleConfig,
|
||||||
|
): GridCycleResult {
|
||||||
|
const { exec, params: p } = cfg;
|
||||||
|
let cash = state.cash;
|
||||||
|
const grids = new Map<Pair, GridStateForPair>();
|
||||||
|
for (const [pair, g] of state.grids) grids.set(pair, { ...g, lots: g.lots.map((l) => (l ? { ...l } : null)) });
|
||||||
|
const trades: ClosedTrade[] = [];
|
||||||
|
const equitySnapshots: EquitySnapshot[] = [];
|
||||||
|
const lastClose = new Map<Pair, number>();
|
||||||
|
const cursorBucket = Math.floor(state.cursorTs / p.tfMs) * p.tfMs;
|
||||||
|
|
||||||
|
const pairs = cfg.pairs.filter((pr) => candles15ByPair.has(pr));
|
||||||
|
|
||||||
|
const equity = (): number => {
|
||||||
|
let eq = cash;
|
||||||
|
for (const [pair, g] of grids) {
|
||||||
|
const last = lastClose.get(pair) ?? 0;
|
||||||
|
for (const lot of g.lots) if (lot) eq += lot.qty * last;
|
||||||
|
}
|
||||||
|
return eq;
|
||||||
|
};
|
||||||
|
|
||||||
|
const contexts = pairs.map((pair) => {
|
||||||
|
const c15 = candles15ByPair.get(pair)!;
|
||||||
|
const c4h = aggregateTf(c15, p.tfMs);
|
||||||
|
let next4h = 0;
|
||||||
|
while (next4h < c4h.length && c4h[next4h].ts < cursorBucket) next4h++;
|
||||||
|
for (const c of c15) {
|
||||||
|
if (c.ts > state.cursorTs) break;
|
||||||
|
lastClose.set(pair, c.close);
|
||||||
|
}
|
||||||
|
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h };
|
||||||
|
});
|
||||||
|
const byPair = new Map(contexts.map((c) => [c.pair, c]));
|
||||||
|
|
||||||
|
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
|
||||||
|
for (const ctx of contexts) {
|
||||||
|
for (const candle of candles15ByPair.get(ctx.pair)!) {
|
||||||
|
if (candle.ts > state.cursorTs) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair) - PAIRS.indexOf(b.pair));
|
||||||
|
|
||||||
|
const sell = (pair: Pair, ts: number, price: number, lot: GridLot, reason: ClosedTrade['exitReason']): void => {
|
||||||
|
const fill = price * (1 - exec.slippage);
|
||||||
|
const proceeds = lot.qty * fill;
|
||||||
|
const fee = proceeds * exec.feeRate;
|
||||||
|
cash += proceeds - fee;
|
||||||
|
const pnl = proceeds - fee - lot.entryCost;
|
||||||
|
trades.push({
|
||||||
|
pair, entryTs: lot.entryTs, entryPrice: lot.entryPrice, exitTs: ts, exitPrice: fill,
|
||||||
|
qty: lot.qty, pnl, r: pnl / lot.riskAmount, exitReason: reason, side: 'long',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let cursorTs = state.cursorTs;
|
||||||
|
let lastEquityBucket = -1;
|
||||||
|
|
||||||
|
for (const { ts, pair, candle } of timeline) {
|
||||||
|
const ctx = byPair.get(pair)!;
|
||||||
|
const bucket = Math.floor(ts / p.tfMs) * p.tfMs;
|
||||||
|
|
||||||
|
// 1) Neu abgeschlossene Tf-Bars: Aktivierung / verlustfreies Re-Center
|
||||||
|
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
|
||||||
|
const i = ctx.next4h++;
|
||||||
|
const bar = ctx.c4h[i];
|
||||||
|
|
||||||
|
const g = grids.get(pair);
|
||||||
|
if (g) {
|
||||||
|
const outOfRange = bar.close < g.lowerBound || bar.close > g.upperBound;
|
||||||
|
if (outOfRange && g.lots.every((l) => !l)) grids.delete(pair);
|
||||||
|
} else if (!Number.isNaN(ctx.atr[i]) && !Number.isNaN(ctx.adx[i]) && ctx.adx[i] < p.adxMax) {
|
||||||
|
const spacing = p.spacingAtrMult * ctx.atr[i];
|
||||||
|
const budgetPerLevel = equity() / pairs.length / p.gridLevels;
|
||||||
|
if (spacing > 0 && budgetPerLevel >= cfg.minNotionalUsdt) {
|
||||||
|
grids.set(pair, {
|
||||||
|
center: bar.close,
|
||||||
|
spacing,
|
||||||
|
lowerBound: bar.close - (p.gridLevels + 1) * spacing,
|
||||||
|
upperBound: bar.close + (p.gridLevels + 1) * spacing,
|
||||||
|
budgetPerLevel,
|
||||||
|
activatedTs: bar.ts + p.tfMs,
|
||||||
|
lots: Array(p.gridLevels).fill(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 15m-Fills: Sells zuerst (nur Lots von vor diesem Bar), dann Buys
|
||||||
|
const g = grids.get(pair);
|
||||||
|
if (g) {
|
||||||
|
for (let k = 0; k < g.lots.length; k++) {
|
||||||
|
const lot = g.lots[k];
|
||||||
|
if (!lot || lot.entryTs >= ts) continue;
|
||||||
|
const tp = g.center - k * g.spacing;
|
||||||
|
if (candle.high >= tp) {
|
||||||
|
sell(pair, ts, tp, lot, 'grid_tp');
|
||||||
|
g.lots[k] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = 0; k < g.lots.length; k++) {
|
||||||
|
const levelPrice = g.center - (k + 1) * g.spacing;
|
||||||
|
if (!g.lots[k] && candle.low <= levelPrice) {
|
||||||
|
const fill = levelPrice * (1 + exec.slippage);
|
||||||
|
const qty = g.budgetPerLevel / fill;
|
||||||
|
const cost = qty * fill;
|
||||||
|
const fee = cost * exec.feeRate;
|
||||||
|
if (cash >= cost + fee) {
|
||||||
|
cash -= cost + fee;
|
||||||
|
g.lots[k] = {
|
||||||
|
levelIdx: k, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee,
|
||||||
|
riskAmount: Math.max((levelPrice - g.lowerBound) * qty, 1e-9),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastClose.set(pair, candle.close);
|
||||||
|
cursorTs = Math.max(cursorTs, ts);
|
||||||
|
|
||||||
|
// 3) Equity-Punkt einmal pro Tf-Bucket
|
||||||
|
if (bucket !== lastEquityBucket) {
|
||||||
|
lastEquityBucket = bucket;
|
||||||
|
equitySnapshots.push({ ts: bucket, equity: equity(), cash });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cash, grids, cursorTs, closedTrades: trades, equitySnapshots, equity: equity() };
|
||||||
|
}
|
||||||
181
src/server/live/grid-engine.ts
Normal file
181
src/server/live/grid-engine.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { botState, equitySnapshots, gridLots, gridState, paperTrades } from '../db/schema';
|
||||||
|
import { getCandles } from '../market/candle-store';
|
||||||
|
import { H4 } from '../market/aggregate';
|
||||||
|
import { DEFAULT_GRID_PARAMS } from '../backtest/grid';
|
||||||
|
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||||
|
import type { Candle, Pair } from '../types';
|
||||||
|
import {
|
||||||
|
processGridCycle,
|
||||||
|
type GridCycleConfig,
|
||||||
|
type GridCycleResult,
|
||||||
|
type GridLiveState,
|
||||||
|
type GridStateForPair,
|
||||||
|
} from './grid-cycle';
|
||||||
|
|
||||||
|
const M15 = 15 * 60 * 1000;
|
||||||
|
const BOT_STATE_ID = 2; // id=1 gehört der Trend-Engine
|
||||||
|
const START_CAPITAL = 1000;
|
||||||
|
/** ATR/ADX(14) brauchen nur ~30 Bars — 200 ist reichlich Warmup. */
|
||||||
|
const WARMUP_TF_BARS = 200;
|
||||||
|
|
||||||
|
/** Live-Spec aus dem Walk-Forward: No-Stop, XRP only, 3×ATR, 8 Levels, kein ADX-Filter. */
|
||||||
|
export const GRID_CYCLE_CONFIG: GridCycleConfig = {
|
||||||
|
exec: DEFAULT_EXEC,
|
||||||
|
params: { ...DEFAULT_GRID_PARAMS, spacingAtrMult: 3, gridLevels: 8, adxMax: 100, hardStop: false },
|
||||||
|
minNotionalUsdt: 10,
|
||||||
|
pairs: ['XRP_USDT'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GridEngineStatus {
|
||||||
|
lastCycleAt: number | null;
|
||||||
|
lastCycleOk: boolean;
|
||||||
|
lastError: string | null;
|
||||||
|
cursorTs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zweite Paper-Engine (No-Stop-Grid). Holt selbst keine Candles —
|
||||||
|
* läuft im Zyklus NACH der Trend-Engine, deren Gap-Fetch die DB füllt.
|
||||||
|
*/
|
||||||
|
export class GridEngine {
|
||||||
|
status: GridEngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, cursorTs: null };
|
||||||
|
private cycling = false;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||||
|
if (row) {
|
||||||
|
this.status.cursorTs = row.cursorTs.getTime();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cursor = Math.floor(Date.now() / M15) * M15 - M15;
|
||||||
|
await db.insert(botState).values({
|
||||||
|
id: BOT_STATE_ID,
|
||||||
|
cash: START_CAPITAL,
|
||||||
|
startCapital: START_CAPITAL,
|
||||||
|
cursorTs: new Date(cursor),
|
||||||
|
});
|
||||||
|
this.status.cursorTs = cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runCycle(): Promise<void> {
|
||||||
|
if (this.cycling) return;
|
||||||
|
this.cycling = true;
|
||||||
|
try {
|
||||||
|
const state = await this.loadState();
|
||||||
|
const tfMs = GRID_CYCLE_CONFIG.params.tfMs;
|
||||||
|
const from = Math.floor(state.cursorTs / tfMs) * tfMs - WARMUP_TF_BARS * tfMs;
|
||||||
|
const candles15 = new Map<Pair, Candle[]>();
|
||||||
|
for (const pair of GRID_CYCLE_CONFIG.pairs) {
|
||||||
|
candles15.set(pair, await getCandles(pair, from));
|
||||||
|
}
|
||||||
|
const result = processGridCycle(candles15, state, GRID_CYCLE_CONFIG);
|
||||||
|
await this.persist(state, result);
|
||||||
|
this.status.lastCycleAt = Date.now();
|
||||||
|
this.status.lastCycleOk = true;
|
||||||
|
this.status.lastError = null;
|
||||||
|
this.status.cursorTs = result.cursorTs;
|
||||||
|
} catch (err) {
|
||||||
|
this.status.lastCycleAt = Date.now();
|
||||||
|
this.status.lastCycleOk = false;
|
||||||
|
this.status.lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('Grid-Zyklus fehlgeschlagen:', err);
|
||||||
|
} finally {
|
||||||
|
this.cycling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadState(): Promise<GridLiveState> {
|
||||||
|
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||||
|
if (!row) throw new Error('bot_state (grid) fehlt — init() nicht gelaufen?');
|
||||||
|
const stateRows = await db.select().from(gridState);
|
||||||
|
const lotRows = await db.select().from(gridLots);
|
||||||
|
const grids = new Map<Pair, GridStateForPair>();
|
||||||
|
for (const s of stateRows) {
|
||||||
|
const levels = GRID_CYCLE_CONFIG.params.gridLevels;
|
||||||
|
const lots: GridStateForPair['lots'] = Array(levels).fill(null);
|
||||||
|
for (const l of lotRows) {
|
||||||
|
if (l.pair === s.pair && l.levelIdx < levels) {
|
||||||
|
lots[l.levelIdx] = {
|
||||||
|
levelIdx: l.levelIdx,
|
||||||
|
qty: l.qty,
|
||||||
|
entryTs: l.entryTs.getTime(),
|
||||||
|
entryPrice: l.entryPrice,
|
||||||
|
entryCost: l.entryCost,
|
||||||
|
riskAmount: l.riskAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grids.set(s.pair as Pair, {
|
||||||
|
center: s.center,
|
||||||
|
spacing: s.spacing,
|
||||||
|
lowerBound: s.lowerBound,
|
||||||
|
upperBound: s.upperBound,
|
||||||
|
budgetPerLevel: s.budgetPerLevel,
|
||||||
|
activatedTs: s.activatedTs.getTime(),
|
||||||
|
lots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { cash: row.cash, grids, cursorTs: row.cursorTs.getTime() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(prev: GridLiveState, result: GridCycleResult): Promise<void> {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Grid-State + Lots vollständig ersetzen (kleine Mengen: ≤1 Grid, ≤8 Lots)
|
||||||
|
await tx.delete(gridLots);
|
||||||
|
await tx.delete(gridState);
|
||||||
|
for (const [pair, g] of result.grids) {
|
||||||
|
await tx.insert(gridState).values({
|
||||||
|
pair,
|
||||||
|
center: g.center,
|
||||||
|
spacing: g.spacing,
|
||||||
|
lowerBound: g.lowerBound,
|
||||||
|
upperBound: g.upperBound,
|
||||||
|
budgetPerLevel: g.budgetPerLevel,
|
||||||
|
activatedTs: new Date(g.activatedTs),
|
||||||
|
});
|
||||||
|
for (const lot of g.lots) {
|
||||||
|
if (!lot) continue;
|
||||||
|
await tx.insert(gridLots).values({
|
||||||
|
pair,
|
||||||
|
levelIdx: lot.levelIdx,
|
||||||
|
qty: lot.qty,
|
||||||
|
entryTs: new Date(lot.entryTs),
|
||||||
|
entryPrice: lot.entryPrice,
|
||||||
|
entryCost: lot.entryCost,
|
||||||
|
riskAmount: lot.riskAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.closedTrades.length > 0) {
|
||||||
|
await tx.insert(paperTrades).values(
|
||||||
|
result.closedTrades.map((t) => ({
|
||||||
|
bot: 'grid',
|
||||||
|
pair: t.pair,
|
||||||
|
side: t.side,
|
||||||
|
entryTs: new Date(t.entryTs),
|
||||||
|
entryPrice: t.entryPrice,
|
||||||
|
exitTs: new Date(t.exitTs),
|
||||||
|
exitPrice: t.exitPrice,
|
||||||
|
qty: t.qty,
|
||||||
|
pnl: t.pnl,
|
||||||
|
r: t.r,
|
||||||
|
exitReason: t.exitReason,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const s of result.equitySnapshots) {
|
||||||
|
const row = { bot: 'grid', ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
||||||
|
await tx
|
||||||
|
.insert(equitySnapshots)
|
||||||
|
.values(row)
|
||||||
|
.onConflictDoUpdate({ target: [equitySnapshots.bot, equitySnapshots.ts], set: row });
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(botState)
|
||||||
|
.set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() })
|
||||||
|
.where(eq(botState.id, BOT_STATE_ID));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user