User: "Baue ein Eingabefeld im Dashboard ein in welchem ich das Margin Verhaeltnis von M15 / BRK manuell setzen kann". ⚠⚠ EHRLICHE EINORDNUNG, weil die Umsetzung NICHT ist, wonach es klingt: eine GLEICHZEITIGE Aufteilung (z.B. 40 % manuell / 50 % reserviert fuer BRK) ist heute nicht baubar. Der Bot haelt EINE Position - _check_auto_squeeze feuert "nur FLAT", und Trailing, Notfall-Stop, S/R-Close, Time-Stop und Breaker verwalten je EINE Position (42 Einzel-Positions-Annahmen in engine.py, 25 in trader.py). Mit zwei Positionen auf demselben Symbol haengt der GESAMTE Schutz-Stack an einer davon, die andere liefe ungeschuetzt. Das ist kein Aufwands-, sondern ein Sicherheitsargument. GEBAUT ist deshalb das, was heute wirkt und dieselbe Frage beantwortet: je Pfad ein eigener Einsatz-Prozentsatz. 0 = globaler Wert (bisheriges Verhalten). Quellen-abhaengig verdrahtet wie die SL-Zeitebene seit 05.08.: _open() setzt config.MARGIN_BUFFER fuer die Dauer des einen Aufrufs und stellt ihn im finally-Zweig zurueck. Manuelle Trades bleiben beim globalen Wert (der Mensch ist die gemessen bessere Population, +1,56 gegen -4,71 EUR/Lot). ⚠ Direkt gesetzt statt ueber set_margin_buffer(): das loggt je Trade eine Zeile UND wuerde den globalen Wert dauerhaft ueberschreiben. ⚠⚠ ZWEI ECHTE FEHLER VOM LINTER GEFANGEN, die py_compile durchgewinkt hat: "config" war in engine.py gar nicht importiert (nur Einzelnamen via "from core.config import ..."), und in server.py hiess die Engine "eng" statt "app.state.engine". Beide sassen in Pfaden, die erst beim ERSTEN autonomen Trade bzw. beim ersten Klick gefeuert haetten - also genau die Klasse, fuer die ruff F821 am 07.08. eingebaut wurde. ⚠ Frontend: $streng statt $, weil LEER hier ausdruecklich 0 = "global" bedeutet und nicht "unveraendert"; mit dem Auffang-Element waere .value undefined -> NaN -> null. Der Render ueberschreibt das Feld nicht, solange es den Fokus hat (sonst frisst der 1-s-Snapshot die laufende Eingabe). ⚠ Beim Committen selbst zweimal danebengegriffen, beides dokumentiert weil es Fehlerklassen sind: (a) Backticks in einer Bash-Commit-Nachricht werden als Kommando-Substitution ausgefuehrt und loeschen die eingeklammerten Namen aus dem Text; (b) ein Bash-Pfad (/c/Users/...) an Python uebergeben schlaegt fehl, und "git commit --amend -F <fehlende Datei>" nahm daraufhin eine STALE COMMIT_EDITMSG - die Nachricht eines fremden Commits. Der Code war davon nie betroffen (Arbeitsbaum sauber, Remote synchron), nur die Beschriftung. Ende-zu-Ende geprueft: 50/25 gesetzt -> Snapshot -> persistiert; 150 abgelehnt; 0/0 zurueck. Deploy ueber tools/deploy.py --feld margin_brk, alle 5 Schritte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
520 lines
21 KiB
Python
520 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
server.py — Web-Backend für den Oil Trading Agent
|
||
===================================================
|
||
FastAPI-Server, der die headless `TradingEngine` (core/engine.py) fährt und
|
||
ihren Zustand per REST bereitstellt. Grundlage für die Mobile-Web-Oberfläche.
|
||
|
||
Läuft auf demselben Windows-Rechner wie das MT5-Terminal. Das Tkinter-Widget
|
||
ist NICHT nötig — Server und Widget teilen sich nur die core/-Logik. Betreibe
|
||
entweder das Widget ODER den Server gegen dasselbe MT5-Terminal.
|
||
|
||
Start:
|
||
pip install fastapi "uvicorn[standard]"
|
||
python server.py
|
||
→ http://localhost:8000/api/snapshot
|
||
|
||
Aktuell (Schritt 1): nur Read-only-Endpoints.
|
||
GET / → Status-Kurzinfo
|
||
GET /api/health → läuft die Engine, ist MT5 verbunden?
|
||
GET /api/snapshot → kompletter Live-Zustand (Markt/Position/Analyse/Agent)
|
||
"""
|
||
from __future__ import annotations
|
||
import time
|
||
import asyncio
|
||
import contextlib
|
||
import secrets
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import uvicorn
|
||
from fastapi import (FastAPI, WebSocket, WebSocketDisconnect,
|
||
Header, HTTPException, Body)
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
except ImportError:
|
||
print('pip install fastapi "uvicorn[standard]"')
|
||
sys.exit(1)
|
||
|
||
from core.config import save_config
|
||
from core.engine import TradingEngine
|
||
from core.logger import get_logger
|
||
|
||
log = get_logger("server")
|
||
|
||
WEB_DIR = Path(__file__).parent / "web"
|
||
WS_PUSH_S = 1.0 # Push-Intervall des Live-Snapshots
|
||
|
||
# Im Heim-VPN (WireGuard) erreichen Handy/Frontend den Server direkt; CORS
|
||
# offen lassen ist hier vertretbar. Bei öffentlichem Betrieb einschränken.
|
||
HOST = "0.0.0.0"
|
||
PORT = 8000
|
||
|
||
|
||
def _ensure_token(engine: TradingEngine) -> str:
|
||
"""App-Token für Trade-Aktionen sicherstellen — bei Bedarf generieren."""
|
||
cfg = engine.cfg
|
||
if "web" not in cfg:
|
||
cfg["web"] = {}
|
||
token = cfg["web"].get("api_token", "").strip()
|
||
if not token:
|
||
token = secrets.token_urlsafe(16)
|
||
cfg["web"]["api_token"] = token
|
||
try:
|
||
save_config(cfg)
|
||
except Exception as e:
|
||
log.warning(f"Token speichern: {e}")
|
||
log.warning("=" * 54)
|
||
log.warning(f" APP-TOKEN (für Trade-Steuerung am Handy):\n {token}")
|
||
log.warning(" Einmalig im Handy-UI eingeben. Liegt in [web] api_token.")
|
||
log.warning("=" * 54)
|
||
return token
|
||
|
||
|
||
@contextlib.asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
engine = TradingEngine()
|
||
app.state.token = _ensure_token(engine)
|
||
if not engine.start():
|
||
log.error(f"Engine-Start fehlgeschlagen: {engine._error}")
|
||
# Server läuft trotzdem weiter, damit /api/health den Fehler zeigt.
|
||
app.state.engine = engine
|
||
try:
|
||
yield
|
||
finally:
|
||
engine.stop()
|
||
log.info("Engine gestoppt")
|
||
|
||
|
||
def _require_auth(x_auth_token: str | None):
|
||
"""Prüft den App-Token (konstante-Zeit-Vergleich).
|
||
Ist die Token-Pflicht abgeschaltet ([web] require_token=false), entfällt
|
||
die Prüfung — der Zugang ist dann anderweitig abzusichern (z.B. WireGuard)."""
|
||
if not getattr(app.state.engine, "require_token", True):
|
||
return
|
||
expected = app.state.token
|
||
if not x_auth_token or not secrets.compare_digest(x_auth_token, expected):
|
||
raise HTTPException(status_code=401, detail="Ungültiger Token")
|
||
|
||
|
||
app = FastAPI(title="Oil Trading Agent", version="1.0", lifespan=lifespan)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def _no_cache(request, call_next):
|
||
"""UI-Dateien NIE cachen (no-store) — sonst läuft am Handy alte JS/HTML
|
||
weiter. no-store statt no-cache, damit der Browser jedesmal frisch lädt."""
|
||
resp = await call_next(request)
|
||
path = request.url.path
|
||
if path == "/" or path.endswith((".js", ".css", ".html", ".json")):
|
||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||
resp.headers["Pragma"] = "no-cache"
|
||
resp.headers["Expires"] = "0"
|
||
return resp
|
||
|
||
|
||
@app.get("/api/health")
|
||
def health():
|
||
eng = app.state.engine
|
||
return {"running": eng._running,
|
||
"connected": eng._connected,
|
||
"error": eng._error,
|
||
"symbol": eng.data.symbol,
|
||
"wave_tf": eng._wave_tf_label}
|
||
|
||
|
||
@app.get("/api/snapshot")
|
||
def snapshot():
|
||
eng = app.state.engine
|
||
return JSONResponse(content=_json_safe(eng.snapshot_cached()))
|
||
|
||
|
||
@app.get("/api/logs")
|
||
def logs(lines: int = 200):
|
||
"""Letzte N Zeilen der Logdatei (nur das Dateiende lesen — effizient)."""
|
||
from core.logger import LOG_FILE
|
||
import os
|
||
lines = max(1, min(lines, 1000))
|
||
try:
|
||
size = os.path.getsize(LOG_FILE)
|
||
with open(LOG_FILE, "rb") as f:
|
||
f.seek(max(0, size - 256 * 1024)) # nur die letzten 256 KB
|
||
data = f.read().decode("utf-8", "ignore")
|
||
tail = data.splitlines()[-lines:]
|
||
return {"lines": tail}
|
||
except Exception as e:
|
||
return {"error": str(e), "lines": []}
|
||
|
||
|
||
def _add_net(stats: dict, rate: float, trader=None) -> dict:
|
||
"""Ergänzt eine stats_overview-Periode um Netto-Werte nach Quellensteuer.
|
||
|
||
⚠⚠ SEIT 2026-08-12 AUS DEN **ECHTEN** BUCHUNGEN, nicht mehr geschätzt.
|
||
Die alte Rechnung war `net_pnl = total_pnl − gross_win × 26,375 %` und
|
||
unterstellte, die WHT sei endgültig verloren. Sie wird aber täglich per
|
||
`Tax settlement` erstattet — gemessen über 90 Tage zu **99 %**
|
||
(einbehalten −2.625,89 €, erstattet +2.608,83 €, verblieben −17,06 €).
|
||
Die Schätzung sagte −2.619,93 € und machte damit aus **+153 € rund
|
||
−2.450 €**. ✅ Die Schätzung der EINBEHALTENEN Summe war fast exakt —
|
||
falsch war allein die Annahme, sie bleibe weg.
|
||
⚠ Fällt der Broker aus, wird auf die alte Schätzung zurückgefallen; das Feld
|
||
`wht_quelle` sagt, welche Zahl gerade drinsteht („echt" / „geschätzt").
|
||
⚠ KURZE ZEITRÄUME sind naturgemäss pessimistisch: die Erstattung kommt erst
|
||
am Folgetag. Für „today" fehlt sie also meist — deshalb `wht_lag`.
|
||
"""
|
||
if not stats or stats.get("error"):
|
||
return stats
|
||
gw = stats.get("gross_win", 0.0) or 0.0
|
||
gl = stats.get("gross_loss", 0.0) or 0.0
|
||
tp = stats.get("total_pnl", 0.0) or 0.0
|
||
echt = None
|
||
if trader is not None:
|
||
try:
|
||
echt = trader.steuer_buchungen(int(stats.get("since") or 0),
|
||
int(stats.get("until") or time.time()))
|
||
except Exception:
|
||
echt = None
|
||
if echt and echt.get("n"):
|
||
wht = -float(echt["netto"]) # netto ist negativ → Last positiv
|
||
stats["wht_quelle"] = "echt"
|
||
stats["wht_brutto"] = echt["wht"]
|
||
stats["wht_erstattet"] = echt["tax"]
|
||
stats["wht_lag"] = bool(echt["tax"] == 0 and echt["wht"] < 0)
|
||
else:
|
||
wht = gw * rate
|
||
stats["wht_quelle"] = "geschätzt"
|
||
stats["wht_pct"] = round(rate * 100, 3)
|
||
stats["wht"] = round(wht, 2)
|
||
stats["net_pnl"] = round(tp - wht, 2)
|
||
stats["net_profit_factor"] = ((gw - wht) / gl) if gl > 0 else None
|
||
stats["net_avg_win"] = (stats.get("avg_win", 0.0) or 0.0) * (
|
||
1 - (wht / gw if gw > 0 else 0.0))
|
||
return stats
|
||
|
||
|
||
def _add_net_setup(s: dict, rate: float) -> dict:
|
||
"""Netto-Werte nach WHT für eine Setup-Zeile (nur Gewinnseite besteuert)."""
|
||
gw = s.get("gross_win", 0.0) or 0.0
|
||
gl = s.get("gross_loss", 0.0) or 0.0
|
||
s["net_pnl"] = (s.get("total_pnl", 0.0) or 0.0) - gw * rate
|
||
s["net_profit_factor"] = (gw * (1 - rate) / gl) if gl > 0 else None
|
||
return s
|
||
|
||
|
||
@app.get("/api/stats")
|
||
def stats():
|
||
eng = app.state.engine
|
||
try:
|
||
rate = float(eng.cfg["trading"].get("wht_pct", "0")) / 100.0
|
||
except Exception:
|
||
rate = 0.0
|
||
out: dict = {"wht_pct": round(rate * 100, 3)}
|
||
for period in ("today", "week", "all"):
|
||
try:
|
||
out[period] = _add_net(eng.history.stats_overview(period), rate,
|
||
getattr(eng, "trader", None))
|
||
except Exception as e:
|
||
out[period] = {"error": str(e)}
|
||
try:
|
||
out["setups"] = [_add_net_setup(s, rate)
|
||
for s in eng.history.setup_stats("all")]
|
||
except Exception as e:
|
||
out["setups"] = [{"error": str(e)}]
|
||
try:
|
||
out["n_total"] = eng.history.n_total_trades()
|
||
except Exception:
|
||
out["n_total"] = None
|
||
return JSONResponse(content=_json_safe(out))
|
||
|
||
|
||
@app.get("/api/news")
|
||
def news():
|
||
eng = app.state.engine
|
||
headlines, last_fetch, error = eng.news.snapshot()
|
||
return JSONResponse(content=_json_safe({
|
||
"headlines": headlines[:30],
|
||
"last_fetch": last_fetch,
|
||
"error": error,
|
||
"sentiment": eng.news.sentiment_snapshot(),
|
||
}))
|
||
|
||
|
||
@app.get("/api/squeeze_monitor")
|
||
def squeeze_monitor():
|
||
"""Auto-Squeeze-B4-Monitor: Live-Trades (echt) + Mechanik (Entry-Edge isoliert)
|
||
gegen die Backtest-Erwartung. Read-only, kein Token. Cache-TTL 60 s im Engine."""
|
||
return JSONResponse(content=_json_safe(app.state.engine.squeeze_monitor()))
|
||
|
||
|
||
@app.get("/api/pbreak_accuracy")
|
||
def pbreak_accuracy(period: str = "all"):
|
||
"""Trefferquote der Abprall/Durchbruch-Prognose (P(break)-Modell), ausgewertet
|
||
gegen candles_m1. period: today|week|all. Read-only, kein Token."""
|
||
return JSONResponse(content=_json_safe(app.state.engine.history.pbreak_accuracy(period)))
|
||
|
||
|
||
@app.websocket("/ws")
|
||
async def ws_snapshot(websocket: WebSocket):
|
||
"""Pusht den Live-Snapshot im Sekundentakt. Mehrere Geräte parallel ok."""
|
||
await websocket.accept()
|
||
eng = app.state.engine
|
||
loop = asyncio.get_event_loop()
|
||
try:
|
||
while True:
|
||
# snapshot() ist blockierend (Locks + sqlite) → im Threadpool holen,
|
||
# damit der Event-Loop frei bleibt. Cache teilt EINE Berechnung über
|
||
# alle WS-Clients (TTL) → DB-Last unabhängig von der Geräteanzahl.
|
||
snap = await loop.run_in_executor(None, eng.snapshot_cached)
|
||
await websocket.send_json(_json_safe(snap))
|
||
await asyncio.sleep(WS_PUSH_S)
|
||
except WebSocketDisconnect:
|
||
pass
|
||
except Exception as e:
|
||
log.warning(f"WebSocket beendet: {e}")
|
||
|
||
|
||
# ── Trade-Steuerung (token-geschützt) ─────────────────────────────────────────
|
||
@app.post("/api/auth/check")
|
||
async def auth_check(x_auth_token: str | None = Header(default=None)):
|
||
"""Frontend prüft hiermit, ob der eingegebene Token gültig ist."""
|
||
_require_auth(x_auth_token)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/order")
|
||
async def order(payload: dict = Body(...),
|
||
x_auth_token: str | None = Header(default=None)):
|
||
_require_auth(x_auth_token)
|
||
side = (payload.get("side") or "").lower()
|
||
eng = app.state.engine
|
||
loop = asyncio.get_event_loop()
|
||
if side == "long":
|
||
fn = eng.open_long
|
||
elif side == "short":
|
||
fn = eng.open_short
|
||
elif side == "close":
|
||
fn = eng.close
|
||
else:
|
||
raise HTTPException(status_code=400, detail="side muss long|short|close sein")
|
||
log.info(f"[WEB] Order angefordert: {side}")
|
||
err = await loop.run_in_executor(None, fn) # blockierender MT5-Call
|
||
if err:
|
||
log.warning(f"[WEB] Order {side} fehlgeschlagen: {err}")
|
||
return JSONResponse(status_code=200, content={"ok": False, "error": str(err)})
|
||
return {"ok": True, "side": side}
|
||
|
||
|
||
@app.post("/api/emergency")
|
||
async def emergency(payload: dict = Body(...),
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Notfall-Verlust-Schwelle setzen/löschen. `value`: Betrag (Kontowährung)
|
||
oder null/0 = aus. Der Server schließt die Position, sobald P&L ≤ −value."""
|
||
_require_auth(x_auth_token)
|
||
cur = app.state.engine.set_emergency(payload.get("value"))
|
||
log.info(f"[WEB] Notfall-Stop gesetzt: {cur}")
|
||
return {"ok": True, "emergency_loss": cur}
|
||
|
||
|
||
@app.post("/api/takeprofit")
|
||
async def takeprofit(payload: dict = Body(...),
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Gewinn-Ziel setzen/löschen. `value`: Betrag (Kontowährung) oder null/0 = aus.
|
||
Der Server schließt die Position, sobald P&L ≥ +value."""
|
||
_require_auth(x_auth_token)
|
||
cur = app.state.engine.set_takeprofit(payload.get("value"))
|
||
log.info(f"[WEB] Gewinn-Ziel gesetzt: {cur}")
|
||
return {"ok": True, "takeprofit": cur}
|
||
|
||
|
||
@app.post("/api/sltp")
|
||
async def set_sltp(payload: dict = Body(...),
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Manuelles SL/TP der offenen Position setzen (deaktiviert das Trailing).
|
||
`sl`/`tp`: Preis oder null (= unverändert). Broker-Prüfung → error bei Ablehnung."""
|
||
_require_auth(x_auth_token)
|
||
res = await asyncio.get_event_loop().run_in_executor(
|
||
None, lambda: app.state.engine.set_sltp(payload.get("sl"), payload.get("tp")))
|
||
log.info(f"[WEB] SL/TP: {res}")
|
||
return res
|
||
|
||
|
||
@app.post("/api/srclose")
|
||
async def toggle_srclose(x_auth_token: str | None = Header(default=None)):
|
||
"""Automatischen S/R-Close (P(break)-Regel) an/aus."""
|
||
_require_auth(x_auth_token)
|
||
eng = app.state.engine
|
||
state = eng.set_sr_autoclose(not eng._auto_sr_close)
|
||
return {"ok": True, "enabled": state}
|
||
|
||
|
||
@app.post("/api/autom15")
|
||
async def toggle_autom15(x_auth_token: str | None = Header(default=None)):
|
||
"""M15-Karten-Auto-Entry an/aus (User-Test 2026-08-19, gemessen negativ)."""
|
||
_require_auth(x_auth_token)
|
||
eng = app.state.engine
|
||
state = eng.set_auto_m15(not eng._auto_m15)
|
||
log.info(f"[WEB] Auto-M15-Entry {'AN' if state else 'AUS'}")
|
||
return {"ok": True, "enabled": state}
|
||
|
||
|
||
@app.post("/api/circuit")
|
||
async def toggle_circuit(x_auth_token: str | None = Header(default=None)):
|
||
"""Tagesverlust-Stopp (Circuit Breaker) an/aus (User-Wunsch 2026-08-19)."""
|
||
_require_auth(x_auth_token)
|
||
eng = app.state.engine
|
||
state = eng.set_circuit_breaker(not (eng._cb_limit_pct > 0))
|
||
# Herkunft mitloggen wie bei den anderen Schaltern -- sonst ist beim
|
||
# Nachvollziehen eines Zustandswechsels nicht feststellbar, ob Dashboard
|
||
# oder Code geschaltet hat (Fix-Muster vom 02.08.).
|
||
log.info(f"[WEB] Circuit-Breaker {'AN' if state else 'AUS'}")
|
||
return {"ok": True, "enabled": state, "limit_pct": eng._cb_limit_pct}
|
||
|
||
|
||
@app.post("/api/autosqueeze")
|
||
async def toggle_autosqueeze(x_auth_token: str | None = Header(default=None)):
|
||
"""Autonomen Squeeze-Entry (echte Order auf Ausbruch) an/aus."""
|
||
_require_auth(x_auth_token)
|
||
eng = app.state.engine
|
||
state = eng.set_auto_squeeze(not eng._auto_squeeze)
|
||
# ⚠ Herkunft mitloggen (Fix 2026-08-02, Review-Durchgang 1): `set_auto_squeeze`
|
||
# loggt nur „Auto-Squeeze-Entry AN/AUS" ohne Quelle — beim Nachvollziehen eines
|
||
# Zustandswechsels war dadurch nicht feststellbar, ob der Dashboard-Button oder
|
||
# Code dahinterstand (real: BRK stand auf AUS und es kostete mehrere Schritte,
|
||
# das als Nutzeraktion zu belegen). `/api/autosignal` macht das längst richtig —
|
||
# bei einem Schalter für autonome ECHTGELD-Einstiege gehört die Herkunft ins Log.
|
||
get_logger("server").info(f"[WEB] Auto-Squeeze-Entry {'AN' if state else 'AUS'}")
|
||
return {"ok": True, "enabled": state}
|
||
|
||
|
||
@app.post("/api/manualmargin")
|
||
async def set_manual_margin(body: dict,
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Feste Einsatz-Margin in Kontowährung setzen (User-Wunsch 2026-08-04).
|
||
`{"value": 0}` = automatisch (wie bisher `margin_buffer_pct` % der freien Margin).
|
||
|
||
⚠ Wirkt auf ALLE neuen Positionen — auch die autonomen. Die freie Margin bleibt
|
||
Obergrenze; `calc_lots` deckelt einen zu hohen Wunschwert und loggt das."""
|
||
_require_auth(x_auth_token)
|
||
try:
|
||
v = float(body.get("value") or 0)
|
||
except (TypeError, ValueError):
|
||
return {"ok": False, "error": "ungültiger Wert"}
|
||
eng = app.state.engine
|
||
val = eng.set_manual_margin(v)
|
||
get_logger("server").info(
|
||
f"[WEB] Einsatz-Margin: {'automatisch' if val <= 0 else f'{val:.2f}'}")
|
||
return {"ok": True, "value": val}
|
||
|
||
|
||
@app.post("/api/automargin")
|
||
async def set_auto_margin(body: dict,
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Einsatz-Prozentsatz JE AUTONOMEM PFAD (User-Wunsch 2026-08-19).
|
||
|
||
`{"brk": 50, "m15": 25}` — 0 heisst "globaler Wert" (bisheriges Verhalten).
|
||
⚠ KEINE gleichzeitige Reservierung: der Bot haelt EINE Position, es kann
|
||
also nie BRK und M15 nebeneinander geben. Das Feld steuert die GROESSE der
|
||
jeweiligen Position. Manuelle Trades bleiben beim globalen Prozentsatz."""
|
||
_require_auth(x_auth_token)
|
||
try:
|
||
brk = float(body.get("brk") or 0)
|
||
m15 = float(body.get("m15") or 0)
|
||
except (TypeError, ValueError):
|
||
return {"ok": False, "error": "ungueltiger Wert"}
|
||
if brk < 0 or m15 < 0 or brk > 99 or m15 > 99:
|
||
return {"ok": False, "error": "erlaubt sind 0 (=global) bis 99"}
|
||
r = app.state.engine.set_auto_margin(brk, m15)
|
||
log.info(f"[WEB] Einsatz je Pfad: BRK {r['brk']:.0f}% · M15 {r['m15']:.0f}%")
|
||
return {"ok": True, **r}
|
||
|
||
|
||
@app.post("/api/marginpct")
|
||
async def set_margin_pct(body: dict,
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Einsatz als PROZENT der freien Margin setzen (User-Wunsch 2026-08-05).
|
||
|
||
Gegenstück zu `/api/manualmargin` (fester Betrag). ⚠ Ein gesetzter FESTER
|
||
Betrag hat Vorrang; der Prozentsatz bleibt dann nur noch die Obergrenze.
|
||
Wird auf 1–99 % geklemmt — 100 % ließe keinen Puffer für Spread/Swap."""
|
||
_require_auth(x_auth_token)
|
||
try:
|
||
v = float(body.get("value") or 0)
|
||
except (TypeError, ValueError):
|
||
return {"ok": False, "error": "ungültiger Wert"}
|
||
if v <= 0:
|
||
return {"ok": False, "error": "Prozentsatz muss > 0 sein"}
|
||
val = app.state.engine.set_margin_pct(v)
|
||
get_logger("server").info(f"[WEB] Einsatz-Prozentsatz: {val:.0f} % der freien Margin")
|
||
return {"ok": True, "value": val}
|
||
|
||
|
||
# ⚠⚠ `/api/autosignal` ENTFERNT am 2026-08-12 (User-Entscheidung). Der autonome
|
||
# Entry auf die Empfehlung war seit dem 01.08. aus und auf BEIDEN
|
||
# Ausführungs-Achsen gemessen negativ — Markt-Einstieg in allen 8 Feldern,
|
||
# ruhende Order mit Touch-Fill in allen 6 und dabei sogar schlechter als
|
||
# Markt. Ursache strukturell: das Bestätigungs-Level wandert.
|
||
# Sicherung: `.removed_backup/auto_signal_2026-08-12.py` + Git-Historie.
|
||
|
||
|
||
@app.post("/api/srclose_min")
|
||
async def srclose_min(payload: dict = Body(...),
|
||
x_auth_token: str | None = Header(default=None)):
|
||
"""Mindestgewinn (EUR) für den S/R-Auto-Close setzen. 0/leer = aus."""
|
||
_require_auth(x_auth_token)
|
||
get_logger("server").info(f"[WEB] Mindestgewinn angefordert: {payload.get('value')!r}")
|
||
cur = app.state.engine.set_sr_close_min_gain(payload.get("value"))
|
||
return {"ok": True, "min_gain": cur}
|
||
|
||
|
||
@app.post("/api/trail")
|
||
async def toggle_trail(x_auth_token: str | None = Header(default=None)):
|
||
_require_auth(x_auth_token)
|
||
state = await asyncio.get_event_loop().run_in_executor(
|
||
None, app.state.engine.toggle_trail)
|
||
return {"ok": True, "enabled": state}
|
||
|
||
|
||
def _json_safe(obj):
|
||
"""Macht den Snapshot robust JSON-fähig (numpy-Floats, datetime, Tupel)."""
|
||
import datetime as _dt
|
||
if isinstance(obj, dict):
|
||
return {str(k): _json_safe(v) for k, v in obj.items()}
|
||
if isinstance(obj, (list, tuple)):
|
||
return [_json_safe(v) for v in obj]
|
||
if isinstance(obj, _dt.datetime):
|
||
return obj.isoformat()
|
||
# numpy-Skalare → Python-Zahl
|
||
if hasattr(obj, "item") and not isinstance(obj, (str, bytes)):
|
||
try:
|
||
return obj.item()
|
||
except Exception:
|
||
return str(obj)
|
||
return obj
|
||
|
||
|
||
# Frontend zuletzt mounten, damit /api/* und /ws Vorrang haben.
|
||
# html=True liefert web/index.html unter "/" aus.
|
||
if WEB_DIR.is_dir():
|
||
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
|
||
else:
|
||
log.warning(f"Web-Verzeichnis fehlt: {WEB_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print("=" * 58)
|
||
print(" Oil Trading Agent — Web-Backend (FastAPI)")
|
||
print(f" Mobile-UI: http://localhost:{PORT}/")
|
||
print(f" JSON-API: http://localhost:{PORT}/api/snapshot")
|
||
print("=" * 58)
|
||
# access_log=False: Uvicorns Zugriffs-Log (eine Zeile je HTTP-Request) schweigt
|
||
# jetzt — seit dem 4-s-REST-Polling (2026-07-22) + WS-Push flutete das die Konsole
|
||
# (GET /api/snapshot alle paar Sekunden). Betrifft NUR das Access-Log, nicht die
|
||
# eigenen oil.*-Logger (core/logger.py, eigene Hierarchie) oder Uvicorn-Fehler.
|
||
uvicorn.run(app, host=HOST, port=PORT, log_level="info", access_log=False)
|