Mikecode123 commited on
Commit
6ca1e7a
·
verified ·
1 Parent(s): 89cfd03

Upload 11 files

Browse files
Files changed (11) hide show
  1. __init__.py +0 -0
  2. config.py +29 -0
  3. deriv_client.py +119 -0
  4. main.py +251 -0
  5. paper_trader.py +79 -0
  6. prediction_engine.py +70 -0
  7. qwen_reasoner.py +132 -0
  8. risk.py +35 -0
  9. schemas.py +104 -0
  10. strategy_ob_fvg.py +245 -0
  11. supabase_client.py +46 -0
__init__.py ADDED
File without changes
config.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from functools import lru_cache
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+
8
+ class Settings:
9
+ HF_TOKEN: str = os.getenv("HF_TOKEN", "")
10
+ QWEN_MODEL: str = os.getenv("QWEN_MODEL", "Qwen/Qwen2.5-7B-Instruct")
11
+
12
+ SUPABASE_URL: str = os.getenv("SUPABASE_URL", "")
13
+ SUPABASE_SERVICE_ROLE_KEY: str = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
14
+
15
+ DERIV_APP_ID: str = os.getenv("DERIV_APP_ID", "1089")
16
+ DERIV_API_TOKEN: str = os.getenv("DERIV_API_TOKEN", "")
17
+ DERIV_WS_URL: str = "wss://ws.derivws.com/websockets/v3"
18
+
19
+ ENCRYPTION_KEY: str = os.getenv("ENCRYPTION_KEY", "")
20
+ CORS_ORIGINS: list[str] = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",")]
21
+
22
+ DEMO_STARTING_BALANCE: float = 10_000.0
23
+ MAX_DAILY_LOSS_DEFAULT: float = 500.0
24
+ MAX_OPEN_TRADES_DEFAULT: int = 5
25
+
26
+
27
+ @lru_cache
28
+ def get_settings() -> Settings:
29
+ return Settings()
deriv_client.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Minimal async Deriv WebSocket client.
3
+
4
+ Supports the read-only operations needed by the strategy engine plus
5
+ contract buy for live mode. Token is only attached when the operation
6
+ requires it (balance, buy, portfolio).
7
+
8
+ Docs: https://api.deriv.com/api-explorer/
9
+ """
10
+ from __future__ import annotations
11
+ import asyncio
12
+ import json
13
+ import uuid
14
+ from contextlib import asynccontextmanager
15
+ from typing import Any, Optional
16
+ import websockets
17
+
18
+ from .config import get_settings
19
+ from .schemas import Candle
20
+
21
+
22
+ class DerivError(RuntimeError):
23
+ pass
24
+
25
+
26
+ class DerivClient:
27
+ def __init__(self, app_id: Optional[str] = None,
28
+ token: Optional[str] = None):
29
+ s = get_settings()
30
+ self.app_id = app_id or s.DERIV_APP_ID
31
+ self.token = token # may be None for public ops
32
+ self.url = f"{s.DERIV_WS_URL}?app_id={self.app_id}"
33
+
34
+ @asynccontextmanager
35
+ async def _connect(self):
36
+ async with websockets.connect(self.url, max_size=2**22) as ws:
37
+ if self.token:
38
+ await self._call(ws, {"authorize": self.token})
39
+ yield ws
40
+
41
+ async def _call(self, ws, payload: dict[str, Any]) -> dict[str, Any]:
42
+ req_id = payload.get("req_id") or str(uuid.uuid4())
43
+ payload = {**payload, "req_id": req_id}
44
+ await ws.send(json.dumps(payload))
45
+ while True:
46
+ msg = json.loads(await ws.recv())
47
+ if msg.get("req_id") == req_id or msg.get("echo_req", {}).get("req_id") == req_id:
48
+ if "error" in msg:
49
+ raise DerivError(msg["error"].get("message", "Deriv error"))
50
+ return msg
51
+
52
+ # ---------- public ----------
53
+
54
+ async def candles(self, symbol: str, granularity: int = 60,
55
+ count: int = 200) -> list[Candle]:
56
+ async with self._connect() as ws:
57
+ resp = await self._call(ws, {
58
+ "ticks_history": symbol,
59
+ "adjust_start_time": 1,
60
+ "count": count,
61
+ "end": "latest",
62
+ "granularity": granularity,
63
+ "style": "candles",
64
+ })
65
+ rows = resp.get("candles", [])
66
+ return [Candle(epoch=r["epoch"], open=float(r["open"]),
67
+ high=float(r["high"]), low=float(r["low"]),
68
+ close=float(r["close"]), volume=float(r.get("volume", 0)))
69
+ for r in rows]
70
+
71
+ async def tick(self, symbol: str) -> dict[str, Any]:
72
+ async with self._connect() as ws:
73
+ resp = await self._call(ws, {"ticks": symbol})
74
+ return resp.get("tick", {})
75
+
76
+ async def active_symbols(self) -> list[dict[str, Any]]:
77
+ async with self._connect() as ws:
78
+ resp = await self._call(ws, {
79
+ "active_symbols": "brief", "product_type": "basic"})
80
+ return resp.get("active_symbols", [])
81
+
82
+ # ---------- authenticated ----------
83
+
84
+ async def balance(self) -> dict[str, Any]:
85
+ if not self.token:
86
+ raise DerivError("Token required for balance()")
87
+ async with self._connect() as ws:
88
+ resp = await self._call(ws, {"balance": 1})
89
+ return resp.get("balance", {})
90
+
91
+ async def buy_contract(self, *, symbol: str, contract_type: str,
92
+ amount: float, duration: int = 5,
93
+ duration_unit: str = "m",
94
+ currency: str = "USD") -> dict[str, Any]:
95
+ """
96
+ contract_type: 'CALL' (BUY) or 'PUT' (SELL) for rise/fall contracts.
97
+ """
98
+ if not self.token:
99
+ raise DerivError("Token required for buy_contract()")
100
+ proposal = {
101
+ "proposal": 1, "amount": amount, "basis": "stake",
102
+ "contract_type": contract_type, "currency": currency,
103
+ "duration": duration, "duration_unit": duration_unit,
104
+ "symbol": symbol,
105
+ }
106
+ async with self._connect() as ws:
107
+ p = await self._call(ws, proposal)
108
+ prop = p.get("proposal", {})
109
+ if "id" not in prop:
110
+ raise DerivError("No proposal id returned")
111
+ b = await self._call(ws, {"buy": prop["id"], "price": amount})
112
+ return b.get("buy", {})
113
+
114
+ async def portfolio(self) -> dict[str, Any]:
115
+ if not self.token:
116
+ raise DerivError("Token required for portfolio()")
117
+ async with self._connect() as ws:
118
+ resp = await self._call(ws, {"portfolio": 1})
119
+ return resp.get("portfolio", {})
main.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Trading Backend — FastAPI entry point.
3
+
4
+ Endpoints:
5
+ GET /health
6
+ GET /models
7
+ GET /portfolio?mode=demo
8
+ GET /history?mode=demo&limit=50
9
+ GET /performance?mode=demo
10
+ GET /confidence?symbol=R_10
11
+ POST /predict
12
+ POST /reason
13
+ POST /paper-trade
14
+ POST /trade (live, requires DERIV_API_TOKEN)
15
+ POST /feedback
16
+ POST /retrain-request
17
+ """
18
+ from __future__ import annotations
19
+ import logging
20
+ from typing import Optional
21
+ from fastapi import FastAPI, HTTPException, Query
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+
24
+ from . import supabase_client as sb
25
+ from .config import get_settings
26
+ from .deriv_client import DerivClient, DerivError
27
+ from .paper_trader import (open_paper_trade, close_paper_trade,
28
+ get_or_create_portfolio)
29
+ from .prediction_engine import ensemble, heuristic_forecast
30
+ from .qwen_reasoner import reason as qwen_reason
31
+ from .risk import validate_trade
32
+ from .schemas import (PredictRequest, ReasonRequest, PaperTradeRequest,
33
+ TradeRequest, TradeResponse, FeedbackRequest,
34
+ RetrainRequest, StrategySignal)
35
+ from .strategy_ob_fvg import generate_signal
36
+
37
+ log = logging.getLogger("uvicorn.error")
38
+ settings = get_settings()
39
+
40
+ app = FastAPI(title="AI Trading Backend", version="0.1.0")
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=settings.CORS_ORIGINS,
44
+ allow_credentials=True,
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+
50
+ # -------------------------- system ------------------------------------------
51
+
52
+ @app.get("/health")
53
+ async def health():
54
+ return {
55
+ "ok": True,
56
+ "qwen_configured": bool(settings.HF_TOKEN),
57
+ "supabase_configured": bool(sb.sb()),
58
+ "deriv_live_enabled": bool(settings.DERIV_API_TOKEN),
59
+ "model": settings.QWEN_MODEL,
60
+ }
61
+
62
+
63
+ @app.get("/models")
64
+ async def models():
65
+ return {
66
+ "reasoner": settings.QWEN_MODEL,
67
+ "forecasters": ["heuristic-momentum-v1", "lstm-stub", "xgboost-stub",
68
+ "ensemble-v1"],
69
+ "strategies": ["ob_fvg"],
70
+ }
71
+
72
+
73
+ # -------------------------- portfolio / history ------------------------------
74
+
75
+ @app.get("/portfolio")
76
+ async def portfolio(mode: str = "demo"):
77
+ return await get_or_create_portfolio(None, mode)
78
+
79
+
80
+ @app.get("/history")
81
+ async def history(mode: str = "demo", limit: int = 50):
82
+ return sb.select("trade_history", eq={"mode": mode},
83
+ order="opened_at", desc=True, limit=limit)
84
+
85
+
86
+ @app.get("/performance")
87
+ async def performance(mode: str = "demo"):
88
+ trades = sb.select("trade_history", eq={"mode": mode, "status": "closed"},
89
+ order="closed_at", desc=True, limit=500)
90
+ if not trades:
91
+ return {"total_trades": 0, "win_rate": 0, "total_pnl": 0,
92
+ "profit_factor": 0}
93
+ wins = [t for t in trades if (t.get("pnl") or 0) > 0]
94
+ losses = [t for t in trades if (t.get("pnl") or 0) < 0]
95
+ gross_win = sum(t["pnl"] for t in wins) or 0.0
96
+ gross_loss = abs(sum(t["pnl"] for t in losses)) or 1e-9
97
+ return {
98
+ "total_trades": len(trades),
99
+ "winning_trades": len(wins),
100
+ "losing_trades": len(losses),
101
+ "win_rate": len(wins) / len(trades),
102
+ "total_pnl": sum(t["pnl"] or 0 for t in trades),
103
+ "profit_factor": gross_win / gross_loss,
104
+ }
105
+
106
+
107
+ @app.get("/confidence")
108
+ async def confidence(symbol: str = "R_10"):
109
+ rows = sb.select("predictions", eq={"symbol": symbol},
110
+ order="created_at", desc=True, limit=20)
111
+ if not rows:
112
+ return {"symbol": symbol, "avg_confidence": 0, "samples": 0}
113
+ avg = sum(float(r.get("confidence") or 0) for r in rows) / len(rows)
114
+ return {"symbol": symbol, "avg_confidence": avg, "samples": len(rows)}
115
+
116
+
117
+ # -------------------------- core AI loop ------------------------------------
118
+
119
+ @app.post("/predict", response_model=StrategySignal)
120
+ async def predict(req: PredictRequest):
121
+ """Run OB+FVG strategy on latest Deriv candles and emit a signal."""
122
+ granularity = _granularity_seconds(req.timeframe)
123
+ client = DerivClient()
124
+ try:
125
+ candles = await client.candles(req.symbol, granularity=granularity,
126
+ count=req.lookback)
127
+ except DerivError as e:
128
+ raise HTTPException(502, f"Deriv error: {e}")
129
+
130
+ signal = generate_signal(req.symbol, req.timeframe, candles)
131
+ forecast = ensemble(candles)
132
+
133
+ # persist
134
+ pred = sb.insert("predictions", {
135
+ "symbol": req.symbol,
136
+ "timeframe": req.timeframe,
137
+ "decision": signal.decision,
138
+ "confidence": round(signal.confidence, 3),
139
+ "risk_score": round(1 - signal.confidence, 3),
140
+ "success_probability": round(signal.confidence, 3),
141
+ "reasoning": signal.rationale,
142
+ "trade_plan": {"entry": signal.entry, "sl": signal.sl, "tp": signal.tp},
143
+ "indicators": signal.indicators,
144
+ "market_state": {"forecast": forecast},
145
+ "suggested_entry": signal.entry,
146
+ "suggested_sl": signal.sl,
147
+ "suggested_tp": signal.tp,
148
+ "model_version": "ob_fvg-v1",
149
+ })
150
+ sb.insert("live_signals", {
151
+ "symbol": req.symbol,
152
+ "decision": signal.decision,
153
+ "confidence": round(signal.confidence, 3),
154
+ "price": signal.price,
155
+ "ob_zone": signal.ob.model_dump() if signal.ob else None,
156
+ "fvg_zone": signal.fvg.model_dump() if signal.fvg else None,
157
+ "reasoning": signal.rationale,
158
+ })
159
+ return signal
160
+
161
+
162
+ @app.post("/reason")
163
+ async def reason_endpoint(req: ReasonRequest):
164
+ return await qwen_reason(req.symbol, req.timeframe,
165
+ req.indicators, req.prediction, req.market_state)
166
+
167
+
168
+ # -------------------------- trading ------------------------------------------
169
+
170
+ @app.post("/paper-trade", response_model=TradeResponse)
171
+ async def paper_trade(req: PaperTradeRequest):
172
+ client = DerivClient()
173
+ tick = await client.tick(req.symbol)
174
+ price = float(tick.get("quote") or 0)
175
+ if not price:
176
+ raise HTTPException(502, "Could not fetch current price")
177
+
178
+ pf = await get_or_create_portfolio(None, "demo")
179
+ check = validate_trade(
180
+ balance=float(pf.get("balance") or 0),
181
+ open_positions=int(pf.get("open_positions") or 0),
182
+ today_pnl=float(pf.get("realized_pnl") or 0),
183
+ trade_size=req.size,
184
+ confidence=0.7, # passed when called from /predict; user override OK
185
+ max_daily_loss=settings.MAX_DAILY_LOSS_DEFAULT,
186
+ max_open_trades=settings.MAX_OPEN_TRADES_DEFAULT,
187
+ risk_percent=2.0,
188
+ )
189
+ if not check.ok:
190
+ return TradeResponse(ok=False, message=check.reason or "Rejected")
191
+
192
+ row = await open_paper_trade(req, current_price=price)
193
+ return TradeResponse(ok=True, trade_id=row.get("id"),
194
+ message="Paper trade opened")
195
+
196
+
197
+ @app.post("/trade", response_model=TradeResponse)
198
+ async def live_trade(req: TradeRequest):
199
+ if not settings.DERIV_API_TOKEN:
200
+ raise HTTPException(403,
201
+ "Live trading disabled: set DERIV_API_TOKEN in Space secrets.")
202
+ client = DerivClient(token=settings.DERIV_API_TOKEN)
203
+ contract_type = "CALL" if req.side == "BUY" else "PUT"
204
+ try:
205
+ buy = await client.buy_contract(
206
+ symbol=req.symbol, contract_type=contract_type,
207
+ amount=req.size, duration=5, duration_unit="m",
208
+ )
209
+ except DerivError as e:
210
+ raise HTTPException(502, f"Deriv: {e}")
211
+ row = sb.insert("trade_history", {
212
+ "mode": "live",
213
+ "symbol": req.symbol,
214
+ "side": req.side,
215
+ "entry_price": float(buy.get("buy_price") or 0),
216
+ "size": req.size,
217
+ "stop_loss": req.sl,
218
+ "take_profit": req.tp,
219
+ "status": "open",
220
+ "deriv_contract_id": str(buy.get("contract_id") or ""),
221
+ "prediction_id": req.prediction_id,
222
+ })
223
+ return TradeResponse(ok=True, trade_id=(row or {}).get("id"),
224
+ contract_id=str(buy.get("contract_id") or ""),
225
+ message="Live contract bought")
226
+
227
+
228
+ # -------------------------- feedback / retrain -------------------------------
229
+
230
+ @app.post("/feedback")
231
+ async def feedback(req: FeedbackRequest):
232
+ sb.insert("feedback", req.model_dump())
233
+ return {"ok": True}
234
+
235
+
236
+ @app.post("/retrain-request")
237
+ async def retrain(req: RetrainRequest):
238
+ sb.insert("logs", {
239
+ "level": "info", "source": "retrain",
240
+ "message": f"Retrain requested for {req.model_name}",
241
+ "meta": req.model_dump(),
242
+ })
243
+ return {"ok": True, "queued": True}
244
+
245
+
246
+ # -------------------------- helpers ------------------------------------------
247
+
248
+ def _granularity_seconds(tf: str) -> int:
249
+ table = {"1m": 60, "5m": 300, "15m": 900, "30m": 1800,
250
+ "1h": 3600, "4h": 14400, "1d": 86400}
251
+ return table.get(tf, 60)
paper_trader.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Virtual paper-trading engine. Uses live Deriv prices but never executes."""
2
+ from __future__ import annotations
3
+ from typing import Any, Optional
4
+ from datetime import datetime, timezone
5
+ import uuid
6
+
7
+ from . import supabase_client as sb
8
+ from .config import get_settings
9
+ from .schemas import PaperTradeRequest
10
+
11
+
12
+ async def get_or_create_portfolio(user_id: Optional[str], mode: str = "demo") -> dict:
13
+ rows = sb.select("portfolio", eq={"mode": mode, "user_id": user_id} if user_id
14
+ else {"mode": mode}, limit=1)
15
+ if rows:
16
+ return rows[0]
17
+ s = get_settings()
18
+ created = sb.insert("portfolio", {
19
+ "user_id": user_id, "mode": mode,
20
+ "balance": s.DEMO_STARTING_BALANCE,
21
+ "equity": s.DEMO_STARTING_BALANCE,
22
+ })
23
+ return created or {
24
+ "balance": s.DEMO_STARTING_BALANCE,
25
+ "equity": s.DEMO_STARTING_BALANCE,
26
+ "open_positions": 0, "realized_pnl": 0, "unrealized_pnl": 0,
27
+ }
28
+
29
+
30
+ async def open_paper_trade(req: PaperTradeRequest, current_price: float,
31
+ user_id: Optional[str] = None) -> dict[str, Any]:
32
+ portfolio = await get_or_create_portfolio(user_id, "demo")
33
+ entry = req.entry or current_price
34
+ row = sb.insert("trade_history", {
35
+ "id": str(uuid.uuid4()),
36
+ "user_id": user_id,
37
+ "prediction_id": req.prediction_id,
38
+ "mode": "demo",
39
+ "symbol": req.symbol,
40
+ "side": req.side,
41
+ "entry_price": entry,
42
+ "size": req.size,
43
+ "stop_loss": req.sl,
44
+ "take_profit": req.tp,
45
+ "status": "open",
46
+ "reason_opened": "AI signal accepted",
47
+ "opened_at": datetime.now(timezone.utc).isoformat(),
48
+ })
49
+ if portfolio.get("id"):
50
+ sb.update("portfolio", portfolio["id"], {
51
+ "open_positions": (portfolio.get("open_positions") or 0) + 1,
52
+ })
53
+ return row or {}
54
+
55
+
56
+ async def close_paper_trade(trade_id: str, exit_price: float,
57
+ reason: str = "manual close") -> dict[str, Any]:
58
+ rows = sb.select("trade_history", eq={"id": trade_id}, limit=1)
59
+ if not rows:
60
+ return {"ok": False, "message": "Trade not found"}
61
+ t = rows[0]
62
+ sign = 1 if t["side"] == "BUY" else -1
63
+ pnl = sign * (exit_price - float(t["entry_price"])) * float(t["size"])
64
+ sb.update("trade_history", trade_id, {
65
+ "exit_price": exit_price,
66
+ "pnl": pnl,
67
+ "status": "closed",
68
+ "reason_closed": reason,
69
+ "closed_at": datetime.now(timezone.utc).isoformat(),
70
+ })
71
+ pf = await get_or_create_portfolio(t.get("user_id"), "demo")
72
+ if pf.get("id"):
73
+ sb.update("portfolio", pf["id"], {
74
+ "realized_pnl": float(pf.get("realized_pnl") or 0) + pnl,
75
+ "balance": float(pf.get("balance") or 0) + pnl,
76
+ "equity": float(pf.get("equity") or 0) + pnl,
77
+ "open_positions": max(0, (pf.get("open_positions") or 1) - 1),
78
+ })
79
+ return {"ok": True, "pnl": pnl}
prediction_engine.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prediction engine — numerical forecasters.
3
+
4
+ For the initial slice we ship one pure-Python heuristic forecaster + stubs
5
+ for LSTM / XGBoost / Transformer slots. Heavy models load lazily so the
6
+ Space starts fast.
7
+ """
8
+ from __future__ import annotations
9
+ from typing import Any
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from .schemas import Candle
14
+
15
+
16
+ def _df(candles: list[Candle]) -> pd.DataFrame:
17
+ return pd.DataFrame([c.model_dump() for c in candles])
18
+
19
+
20
+ def heuristic_forecast(candles: list[Candle], horizon: int = 5) -> dict[str, Any]:
21
+ """Quick momentum + mean-reversion blend, returns next-N candle direction."""
22
+ if len(candles) < 20:
23
+ return {"direction": "flat", "expected_return": 0.0, "confidence": 0.0,
24
+ "horizon": horizon, "model": "heuristic"}
25
+ df = _df(candles)
26
+ rets = df["close"].pct_change().dropna()
27
+ momentum = rets.tail(10).mean()
28
+ vol = rets.tail(20).std() or 1e-9
29
+ z = momentum / vol
30
+ expected = float(np.tanh(z) * vol * horizon)
31
+ direction = "up" if expected > 0 else "down" if expected < 0 else "flat"
32
+ confidence = float(min(abs(z) / 3, 0.9))
33
+ return {
34
+ "direction": direction,
35
+ "expected_return": expected,
36
+ "confidence": confidence,
37
+ "horizon": horizon,
38
+ "model": "heuristic-momentum-v1",
39
+ }
40
+
41
+
42
+ # ---- stubs for future swap-in -----
43
+
44
+ def lstm_forecast(candles: list[Candle], horizon: int = 5) -> dict[str, Any]:
45
+ """Placeholder — same contract as heuristic. Swap in a trained LSTM."""
46
+ base = heuristic_forecast(candles, horizon)
47
+ base["model"] = "lstm-stub"
48
+ return base
49
+
50
+
51
+ def xgboost_forecast(candles: list[Candle], horizon: int = 5) -> dict[str, Any]:
52
+ base = heuristic_forecast(candles, horizon)
53
+ base["model"] = "xgboost-stub"
54
+ return base
55
+
56
+
57
+ def ensemble(candles: list[Candle], horizon: int = 5) -> dict[str, Any]:
58
+ """Average available models. Currently only the heuristic is real."""
59
+ models = [heuristic_forecast(candles, horizon)]
60
+ expected = float(np.mean([m["expected_return"] for m in models]))
61
+ conf = float(np.mean([m["confidence"] for m in models]))
62
+ direction = "up" if expected > 0 else "down" if expected < 0 else "flat"
63
+ return {
64
+ "direction": direction,
65
+ "expected_return": expected,
66
+ "confidence": conf,
67
+ "horizon": horizon,
68
+ "components": [m["model"] for m in models],
69
+ "model": "ensemble-v1",
70
+ }
qwen_reasoner.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Qwen reasoning engine via Hugging Face Inference API (chat completions).
3
+
4
+ Qwen is NOT a forecaster — it ingests structured market context and emits
5
+ a trading decision with rationale.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ import re
10
+ from typing import Any
11
+ import httpx
12
+
13
+ from .config import get_settings
14
+ from .schemas import ReasonResponse
15
+
16
+
17
+ SYSTEM_PROMPT = """You are the reasoning engine of an autonomous trading system.
18
+ You receive structured market context (indicators, prediction model output,
19
+ detected Order Blocks / Fair Value Gaps) and must decide BUY, SELL, or WAIT.
20
+
21
+ Be conservative. WAIT is always a valid output. Never invent prices.
22
+
23
+ Respond ONLY with a single minified JSON object matching this schema:
24
+
25
+ {
26
+ "decision": "BUY" | "SELL" | "WAIT",
27
+ "confidence": <float 0..1>,
28
+ "risk_score": <float 0..1>, // 0 = low risk, 1 = high risk
29
+ "success_probability": <float 0..1>,
30
+ "reasoning": "<concise plain-English rationale, 1-3 sentences>",
31
+ "trade_plan": {
32
+ "entry": <float>, "sl": <float>, "tp": <float>,
33
+ "size_hint": <float 0..1> // fraction of risk budget
34
+ }
35
+ }
36
+ """
37
+
38
+
39
+ def _build_user_prompt(symbol: str, timeframe: str,
40
+ indicators: dict, prediction: dict,
41
+ market_state: dict) -> str:
42
+ payload = {
43
+ "symbol": symbol,
44
+ "timeframe": timeframe,
45
+ "indicators": indicators,
46
+ "prediction_model_output": prediction,
47
+ "market_state": market_state,
48
+ }
49
+ return (
50
+ "Market context:\n"
51
+ f"{json.dumps(payload, default=str)}\n\n"
52
+ "Return the JSON decision now."
53
+ )
54
+
55
+
56
+ def _fallback(prediction: dict, indicators: dict) -> ReasonResponse:
57
+ """Deterministic fallback when the LLM call fails or returns garbage."""
58
+ direction = prediction.get("direction", "flat")
59
+ decision = "BUY" if direction == "up" else "SELL" if direction == "down" else "WAIT"
60
+ conf = float(prediction.get("confidence", 0.3))
61
+ return ReasonResponse(
62
+ decision=decision if conf > 0.4 else "WAIT",
63
+ confidence=conf,
64
+ risk_score=1.0 - conf,
65
+ success_probability=conf,
66
+ reasoning=("Qwen unavailable — fell back to deterministic rule using "
67
+ f"prediction direction={direction}, conf={conf:.2f}."),
68
+ trade_plan={
69
+ "entry": indicators.get("price", 0),
70
+ "sl": 0, "tp": 0, "size_hint": min(conf, 0.5),
71
+ },
72
+ )
73
+
74
+
75
+ def _extract_json(text: str) -> dict[str, Any] | None:
76
+ # Try direct
77
+ try:
78
+ return json.loads(text)
79
+ except Exception:
80
+ pass
81
+ # Try first {...} block
82
+ m = re.search(r"\{.*\}", text, re.DOTALL)
83
+ if m:
84
+ try:
85
+ return json.loads(m.group(0))
86
+ except Exception:
87
+ return None
88
+ return None
89
+
90
+
91
+ async def reason(symbol: str, timeframe: str,
92
+ indicators: dict, prediction: dict,
93
+ market_state: dict) -> ReasonResponse:
94
+ s = get_settings()
95
+ if not s.HF_TOKEN:
96
+ return _fallback(prediction, indicators)
97
+
98
+ url = f"https://api-inference.huggingface.co/models/{s.QWEN_MODEL}/v1/chat/completions"
99
+ headers = {"Authorization": f"Bearer {s.HF_TOKEN}",
100
+ "Content-Type": "application/json"}
101
+ body = {
102
+ "model": s.QWEN_MODEL,
103
+ "messages": [
104
+ {"role": "system", "content": SYSTEM_PROMPT},
105
+ {"role": "user", "content": _build_user_prompt(
106
+ symbol, timeframe, indicators, prediction, market_state)},
107
+ ],
108
+ "temperature": 0.2,
109
+ "max_tokens": 400,
110
+ "response_format": {"type": "json_object"},
111
+ }
112
+ try:
113
+ async with httpx.AsyncClient(timeout=30) as client:
114
+ r = await client.post(url, headers=headers, json=body)
115
+ r.raise_for_status()
116
+ data = r.json()
117
+ content = data["choices"][0]["message"]["content"]
118
+ parsed = _extract_json(content)
119
+ if not parsed:
120
+ return _fallback(prediction, indicators)
121
+ return ReasonResponse(
122
+ decision=parsed.get("decision", "WAIT"),
123
+ confidence=float(parsed.get("confidence", 0.3)),
124
+ risk_score=float(parsed.get("risk_score", 0.5)),
125
+ success_probability=float(parsed.get("success_probability", 0.5)),
126
+ reasoning=str(parsed.get("reasoning", "")),
127
+ trade_plan=parsed.get("trade_plan", {}) or {},
128
+ )
129
+ except Exception as e:
130
+ fb = _fallback(prediction, indicators)
131
+ fb.reasoning = f"[Qwen error: {type(e).__name__}] " + fb.reasoning
132
+ return fb
risk.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-trade risk gates. Reject before any execution."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class RiskCheckResult:
9
+ ok: bool
10
+ reason: Optional[str] = None
11
+
12
+
13
+ def validate_trade(*, balance: float, open_positions: int, today_pnl: float,
14
+ trade_size: float, confidence: float,
15
+ max_daily_loss: float, max_open_trades: int,
16
+ risk_percent: float,
17
+ confidence_threshold: float = 0.6) -> RiskCheckResult:
18
+ if balance <= 0:
19
+ return RiskCheckResult(False, "Zero balance.")
20
+ if trade_size <= 0:
21
+ return RiskCheckResult(False, "Trade size must be positive.")
22
+ if trade_size > balance:
23
+ return RiskCheckResult(False, "Trade size exceeds balance.")
24
+ if open_positions >= max_open_trades:
25
+ return RiskCheckResult(False, f"Max open trades reached ({max_open_trades}).")
26
+ if today_pnl <= -abs(max_daily_loss):
27
+ return RiskCheckResult(False, f"Daily loss limit hit ({today_pnl:.2f}).")
28
+ if confidence < confidence_threshold:
29
+ return RiskCheckResult(False,
30
+ f"Confidence {confidence:.2f} below threshold {confidence_threshold:.2f}.")
31
+ max_risk = balance * (risk_percent / 100.0)
32
+ if trade_size > max_risk:
33
+ return RiskCheckResult(False,
34
+ f"Trade size {trade_size:.2f} exceeds {risk_percent}% risk cap ({max_risk:.2f}).")
35
+ return RiskCheckResult(True)
schemas.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Any, Optional, Literal
3
+ from pydantic import BaseModel, Field
4
+
5
+ Decision = Literal["BUY", "SELL", "WAIT"]
6
+ Mode = Literal["demo", "live"]
7
+ Side = Literal["BUY", "SELL"]
8
+
9
+
10
+ class Candle(BaseModel):
11
+ epoch: int
12
+ open: float
13
+ high: float
14
+ low: float
15
+ close: float
16
+ volume: float = 0.0
17
+
18
+
19
+ class FVG(BaseModel):
20
+ kind: Literal["bullish", "bearish"]
21
+ top: float
22
+ bottom: float
23
+ index: int # index of middle candle in series
24
+ filled: bool = False
25
+
26
+
27
+ class OrderBlock(BaseModel):
28
+ kind: Literal["bullish", "bearish"]
29
+ top: float
30
+ bottom: float
31
+ index: int # index of OB candle
32
+ fvg_index: int # the FVG this OB anchors
33
+ mitigated: bool = False
34
+
35
+
36
+ class StrategySignal(BaseModel):
37
+ symbol: str
38
+ timeframe: str
39
+ decision: Decision
40
+ confidence: float = Field(ge=0, le=1)
41
+ price: float
42
+ entry: Optional[float] = None
43
+ sl: Optional[float] = None
44
+ tp: Optional[float] = None
45
+ ob: Optional[OrderBlock] = None
46
+ fvg: Optional[FVG] = None
47
+ rationale: str
48
+ indicators: dict[str, Any] = {}
49
+
50
+
51
+ class PredictRequest(BaseModel):
52
+ symbol: str = "R_10"
53
+ timeframe: str = "1m"
54
+ lookback: int = 200
55
+
56
+
57
+ class ReasonRequest(BaseModel):
58
+ symbol: str
59
+ timeframe: str = "1m"
60
+ indicators: dict[str, Any]
61
+ prediction: dict[str, Any]
62
+ market_state: dict[str, Any] = {}
63
+
64
+
65
+ class ReasonResponse(BaseModel):
66
+ decision: Decision
67
+ confidence: float
68
+ risk_score: float
69
+ success_probability: float
70
+ reasoning: str
71
+ trade_plan: dict[str, Any]
72
+
73
+
74
+ class PaperTradeRequest(BaseModel):
75
+ symbol: str
76
+ side: Side
77
+ size: float
78
+ entry: Optional[float] = None
79
+ sl: Optional[float] = None
80
+ tp: Optional[float] = None
81
+ prediction_id: Optional[str] = None
82
+
83
+
84
+ class TradeRequest(PaperTradeRequest):
85
+ pass
86
+
87
+
88
+ class TradeResponse(BaseModel):
89
+ ok: bool
90
+ trade_id: Optional[str] = None
91
+ contract_id: Optional[str] = None
92
+ message: str
93
+ pnl: Optional[float] = None
94
+
95
+
96
+ class FeedbackRequest(BaseModel):
97
+ prediction_id: str
98
+ rating: int = Field(ge=1, le=5)
99
+ comment: Optional[str] = None
100
+
101
+
102
+ class RetrainRequest(BaseModel):
103
+ model_name: str
104
+ reason: Optional[str] = None
strategy_ob_fvg.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Order Block + Fair Value Gap strategy.
3
+
4
+ Definitions
5
+ -----------
6
+ Fair Value Gap (3-candle imbalance):
7
+ - Bullish FVG: candle[i-1].high < candle[i+1].low
8
+ => gap zone = (candle[i-1].high, candle[i+1].low)
9
+ - Bearish FVG: candle[i-1].low > candle[i+1].high
10
+ => gap zone = (candle[i+1].high, candle[i-1].low)
11
+
12
+ Order Block (anchor candle):
13
+ - Bullish OB = last bearish candle (close < open) immediately preceding
14
+ the bullish impulse that created the FVG.
15
+ - Bearish OB = last bullish candle (close > open) immediately preceding
16
+ the bearish impulse.
17
+ - OB zone = (low, high) of that candle.
18
+
19
+ Signal logic
20
+ ------------
21
+ When current price retraces into an unmitigated OB zone of the same direction
22
+ and the FVG anchoring it is still (at least partially) unfilled, emit a signal:
23
+
24
+ Bullish OB tag -> BUY
25
+ Bearish OB tag -> SELL
26
+
27
+ Risk plan
28
+ ---------
29
+ entry = midpoint of OB zone
30
+ SL = OB extreme on the protected side - 1 * ATR(14) buffer
31
+ TP = nearest swing liquidity in trade direction, capped at 3R / floored at 1.5R
32
+ """
33
+ from __future__ import annotations
34
+ from dataclasses import dataclass, asdict
35
+ from typing import Optional
36
+ import numpy as np
37
+ import pandas as pd
38
+
39
+ from .schemas import Candle, FVG, OrderBlock, StrategySignal
40
+
41
+
42
+ # ----------------------------- helpers --------------------------------------
43
+
44
+ def candles_to_df(candles: list[Candle]) -> pd.DataFrame:
45
+ return pd.DataFrame([c.model_dump() for c in candles])
46
+
47
+
48
+ def atr(df: pd.DataFrame, period: int = 14) -> float:
49
+ if len(df) < period + 1:
50
+ return float((df["high"] - df["low"]).mean() or 0.0)
51
+ h, l, c = df["high"].values, df["low"].values, df["close"].values
52
+ tr = np.maximum.reduce([
53
+ h[1:] - l[1:],
54
+ np.abs(h[1:] - c[:-1]),
55
+ np.abs(l[1:] - c[:-1]),
56
+ ])
57
+ return float(pd.Series(tr).rolling(period).mean().iloc[-1])
58
+
59
+
60
+ def ema(series: pd.Series, period: int) -> float:
61
+ return float(series.ewm(span=period, adjust=False).mean().iloc[-1])
62
+
63
+
64
+ def rsi(series: pd.Series, period: int = 14) -> float:
65
+ delta = series.diff()
66
+ up = delta.clip(lower=0).rolling(period).mean()
67
+ down = (-delta.clip(upper=0)).rolling(period).mean()
68
+ rs = up / down.replace(0, np.nan)
69
+ val = 100 - (100 / (1 + rs.iloc[-1]))
70
+ return float(val) if not np.isnan(val) else 50.0
71
+
72
+
73
+ def swing_high(df: pd.DataFrame, lookback: int = 50) -> float:
74
+ return float(df["high"].tail(lookback).max())
75
+
76
+
77
+ def swing_low(df: pd.DataFrame, lookback: int = 50) -> float:
78
+ return float(df["low"].tail(lookback).min())
79
+
80
+
81
+ # ----------------------------- detectors ------------------------------------
82
+
83
+ def detect_fvgs(df: pd.DataFrame, max_age: int = 100) -> list[FVG]:
84
+ fvgs: list[FVG] = []
85
+ n = len(df)
86
+ start = max(1, n - max_age - 1)
87
+ end = n - 1 # need i+1 to exist
88
+ h = df["high"].values
89
+ l = df["low"].values
90
+ for i in range(start, end):
91
+ # bullish: gap between prev high and next low
92
+ if h[i - 1] < l[i + 1]:
93
+ fvgs.append(FVG(kind="bullish", bottom=float(h[i - 1]),
94
+ top=float(l[i + 1]), index=i))
95
+ # bearish: gap between next high and prev low
96
+ elif l[i - 1] > h[i + 1]:
97
+ fvgs.append(FVG(kind="bearish", bottom=float(h[i + 1]),
98
+ top=float(l[i - 1]), index=i))
99
+ # mark filled if price has since traded through
100
+ last_close = float(df["close"].iloc[-1])
101
+ last_high = float(df["high"].max())
102
+ last_low = float(df["low"].min())
103
+ for f in fvgs:
104
+ post = df.iloc[f.index + 1:]
105
+ if f.kind == "bullish":
106
+ f.filled = bool((post["low"] <= f.bottom).any())
107
+ else:
108
+ f.filled = bool((post["high"] >= f.top).any())
109
+ return fvgs
110
+
111
+
112
+ def detect_order_blocks(df: pd.DataFrame, fvgs: list[FVG]) -> list[OrderBlock]:
113
+ obs: list[OrderBlock] = []
114
+ o = df["open"].values
115
+ c = df["close"].values
116
+ h = df["high"].values
117
+ l = df["low"].values
118
+ for f in fvgs:
119
+ # search backwards from FVG anchor for last opposite-color candle
120
+ ob_kind = "bullish" if f.kind == "bullish" else "bearish"
121
+ # bullish OB = last bearish (red) candle before bullish impulse
122
+ # bearish OB = last bullish (green) candle before bearish impulse
123
+ want_red = (ob_kind == "bullish")
124
+ idx = None
125
+ for j in range(f.index - 1, max(f.index - 10, -1), -1):
126
+ is_red = c[j] < o[j]
127
+ if want_red and is_red:
128
+ idx = j
129
+ break
130
+ if (not want_red) and (c[j] > o[j]):
131
+ idx = j
132
+ break
133
+ if idx is None:
134
+ continue
135
+ ob = OrderBlock(
136
+ kind=ob_kind,
137
+ top=float(h[idx]),
138
+ bottom=float(l[idx]),
139
+ index=idx,
140
+ fvg_index=f.index,
141
+ )
142
+ # mitigated if price has revisited the zone after creation
143
+ post = df.iloc[idx + 1:]
144
+ if ob.kind == "bullish":
145
+ ob.mitigated = bool((post["low"] <= ob.top).any() and
146
+ (post["low"] <= ob.bottom).any())
147
+ else:
148
+ ob.mitigated = bool((post["high"] >= ob.bottom).any() and
149
+ (post["high"] >= ob.top).any())
150
+ obs.append(ob)
151
+ return obs
152
+
153
+
154
+ # ----------------------------- signal generator -----------------------------
155
+
156
+ def generate_signal(symbol: str, timeframe: str,
157
+ candles: list[Candle]) -> StrategySignal:
158
+ if len(candles) < 30:
159
+ return StrategySignal(
160
+ symbol=symbol, timeframe=timeframe, decision="WAIT",
161
+ confidence=0.0, price=candles[-1].close if candles else 0.0,
162
+ rationale="Insufficient candle history (need >= 30).",
163
+ )
164
+
165
+ df = candles_to_df(candles)
166
+ price = float(df["close"].iloc[-1])
167
+ a = atr(df)
168
+ rsi_v = rsi(df["close"])
169
+ ema20 = ema(df["close"], 20)
170
+ ema50 = ema(df["close"], 50)
171
+ trend = "up" if ema20 > ema50 else "down"
172
+
173
+ fvgs = detect_fvgs(df)
174
+ obs = detect_order_blocks(df, fvgs)
175
+
176
+ # find the most recent valid (unmitigated) OB whose FVG is unfilled
177
+ candidate: Optional[OrderBlock] = None
178
+ paired_fvg: Optional[FVG] = None
179
+ for ob in reversed(obs):
180
+ f = next((x for x in fvgs if x.index == ob.fvg_index), None)
181
+ if not f or f.filled or ob.mitigated:
182
+ continue
183
+ # require current price near OB (within 2 ATR)
184
+ dist = min(abs(price - ob.top), abs(price - ob.bottom))
185
+ if dist > 2 * a and not (ob.bottom <= price <= ob.top):
186
+ continue
187
+ candidate, paired_fvg = ob, f
188
+ break
189
+
190
+ indicators = {
191
+ "ema20": ema20, "ema50": ema50, "rsi14": rsi_v,
192
+ "atr14": a, "trend": trend,
193
+ "swing_high": swing_high(df), "swing_low": swing_low(df),
194
+ "fvg_count": len(fvgs), "ob_count": len(obs),
195
+ }
196
+
197
+ if not candidate or not paired_fvg:
198
+ return StrategySignal(
199
+ symbol=symbol, timeframe=timeframe, decision="WAIT",
200
+ confidence=0.25, price=price,
201
+ rationale="No unmitigated OB / unfilled FVG confluence near price.",
202
+ indicators=indicators,
203
+ )
204
+
205
+ # build trade plan
206
+ if candidate.kind == "bullish":
207
+ entry = (candidate.top + candidate.bottom) / 2
208
+ sl = candidate.bottom - a
209
+ tp_liquidity = indicators["swing_high"]
210
+ r = entry - sl
211
+ tp = max(min(tp_liquidity, entry + 3 * r), entry + 1.5 * r)
212
+ decision = "BUY"
213
+ else:
214
+ entry = (candidate.top + candidate.bottom) / 2
215
+ sl = candidate.top + a
216
+ tp_liquidity = indicators["swing_low"]
217
+ r = sl - entry
218
+ tp = min(max(tp_liquidity, entry - 3 * r), entry - 1.5 * r)
219
+ decision = "SELL"
220
+
221
+ # confidence model: trend alignment + RSI sanity + freshness
222
+ trend_align = (decision == "BUY" and trend == "up") or \
223
+ (decision == "SELL" and trend == "down")
224
+ rsi_ok = (decision == "BUY" and rsi_v < 65) or \
225
+ (decision == "SELL" and rsi_v > 35)
226
+ freshness = max(0.0, 1.0 - (len(df) - 1 - candidate.index) / 50)
227
+ confidence = float(np.clip(
228
+ 0.40 + 0.20 * trend_align + 0.15 * rsi_ok + 0.25 * freshness, 0, 0.95
229
+ ))
230
+
231
+ rationale = (
232
+ f"{candidate.kind.title()} OB at [{candidate.bottom:.5f}, "
233
+ f"{candidate.top:.5f}] anchors an unfilled {paired_fvg.kind} FVG. "
234
+ f"Price {price:.5f} is reacting to the zone. Trend {trend.upper()} "
235
+ f"(EMA20 {ema20:.5f} vs EMA50 {ema50:.5f}), RSI14 {rsi_v:.1f}, "
236
+ f"ATR14 {a:.5f}. Plan: entry {entry:.5f}, SL {sl:.5f}, TP {tp:.5f}."
237
+ )
238
+
239
+ return StrategySignal(
240
+ symbol=symbol, timeframe=timeframe, decision=decision,
241
+ confidence=confidence, price=price,
242
+ entry=entry, sl=sl, tp=tp,
243
+ ob=candidate, fvg=paired_fvg, rationale=rationale,
244
+ indicators=indicators,
245
+ )
supabase_client.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin Supabase wrapper used by the backend."""
2
+ from __future__ import annotations
3
+ from functools import lru_cache
4
+ from typing import Any, Optional
5
+ from supabase import create_client, Client
6
+
7
+ from .config import get_settings
8
+
9
+
10
+ @lru_cache
11
+ def sb() -> Optional[Client]:
12
+ s = get_settings()
13
+ if not s.SUPABASE_URL or not s.SUPABASE_SERVICE_ROLE_KEY:
14
+ return None
15
+ return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
16
+
17
+
18
+ def insert(table: str, row: dict[str, Any]) -> Optional[dict]:
19
+ c = sb()
20
+ if not c:
21
+ return None
22
+ res = c.table(table).insert(row).execute()
23
+ return (res.data or [None])[0]
24
+
25
+
26
+ def update(table: str, row_id: str, patch: dict[str, Any]) -> Optional[dict]:
27
+ c = sb()
28
+ if not c:
29
+ return None
30
+ res = c.table(table).update(patch).eq("id", row_id).execute()
31
+ return (res.data or [None])[0]
32
+
33
+
34
+ def select(table: str, *, eq: Optional[dict] = None,
35
+ order: Optional[str] = None, desc: bool = True,
36
+ limit: int = 100) -> list[dict]:
37
+ c = sb()
38
+ if not c:
39
+ return []
40
+ q = c.table(table).select("*")
41
+ for k, v in (eq or {}).items():
42
+ q = q.eq(k, v)
43
+ if order:
44
+ q = q.order(order, desc=desc)
45
+ res = q.limit(limit).execute()
46
+ return res.data or []