Your Name commited on
Commit
848e6c4
·
1 Parent(s): 24d8dad

Implement Hermes Futures Desk: dual-datasource trading engine, dashboard UI, tests, and documentation

Browse files

Adds futures dashboard API, dual-datasource client (KuCoin live + Binance public fallback), trade cycle and state management, luxury dashboard frontend, sync_hf helper, runtime verification script, and full docs/ developer documentation set. Test suite: 70/70 passing.

.gitignore CHANGED
@@ -1,7 +1,13 @@
1
  # Environment and secrets
2
  .env
3
  .env.local
 
4
  *.pem
 
 
 
 
 
5
 
6
  # Dependencies and build
7
  node_modules/
@@ -11,3 +17,11 @@ __pycache__/
11
  # Logs and temp
12
  *.log
13
  .DS_Store
 
 
 
 
 
 
 
 
 
1
  # Environment and secrets
2
  .env
3
  .env.local
4
+ .env.txt
5
  *.pem
6
+ api.txt
7
+ *token*.txt
8
+ *secret*.txt
9
+ *credentials*
10
+ .pytest_cache/
11
 
12
  # Dependencies and build
13
  node_modules/
 
17
  # Logs and temp
18
  *.log
19
  .DS_Store
20
+
21
+ # Read-only runtime audit outputs
22
+ futures_runtime_audit*.json
23
+ .runtime_audit/
24
+
25
+ # Local scratch / one-off probes (machine-specific, not for the shared repo)
26
+ _run_tests.bat
27
+ hermes_overlay/_probe_ds4_schema.py
DEVELOPER_DOCUMENTATION.md ADDED
@@ -0,0 +1,2048 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hermes Futures Desk — Complete Developer Documentation
2
+
3
+ > Canonical combined developer reference for the UI v3 repository snapshot. The modular source documents live under `asset-space/docs/`.
4
+
5
+ ## Contents
6
+
7
+ 1. [Developer Guide](#developer-guide)
8
+ 2. [Architecture](#architecture)
9
+ 3. [API Reference](#api-reference)
10
+ 4. [Datasource Pipeline and Contracts](#datasource-pipeline-and-contracts)
11
+ 5. [Frontend Guide](#frontend-guide)
12
+ 6. [Environment Configuration](#environment-configuration)
13
+ 7. [Deployment Runbook](#deployment-runbook)
14
+ 8. [Security and Safety](#security-and-safety)
15
+ 9. [Operations and Troubleshooting](#operations-and-troubleshooting)
16
+ 10. [Testing and Verification](#testing-and-verification)
17
+ 11. [Contributing](#contributing)
18
+ 12. [Project Status](#project-status)
19
+
20
+ ---
21
+
22
+ ## Hermes Futures Desk — Complete Developer Guide
23
+
24
+ ### 1. Purpose
25
+
26
+ Hermes Futures Desk is an authenticated Futures analysis and risk-management layer installed into the existing Hermes Agent runtime. It discovers markets, normalizes real market data, produces deterministic `LONG`, `SHORT`, or `NO_TRADE` outcomes, calculates a bounded trade plan, applies server-side risk controls, and optionally routes a fully revalidated plan to the existing Paper execution path.
27
+
28
+ The system is intentionally conservative:
29
+
30
+ - Datasource 4 is authoritative for Futures verification and safety.
31
+ - Binance public data may fill missing or unusable market fields but cannot override DS4 safety.
32
+ - Datasource 2 provides complementary context only.
33
+ - External AI provides advisory explanation only.
34
+ - The browser is never trusted to authorize execution.
35
+ - No new web server, FastAPI application, port, or trading engine is created.
36
+
37
+ ### 2. Runtime summary
38
+
39
+ ```text
40
+ Hugging Face Space / Docker container
41
+ └── /opt/hermes Upstream Hermes Agent source/runtime
42
+ ├── dashboard on 0.0.0.0:7860 Existing Hermes web application
43
+ ├── tools/futures_dashboard_api.py Installed repository overlay
44
+ ├── tools/templates/...html Installed Luxury dashboard template
45
+ ├── trading/* Installed Futures modules
46
+ └── .hermes_futures_overlay_manifest.json
47
+
48
+ /opt/data
49
+ ├── scripts/ Entrypoint, persistence, runtime audit
50
+ ├── hermes_overlay/ Restored/persisted copy; not preferred over image overlay
51
+ ├── futures_symbols_cache.json Optional symbol cache
52
+ ├── telegram_state.json Telegram owner/watchlist/alert state
53
+ └── persistent Hermes data
54
+
55
+ /opt/hermesface_overlay Immutable overlay copied from the current image
56
+ ```
57
+
58
+ The Docker image clones Hermes Agent into `/opt/hermes`, installs Python/Node dependencies, copies repository scripts into `/opt/data/scripts`, and copies the current overlay into both `/opt/data/hermes_overlay` and `/opt/hermesface_overlay`. At startup, `scripts/sync_hf.py` installs the overlay into `/opt/hermes`, writes a SHA-256 manifest, patches the existing Hermes dashboard to include the routers, and starts the dashboard on port `7860`.
59
+
60
+ ### 3. Main modules
61
+
62
+ | Module | Responsibility |
63
+ |---|---|
64
+ | `scripts/entrypoint.sh` | Runtime directory creation, dashboard auth configuration, and handoff to `sync_hf.py`. |
65
+ | `scripts/sync_hf.py` | Persistence restore/sync, overlay installation, manifest generation, router mounting, Telegram polling isolation, and process startup. |
66
+ | `hermes_overlay/tools/futures_dashboard_api.py` | Authenticated Futures HTTP routes, runtime file diagnostics, symbol catalog, market endpoint, analysis route, and Paper revalidation route. |
67
+ | `hermes_overlay/tools/templates/hermes_futures_desk_luxury.html` | Luxury Obsidian & Gold single-page dashboard. |
68
+ | `hermes_overlay/trading/dual_datasource_client.py` | DS4 → Binance → DS2 acquisition, normalization, provenance, health metadata, and `noTradeGuard` aggregation. |
69
+ | `hermes_overlay/trading/binance_public_client.py` | Unauthenticated Binance Futures fallback and explicit regional-restriction reporting. |
70
+ | `hermes_overlay/trading/trade_cycle.py` | Deterministic signal scoring, SL/TP construction, risk sizing, plan creation, and optional Paper orchestration. |
71
+ | `hermes_overlay/trading/risk.py` | Risk profiles, leverage caps/haircut, quantity sizing, and hard risk gates. |
72
+ | `hermes_overlay/trading/futures_execution.py` | Existing Paper account/position book and execution validation. |
73
+ | `hermes_overlay/trading/state.py` | Bounded in-memory dashboard state; no decision authority. |
74
+ | `hermes_overlay/trading/symbols.py` | Symbol normalization for DS4 and CCXT formats. |
75
+ | `hermes_overlay/external_ai/advisory.py` | Optional OpenRouter → Google → Hugging Face advisory chain. |
76
+ | `hermes_overlay/tools/telegram_bot.py` | Webhook-only, analysis-only Telegram adapter and owner bootstrap. |
77
+ | `scripts/verify_futures_runtime.py` | Read-only deployed runtime audit; never calls Paper Execute. |
78
+
79
+ ### 4. End-to-end request flow
80
+
81
+ #### 4.1 Market display
82
+
83
+ 1. Browser requests `GET /api/futures/market` with symbol, interval, and limit.
84
+ 2. Router normalizes the symbol and calls `get_market_context()`.
85
+ 3. Datasource client requests DS4 with bounded KuCoin-compatible millisecond `from`/`to` parameters.
86
+ 4. Missing, unusable, or stale fields are requested from Binance public fallback.
87
+ 5. Datasource 2 is queried for complementary context and may fill only still-missing legitimate fields.
88
+ 6. Each normalized field receives source, timestamp, freshness, validity, and fallback metadata.
89
+ 7. The endpoint returns only real normalized values or an explicit `partial`, `stale`, or `unavailable` state.
90
+ 8. The browser renders charts and diagnostics without modifying server decisions.
91
+
92
+ #### 4.2 Deterministic analysis
93
+
94
+ 1. Browser sends `POST /api/futures/analyze`.
95
+ 2. Server runs `run_futures_cycle(..., execute=False)`.
96
+ 3. DS4 safety state, Futures verification, required fields, and freshness gates are checked first.
97
+ 4. A deterministic score is calculated only from normalized real inputs.
98
+ 5. A directional plan is created only when score and confirmation thresholds pass.
99
+ 6. SL/TP are derived from ATR and configured reward-to-risk rules.
100
+ 7. Risk sizing calculates quantity from equity loss at Stop Loss.
101
+ 8. Slippage is estimated from the real order book.
102
+ 9. The result is stored as a bounded server-side plan and returned with a `planId`.
103
+
104
+ #### 4.3 Paper execution
105
+
106
+ Paper execution is not a continuation of browser state. The server performs all checks again:
107
+
108
+ - requested symbol is a verified Futures contract;
109
+ - `planId` matches the latest server plan;
110
+ - symbol and risk profile have not changed;
111
+ - plan was not already executed;
112
+ - plan has not expired;
113
+ - decision is `LONG` or `SHORT`;
114
+ - DS4 verification and trading readiness remain valid;
115
+ - `noTradeGuard` is false;
116
+ - plan is marked executable and risk-approved;
117
+ - runtime trading mode is `paper`;
118
+ - a fresh analysis-only cycle still authorizes the plan.
119
+
120
+ Only after these checks does the server invoke the existing Paper execution path.
121
+
122
+ ### 5. Datasource authority
123
+
124
+ ```text
125
+ Datasource 4 → Binance public fallback → Datasource 2
126
+ ```
127
+
128
+ Datasource 4 owns contract verification and all Futures safety semantics. The critical fields are:
129
+
130
+ ```text
131
+ contract
132
+ ticker
133
+ orderbook
134
+ funding
135
+ openInterest
136
+ ```
137
+
138
+ OHLCV, indicators, sentiment, and ATR are also normalized and attributed. Missing or non-fresh critical fields activate `noTradeGuard` and set `tradingReadiness=blocked`.
139
+
140
+ Binance public data is unauthenticated and field-level only. HTTP 451 is represented as `Regionally restricted`; it is never reported as healthy. Datasource 2 cannot verify Futures, clear `noTradeGuard`, or override DS4 data that is present and usable.
141
+
142
+ ### 6. Health model
143
+
144
+ The code deliberately separates:
145
+
146
+ - `transportStatus`: whether the HTTP request succeeded;
147
+ - `dataUsability`: whether parsed data is suitable for use;
148
+ - `freshness`: whether provider timestamp or authoritative DS4 state proves freshness;
149
+ - `completeness`: whether expected fields were supplied;
150
+ - `mergeStatus`: whether the combined context is complete;
151
+ - `tradingReadiness`: whether deterministic safety gates allow a plan.
152
+
153
+ A successful HTTP response does not make market data fresh. Fallback data without a provider timestamp remains `unknown` and cannot pass a Futures freshness gate.
154
+
155
+ ### 7. Analysis and plan states
156
+
157
+ #### Analysis states
158
+
159
+ ```text
160
+ NOT_ANALYZED
161
+ ANALYZING
162
+ LONG
163
+ SHORT
164
+ NO_TRADE
165
+ ANALYSIS_FAILED
166
+ STALE
167
+ API_UNAVAILABLE
168
+ ```
169
+
170
+ #### Plan types
171
+
172
+ - `directional_plan`: a valid directional plan before final execution checks.
173
+ - `non_executable_plan`: directional values exist but one or more safety/risk gates block execution.
174
+ - rejected/no-direction analysis: `NO_TRADE` with no executable plan geometry.
175
+
176
+ #### Market endpoint states
177
+
178
+ - `available`: real candles and required display fields are fresh and usable.
179
+ - `partial`: values exist but freshness or completeness is not fully proven.
180
+ - `stale`: required display data is stale or invalid.
181
+ - `unavailable`: real candles or a current price could not be obtained.
182
+
183
+ ### 8. Deterministic scoring and risk rules
184
+
185
+ Default analysis thresholds are environment-overridable:
186
+
187
+ | Setting | Default |
188
+ |---|---:|
189
+ | Minimum absolute signal score | `0.55` |
190
+ | Minimum signal components | `3` |
191
+ | Minimum direction confirmations | `2` |
192
+ | Stop ATR multiplier | `1.2` |
193
+ | Take Profit reward-to-risk | `1.8` |
194
+ | Minimum stop distance | `20` bps |
195
+ | Plan maximum age | `20` seconds |
196
+ | Requested leverage | `5x` |
197
+
198
+ Risk profiles:
199
+
200
+ | Profile | Equity risk | Maximum leverage |
201
+ |---|---:|---:|
202
+ | Conservative | 1% | 5x |
203
+ | Moderate | 3% | 10x |
204
+ | Aggressive | 5% | 15x |
205
+
206
+ Sizing is based on loss at Stop Loss:
207
+
208
+ ```text
209
+ risk_amount = account_equity × risk_percent
210
+ stop_distance = abs(entry_price - stop_loss)
211
+ quantity = risk_amount / stop_distance
212
+ ```
213
+
214
+ When ATR is at least 3% of price, effective leverage is reduced by 50% and never increased beyond the risk-profile cap.
215
+
216
+ ### 9. Frontend behavior
217
+
218
+ The dashboard is a single packaged HTML template with inline CSS and JavaScript. It uses the existing authenticated FastAPI origin and `fetch(..., credentials='same-origin', cache='no-store')`.
219
+
220
+ Major features:
221
+
222
+ - symbol search and verified/market-only catalog counts;
223
+ - local watchlist and recent markets;
224
+ - real candle/line chart, four intervals, three candle limits, volume, crosshair, and tooltip;
225
+ - market source, freshness, funding, Open Interest, best bid/ask/spread, and readiness;
226
+ - display-only diagnostics from returned candles;
227
+ - per-field provenance;
228
+ - deterministic analysis and Paper Execute controls;
229
+ - plan geometry and execution checklist;
230
+ - datasource detail cards and sanitized technical diagnostics;
231
+ - Paper account and positions;
232
+ - local activity/history, JSON export, copy summary, density/theme preferences;
233
+ - manual and automatic refresh controls.
234
+
235
+ Browser storage never grants server permission. Selecting a historical symbol or changing risk invalidates the current browser plan and requires a new server analysis.
236
+
237
+ ### 10. Authentication
238
+
239
+ The upstream Hermes dashboard is protected by its existing authentication middleware. The Futures router also supports local HTTP Basic enforcement when `HERMES_ADMIN_PASSWORD` is set:
240
+
241
+ ```text
242
+ username: HERMES_DASHBOARD_BASIC_AUTH_USERNAME (default: admin)
243
+ password: HERMES_ADMIN_PASSWORD
244
+ ```
245
+
246
+ `entrypoint.sh` writes a hashed credential into Hermes `config.yaml` before the server binds publicly. Secrets must be configured as Hugging Face Space secrets or injected environment variables, never committed.
247
+
248
+ ### 11. Telegram model
249
+
250
+ Telegram is webhook-only and analysis-only:
251
+
252
+ - `POST /api/telegram/webhook` validates `X-Telegram-Bot-Api-Secret-Token`.
253
+ - Owner bootstrap uses a one-time private-chat `/claim <secret>` command.
254
+ - Authorized users come from configured IDs or the persisted owner.
255
+ - Commands call `run_futures_cycle(..., execute=False)` only.
256
+ - Direct delivery may use a proxy; proactive delivery may use an HMAC relay.
257
+ - Polling must remain disabled.
258
+
259
+ No Telegram command can execute a Futures position.
260
+
261
+ ### 12. Runtime integrity
262
+
263
+ During overlay installation, `sync_hf.py` copies the current image overlay into `/opt/hermes` and writes `.hermes_futures_overlay_manifest.json`. The status endpoint compares repository/overlay/runtime/template/router hashes when those paths are available.
264
+
265
+ Runtime status semantics:
266
+
267
+ - `verified`: evidence exists and all expected hashes match;
268
+ - `mismatch`: evidence exists and one or more hashes differ;
269
+ - `unknown`: required evidence is unavailable.
270
+
271
+ Missing files must never be reported as verified.
272
+
273
+ ### 13. Development workflow
274
+
275
+ 1. Start from the current repository files under `asset-space/hermes_overlay`.
276
+ 2. Do not copy old loose reference files over the repository.
277
+ 3. Keep changes focused and additive to the API contract.
278
+ 4. Preserve the single server and port architecture.
279
+ 5. Add tests for normalization, provenance, state transitions, and server-side execution checks.
280
+ 6. Run static checks and focused Futures tests.
281
+ 7. Review secrets and generated files before commit.
282
+ 8. Deploy through the existing Space workflow.
283
+ 9. Verify the installed hashes and authenticated routes.
284
+ 10. Inspect browser Console and Network.
285
+ 11. Never click Paper Execute during deployment verification.
286
+
287
+ ### 14. Read-only runtime audit
288
+
289
+ ```bash
290
+ export HERMES_ADMIN_PASSWORD='...'
291
+ export HERMES_DASHBOARD_BASIC_AUTH_USERNAME='admin'
292
+ python scripts/verify_futures_runtime.py \
293
+ --base-url https://really-amin-asset.hf.space \
294
+ --symbol BTCUSDT \
295
+ --analyze \
296
+ --report .runtime_audit/futures_runtime_audit.json
297
+ ```
298
+
299
+ The utility checks `/futures`, status, symbols, positions, all market intervals, and optionally one analysis-only request. It never calls `/api/futures/paper/execute`.
300
+
301
+ ### 15. Known deployment limitations
302
+
303
+ - Binance public Futures endpoints may return HTTP 451 in the current Hugging Face region.
304
+ - Real DS4 payload names and provider timestamps must be verified against live deployed responses.
305
+ - The current package has static validation results but not a completed authenticated production verification cycle.
306
+ - The UI uses a single large HTML template; future refactoring must preserve runtime template installation and avoid introducing a second frontend server.
307
+
308
+ ### 16. Definition of done
309
+
310
+ A change is complete only when:
311
+
312
+ - the correct template and router are installed and hash-verified;
313
+ - authenticated routes return expected structured responses;
314
+ - real market data renders for 1m, 5m, 15m, and 1h or returns an explicit unavailable state;
315
+ - Console has no critical error and Network requests are authenticated;
316
+ - datasource health and attribution are truthful;
317
+ - deterministic safety logic is unchanged;
318
+ - Telegram remains webhook-only;
319
+ - no secret is exposed;
320
+ - no trade is executed during verification.
321
+
322
+
323
+ ---
324
+
325
+ ## Architecture
326
+
327
+ ### System context
328
+
329
+ ```mermaid
330
+ flowchart LR
331
+ U[Authenticated browser] -->|same-origin HTTPS| H[Hermes dashboard / FastAPI :7860]
332
+ T[Telegram webhook] --> H
333
+ H --> R[Futures dashboard router]
334
+ R --> C[Deterministic trade cycle]
335
+ R --> S[Dashboard state]
336
+ C --> D[Dual datasource client]
337
+ D --> DS4[Datasource 4\nAuthoritative]
338
+ D --> B[Binance public\nFallback]
339
+ D --> DS2[Datasource 2\nComplementary]
340
+ C --> K[Risk / sizing]
341
+ C --> E[Paper execution]
342
+ C -. advisory only .-> A[External AI]
343
+ ```
344
+
345
+ ### Container and filesystem architecture
346
+
347
+ ```mermaid
348
+ flowchart TD
349
+ I[Docker image] --> O1[/opt/hermesface_overlay\nimmutable current-image overlay]
350
+ I --> O2[/opt/data/hermes_overlay\npersisted/restored copy]
351
+ I --> S[/opt/data/scripts]
352
+ B[scripts/entrypoint.sh] --> Y[scripts/sync_hf.py]
353
+ Y -->|prefer| O1
354
+ Y -->|fallback only| O2
355
+ Y -->|copy modules| H[/opt/hermes]
356
+ Y --> M[overlay manifest]
357
+ Y --> P[patch existing dashboard router]
358
+ P --> W[Hermes dashboard :7860]
359
+ ```
360
+
361
+ The immutable `/opt/hermesface_overlay` is preferred so a restored dataset containing an older overlay cannot downgrade the current image.
362
+
363
+ ### Layer responsibilities
364
+
365
+ #### HTTP and UI layer
366
+
367
+ `futures_dashboard_api.py` owns request validation, authentication dependency, response shaping, runtime diagnostics, and browser-facing error semantics. It does not implement signal scoring or sizing.
368
+
369
+ #### Datasource layer
370
+
371
+ `dual_datasource_client.py` owns:
372
+
373
+ - HTTP acquisition;
374
+ - KuCoin-compatible time range construction;
375
+ - nested payload discovery;
376
+ - field normalization;
377
+ - source priority;
378
+ - field provenance;
379
+ - source health metadata;
380
+ - `noTradeGuard`, missing-field, stale-field, merge, and readiness results.
381
+
382
+ #### Decision layer
383
+
384
+ `trade_cycle.py` owns deterministic score calculation, decision thresholds, SL/TP construction, risk module orchestration, plan expiry, and optional execution handoff.
385
+
386
+ #### Risk layer
387
+
388
+ `risk.py` owns risk profile lookup, leverage caps, volatility haircut, quantity calculation, margin/notional gates, daily-loss and position-count gates.
389
+
390
+ #### Execution layer
391
+
392
+ `futures_execution.py` owns Paper mode, account/position state, exchange adapter boundaries, slippage estimation, and protective order behavior. The dashboard route never directly constructs an exchange order.
393
+
394
+ #### State layer
395
+
396
+ `state.py` stores only a bounded view of the latest context/plan for the dashboard. It removes raw exchange payloads and does not authorize any decision.
397
+
398
+ ### Trust boundaries
399
+
400
+ | Boundary | Trusted for decisions? | Notes |
401
+ |---|---|---|
402
+ | Browser controls and localStorage | No | Convenience only; server revalidates everything. |
403
+ | Datasource 4 | Yes, for verification/safety | Still subject to parsing, freshness, and completeness checks. |
404
+ | Binance public | No, as authority | Field fallback only; cannot clear DS4 guard. |
405
+ | Datasource 2 | No, as authority | Complementary context only. |
406
+ | External AI | No | Advisory explanation only. |
407
+ | Telegram input | No | Authorized, rate-limited, analysis-only commands. |
408
+ | Server-side latest plan | Partially | Must still pass freshness and execution revalidation. |
409
+
410
+ ### Router installation
411
+
412
+ `sync_hf.py` modifies the existing Hermes dashboard code to include:
413
+
414
+ ```python
415
+ from tools.futures_dashboard_api import router as _futures_dashboard_router
416
+ app.include_router(_futures_dashboard_router)
417
+ from tools.telegram_bot import router as _telegram_router
418
+ app.include_router(_telegram_router)
419
+ ```
420
+
421
+ The patch is idempotent and must not create a new FastAPI app.
422
+
423
+ ### Persistence
424
+
425
+ Hermes data under `/opt/data` may be synchronized to a private Hugging Face Dataset. The repository overlay is also copied into the image, but the immutable image overlay is the source used for installation. Runtime state such as Telegram owner data and symbol cache lives under `/opt/data` and must not be committed.
426
+
427
+ ### Failure behavior
428
+
429
+ - DS4 unreachable: merge may use fallback data for display, but Futures verification/readiness remains blocked.
430
+ - Binance HTTP 451: source status is restricted/unavailable; no bypass is attempted.
431
+ - DS2 unavailable: complementary context is degraded; it does not independently block a plan unless it was the only attempted fill for a still-missing field.
432
+ - Market endpoint exception: HTTP 503 with structured `API_UNAVAILABLE` payload.
433
+ - Analysis exception: HTTP 503, state becomes `ANALYSIS_FAILED`, previous plan is cleared from current state.
434
+ - Template read failure: minimal fallback page is served and trading remains blocked.
435
+
436
+
437
+ ---
438
+
439
+ ## API Reference
440
+
441
+ ### Base and authentication
442
+
443
+ All Futures routes are mounted on the existing Hermes FastAPI application and port. In production, use the Space base URL and an authenticated browser/session or HTTP Basic credentials.
444
+
445
+ When `HERMES_ADMIN_PASSWORD` is set, Futures API routes require:
446
+
447
+ ```http
448
+ Authorization: Basic <base64(username:password)>
449
+ ```
450
+
451
+ Default username: `admin`, configurable with `HERMES_DASHBOARD_BASIC_AUTH_USERNAME`.
452
+
453
+ All Futures responses set `Cache-Control: no-store`. `/futures` also sets no-cache headers and runtime SHA-256 headers.
454
+
455
+ ### `GET /futures`
456
+
457
+ Returns the packaged HTML dashboard.
458
+
459
+ Important response headers:
460
+
461
+ ```text
462
+ X-Hermes-Template-SHA256
463
+ X-Hermes-Router-SHA256
464
+ Cache-Control: no-store, no-cache, must-revalidate, max-age=0
465
+ ```
466
+
467
+ ### `GET /api/futures/status`
468
+
469
+ Returns application status, runtime-file evidence, market health, latest bounded plan state, source metadata, account summary, and diagnostics.
470
+
471
+ Representative shape:
472
+
473
+ ```json
474
+ {
475
+ "application": {
476
+ "status": "online",
477
+ "runtimeStatus": "verified | mismatch | unknown",
478
+ "runtimeFiles": {}
479
+ },
480
+ "marketData": {"status": "healthy | degraded | unavailable"},
481
+ "tradingReadiness": "ready | blocked",
482
+ "mergeStatus": "complete | partial | unknown",
483
+ "analysisState": "NOT_ANALYZED",
484
+ "sourceMetadata": {
485
+ "datasource4": {},
486
+ "binance": {},
487
+ "datasource2": {}
488
+ },
489
+ "fieldSources": {},
490
+ "fieldMetadata": {},
491
+ "verifiedFutures": false,
492
+ "missingRequiredFields": [],
493
+ "staleRequiredFields": [],
494
+ "latestTradePlan": null,
495
+ "latestPlanId": null,
496
+ "latestSignalScore": null,
497
+ "riskApproved": false,
498
+ "tradingMode": "paper",
499
+ "equity": 10000.0,
500
+ "realizedPnlToday": 0.0,
501
+ "openPositionCount": 0,
502
+ "serverTime": 0
503
+ }
504
+ ```
505
+
506
+ Consumers should treat additional fields as additive and avoid strict whole-object equality.
507
+
508
+ ### `GET /api/futures/symbols`
509
+
510
+ Returns the merged catalog.
511
+
512
+ ```json
513
+ {
514
+ "symbols": [
515
+ {
516
+ "symbol": "BTCUSDT",
517
+ "baseAsset": "BTC",
518
+ "quoteAsset": "USDT",
519
+ "futuresVerified": true,
520
+ "marketOnly": false,
521
+ "contractType": "PERPETUAL",
522
+ "status": "TRADING",
523
+ "source": "datasource4",
524
+ "rank": 1,
525
+ "updatedAt": "2026-07-21T00:00:00Z"
526
+ }
527
+ ],
528
+ "source": "...",
529
+ "updatedAt": "...",
530
+ "counts": {
531
+ "total": 0,
532
+ "verifiedFutures": 0,
533
+ "marketOnly": 0
534
+ }
535
+ }
536
+ ```
537
+
538
+ Catalog membership alone does not authorize execution. Only items with `futuresVerified=true` are eligible for Paper revalidation.
539
+
540
+ ### `GET /api/futures/positions`
541
+
542
+ Returns Paper mode and enriched open positions.
543
+
544
+ ```json
545
+ {
546
+ "mode": "paper",
547
+ "positions": [
548
+ {
549
+ "symbol": "BTC/USDT:USDT",
550
+ "side": "long",
551
+ "size": 0.01,
552
+ "entryPrice": 60000.0,
553
+ "markPrice": 60500.0,
554
+ "unrealizedPnl": 5.0
555
+ }
556
+ ]
557
+ }
558
+ ```
559
+
560
+ Mark price enrichment is best-effort. Missing mark data produces `null`, not zero.
561
+
562
+ ### `GET /api/futures/market`
563
+
564
+ Query parameters:
565
+
566
+ | Parameter | Type | Default | Constraints |
567
+ |---|---|---:|---|
568
+ | `symbol` | string | `BTCUSDT` | length 3–32; normalized server-side |
569
+ | `interval` | enum | `5m` | `1m`, `5m`, `15m`, `1h` |
570
+ | `limit` | integer | `120` | 20–500 |
571
+
572
+ Example:
573
+
574
+ ```http
575
+ GET /api/futures/market?symbol=BTCUSDT&interval=5m&limit=120
576
+ ```
577
+
578
+ Successful/partial shape:
579
+
580
+ ```json
581
+ {
582
+ "state": "available | partial | stale | unavailable",
583
+ "analysisState": "NOT_ANALYZED | STALE | API_UNAVAILABLE",
584
+ "dataUsability": "usable | degraded | unavailable",
585
+ "reason": null,
586
+ "symbol": "BTCUSDT",
587
+ "interval": "5m",
588
+ "limit": 120,
589
+ "candles": [
590
+ {"timestamp": 0, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}
591
+ ],
592
+ "currentPrice": null,
593
+ "markPrice": null,
594
+ "change24h": null,
595
+ "volume24h": null,
596
+ "fundingRate": null,
597
+ "openInterest": null,
598
+ "source": "datasource4 | binance_public | datasource2 | mixed | unavailable",
599
+ "sourcesUsed": [],
600
+ "fieldSources": {},
601
+ "fieldMetadata": {},
602
+ "freshness": "fresh | stale | invalid | unknown",
603
+ "verifiedFutures": false,
604
+ "futuresVerification": {},
605
+ "warnings": [],
606
+ "missingFields": [],
607
+ "analysisRequiredFieldsMissing": [],
608
+ "staleRequiredFields": [],
609
+ "mergeStatus": "complete | partial | unavailable",
610
+ "tradingReadiness": "ready | blocked",
611
+ "rejectionReasons": [],
612
+ "sourceMetadata": {},
613
+ "technicalDiagnostics": {},
614
+ "fetchedAt": 0
615
+ }
616
+ ```
617
+
618
+ If acquisition raises, the route returns HTTP `503` with the same high-level keys, empty candles, null values, `state=unavailable`, `analysisState=API_UNAVAILABLE`, and blocked readiness.
619
+
620
+ No mock candles are permitted in production responses.
621
+
622
+ ### `POST /api/futures/analyze`
623
+
624
+ Request:
625
+
626
+ ```json
627
+ {
628
+ "symbol": "BTCUSDT",
629
+ "risk_profile": "moderate",
630
+ "include_external_context": false
631
+ }
632
+ ```
633
+
634
+ Allowed risk profiles:
635
+
636
+ ```text
637
+ conservative
638
+ moderate
639
+ aggressive
640
+ ```
641
+
642
+ Unknown request fields are rejected.
643
+
644
+ Representative response:
645
+
646
+ ```json
647
+ {
648
+ "planId": "server-generated-reference",
649
+ "symbol": "BTCUSDT",
650
+ "decision": "LONG | SHORT | NO_TRADE",
651
+ "analysis_state": "LONG | SHORT | NO_TRADE",
652
+ "score": null,
653
+ "confidence": null,
654
+ "components": {},
655
+ "core_reasons": [],
656
+ "warnings": [],
657
+ "entry": null,
658
+ "stop_loss": null,
659
+ "take_profit": null,
660
+ "reward_to_risk": null,
661
+ "risk_profile": "moderate",
662
+ "risk_percent": null,
663
+ "requested_leverage": 5,
664
+ "effective_leverage": null,
665
+ "quantity": null,
666
+ "estimated_slippage_percent": null,
667
+ "risk_approved": false,
668
+ "rejection_reasons": [],
669
+ "noTradeGuard": true,
670
+ "plan_type": "directional_plan | non_executable_plan",
671
+ "executable": false,
672
+ "futuresVerified": false,
673
+ "trading_readiness": "blocked",
674
+ "created_at": "...",
675
+ "expires_at": "...",
676
+ "external_advisory": null
677
+ }
678
+ ```
679
+
680
+ A `NO_TRADE` response is a successful deterministic evaluation, not an HTTP failure. An internal analysis failure returns HTTP `503` with `detail="Futures analysis failed"` and clears the current plan state.
681
+
682
+ ### `POST /api/futures/paper/execute`
683
+
684
+ Request:
685
+
686
+ ```json
687
+ {
688
+ "symbol": "BTCUSDT",
689
+ "risk_profile": "moderate",
690
+ "planId": "server-generated-reference"
691
+ }
692
+ ```
693
+
694
+ The endpoint may return:
695
+
696
+ - `403` for unverified contract or non-Paper mode;
697
+ - `409` for superseded/unknown plan, symbol/risk change, expiry, prior execution, blocked readiness, failed fresh revalidation, or non-executable plan;
698
+ - `422` for invalid request shape/symbol;
699
+ - `200` for the final Paper result.
700
+
701
+ The endpoint is intentionally absent from the read-only audit tool.
702
+
703
+ ### Telegram routes
704
+
705
+ #### `POST /api/telegram/webhook`
706
+
707
+ Public webhook ingress protected by:
708
+
709
+ ```http
710
+ X-Telegram-Bot-Api-Secret-Token: <TELEGRAM_WEBHOOK_SECRET>
711
+ ```
712
+
713
+ Limits request body to 256 KiB, applies per-user rate limiting, requires owner/allowed-user authorization, and invokes analysis-only commands.
714
+
715
+ #### `GET /api/telegram/status`
716
+
717
+ Returns enabled/mode/webhook/proxy/relay/authorized-user/alert-scheduler status. It does not expose tokens or user IDs.
718
+
719
+ #### `GET /api/telegram/bootstrap/status`
720
+
721
+ Requires the same Telegram secret header and returns only:
722
+
723
+ ```json
724
+ {"ok": true, "ownerClaimed": true, "bootstrapConsumed": true}
725
+ ```
726
+
727
+
728
+ ---
729
+
730
+ ## Datasource Pipeline and Contracts
731
+
732
+ ### Priority and authority
733
+
734
+ ```text
735
+ Datasource 4 → Binance public fallback → Datasource 2
736
+ ```
737
+
738
+ Priority describes fill order, not equal trust.
739
+
740
+ #### Datasource 4
741
+
742
+ Authoritative for:
743
+
744
+ - Futures contract verification;
745
+ - `dataState`;
746
+ - `noTradeGuard`;
747
+ - safety status and rejection reasons;
748
+ - primary Futures market fields.
749
+
750
+ #### Binance public
751
+
752
+ - unauthenticated;
753
+ - called only for missing, unusable, or stale fields;
754
+ - cannot verify a contract or clear DS4 safety;
755
+ - HTTP 451 is `restricted` / `Regionally restricted`;
756
+ - provider timestamps are required to claim freshness.
757
+
758
+ #### Datasource 2
759
+
760
+ - complementary news, sentiment, indicator, order-book, volume, trending, gainers, and correlation context;
761
+ - may fill a still-missing legitimate field only after Binance;
762
+ - cannot become Futures verification or safety authority.
763
+
764
+ ### Datasource 4 request
765
+
766
+ The DS4 snapshot endpoint is called with:
767
+
768
+ ```text
769
+ /api/short-hunter/snapshot/{SYMBOL}
770
+ ```
771
+
772
+ Parameters:
773
+
774
+ ```text
775
+ interval: 1m | 5m | 15m | 1h
776
+ limit: 1..500 internally; market API exposes 20..500
777
+ from: epoch milliseconds
778
+ to: epoch milliseconds
779
+ ```
780
+
781
+ `normalize_epoch_milliseconds()` accepts contemporary epoch seconds or milliseconds and prevents double conversion. `build_kucoin_time_range()` enforces supported interval, bounded limit, positive ordered timestamps, and millisecond units.
782
+
783
+ ### Normalized fields
784
+
785
+ The merged envelope may contain:
786
+
787
+ ```text
788
+ contract
789
+ ticker
790
+ ohlcv
791
+ orderbook
792
+ funding
793
+ openInterest
794
+ indicators
795
+ sentiment
796
+ atr
797
+ market_context
798
+ ```
799
+
800
+ #### Contract
801
+
802
+ Normalized contract data should expose symbol, status, type/instrument, and explicit verification evidence when present. The presence of a generic `contract` object alone does not prove Futures status. Verification requires an explicit DS4 flag or a recognized Futures/perpetual/swap contract type.
803
+
804
+ #### Ticker
805
+
806
+ Accepted aliases are normalized to a bounded ticker object. Consumers should prefer normalized canonical keys where available and tolerate provider-specific supplemental keys.
807
+
808
+ Common price candidates:
809
+
810
+ ```text
811
+ markPrice
812
+ lastPrice
813
+ last
814
+ price
815
+ close
816
+ indexPrice
817
+ ```
818
+
819
+ #### OHLCV
820
+
821
+ Canonical candle shape:
822
+
823
+ ```json
824
+ {
825
+ "timestamp": 0,
826
+ "open": 0.0,
827
+ "high": 0.0,
828
+ "low": 0.0,
829
+ "close": 0.0,
830
+ "volume": 0.0
831
+ }
832
+ ```
833
+
834
+ A usable OHLCV series requires at least four valid positive close values. The market endpoint never invents missing candles.
835
+
836
+ #### Order book
837
+
838
+ Canonical shape:
839
+
840
+ ```json
841
+ {
842
+ "bids": [[60000.0, 0.5]],
843
+ "asks": [[60001.0, 0.4]],
844
+ "timestamp": 0
845
+ }
846
+ ```
847
+
848
+ Both sides must have at least one valid level. Prices must be positive; quantities must be non-negative.
849
+
850
+ #### Funding
851
+
852
+ Canonical values may include:
853
+
854
+ ```text
855
+ currentFundingRate
856
+ fundingRate
857
+ lastFundingRate
858
+ rate
859
+ nextFundingTime
860
+ ```
861
+
862
+ #### Open Interest
863
+
864
+ Canonical values may include:
865
+
866
+ ```text
867
+ openInterest
868
+ sumOpenInterest
869
+ oi
870
+ changeFraction
871
+ change24h
872
+ changePercent
873
+ ```
874
+
875
+ ### Field usability
876
+
877
+ `_is_usable(field, value)` performs field-specific validation. Empty values, non-finite values, invalid OHLCV, incomplete order books, and invalid contract/funding/OI shapes are rejected.
878
+
879
+ ### Per-field provenance
880
+
881
+ Every owned field receives metadata:
882
+
883
+ ```json
884
+ {
885
+ "value": "bounded or summarized value",
886
+ "source": "datasource4 | binance_public | datasource2 | unavailable",
887
+ "timestamp": "provider timestamp or null",
888
+ "freshness": "fresh | stale | invalid | unknown",
889
+ "validity": "valid | unavailable",
890
+ "observedAt": "server observation time",
891
+ "freshnessBasis": "field_timestamp | datasource4_dataState | missing_provider_timestamp | unavailable",
892
+ "fallbackStatus": "primary | fallback | not_filled"
893
+ }
894
+ ```
895
+
896
+ The public API bounds large values:
897
+
898
+ - OHLCV becomes count plus latest candle summary where appropriate;
899
+ - order book becomes level counts and best bid/ask summary;
900
+ - diagnostics are sanitized and size-limited.
901
+
902
+ ### Freshness
903
+
904
+ Freshness is based on provider timestamp relative to interval, or on an explicit authoritative DS4 fresh state. Transport success alone is not freshness evidence.
905
+
906
+ Required fields with `stale`, `invalid`, or `unknown` freshness block readiness.
907
+
908
+ ### Critical fields and readiness
909
+
910
+ Critical fields:
911
+
912
+ ```text
913
+ contract
914
+ ticker
915
+ orderbook
916
+ funding
917
+ openInterest
918
+ ```
919
+
920
+ The combined context sets:
921
+
922
+ ```text
923
+ missingRequiredFields
924
+ staleRequiredFields
925
+ noTradeGuard
926
+ noTradeReasons
927
+ mergeStatus
928
+ tradingReadiness
929
+ ```
930
+
931
+ Readiness is `ready` only when no guard remains. DS4 verification failure, DS4 `noTradeGuard`, missing critical fields, or non-fresh critical fields results in `blocked`.
932
+
933
+ ### Source metadata
934
+
935
+ Each source returns structured fields:
936
+
937
+ ```json
938
+ {
939
+ "name": "Datasource 4",
940
+ "url": "...",
941
+ "status": "ok | degraded | unreachable | unavailable | standby",
942
+ "transportStatus": "healthy | degraded | unavailable | restricted | standby",
943
+ "dataUsability": "usable | degraded | unavailable | not_used",
944
+ "endpoint": "...",
945
+ "httpStatus": 200,
946
+ "latencyMs": 120.4,
947
+ "lastSuccess": "...",
948
+ "freshness": "fresh | stale | unknown",
949
+ "completeness": "complete | partial | unknown",
950
+ "suppliedFields": [],
951
+ "missingFields": [],
952
+ "reason": "concise operator-facing summary"
953
+ }
954
+ ```
955
+
956
+ Detailed endpoint/provider errors belong only under `technicalDiagnostics`, separated by source and sanitized before exposure.
957
+
958
+ ### Merge diagnostics
959
+
960
+ Cross-source problems are not assigned to a datasource card. They appear under:
961
+
962
+ ```text
963
+ technicalDiagnostics.merge.status
964
+ technicalDiagnostics.merge.missingCriticalFields
965
+ technicalDiagnostics.merge.tradingReadiness
966
+ technicalDiagnostics.merge.rejectionReasons
967
+ ```
968
+
969
+ ### Adding a new provider mapping
970
+
971
+ 1. Capture a real redacted payload.
972
+ 2. Add the narrowest legitimate alias to the relevant normalizer.
973
+ 3. Preserve provider timestamp and source name.
974
+ 4. Add field-specific validity checks.
975
+ 5. Do not infer Futures verification from generic market data.
976
+ 6. Do not let the provider clear DS4 guard state.
977
+ 7. Add focused tests for positive, missing, malformed, stale, and ambiguous cases.
978
+ 8. Verify source-specific diagnostics remain correctly attributed.
979
+
980
+
981
+ ---
982
+
983
+ ## Frontend Guide
984
+
985
+ ### File and runtime
986
+
987
+ The entire Futures dashboard UI is packaged in:
988
+
989
+ ```text
990
+ hermes_overlay/tools/templates/hermes_futures_desk_luxury.html
991
+ ```
992
+
993
+ The router reads this file at request time from its installed `tools/templates` directory and serves it at `/futures`. Do not add a second frontend server, bundler process, or port.
994
+
995
+ ### Design system
996
+
997
+ The UI uses an Obsidian & Gold workstation theme with a light-theme option. Operational values use readable sans-serif/monospace styling. Decorative serif/italic styling is limited to headings and visual accents.
998
+
999
+ Responsive modes cover desktop, tablet, and mobile. `prefers-reduced-motion` is respected.
1000
+
1001
+ ### Main UI regions
1002
+
1003
+ - fixed/collapsible navigation and local market lists;
1004
+ - command deck with symbol, risk, advisory, Analyze, and Paper Execute controls;
1005
+ - selected-market header and chart;
1006
+ - market diagnostics and field provenance;
1007
+ - decision, score, risk approval, and execution mode;
1008
+ - trade-plan geometry and execution checklist;
1009
+ - signal reasons and components;
1010
+ - Paper account and positions;
1011
+ - datasource health and technical diagnostics;
1012
+ - Telegram operational status;
1013
+ - local activity/history and export actions.
1014
+
1015
+ ### API usage
1016
+
1017
+ The helper uses:
1018
+
1019
+ ```javascript
1020
+ fetch(url, {
1021
+ credentials: 'same-origin',
1022
+ cache: 'no-store'
1023
+ })
1024
+ ```
1025
+
1026
+ Primary calls:
1027
+
1028
+ ```text
1029
+ GET /api/futures/status
1030
+ GET /api/futures/symbols
1031
+ GET /api/futures/positions
1032
+ GET /api/futures/market
1033
+ POST /api/futures/analyze
1034
+ POST /api/futures/paper/execute
1035
+ GET /api/telegram/status
1036
+ ```
1037
+
1038
+ No backend route is declared in the template.
1039
+
1040
+ ### Refresh behavior
1041
+
1042
+ Default intervals:
1043
+
1044
+ - clock and age labels: 1 second;
1045
+ - status and positions: 5 seconds while auto-refresh is enabled and page is visible;
1046
+ - market data: 15 seconds;
1047
+ - Telegram status: 60 seconds.
1048
+
1049
+ When the page becomes visible again, status and market data refresh if automatic refresh is enabled.
1050
+
1051
+ ### Chart behavior
1052
+
1053
+ Supported intervals:
1054
+
1055
+ ```text
1056
+ 1m 5m 15m 1h
1057
+ ```
1058
+
1059
+ Supported candle limits:
1060
+
1061
+ ```text
1062
+ 60 120 240
1063
+ ```
1064
+
1065
+ Modes:
1066
+
1067
+ - Candles;
1068
+ - Line;
1069
+ - optional volume bars.
1070
+
1071
+ The chart is an inline SVG and uses only `candles` returned by the market endpoint. Crosshair, OHLCV legend, tooltip, current-price reference, price labels, visible high/low, range position, and last-candle age are derived from the returned series.
1072
+
1073
+ No visual interpolation or fallback is permitted to create production candles.
1074
+
1075
+ ### Display-only diagnostics
1076
+
1077
+ The UI calculates visible trend, average candle range, relative last-candle volume, realized variation, last-candle direction, and range position from the real returned candles. These values are explicitly informational and must never change:
1078
+
1079
+ ```text
1080
+ LONG / SHORT / NO_TRADE
1081
+ risk approval
1082
+ noTradeGuard
1083
+ Entry / SL / TP
1084
+ leverage
1085
+ quantity
1086
+ execution eligibility
1087
+ ```
1088
+
1089
+ ### Local browser state
1090
+
1091
+ The UI stores only convenience preferences/history in `localStorage`.
1092
+
1093
+ Known keys:
1094
+
1095
+ ```text
1096
+ hermes_theme
1097
+ hermes_auto_refresh
1098
+ hermes_compact
1099
+ ```
1100
+
1101
+ Watchlist, recent markets, local analysis history, and workspace activity use Hermes-prefixed local keys defined in the template. They are not synchronized to the server and are not trusted for execution.
1102
+
1103
+ ### Keyboard shortcuts
1104
+
1105
+ | Key | Action |
1106
+ |---|---|
1107
+ | `/` | Focus and select symbol search. |
1108
+ | `A` | Run analysis when not already analyzing. |
1109
+ | `R` | Manual refresh. |
1110
+ | `D` | Toggle display density. |
1111
+ | `T` | Toggle theme. |
1112
+ | `?` | Open shortcut help. |
1113
+ | `Escape` | Close overlays/help. |
1114
+
1115
+ There is deliberately no keyboard shortcut for Paper Execute.
1116
+
1117
+ ### Analysis state rendering
1118
+
1119
+ Use the server result to set one of:
1120
+
1121
+ ```text
1122
+ NOT_ANALYZED
1123
+ ANALYZING
1124
+ LONG
1125
+ SHORT
1126
+ NO_TRADE
1127
+ ANALYSIS_FAILED
1128
+ STALE
1129
+ API_UNAVAILABLE
1130
+ ```
1131
+
1132
+ Important rules:
1133
+
1134
+ - initial state is “Waiting for analysis,” not `NO_TRADE`;
1135
+ - HTTP/network failure is `ANALYSIS_FAILED` or `API_UNAVAILABLE`;
1136
+ - score is “Unavailable” when components do not exist, not numeric zero;
1137
+ - expiry is prominent only for a valid directional plan;
1138
+ - rejected/incomplete analysis is not presented as executable;
1139
+ - changing symbol or risk invalidates the current browser plan;
1140
+ - server state remains authoritative.
1141
+
1142
+ ### Execute availability
1143
+
1144
+ The button is disabled unless the latest browser plan mirrors all required server fields. The UI displays a concrete disabled reason such as:
1145
+
1146
+ ```text
1147
+ Run analysis first
1148
+ No directional plan
1149
+ Risk approval failed
1150
+ noTradeGuard active
1151
+ Market-only symbol
1152
+ Plan expired
1153
+ Symbol changed
1154
+ Risk profile changed
1155
+ Plan already executed
1156
+ Required Futures fields unavailable
1157
+ ```
1158
+
1159
+ These checks improve UX but do not replace backend revalidation.
1160
+
1161
+ ### Adding a UI feature safely
1162
+
1163
+ 1. Reuse existing API fields or add an additive backend field.
1164
+ 2. Render missing values as `Unavailable`, never zero or fabricated content.
1165
+ 3. Keep browser calculations labeled display-only.
1166
+ 4. Do not add another Execute path or shortcut.
1167
+ 5. Invalidate plan display when relevant controls change.
1168
+ 6. Keep DOM IDs unique and update static ID checks.
1169
+ 7. Preserve responsive and reduced-motion behavior.
1170
+ 8. Do not display raw provider errors or secrets.
1171
+ 9. Verify Console and Network in an authenticated deployed session.
1172
+
1173
+
1174
+ ---
1175
+
1176
+ ## Environment Configuration
1177
+
1178
+ Configure secrets in Hugging Face Space Settings or inject them at container runtime. Never commit a populated `.env` file.
1179
+
1180
+ ### Core persistence
1181
+
1182
+ | Variable | Default | Purpose |
1183
+ |---|---|---|
1184
+ | `HF_TOKEN` | none | Hugging Face token with required repository access. |
1185
+ | `HERMES_DATASET_REPO` | derived/none | Private Dataset used to persist `/opt/data`. |
1186
+ | `AUTO_CREATE_DATASET` | `true` | Create the private Dataset when missing. |
1187
+ | `SYNC_INTERVAL` | `60` | Persistence sync interval in seconds. |
1188
+ | `HF_HUB_DOWNLOAD_TIMEOUT` | implementation default | Hub download timeout. |
1189
+ | `HF_HUB_UPLOAD_TIMEOUT` | implementation default | Hub upload timeout. |
1190
+ | `HERMES_HOME` | `/opt/data` | Persistent Hermes data root. |
1191
+ | `MAX_BACKUPS` | script default | Backup retention used by persistence helper. |
1192
+
1193
+ ### Dashboard authentication
1194
+
1195
+ | Variable | Default | Purpose |
1196
+ |---|---|---|
1197
+ | `HERMES_ADMIN_PASSWORD` | none | Required production dashboard password. |
1198
+ | `HERMES_ADMIN_USERNAME` | `admin` | Username written to Hermes dashboard config by entrypoint. |
1199
+ | `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | `admin` | Username checked by Futures router and runtime audit. |
1200
+
1201
+ For the audit tool only:
1202
+
1203
+ | Variable | Purpose |
1204
+ |---|---|
1205
+ | `HERMES_DASHBOARD_COOKIE` | Existing authenticated session cookie when Basic auth is not used. |
1206
+ | `HERMES_FUTURES_BASE_URL` | Default audit target base URL. |
1207
+
1208
+ ### Datasources
1209
+
1210
+ | Variable | Default | Purpose |
1211
+ |---|---|---|
1212
+ | `DS4_BASE_URL` | DS4 Hugging Face Space URL | Authoritative Datasource 4 base. |
1213
+ | `DS4_TIMEOUT_S` | `6` | DS4 request timeout. |
1214
+ | `DS2_BASE_URL` | DS2 Hugging Face Space URL | Complementary Datasource 2 base. |
1215
+ | `DS2_TIMEOUT_S` | `6` | DS2 request timeout. |
1216
+ | `HERMES_SYMBOL_CACHE_PATH` | `/opt/data/futures_symbols_cache.json` | Symbol catalog cache. |
1217
+
1218
+ ### Binance fallback
1219
+
1220
+ | Variable | Default | Purpose |
1221
+ |---|---|---|
1222
+ | `BINANCE_PUBLIC_FALLBACK_ENABLED` | `true` | Enable unauthenticated field fallback. |
1223
+ | `BINANCE_FUTURES_PUBLIC_BASE_URL` | `https://fapi.binance.com` | Binance Futures public base. |
1224
+ | `BINANCE_FUTURES_TIMEOUT_S` | `5` | Request timeout. |
1225
+ | `BINANCE_FUTURES_MAX_RETRIES` | `2` | Retry bound. |
1226
+ | `BINANCE_KLINE_INTERVAL` | `5m` | Default fallback interval. |
1227
+ | `BINANCE_KLINE_LIMIT` | `100` | Default kline count. |
1228
+ | `BINANCE_ATR_PERIOD` | `14` | ATR lookback in fallback client. |
1229
+ | `BINANCE_ORDERBOOK_LIMIT` | `20` | Depth level limit. |
1230
+ | `BINANCE_OI_PERIOD` | `5m` | Open Interest history period. |
1231
+
1232
+ A regional HTTP 451 must be surfaced honestly. Do not use raw IP, DNS bypass, or TLS bypass.
1233
+
1234
+ ### Deterministic analysis
1235
+
1236
+ | Variable | Default | Purpose |
1237
+ |---|---:|---|
1238
+ | `FUTURES_MIN_SIGNAL_SCORE` | `0.55` | Minimum absolute deterministic score. |
1239
+ | `FUTURES_MIN_SIGNAL_COMPONENTS` | `3` | Minimum present scoring components. |
1240
+ | `FUTURES_MIN_DIRECTION_CONFIRMATIONS` | `2` | Minimum components confirming direction. |
1241
+ | `FUTURES_STOP_ATR_MULTIPLIER` | `1.2` | ATR stop-distance multiplier. |
1242
+ | `FUTURES_TAKE_PROFIT_RR` | `1.8` | Target reward-to-risk. |
1243
+ | `FUTURES_MIN_STOP_BPS` | `20` | Minimum stop distance in basis points. |
1244
+ | `FUTURES_PLAN_MAX_AGE_SECONDS` | `20` | Plan expiry window. |
1245
+ | `FUTURES_DEFAULT_LEVERAGE` | `5` | Requested leverage before caps/haircut. |
1246
+
1247
+ Changing these variables changes deterministic behavior and requires explicit review, tests, and deployment evidence.
1248
+
1249
+ ### Execution and Paper account
1250
+
1251
+ | Variable | Default | Purpose |
1252
+ |---|---|---|
1253
+ | `PAPER_EQUITY_USDT` | implementation default | Initial Paper account equity. |
1254
+ | `FUTURES_EXCHANGE_ID` | implementation default | Exchange adapter ID. |
1255
+ | `FUTURES_API_KEY` | none | Exchange credential boundary. Do not set for routine Paper-only development. |
1256
+ | `FUTURES_API_SECRET` | none | Exchange secret. |
1257
+ | `FUTURES_API_PASSPHRASE` | none | Optional exchange passphrase. |
1258
+
1259
+ Do not introduce credentials into source, logs, diagnostics, screenshots, patches, or generated reports.
1260
+
1261
+ ### External advisory
1262
+
1263
+ | Variable | Default | Purpose |
1264
+ |---|---|---|
1265
+ | `EXTERNAL_AI_ENABLED` | `true` | Allow advisory when explicitly requested. |
1266
+ | `EXTERNAL_AI_TIMEOUT_SECONDS` | `8` | Legacy/advisory timeout. |
1267
+ | `EXTERNAL_AI_PROVIDER_TIMEOUT_SECONDS` | `8` | Per-provider timeout. |
1268
+ | `EXTERNAL_AI_TOTAL_TIMEOUT_SECONDS` | `15` | Total advisory budget. |
1269
+ | `OPENROUTER_ANALYSIS_MODEL` | configured model | OpenRouter model. |
1270
+ | `GOOGLE_ANALYSIS_MODEL` | configured model | Google model. |
1271
+ | `HF_ANALYSIS_MODEL` | configured model | Hugging Face model. |
1272
+ | `OPENROUTER_API_KEY` | none | OpenRouter credential. |
1273
+ | `GOOGLE_API_KEY` | none | Google credential. |
1274
+
1275
+ Provider order is OpenRouter → Google → Hugging Face. Advisory output cannot change the deterministic plan.
1276
+
1277
+ ### Telegram webhook
1278
+
1279
+ | Variable | Default | Purpose |
1280
+ |---|---|---|
1281
+ | `TELEGRAM_ENABLED` | `false` | Enable webhook adapter. |
1282
+ | `TELEGRAM_MODE` | `webhook` | Must remain webhook mode. |
1283
+ | `TELEGRAM_PUBLIC_BASE_URL` | Space URL | Webhook target base. |
1284
+ | `TELEGRAM_WEBHOOK_PATH` | `/api/telegram/webhook` | Webhook path. |
1285
+ | `TELEGRAM_WEBHOOK_SECRET` | none | Telegram secret-token header value. |
1286
+ | `TELEGRAM_BOOTSTRAP_SECRET` | none | One-time owner claim secret. |
1287
+ | `TELEGRAM_ALLOWED_USER_IDS` | empty | Comma-separated authorized IDs. |
1288
+ | `TELEGRAM_BOT_TOKEN` | none | Telegram bot token. |
1289
+ | `TELEGRAM_PROXY_URL` | empty | Optional direct Bot API proxy. |
1290
+ | `TELEGRAM_RELAY_URL` | empty | Optional proactive relay. |
1291
+ | `TELEGRAM_RELAY_SECRET` | empty | HMAC relay secret. |
1292
+ | `TELEGRAM_STATE_PATH` | `/opt/data/telegram_state.json` | Persisted owner/watchlist state. |
1293
+ | `TELEGRAM_ALERTS_ENABLED` | `false` | External-scheduler alert evaluation status. |
1294
+ | `TELEGRAM_COMMAND_RATE_LIMIT` | `10` | Commands per user per minute. |
1295
+ | `TELEGRAM_SCAN_MAX_SYMBOLS` | `300` | Maximum verified catalog candidates. |
1296
+ | `TELEGRAM_SCAN_SHORTLIST_SIZE` | `20` | Deterministic shortlist size. |
1297
+ | `TELEGRAM_SCAN_RESULT_COUNT` | `10` | Displayed result count. |
1298
+ | `TELEGRAM_SCAN_MAX_CONCURRENCY` | `4` | Analysis concurrency. |
1299
+
1300
+ ### MCP isolation
1301
+
1302
+ | Variable | Default | Requirement |
1303
+ |---|---|---|
1304
+ | `LINEAR_MCP_ENABLED` | `false` | Keep unchanged unless separately approved. |
1305
+ | `UNREAL_ENGINE_MCP_ENABLED` | `false` | Keep disabled unless separately approved. |
1306
+
1307
+ ### Runtime integrity paths
1308
+
1309
+ Advanced overrides used by diagnostics:
1310
+
1311
+ ```text
1312
+ HERMES_FUTURES_OVERLAY_MANIFEST
1313
+ HERMES_OVERLAY_SOURCE
1314
+ HERMES_SYNC_SCRIPT
1315
+ ```
1316
+
1317
+ These should normally use their runtime defaults.
1318
+
1319
+
1320
+ ---
1321
+
1322
+ ## Deployment Runbook
1323
+
1324
+ ### Preconditions
1325
+
1326
+ - Work from the current repository under `asset-space`.
1327
+ - Review all diffs.
1328
+ - Ensure no real `.env`, tokens, cookies, screenshots, cache, ZIP archives, runtime reports, or temporary probes are staged.
1329
+ - Confirm no architecture change introduces a second app, frontend server, or port.
1330
+ - Do not execute a trade during deployment verification.
1331
+
1332
+ ### Build behavior
1333
+
1334
+ The Dockerfile:
1335
+
1336
+ 1. clones upstream Hermes Agent into `/opt/hermes`;
1337
+ 2. installs Node, web dashboard, Playwright, Python, CCXT, and HTTPX dependencies;
1338
+ 3. creates non-root user `hermes` and `/opt/data` directories;
1339
+ 4. copies scripts into `/opt/data/scripts`;
1340
+ 5. copies overlay into `/opt/data/hermes_overlay` and immutable `/opt/hermesface_overlay`;
1341
+ 6. starts `/opt/data/scripts/entrypoint.sh`.
1342
+
1343
+ ### Startup behavior
1344
+
1345
+ `entrypoint.sh`:
1346
+
1347
+ 1. starts DNS pre-resolution in the background;
1348
+ 2. activates `/opt/hermes/.venv`;
1349
+ 3. creates persistent directories and baseline config files;
1350
+ 4. writes hashed dashboard Basic auth when `HERMES_ADMIN_PASSWORD` is configured;
1351
+ 5. calls `scripts/sync_hf.py`.
1352
+
1353
+ `sync_hf.py`:
1354
+
1355
+ 1. restores persistent data when configured;
1356
+ 2. disables legacy Telegram gateway polling in webhook mode;
1357
+ 3. installs the current image overlay into `/opt/hermes`;
1358
+ 4. writes and verifies the overlay hash manifest;
1359
+ 5. mounts Futures and Telegram routers into the existing dashboard app;
1360
+ 6. starts Hermes dashboard on port `7860`;
1361
+ 7. manages persistence/sync helpers.
1362
+
1363
+ ### Hugging Face deployment procedure
1364
+
1365
+ 1. Review the final diff.
1366
+ 2. Commit the smallest coherent change.
1367
+ 3. Push to `main` of the repository backing `Really-amin/Asset`.
1368
+ 4. Monitor Space build logs.
1369
+ 5. Wait until Space is fully `RUNNING`.
1370
+ 6. Record the serving repository revision.
1371
+ 7. Run the read-only runtime audit.
1372
+ 8. Open an authenticated browser session.
1373
+ 9. Inspect Console and Network.
1374
+ 10. Verify Telegram status and MCP isolation.
1375
+
1376
+ ### Read-only runtime audit
1377
+
1378
+ ```bash
1379
+ export HERMES_ADMIN_PASSWORD='...'
1380
+ export HERMES_DASHBOARD_BASIC_AUTH_USERNAME='admin'
1381
+ python scripts/verify_futures_runtime.py \
1382
+ --base-url https://really-amin-asset.hf.space \
1383
+ --symbol BTCUSDT \
1384
+ --analyze \
1385
+ --report .runtime_audit/futures_runtime_audit.json
1386
+ ```
1387
+
1388
+ Expected properties:
1389
+
1390
+ - `/futures` returns 200;
1391
+ - body SHA-256 equals `X-Hermes-Template-SHA256`;
1392
+ - status, symbols, and positions return authenticated 200 responses;
1393
+ - each market interval returns 200 or structured 503;
1394
+ - market payload shape includes real canonical candle objects;
1395
+ - optional analysis returns 200;
1396
+ - `paperExecuteCalled` remains false.
1397
+
1398
+ ### Browser verification
1399
+
1400
+ Check:
1401
+
1402
+ - no critical JavaScript error;
1403
+ - authenticated API requests are not redirected to login HTML;
1404
+ - `/api/futures/market` is requested for each selected interval;
1405
+ - chart code executes and state messages match payload;
1406
+ - no stale cached template is served;
1407
+ - selected-market header, provenance, diagnostics, plan, account, and datasource sections render;
1408
+ - Execute remains disabled unless a valid server plan exists;
1409
+ - do not click Execute.
1410
+
1411
+ ### Runtime file verification
1412
+
1413
+ Use `/api/futures/status` and `/futures` response headers to compare:
1414
+
1415
+ ```text
1416
+ repository/image overlay template
1417
+ repository/image overlay router
1418
+ installed /opt/hermes template
1419
+ installed /opt/hermes router
1420
+ served HTML body
1421
+ installation manifest
1422
+ ```
1423
+
1424
+ Interpretation:
1425
+
1426
+ - `verified`: all available expected files match;
1427
+ - `mismatch`: at least one available expected hash differs;
1428
+ - `unknown`: evidence is missing; investigate filesystem/install path.
1429
+
1430
+ ### Rollback
1431
+
1432
+ 1. Identify the last known healthy commit.
1433
+ 2. Revert only the faulty change; do not copy old reference directories over the current backend.
1434
+ 3. Push the revert.
1435
+ 4. Wait for Space rebuild and `RUNNING` state.
1436
+ 5. Repeat runtime audit and browser verification.
1437
+ 6. Confirm deterministic thresholds, Telegram webhook-only mode, and port 7860 remain unchanged.
1438
+
1439
+ ### Release evidence to retain
1440
+
1441
+ - commit hash;
1442
+ - serving Space revision;
1443
+ - sanitized audit JSON;
1444
+ - static/focused/regression test summary;
1445
+ - runtime hash result;
1446
+ - Console/Network findings;
1447
+ - provider limitations such as Binance 451;
1448
+ - explicit statement that no secret was exposed and no trade was executed.
1449
+
1450
+
1451
+ ---
1452
+
1453
+ ## Security and Safety
1454
+
1455
+ ### Non-negotiable invariants
1456
+
1457
+ Do not change or weaken:
1458
+
1459
+ - deterministic `LONG`, `SHORT`, and `NO_TRADE` decisions;
1460
+ - Datasource 4 authority;
1461
+ - `noTradeGuard`;
1462
+ - Futures verification;
1463
+ - provider timestamp and freshness checks;
1464
+ - risk approval;
1465
+ - Stop Loss and Take Profit rules;
1466
+ - leverage caps and volatility haircut;
1467
+ - quantity/sizing logic;
1468
+ - Paper execution validation;
1469
+ - Telegram webhook-only isolation;
1470
+ - single FastAPI application and port 7860 architecture.
1471
+
1472
+ ### Browser trust model
1473
+
1474
+ The browser is untrusted for execution. It may display and calculate convenience diagnostics, but the server ignores browser-derived authorization.
1475
+
1476
+ Server-side Paper checks include plan reference, symbol/risk identity, expiry, executed flag, direction, DS4 verification, readiness, guard state, risk approval, executable flag, Paper mode, and fresh re-analysis.
1477
+
1478
+ ### Secret handling
1479
+
1480
+ Never expose or commit:
1481
+
1482
+ ```text
1483
+ HF_TOKEN
1484
+ HERMES_ADMIN_PASSWORD
1485
+ FUTURES_API_KEY
1486
+ FUTURES_API_SECRET
1487
+ FUTURES_API_PASSPHRASE
1488
+ OPENROUTER_API_KEY
1489
+ GOOGLE_API_KEY
1490
+ TELEGRAM_BOT_TOKEN
1491
+ TELEGRAM_WEBHOOK_SECRET
1492
+ TELEGRAM_BOOTSTRAP_SECRET
1493
+ TELEGRAM_RELAY_SECRET
1494
+ cookies or Authorization headers
1495
+ ```
1496
+
1497
+ Diagnostics redact keys and text matching authorization, cookie, token, secret, password, or API key patterns. Continue to sanitize new error fields before they reach API responses or UI.
1498
+
1499
+ ### Market-data integrity
1500
+
1501
+ - No fabricated production candles, prices, funding, Open Interest, or order-book levels.
1502
+ - Missing values are `null`/`Unavailable`, not zero.
1503
+ - HTTP success is not data freshness.
1504
+ - Provider errors in main UI are concise; detailed errors remain sanitized under Technical Diagnostics.
1505
+ - Binance regional restriction must not be bypassed with raw IP, DNS override, or disabled TLS.
1506
+
1507
+ ### External AI boundary
1508
+
1509
+ External AI may return market bias, confidence, summary, and warnings. It must never modify:
1510
+
1511
+ ```text
1512
+ decision
1513
+ risk approval
1514
+ noTradeGuard
1515
+ Entry
1516
+ Stop Loss
1517
+ Take Profit
1518
+ leverage
1519
+ quantity
1520
+ execution availability
1521
+ ```
1522
+
1523
+ Bulk scans must not use advisory AI.
1524
+
1525
+ ### Telegram boundary
1526
+
1527
+ - Webhook secret-token validation is mandatory.
1528
+ - Request size is bounded.
1529
+ - Owner bootstrap is one-time, secret-checked, and private-chat only.
1530
+ - Users are authorized by configured IDs or persisted owner.
1531
+ - Commands are rate-limited.
1532
+ - Callback nonces expire and are user-bound.
1533
+ - Telegram performs analysis only and contains no order path.
1534
+ - Polling remains disabled.
1535
+
1536
+ ### Development safety
1537
+
1538
+ During ordinary development and deployment verification:
1539
+
1540
+ - do not call Paper Execute;
1541
+ - do not run Testnet or Live execution;
1542
+ - use the read-only audit script;
1543
+ - use Paper account endpoints only for display verification;
1544
+ - do not add an execution keyboard shortcut;
1545
+ - do not allow a UI feature to write plan or risk state directly.
1546
+
1547
+ ### Review checklist for security-sensitive changes
1548
+
1549
+ - Does the change alter a deterministic threshold or formula?
1550
+ - Can fallback data override DS4 safety?
1551
+ - Can a missing timestamp be treated as fresh?
1552
+ - Can the browser enable execution without server state?
1553
+ - Can a raw error include a secret?
1554
+ - Can a Telegram request bypass authorization or webhook validation?
1555
+ - Does the change introduce a second network service or port?
1556
+ - Does it add an exchange credential requirement?
1557
+ - Are failure states blocked by default?
1558
+
1559
+
1560
+ ---
1561
+
1562
+ ## Operations and Troubleshooting
1563
+
1564
+ ### Diagnostic order
1565
+
1566
+ 1. Confirm Space is `RUNNING`.
1567
+ 2. Fetch `/futures` and inspect response/hash headers.
1568
+ 3. Check `/api/futures/status` with authentication.
1569
+ 4. Inspect runtime file status and datasource metadata.
1570
+ 5. Check Browser Console and Network.
1571
+ 6. Inspect market endpoint for one symbol/interval.
1572
+ 7. Compare DS4 raw/normalized fields and timestamps.
1573
+ 8. Check provider-specific diagnostics.
1574
+ 9. Run one analysis-only request.
1575
+ 10. Do not test Paper Execute during diagnosis.
1576
+
1577
+ ### Common issues
1578
+
1579
+ #### Dashboard returns 401
1580
+
1581
+ Likely causes:
1582
+
1583
+ - missing/incorrect `HERMES_ADMIN_PASSWORD`;
1584
+ - wrong Basic username;
1585
+ - browser session expired;
1586
+ - reverse proxy did not preserve auth.
1587
+
1588
+ Actions:
1589
+
1590
+ - confirm `HERMES_ADMIN_USERNAME` and `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` alignment;
1591
+ - re-authenticate;
1592
+ - verify `entrypoint.sh` logged successful auth configuration;
1593
+ - never print the password in logs or reports.
1594
+
1595
+ #### `/futures` returns 200 but old UI appears
1596
+
1597
+ Possible causes:
1598
+
1599
+ - stale installed overlay;
1600
+ - wrong template path;
1601
+ - restored old overlay taking precedence;
1602
+ - CDN/browser cache;
1603
+ - duplicate old page implementation.
1604
+
1605
+ Actions:
1606
+
1607
+ - compare `X-Hermes-Template-SHA256` with body hash;
1608
+ - inspect `application.runtimeFiles` in status;
1609
+ - confirm `/opt/hermesface_overlay` is preferred;
1610
+ - confirm installed `/opt/hermes/tools/templates/...` matches manifest;
1611
+ - hard reload only after server-side evidence is checked.
1612
+
1613
+ #### Runtime status is `unknown`
1614
+
1615
+ `unknown` means evidence is absent, not that files match.
1616
+
1617
+ Actions:
1618
+
1619
+ - verify manifest path and permissions;
1620
+ - verify overlay and runtime paths exist;
1621
+ - check `HERMES_FUTURES_OVERLAY_MANIFEST`, `HERMES_OVERLAY_SOURCE`, and `HERMES_SYNC_SCRIPT` overrides;
1622
+ - inspect overlay installation logs.
1623
+
1624
+ #### Runtime status is `mismatch`
1625
+
1626
+ Actions:
1627
+
1628
+ - identify exact mismatched file in status payload;
1629
+ - compare repository/image overlay and `/opt/hermes` file;
1630
+ - verify `sync_hf.py` installed after persistence restore;
1631
+ - rebuild/redeploy from a clean commit.
1632
+
1633
+ #### Chart says market data unavailable
1634
+
1635
+ Check market endpoint payload:
1636
+
1637
+ - HTTP 503 and `API_UNAVAILABLE`: acquisition exception;
1638
+ - `state=unavailable`: no real candles/current price;
1639
+ - `state=stale`: provider timestamp invalid or stale;
1640
+ - `state=partial`: freshness unknown or display fields incomplete.
1641
+
1642
+ Inspect:
1643
+
1644
+ ```text
1645
+ warnings
1646
+ missingFields
1647
+ analysisRequiredFieldsMissing
1648
+ staleRequiredFields
1649
+ sourceMetadata
1650
+ technicalDiagnostics
1651
+ ```
1652
+
1653
+ Do not replace missing candles with mock data.
1654
+
1655
+ #### Binance shows HTTP 451
1656
+
1657
+ This is an expected regional limitation in some Hugging Face regions.
1658
+
1659
+ Correct behavior:
1660
+
1661
+ ```text
1662
+ transportStatus=restricted
1663
+ httpStatus=451
1664
+ dataUsability=unavailable
1665
+ reason=Regionally restricted
1666
+ ```
1667
+
1668
+ Do not use raw-IP or TLS-bypass workarounds. DS4 safety remains authoritative.
1669
+
1670
+ #### KuCoin reports “Parameter 'from' must be milliseconds”
1671
+
1672
+ Verify DS4 request builder uses `build_kucoin_time_range()` and sends integer millisecond `from` and `to`. Check for upstream code that converts an already-millisecond value a second time.
1673
+
1674
+ #### Market data is HTTP 200 but readiness is blocked
1675
+
1676
+ Transport and readiness are separate. Inspect:
1677
+
1678
+ - DS4 Futures verification;
1679
+ - DS4 `noTradeGuard`;
1680
+ - missing critical fields;
1681
+ - non-fresh critical fields;
1682
+ - merge rejection reasons.
1683
+
1684
+ A provider may be reachable while its data is unusable.
1685
+
1686
+ #### `NO_TRADE` displayed before analysis
1687
+
1688
+ The initial state must be `NOT_ANALYZED`. Check frontend initialization and `/api/futures/status.analysisState`. A network error must be `ANALYSIS_FAILED` or `API_UNAVAILABLE`, not `NO_TRADE`.
1689
+
1690
+ #### Signal score shows zero with no components
1691
+
1692
+ The UI must show `Unavailable`. Check whether `latestSignalScore` is `null` and whether signal components are empty. Do not coerce null to zero.
1693
+
1694
+ #### Execute button is disabled
1695
+
1696
+ This is normally correct. Read the visible reason and inspect:
1697
+
1698
+ ```text
1699
+ latest plan exists
1700
+ planId matches
1701
+ symbol and risk match
1702
+ plan not expired
1703
+ LONG/SHORT decision
1704
+ verified Futures
1705
+ risk approved
1706
+ noTradeGuard false
1707
+ tradingReadiness ready
1708
+ executable true
1709
+ not already executed
1710
+ ```
1711
+
1712
+ #### Telegram says Owner setup required
1713
+
1714
+ No configured/persisted owner exists. Use the one-time private `/claim <TELEGRAM_BOOTSTRAP_SECRET>` flow. Remove/rotate the bootstrap secret after claim. Do not expose owner ID in dashboard status.
1715
+
1716
+ #### Telegram proactive alerts unavailable
1717
+
1718
+ Webhook responses can work without outbound connectivity, but proactive alerts need either:
1719
+
1720
+ - direct Telegram access with optional proxy; or
1721
+ - configured relay URL and HMAC secret.
1722
+
1723
+ Keep polling disabled.
1724
+
1725
+ ### Logs and artifacts
1726
+
1727
+ Useful logs:
1728
+
1729
+ ```text
1730
+ Space build log
1731
+ entrypoint startup log
1732
+ sync_hf overlay install/hash log
1733
+ Hermes dashboard log under /opt/data/logs
1734
+ sanitized runtime audit JSON
1735
+ browser Console and Network export without credentials
1736
+ ```
1737
+
1738
+ Never attach raw cookies, Authorization headers, tokens, or unredacted provider payloads.
1739
+
1740
+
1741
+ ---
1742
+
1743
+ ## Testing and Verification
1744
+
1745
+ ### Test locations
1746
+
1747
+ ```text
1748
+ hermes_overlay/tests/
1749
+ ```
1750
+
1751
+ Existing focused areas include:
1752
+
1753
+ - Binance public fallback;
1754
+ - nested DS4 merge behavior;
1755
+ - external advisory boundary;
1756
+ - Futures dashboard/state;
1757
+ - Futures integration;
1758
+ - Luxury template markers;
1759
+ - optional MCP runtime isolation;
1760
+ - Telegram webhook;
1761
+ - trade-cycle field paths.
1762
+
1763
+ ### Recommended validation layers
1764
+
1765
+ #### 1. Static validation
1766
+
1767
+ ```bash
1768
+ python -m compileall hermes_overlay scripts
1769
+ node --check /tmp/hermes_dashboard_script.js
1770
+ ruff check hermes_overlay scripts
1771
+ ```
1772
+
1773
+ Also check:
1774
+
1775
+ - duplicate DOM IDs;
1776
+ - missing JavaScript DOM references;
1777
+ - undefined CSS custom properties;
1778
+ - `git diff --check`;
1779
+ - absence of secrets/generated files.
1780
+
1781
+ #### 2. Focused unit tests
1782
+
1783
+ Required focus:
1784
+
1785
+ - seconds-to-milliseconds conversion;
1786
+ - already-millisecond timestamps;
1787
+ - ordered bounded KuCoin ranges;
1788
+ - ticker/funding/Open Interest aliases;
1789
+ - malformed and ambiguous provider shapes;
1790
+ - per-field source/timestamp/freshness attribution;
1791
+ - transport health versus usability/readiness;
1792
+ - datasource-specific error attribution;
1793
+ - market endpoint canonical shape;
1794
+ - no fabricated values;
1795
+ - safe rejection when required fields are missing or non-fresh;
1796
+ - initial/failure UI states;
1797
+ - Execute-disabled reasons and server safety gates.
1798
+
1799
+ #### 3. Futures regression suite
1800
+
1801
+ Run the existing Futures tests once after focused tests pass. Avoid repeatedly running unrelated broad suites while iterating on a narrow failure.
1802
+
1803
+ #### 4. Read-only deployed audit
1804
+
1805
+ ```bash
1806
+ export HERMES_ADMIN_PASSWORD='...'
1807
+ python scripts/verify_futures_runtime.py \
1808
+ --base-url https://really-amin-asset.hf.space \
1809
+ --symbol BTCUSDT \
1810
+ --analyze \
1811
+ --report .runtime_audit/futures_runtime_audit.json
1812
+ ```
1813
+
1814
+ The audit never calls Paper Execute.
1815
+
1816
+ #### 5. Browser verification
1817
+
1818
+ Authenticated desktop and mobile verification must cover:
1819
+
1820
+ - Console free of critical errors;
1821
+ - valid Network status and JSON payloads;
1822
+ - chart rendering for all intervals;
1823
+ - Candles/Line, volume, tooltip, crosshair;
1824
+ - watchlist/recent/history/export/density/theme/auto-refresh controls;
1825
+ - field provenance and datasource cards;
1826
+ - state machine and score semantics;
1827
+ - visible Execute-disabled reason;
1828
+ - no Paper Execute click.
1829
+
1830
+ ### Acceptance matrix
1831
+
1832
+ | Area | Required result |
1833
+ |---|---|
1834
+ | Runtime files | `verified`, or documented investigation for `unknown`; never unexplained mismatch. |
1835
+ | Status API | 200 authenticated, structured source/runtime fields. |
1836
+ | Symbols API | Accurate total/verified/market-only counts. |
1837
+ | Positions API | Deliberate empty state or formatted real Paper positions. |
1838
+ | Market API | Real canonical candles or explicit structured unavailable state. |
1839
+ | Analysis API | Deterministic result; `NO_TRADE` is allowed and expected when unsafe. |
1840
+ | Paper Execute | Not called during verification. |
1841
+ | Binance 451 | Clearly reported as regional restriction. |
1842
+ | Telegram | Webhook-only; no polling adapter. |
1843
+ | Secrets | None in source, logs, reports, screenshots, or package. |
1844
+
1845
+ ### Testing safety
1846
+
1847
+ Tests must not:
1848
+
1849
+ - place Paper/Testnet/Live orders;
1850
+ - require real exchange credentials;
1851
+ - fabricate production responses in deployed paths;
1852
+ - weaken guards to make assertions pass;
1853
+ - treat an unavailable score as zero;
1854
+ - report missing runtime evidence as verified.
1855
+
1856
+
1857
+ ---
1858
+
1859
+ ## Contributing
1860
+
1861
+ ### Change policy
1862
+
1863
+ - Work from the current repository implementation, not old loose reference files.
1864
+ - Keep API changes additive and backward-compatible.
1865
+ - Keep the existing Hermes application and port 7860.
1866
+ - Prefer focused changes with explicit ownership boundaries.
1867
+ - Do not accept “implemented” without code review, tests, and deployed evidence.
1868
+
1869
+ ### Coding style
1870
+
1871
+ Python configuration:
1872
+
1873
+ ```text
1874
+ Python target: 3.10+
1875
+ line length: 120
1876
+ formatter: Black
1877
+ lint: Ruff
1878
+ ```
1879
+
1880
+ The runtime image currently uses a newer Python version, but overlay code should remain compatible with the configured project target unless deliberately changed.
1881
+
1882
+ ### Module ownership
1883
+
1884
+ - Acquisition/normalization/provenance: `dual_datasource_client.py`.
1885
+ - Binance-only HTTP/normalization: `binance_public_client.py`.
1886
+ - Signal/plan orchestration: `trade_cycle.py`.
1887
+ - Risk formulas: `risk.py`.
1888
+ - Execution behavior: `futures_execution.py`.
1889
+ - Bounded dashboard memory: `state.py`.
1890
+ - HTTP contracts/runtime diagnostics: `futures_dashboard_api.py`.
1891
+ - Visual/UI behavior: Luxury template.
1892
+ - Webhook-only Telegram: `telegram_bot.py`.
1893
+ - Overlay installation/process startup: `sync_hf.py`.
1894
+
1895
+ Do not duplicate logic across layers.
1896
+
1897
+ ### Adding a field
1898
+
1899
+ 1. Define the legitimate source and authority.
1900
+ 2. Add narrow normalization aliases.
1901
+ 3. Add field validity rules.
1902
+ 4. Preserve source, provider timestamp, freshness, and fallback status.
1903
+ 5. Add the field to public bounded metadata only if safe.
1904
+ 6. Add additive API output.
1905
+ 7. Render missing value as `Unavailable`.
1906
+ 8. Add focused tests.
1907
+
1908
+ ### Changing deterministic logic
1909
+
1910
+ A change to score thresholds, weights, SL/TP, leverage, risk profile, sizing, slippage threshold, or expiry is safety-sensitive. The pull request must include:
1911
+
1912
+ - motivation;
1913
+ - before/after behavior;
1914
+ - test coverage;
1915
+ - risk analysis;
1916
+ - confirmation that DS4 authority and guard behavior remain intact;
1917
+ - production verification plan.
1918
+
1919
+ ### Frontend contributions
1920
+
1921
+ - Keep a single template and existing route.
1922
+ - Avoid duplicate DOM IDs.
1923
+ - Avoid undefined CSS variables.
1924
+ - Preserve keyboard accessibility and reduced motion.
1925
+ - Never add a second Execute path or an execution shortcut.
1926
+ - Do not trust localStorage for plan authorization.
1927
+ - Keep raw errors out of primary cards.
1928
+
1929
+ ### Commit hygiene
1930
+
1931
+ Do not stage:
1932
+
1933
+ ```text
1934
+ .env
1935
+ credentials
1936
+ cookies
1937
+ tokens
1938
+ .runtime_audit/
1939
+ __pycache__/
1940
+ *.pyc
1941
+ cache files
1942
+ runtime state
1943
+ screenshots with secrets
1944
+ ZIP packages
1945
+ temporary probes
1946
+ ```
1947
+
1948
+ ### Pull request checklist
1949
+
1950
+ - [ ] Scope is focused.
1951
+ - [ ] No architecture duplication.
1952
+ - [ ] No deterministic guard weakened.
1953
+ - [ ] Source attribution remains correct.
1954
+ - [ ] Missing/stale data fails closed.
1955
+ - [ ] Diagnostics are sanitized.
1956
+ - [ ] API is additive.
1957
+ - [ ] Static checks pass.
1958
+ - [ ] Focused tests pass.
1959
+ - [ ] Futures regression suite ran once.
1960
+ - [ ] Deployment audit/browser plan is documented.
1961
+ - [ ] No trade will be executed during verification.
1962
+
1963
+
1964
+ ---
1965
+
1966
+ ## Project Status
1967
+
1968
+ ### Snapshot
1969
+
1970
+ This documentation describes the UI v3 implementation package prepared on 2026-07-21.
1971
+
1972
+ Repository base recorded by the implementation package:
1973
+
1974
+ ```text
1975
+ 3ff79ee0fce31f8d09a7dac357904169d50d9f3e
1976
+ ```
1977
+
1978
+ Last known deployed revision before this package:
1979
+
1980
+ ```text
1981
+ 24d8dad11c0d7316446e9a26b0b074e8630de139
1982
+ ```
1983
+
1984
+ The package itself was not committed, pushed, or deployed by the implementation environment.
1985
+
1986
+ ### Implemented backend/runtime work
1987
+
1988
+ - packaged Luxury template is the runtime source;
1989
+ - overlay installation and SHA-256 manifest;
1990
+ - runtime status `verified` / `mismatch` / `unknown`;
1991
+ - no-cache Futures responses;
1992
+ - KuCoin millisecond range construction;
1993
+ - conservative DS4 Futures verification;
1994
+ - DS4/Binance/DS2 normalization and priority;
1995
+ - per-field provenance and truthful freshness;
1996
+ - structured source health and merge readiness;
1997
+ - real market endpoint with four intervals;
1998
+ - explicit partial/stale/unavailable semantics;
1999
+ - deterministic state machine and non-executable plan semantics;
2000
+ - server-side plan/symbol/risk/expiry/readiness/risk revalidation;
2001
+ - stronger diagnostic redaction;
2002
+ - read-only runtime audit utility;
2003
+ - Telegram webhook-only and Linear MCP isolation preserved.
2004
+
2005
+ ### Implemented UI v3 work
2006
+
2007
+ - watchlist and recent markets;
2008
+ - manual/automatic refresh and density/theme controls;
2009
+ - keyboard help without execution shortcut;
2010
+ - Candles/Line chart, volume, crosshair, tooltip, four intervals, three limits;
2011
+ - market header, source/freshness/readiness, order-book top values;
2012
+ - display-only diagnostics;
2013
+ - per-field provenance;
2014
+ - plan geometry and execution checklist;
2015
+ - analysis copy/export/history/activity;
2016
+ - expanded datasource and technical diagnostics;
2017
+ - responsive desktop/tablet/mobile layout.
2018
+
2019
+ ### Validation already recorded
2020
+
2021
+ Static validation reported:
2022
+
2023
+ - modified Python files compiled;
2024
+ - dashboard JavaScript passed `node --check`;
2025
+ - DOM ID and static reference checks passed;
2026
+ - CSS custom-property checks passed;
2027
+ - whitespace checks passed;
2028
+ - package ZIP integrity passed.
2029
+
2030
+ Behavioral tests, Futures regression tests, authenticated production verification, and deployment were deferred.
2031
+
2032
+ ### Remaining production work
2033
+
2034
+ 1. Review documentation and final code diff.
2035
+ 2. Run focused tests and existing Futures regression suite.
2036
+ 3. Verify real DS4 field names and timestamps.
2037
+ 4. Perform one safe KuCoin read-only request.
2038
+ 5. Deploy through the repository/Hugging Face workflow.
2039
+ 6. Run authenticated read-only audit.
2040
+ 7. Inspect Browser Console and Network.
2041
+ 8. Verify real market rendering for all intervals and UI features.
2042
+ 9. Run one BTCUSDT analysis-only request.
2043
+ 10. Confirm Telegram webhook-only mode and Linear MCP isolation.
2044
+ 11. Record commit hash and serving Space revision.
2045
+ 12. Confirm no secret exposure and no trade execution.
2046
+
2047
+
2048
+ ---
README.md CHANGED
@@ -6,4 +6,54 @@ colorFrom: green
6
  colorTo: green
7
  pinned: false
8
  app_port: 7860
9
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  colorTo: green
7
  pinned: false
8
  app_port: 7860
9
+ ---
10
+
11
+ # HermesFace / Hermes Futures Desk
12
+
13
+ HermesFace runs the NousResearch Hermes Agent on Hugging Face Spaces and adds an authenticated, deterministic cryptocurrency Futures analysis desk.
14
+
15
+ The Futures Desk is **analysis-first, safety-first, and Paper-trading-first**. Datasource 4 remains the authority for Futures verification and safety. Binance public data is a field-level fallback, Datasource 2 is complementary context, and External AI is advisory only.
16
+
17
+ ## Quick links
18
+
19
+ - [Developer documentation index](docs/README.md)
20
+ - [Complete developer guide](docs/DEVELOPER_GUIDE.md)
21
+ - [Architecture](docs/ARCHITECTURE.md)
22
+ - [API reference](docs/API_REFERENCE.md)
23
+ - [Datasource contracts](docs/DATA_PIPELINE_AND_CONTRACTS.md)
24
+ - [Frontend guide](docs/FRONTEND_GUIDE.md)
25
+ - [Environment reference](docs/ENVIRONMENT_CONFIGURATION.md)
26
+ - [Deployment runbook](docs/DEPLOYMENT_RUNBOOK.md)
27
+ - [Security and safety](docs/SECURITY_AND_SAFETY.md)
28
+ - [Operations and troubleshooting](docs/OPERATIONS_AND_TROUBLESHOOTING.md)
29
+ - [Testing and verification](docs/TESTING_AND_VERIFICATION.md)
30
+ - [Contributing](docs/CONTRIBUTING.md)
31
+ - [Current implementation status](docs/PROJECT_STATUS.md)
32
+
33
+ ## Repository entry points
34
+
35
+ ```text
36
+ app.py
37
+ Dockerfile
38
+ scripts/entrypoint.sh
39
+ scripts/sync_hf.py
40
+ hermes_overlay/tools/futures_dashboard_api.py
41
+ hermes_overlay/tools/templates/hermes_futures_desk_luxury.html
42
+ hermes_overlay/trading/trade_cycle.py
43
+ hermes_overlay/trading/dual_datasource_client.py
44
+ ```
45
+
46
+ ## Local container start
47
+
48
+ ```bash
49
+ cp .env.example .env
50
+ # Fill required secrets without committing the file.
51
+ docker build -t hermesface .
52
+ docker run --rm -p 7860:7860 --env-file .env hermesface
53
+ ```
54
+
55
+ Open `http://localhost:7860/futures` and authenticate with the configured Hermes dashboard credentials.
56
+
57
+ ## Safety notice
58
+
59
+ Do not weaken deterministic decisions, Datasource 4 authority, `noTradeGuard`, freshness checks, Futures verification, risk approval, Stop Loss / Take Profit, leverage, sizing, Paper execution validation, or Telegram webhook-only isolation. Do not use fabricated production market data. Do not execute Paper, Testnet, or Live trades during routine development verification.
docs/API_REFERENCE.md ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Reference
2
+
3
+ ## Base and authentication
4
+
5
+ All Futures routes are mounted on the existing Hermes FastAPI application and port. In production, use the Space base URL and an authenticated browser/session or HTTP Basic credentials.
6
+
7
+ When `HERMES_ADMIN_PASSWORD` is set, Futures API routes require:
8
+
9
+ ```http
10
+ Authorization: Basic <base64(username:password)>
11
+ ```
12
+
13
+ Default username: `admin`, configurable with `HERMES_DASHBOARD_BASIC_AUTH_USERNAME`.
14
+
15
+ All Futures responses set `Cache-Control: no-store`. `/futures` also sets no-cache headers and runtime SHA-256 headers.
16
+
17
+ ## `GET /futures`
18
+
19
+ Returns the packaged HTML dashboard.
20
+
21
+ Important response headers:
22
+
23
+ ```text
24
+ X-Hermes-Template-SHA256
25
+ X-Hermes-Router-SHA256
26
+ Cache-Control: no-store, no-cache, must-revalidate, max-age=0
27
+ ```
28
+
29
+ ## `GET /api/futures/status`
30
+
31
+ Returns application status, runtime-file evidence, market health, latest bounded plan state, source metadata, account summary, and diagnostics.
32
+
33
+ Representative shape:
34
+
35
+ ```json
36
+ {
37
+ "application": {
38
+ "status": "online",
39
+ "runtimeStatus": "verified | mismatch | unknown",
40
+ "runtimeFiles": {}
41
+ },
42
+ "marketData": {"status": "healthy | degraded | unavailable"},
43
+ "tradingReadiness": "ready | blocked",
44
+ "mergeStatus": "complete | partial | unknown",
45
+ "analysisState": "NOT_ANALYZED",
46
+ "sourceMetadata": {
47
+ "datasource4": {},
48
+ "binance": {},
49
+ "datasource2": {}
50
+ },
51
+ "fieldSources": {},
52
+ "fieldMetadata": {},
53
+ "verifiedFutures": false,
54
+ "missingRequiredFields": [],
55
+ "staleRequiredFields": [],
56
+ "latestTradePlan": null,
57
+ "latestPlanId": null,
58
+ "latestSignalScore": null,
59
+ "riskApproved": false,
60
+ "tradingMode": "paper",
61
+ "equity": 10000.0,
62
+ "realizedPnlToday": 0.0,
63
+ "openPositionCount": 0,
64
+ "serverTime": 0
65
+ }
66
+ ```
67
+
68
+ Consumers should treat additional fields as additive and avoid strict whole-object equality.
69
+
70
+ ## `GET /api/futures/symbols`
71
+
72
+ Returns the merged catalog.
73
+
74
+ ```json
75
+ {
76
+ "symbols": [
77
+ {
78
+ "symbol": "BTCUSDT",
79
+ "baseAsset": "BTC",
80
+ "quoteAsset": "USDT",
81
+ "futuresVerified": true,
82
+ "marketOnly": false,
83
+ "contractType": "PERPETUAL",
84
+ "status": "TRADING",
85
+ "source": "datasource4",
86
+ "rank": 1,
87
+ "updatedAt": "2026-07-21T00:00:00Z"
88
+ }
89
+ ],
90
+ "source": "...",
91
+ "updatedAt": "...",
92
+ "counts": {
93
+ "total": 0,
94
+ "verifiedFutures": 0,
95
+ "marketOnly": 0
96
+ }
97
+ }
98
+ ```
99
+
100
+ Catalog membership alone does not authorize execution. Only items with `futuresVerified=true` are eligible for Paper revalidation.
101
+
102
+ ## `GET /api/futures/positions`
103
+
104
+ Returns Paper mode and enriched open positions.
105
+
106
+ ```json
107
+ {
108
+ "mode": "paper",
109
+ "positions": [
110
+ {
111
+ "symbol": "BTC/USDT:USDT",
112
+ "side": "long",
113
+ "size": 0.01,
114
+ "entryPrice": 60000.0,
115
+ "markPrice": 60500.0,
116
+ "unrealizedPnl": 5.0
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ Mark price enrichment is best-effort. Missing mark data produces `null`, not zero.
123
+
124
+ ## `GET /api/futures/market`
125
+
126
+ Query parameters:
127
+
128
+ | Parameter | Type | Default | Constraints |
129
+ |---|---|---:|---|
130
+ | `symbol` | string | `BTCUSDT` | length 3–32; normalized server-side |
131
+ | `interval` | enum | `5m` | `1m`, `5m`, `15m`, `1h` |
132
+ | `limit` | integer | `120` | 20–500 |
133
+
134
+ Example:
135
+
136
+ ```http
137
+ GET /api/futures/market?symbol=BTCUSDT&interval=5m&limit=120
138
+ ```
139
+
140
+ Successful/partial shape:
141
+
142
+ ```json
143
+ {
144
+ "state": "available | partial | stale | unavailable",
145
+ "analysisState": "NOT_ANALYZED | STALE | API_UNAVAILABLE",
146
+ "dataUsability": "usable | degraded | unavailable",
147
+ "reason": null,
148
+ "symbol": "BTCUSDT",
149
+ "interval": "5m",
150
+ "limit": 120,
151
+ "candles": [
152
+ {"timestamp": 0, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}
153
+ ],
154
+ "currentPrice": null,
155
+ "markPrice": null,
156
+ "change24h": null,
157
+ "volume24h": null,
158
+ "fundingRate": null,
159
+ "openInterest": null,
160
+ "source": "datasource4 | binance_public | datasource2 | mixed | unavailable",
161
+ "sourcesUsed": [],
162
+ "fieldSources": {},
163
+ "fieldMetadata": {},
164
+ "freshness": "fresh | stale | invalid | unknown",
165
+ "verifiedFutures": false,
166
+ "futuresVerification": {},
167
+ "warnings": [],
168
+ "missingFields": [],
169
+ "analysisRequiredFieldsMissing": [],
170
+ "staleRequiredFields": [],
171
+ "mergeStatus": "complete | partial | unavailable",
172
+ "tradingReadiness": "ready | blocked",
173
+ "rejectionReasons": [],
174
+ "sourceMetadata": {},
175
+ "technicalDiagnostics": {},
176
+ "fetchedAt": 0
177
+ }
178
+ ```
179
+
180
+ If acquisition raises, the route returns HTTP `503` with the same high-level keys, empty candles, null values, `state=unavailable`, `analysisState=API_UNAVAILABLE`, and blocked readiness.
181
+
182
+ No mock candles are permitted in production responses.
183
+
184
+ ## `POST /api/futures/analyze`
185
+
186
+ Request:
187
+
188
+ ```json
189
+ {
190
+ "symbol": "BTCUSDT",
191
+ "risk_profile": "moderate",
192
+ "include_external_context": false
193
+ }
194
+ ```
195
+
196
+ Allowed risk profiles:
197
+
198
+ ```text
199
+ conservative
200
+ moderate
201
+ aggressive
202
+ ```
203
+
204
+ Unknown request fields are rejected.
205
+
206
+ Representative response:
207
+
208
+ ```json
209
+ {
210
+ "planId": "server-generated-reference",
211
+ "symbol": "BTCUSDT",
212
+ "decision": "LONG | SHORT | NO_TRADE",
213
+ "analysis_state": "LONG | SHORT | NO_TRADE",
214
+ "score": null,
215
+ "confidence": null,
216
+ "components": {},
217
+ "core_reasons": [],
218
+ "warnings": [],
219
+ "entry": null,
220
+ "stop_loss": null,
221
+ "take_profit": null,
222
+ "reward_to_risk": null,
223
+ "risk_profile": "moderate",
224
+ "risk_percent": null,
225
+ "requested_leverage": 5,
226
+ "effective_leverage": null,
227
+ "quantity": null,
228
+ "estimated_slippage_percent": null,
229
+ "risk_approved": false,
230
+ "rejection_reasons": [],
231
+ "noTradeGuard": true,
232
+ "plan_type": "directional_plan | non_executable_plan",
233
+ "executable": false,
234
+ "futuresVerified": false,
235
+ "trading_readiness": "blocked",
236
+ "created_at": "...",
237
+ "expires_at": "...",
238
+ "external_advisory": null
239
+ }
240
+ ```
241
+
242
+ A `NO_TRADE` response is a successful deterministic evaluation, not an HTTP failure. An internal analysis failure returns HTTP `503` with `detail="Futures analysis failed"` and clears the current plan state.
243
+
244
+ ## `POST /api/futures/paper/execute`
245
+
246
+ Request:
247
+
248
+ ```json
249
+ {
250
+ "symbol": "BTCUSDT",
251
+ "risk_profile": "moderate",
252
+ "planId": "server-generated-reference"
253
+ }
254
+ ```
255
+
256
+ The endpoint may return:
257
+
258
+ - `403` for unverified contract or non-Paper mode;
259
+ - `409` for superseded/unknown plan, symbol/risk change, expiry, prior execution, blocked readiness, failed fresh revalidation, or non-executable plan;
260
+ - `422` for invalid request shape/symbol;
261
+ - `200` for the final Paper result.
262
+
263
+ The endpoint is intentionally absent from the read-only audit tool.
264
+
265
+ ## Telegram routes
266
+
267
+ ### `POST /api/telegram/webhook`
268
+
269
+ Public webhook ingress protected by:
270
+
271
+ ```http
272
+ X-Telegram-Bot-Api-Secret-Token: <TELEGRAM_WEBHOOK_SECRET>
273
+ ```
274
+
275
+ Limits request body to 256 KiB, applies per-user rate limiting, requires owner/allowed-user authorization, and invokes analysis-only commands.
276
+
277
+ ### `GET /api/telegram/status`
278
+
279
+ Returns enabled/mode/webhook/proxy/relay/authorized-user/alert-scheduler status. It does not expose tokens or user IDs.
280
+
281
+ ### `GET /api/telegram/bootstrap/status`
282
+
283
+ Requires the same Telegram secret header and returns only:
284
+
285
+ ```json
286
+ {"ok": true, "ownerClaimed": true, "bootstrapConsumed": true}
287
+ ```
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ ## System context
4
+
5
+ ```mermaid
6
+ flowchart LR
7
+ U[Authenticated browser] -->|same-origin HTTPS| H[Hermes dashboard / FastAPI :7860]
8
+ T[Telegram webhook] --> H
9
+ H --> R[Futures dashboard router]
10
+ R --> C[Deterministic trade cycle]
11
+ R --> S[Dashboard state]
12
+ C --> D[Dual datasource client]
13
+ D --> DS4[Datasource 4\nAuthoritative]
14
+ D --> B[Binance public\nFallback]
15
+ D --> DS2[Datasource 2\nComplementary]
16
+ C --> K[Risk / sizing]
17
+ C --> E[Paper execution]
18
+ C -. advisory only .-> A[External AI]
19
+ ```
20
+
21
+ ## Container and filesystem architecture
22
+
23
+ ```mermaid
24
+ flowchart TD
25
+ I[Docker image] --> O1[/opt/hermesface_overlay\nimmutable current-image overlay]
26
+ I --> O2[/opt/data/hermes_overlay\npersisted/restored copy]
27
+ I --> S[/opt/data/scripts]
28
+ B[scripts/entrypoint.sh] --> Y[scripts/sync_hf.py]
29
+ Y -->|prefer| O1
30
+ Y -->|fallback only| O2
31
+ Y -->|copy modules| H[/opt/hermes]
32
+ Y --> M[overlay manifest]
33
+ Y --> P[patch existing dashboard router]
34
+ P --> W[Hermes dashboard :7860]
35
+ ```
36
+
37
+ The immutable `/opt/hermesface_overlay` is preferred so a restored dataset containing an older overlay cannot downgrade the current image.
38
+
39
+ ## Layer responsibilities
40
+
41
+ ### HTTP and UI layer
42
+
43
+ `futures_dashboard_api.py` owns request validation, authentication dependency, response shaping, runtime diagnostics, and browser-facing error semantics. It does not implement signal scoring or sizing.
44
+
45
+ ### Datasource layer
46
+
47
+ `dual_datasource_client.py` owns:
48
+
49
+ - HTTP acquisition;
50
+ - KuCoin-compatible time range construction;
51
+ - nested payload discovery;
52
+ - field normalization;
53
+ - source priority;
54
+ - field provenance;
55
+ - source health metadata;
56
+ - `noTradeGuard`, missing-field, stale-field, merge, and readiness results.
57
+
58
+ ### Decision layer
59
+
60
+ `trade_cycle.py` owns deterministic score calculation, decision thresholds, SL/TP construction, risk module orchestration, plan expiry, and optional execution handoff.
61
+
62
+ ### Risk layer
63
+
64
+ `risk.py` owns risk profile lookup, leverage caps, volatility haircut, quantity calculation, margin/notional gates, daily-loss and position-count gates.
65
+
66
+ ### Execution layer
67
+
68
+ `futures_execution.py` owns Paper mode, account/position state, exchange adapter boundaries, slippage estimation, and protective order behavior. The dashboard route never directly constructs an exchange order.
69
+
70
+ ### State layer
71
+
72
+ `state.py` stores only a bounded view of the latest context/plan for the dashboard. It removes raw exchange payloads and does not authorize any decision.
73
+
74
+ ## Trust boundaries
75
+
76
+ | Boundary | Trusted for decisions? | Notes |
77
+ |---|---|---|
78
+ | Browser controls and localStorage | No | Convenience only; server revalidates everything. |
79
+ | Datasource 4 | Yes, for verification/safety | Still subject to parsing, freshness, and completeness checks. |
80
+ | Binance public | No, as authority | Field fallback only; cannot clear DS4 guard. |
81
+ | Datasource 2 | No, as authority | Complementary context only. |
82
+ | External AI | No | Advisory explanation only. |
83
+ | Telegram input | No | Authorized, rate-limited, analysis-only commands. |
84
+ | Server-side latest plan | Partially | Must still pass freshness and execution revalidation. |
85
+
86
+ ## Router installation
87
+
88
+ `sync_hf.py` modifies the existing Hermes dashboard code to include:
89
+
90
+ ```python
91
+ from tools.futures_dashboard_api import router as _futures_dashboard_router
92
+ app.include_router(_futures_dashboard_router)
93
+ from tools.telegram_bot import router as _telegram_router
94
+ app.include_router(_telegram_router)
95
+ ```
96
+
97
+ The patch is idempotent and must not create a new FastAPI app.
98
+
99
+ ## Persistence
100
+
101
+ Hermes data under `/opt/data` may be synchronized to a private Hugging Face Dataset. The repository overlay is also copied into the image, but the immutable image overlay is the source used for installation. Runtime state such as Telegram owner data and symbol cache lives under `/opt/data` and must not be committed.
102
+
103
+ ## Failure behavior
104
+
105
+ - DS4 unreachable: merge may use fallback data for display, but Futures verification/readiness remains blocked.
106
+ - Binance HTTP 451: source status is restricted/unavailable; no bypass is attempted.
107
+ - DS2 unavailable: complementary context is degraded; it does not independently block a plan unless it was the only attempted fill for a still-missing field.
108
+ - Market endpoint exception: HTTP 503 with structured `API_UNAVAILABLE` payload.
109
+ - Analysis exception: HTTP 503, state becomes `ANALYSIS_FAILED`, previous plan is cleared from current state.
110
+ - Template read failure: minimal fallback page is served and trading remains blocked.
docs/CONTRIBUTING.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ ## Change policy
4
+
5
+ - Work from the current repository implementation, not old loose reference files.
6
+ - Keep API changes additive and backward-compatible.
7
+ - Keep the existing Hermes application and port 7860.
8
+ - Prefer focused changes with explicit ownership boundaries.
9
+ - Do not accept “implemented” without code review, tests, and deployed evidence.
10
+
11
+ ## Coding style
12
+
13
+ Python configuration:
14
+
15
+ ```text
16
+ Python target: 3.10+
17
+ line length: 120
18
+ formatter: Black
19
+ lint: Ruff
20
+ ```
21
+
22
+ The runtime image currently uses a newer Python version, but overlay code should remain compatible with the configured project target unless deliberately changed.
23
+
24
+ ## Module ownership
25
+
26
+ - Acquisition/normalization/provenance: `dual_datasource_client.py`.
27
+ - Binance-only HTTP/normalization: `binance_public_client.py`.
28
+ - Signal/plan orchestration: `trade_cycle.py`.
29
+ - Risk formulas: `risk.py`.
30
+ - Execution behavior: `futures_execution.py`.
31
+ - Bounded dashboard memory: `state.py`.
32
+ - HTTP contracts/runtime diagnostics: `futures_dashboard_api.py`.
33
+ - Visual/UI behavior: Luxury template.
34
+ - Webhook-only Telegram: `telegram_bot.py`.
35
+ - Overlay installation/process startup: `sync_hf.py`.
36
+
37
+ Do not duplicate logic across layers.
38
+
39
+ ## Adding a field
40
+
41
+ 1. Define the legitimate source and authority.
42
+ 2. Add narrow normalization aliases.
43
+ 3. Add field validity rules.
44
+ 4. Preserve source, provider timestamp, freshness, and fallback status.
45
+ 5. Add the field to public bounded metadata only if safe.
46
+ 6. Add additive API output.
47
+ 7. Render missing value as `Unavailable`.
48
+ 8. Add focused tests.
49
+
50
+ ## Changing deterministic logic
51
+
52
+ A change to score thresholds, weights, SL/TP, leverage, risk profile, sizing, slippage threshold, or expiry is safety-sensitive. The pull request must include:
53
+
54
+ - motivation;
55
+ - before/after behavior;
56
+ - test coverage;
57
+ - risk analysis;
58
+ - confirmation that DS4 authority and guard behavior remain intact;
59
+ - production verification plan.
60
+
61
+ ## Frontend contributions
62
+
63
+ - Keep a single template and existing route.
64
+ - Avoid duplicate DOM IDs.
65
+ - Avoid undefined CSS variables.
66
+ - Preserve keyboard accessibility and reduced motion.
67
+ - Never add a second Execute path or an execution shortcut.
68
+ - Do not trust localStorage for plan authorization.
69
+ - Keep raw errors out of primary cards.
70
+
71
+ ## Commit hygiene
72
+
73
+ Do not stage:
74
+
75
+ ```text
76
+ .env
77
+ credentials
78
+ cookies
79
+ tokens
80
+ .runtime_audit/
81
+ __pycache__/
82
+ *.pyc
83
+ cache files
84
+ runtime state
85
+ screenshots with secrets
86
+ ZIP packages
87
+ temporary probes
88
+ ```
89
+
90
+ ## Pull request checklist
91
+
92
+ - [ ] Scope is focused.
93
+ - [ ] No architecture duplication.
94
+ - [ ] No deterministic guard weakened.
95
+ - [ ] Source attribution remains correct.
96
+ - [ ] Missing/stale data fails closed.
97
+ - [ ] Diagnostics are sanitized.
98
+ - [ ] API is additive.
99
+ - [ ] Static checks pass.
100
+ - [ ] Focused tests pass.
101
+ - [ ] Futures regression suite ran once.
102
+ - [ ] Deployment audit/browser plan is documented.
103
+ - [ ] No trade will be executed during verification.
docs/DATA_PIPELINE_AND_CONTRACTS.md ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Datasource Pipeline and Contracts
2
+
3
+ ## Priority and authority
4
+
5
+ ```text
6
+ Datasource 4 → Binance public fallback → Datasource 2
7
+ ```
8
+
9
+ Priority describes fill order, not equal trust.
10
+
11
+ ### Datasource 4
12
+
13
+ Authoritative for:
14
+
15
+ - Futures contract verification;
16
+ - `dataState`;
17
+ - `noTradeGuard`;
18
+ - safety status and rejection reasons;
19
+ - primary Futures market fields.
20
+
21
+ ### Binance public
22
+
23
+ - unauthenticated;
24
+ - called only for missing, unusable, or stale fields;
25
+ - cannot verify a contract or clear DS4 safety;
26
+ - HTTP 451 is `restricted` / `Regionally restricted`;
27
+ - provider timestamps are required to claim freshness.
28
+
29
+ ### Datasource 2
30
+
31
+ - complementary news, sentiment, indicator, order-book, volume, trending, gainers, and correlation context;
32
+ - may fill a still-missing legitimate field only after Binance;
33
+ - cannot become Futures verification or safety authority.
34
+
35
+ ## Datasource 4 request
36
+
37
+ The DS4 snapshot endpoint is called with:
38
+
39
+ ```text
40
+ /api/short-hunter/snapshot/{SYMBOL}
41
+ ```
42
+
43
+ Parameters:
44
+
45
+ ```text
46
+ interval: 1m | 5m | 15m | 1h
47
+ limit: 1..500 internally; market API exposes 20..500
48
+ from: epoch milliseconds
49
+ to: epoch milliseconds
50
+ ```
51
+
52
+ `normalize_epoch_milliseconds()` accepts contemporary epoch seconds or milliseconds and prevents double conversion. `build_kucoin_time_range()` enforces supported interval, bounded limit, positive ordered timestamps, and millisecond units.
53
+
54
+ ## Normalized fields
55
+
56
+ The merged envelope may contain:
57
+
58
+ ```text
59
+ contract
60
+ ticker
61
+ ohlcv
62
+ orderbook
63
+ funding
64
+ openInterest
65
+ indicators
66
+ sentiment
67
+ atr
68
+ market_context
69
+ ```
70
+
71
+ ### Contract
72
+
73
+ Normalized contract data should expose symbol, status, type/instrument, and explicit verification evidence when present. The presence of a generic `contract` object alone does not prove Futures status. Verification requires an explicit DS4 flag or a recognized Futures/perpetual/swap contract type.
74
+
75
+ ### Ticker
76
+
77
+ Accepted aliases are normalized to a bounded ticker object. Consumers should prefer normalized canonical keys where available and tolerate provider-specific supplemental keys.
78
+
79
+ Common price candidates:
80
+
81
+ ```text
82
+ markPrice
83
+ lastPrice
84
+ last
85
+ price
86
+ close
87
+ indexPrice
88
+ ```
89
+
90
+ ### OHLCV
91
+
92
+ Canonical candle shape:
93
+
94
+ ```json
95
+ {
96
+ "timestamp": 0,
97
+ "open": 0.0,
98
+ "high": 0.0,
99
+ "low": 0.0,
100
+ "close": 0.0,
101
+ "volume": 0.0
102
+ }
103
+ ```
104
+
105
+ A usable OHLCV series requires at least four valid positive close values. The market endpoint never invents missing candles.
106
+
107
+ ### Order book
108
+
109
+ Canonical shape:
110
+
111
+ ```json
112
+ {
113
+ "bids": [[60000.0, 0.5]],
114
+ "asks": [[60001.0, 0.4]],
115
+ "timestamp": 0
116
+ }
117
+ ```
118
+
119
+ Both sides must have at least one valid level. Prices must be positive; quantities must be non-negative.
120
+
121
+ ### Funding
122
+
123
+ Canonical values may include:
124
+
125
+ ```text
126
+ currentFundingRate
127
+ fundingRate
128
+ lastFundingRate
129
+ rate
130
+ nextFundingTime
131
+ ```
132
+
133
+ ### Open Interest
134
+
135
+ Canonical values may include:
136
+
137
+ ```text
138
+ openInterest
139
+ sumOpenInterest
140
+ oi
141
+ changeFraction
142
+ change24h
143
+ changePercent
144
+ ```
145
+
146
+ ## Field usability
147
+
148
+ `_is_usable(field, value)` performs field-specific validation. Empty values, non-finite values, invalid OHLCV, incomplete order books, and invalid contract/funding/OI shapes are rejected.
149
+
150
+ ## Per-field provenance
151
+
152
+ Every owned field receives metadata:
153
+
154
+ ```json
155
+ {
156
+ "value": "bounded or summarized value",
157
+ "source": "datasource4 | binance_public | datasource2 | unavailable",
158
+ "timestamp": "provider timestamp or null",
159
+ "freshness": "fresh | stale | invalid | unknown",
160
+ "validity": "valid | unavailable",
161
+ "observedAt": "server observation time",
162
+ "freshnessBasis": "field_timestamp | datasource4_dataState | missing_provider_timestamp | unavailable",
163
+ "fallbackStatus": "primary | fallback | not_filled"
164
+ }
165
+ ```
166
+
167
+ The public API bounds large values:
168
+
169
+ - OHLCV becomes count plus latest candle summary where appropriate;
170
+ - order book becomes level counts and best bid/ask summary;
171
+ - diagnostics are sanitized and size-limited.
172
+
173
+ ## Freshness
174
+
175
+ Freshness is based on provider timestamp relative to interval, or on an explicit authoritative DS4 fresh state. Transport success alone is not freshness evidence.
176
+
177
+ Required fields with `stale`, `invalid`, or `unknown` freshness block readiness.
178
+
179
+ ## Critical fields and readiness
180
+
181
+ Critical fields:
182
+
183
+ ```text
184
+ contract
185
+ ticker
186
+ orderbook
187
+ funding
188
+ openInterest
189
+ ```
190
+
191
+ The combined context sets:
192
+
193
+ ```text
194
+ missingRequiredFields
195
+ staleRequiredFields
196
+ noTradeGuard
197
+ noTradeReasons
198
+ mergeStatus
199
+ tradingReadiness
200
+ ```
201
+
202
+ Readiness is `ready` only when no guard remains. DS4 verification failure, DS4 `noTradeGuard`, missing critical fields, or non-fresh critical fields results in `blocked`.
203
+
204
+ ## Source metadata
205
+
206
+ Each source returns structured fields:
207
+
208
+ ```json
209
+ {
210
+ "name": "Datasource 4",
211
+ "url": "...",
212
+ "status": "ok | degraded | unreachable | unavailable | standby",
213
+ "transportStatus": "healthy | degraded | unavailable | restricted | standby",
214
+ "dataUsability": "usable | degraded | unavailable | not_used",
215
+ "endpoint": "...",
216
+ "httpStatus": 200,
217
+ "latencyMs": 120.4,
218
+ "lastSuccess": "...",
219
+ "freshness": "fresh | stale | unknown",
220
+ "completeness": "complete | partial | unknown",
221
+ "suppliedFields": [],
222
+ "missingFields": [],
223
+ "reason": "concise operator-facing summary"
224
+ }
225
+ ```
226
+
227
+ Detailed endpoint/provider errors belong only under `technicalDiagnostics`, separated by source and sanitized before exposure.
228
+
229
+ ## Merge diagnostics
230
+
231
+ Cross-source problems are not assigned to a datasource card. They appear under:
232
+
233
+ ```text
234
+ technicalDiagnostics.merge.status
235
+ technicalDiagnostics.merge.missingCriticalFields
236
+ technicalDiagnostics.merge.tradingReadiness
237
+ technicalDiagnostics.merge.rejectionReasons
238
+ ```
239
+
240
+ ## Adding a new provider mapping
241
+
242
+ 1. Capture a real redacted payload.
243
+ 2. Add the narrowest legitimate alias to the relevant normalizer.
244
+ 3. Preserve provider timestamp and source name.
245
+ 4. Add field-specific validity checks.
246
+ 5. Do not infer Futures verification from generic market data.
247
+ 6. Do not let the provider clear DS4 guard state.
248
+ 7. Add focused tests for positive, missing, malformed, stale, and ambiguous cases.
249
+ 8. Verify source-specific diagnostics remain correctly attributed.
docs/DEPLOYMENT_RUNBOOK.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment Runbook
2
+
3
+ ## Preconditions
4
+
5
+ - Work from the current repository under `asset-space`.
6
+ - Review all diffs.
7
+ - Ensure no real `.env`, tokens, cookies, screenshots, cache, ZIP archives, runtime reports, or temporary probes are staged.
8
+ - Confirm no architecture change introduces a second app, frontend server, or port.
9
+ - Do not execute a trade during deployment verification.
10
+
11
+ ## Build behavior
12
+
13
+ The Dockerfile:
14
+
15
+ 1. clones upstream Hermes Agent into `/opt/hermes`;
16
+ 2. installs Node, web dashboard, Playwright, Python, CCXT, and HTTPX dependencies;
17
+ 3. creates non-root user `hermes` and `/opt/data` directories;
18
+ 4. copies scripts into `/opt/data/scripts`;
19
+ 5. copies overlay into `/opt/data/hermes_overlay` and immutable `/opt/hermesface_overlay`;
20
+ 6. starts `/opt/data/scripts/entrypoint.sh`.
21
+
22
+ ## Startup behavior
23
+
24
+ `entrypoint.sh`:
25
+
26
+ 1. starts DNS pre-resolution in the background;
27
+ 2. activates `/opt/hermes/.venv`;
28
+ 3. creates persistent directories and baseline config files;
29
+ 4. writes hashed dashboard Basic auth when `HERMES_ADMIN_PASSWORD` is configured;
30
+ 5. calls `scripts/sync_hf.py`.
31
+
32
+ `sync_hf.py`:
33
+
34
+ 1. restores persistent data when configured;
35
+ 2. disables legacy Telegram gateway polling in webhook mode;
36
+ 3. installs the current image overlay into `/opt/hermes`;
37
+ 4. writes and verifies the overlay hash manifest;
38
+ 5. mounts Futures and Telegram routers into the existing dashboard app;
39
+ 6. starts Hermes dashboard on port `7860`;
40
+ 7. manages persistence/sync helpers.
41
+
42
+ ## Hugging Face deployment procedure
43
+
44
+ 1. Review the final diff.
45
+ 2. Commit the smallest coherent change.
46
+ 3. Push to `main` of the repository backing `Really-amin/Asset`.
47
+ 4. Monitor Space build logs.
48
+ 5. Wait until Space is fully `RUNNING`.
49
+ 6. Record the serving repository revision.
50
+ 7. Run the read-only runtime audit.
51
+ 8. Open an authenticated browser session.
52
+ 9. Inspect Console and Network.
53
+ 10. Verify Telegram status and MCP isolation.
54
+
55
+ ## Read-only runtime audit
56
+
57
+ ```bash
58
+ export HERMES_ADMIN_PASSWORD='...'
59
+ export HERMES_DASHBOARD_BASIC_AUTH_USERNAME='admin'
60
+ python scripts/verify_futures_runtime.py \
61
+ --base-url https://really-amin-asset.hf.space \
62
+ --symbol BTCUSDT \
63
+ --analyze \
64
+ --report .runtime_audit/futures_runtime_audit.json
65
+ ```
66
+
67
+ Expected properties:
68
+
69
+ - `/futures` returns 200;
70
+ - body SHA-256 equals `X-Hermes-Template-SHA256`;
71
+ - status, symbols, and positions return authenticated 200 responses;
72
+ - each market interval returns 200 or structured 503;
73
+ - market payload shape includes real canonical candle objects;
74
+ - optional analysis returns 200;
75
+ - `paperExecuteCalled` remains false.
76
+
77
+ ## Browser verification
78
+
79
+ Check:
80
+
81
+ - no critical JavaScript error;
82
+ - authenticated API requests are not redirected to login HTML;
83
+ - `/api/futures/market` is requested for each selected interval;
84
+ - chart code executes and state messages match payload;
85
+ - no stale cached template is served;
86
+ - selected-market header, provenance, diagnostics, plan, account, and datasource sections render;
87
+ - Execute remains disabled unless a valid server plan exists;
88
+ - do not click Execute.
89
+
90
+ ## Runtime file verification
91
+
92
+ Use `/api/futures/status` and `/futures` response headers to compare:
93
+
94
+ ```text
95
+ repository/image overlay template
96
+ repository/image overlay router
97
+ installed /opt/hermes template
98
+ installed /opt/hermes router
99
+ served HTML body
100
+ installation manifest
101
+ ```
102
+
103
+ Interpretation:
104
+
105
+ - `verified`: all available expected files match;
106
+ - `mismatch`: at least one available expected hash differs;
107
+ - `unknown`: evidence is missing; investigate filesystem/install path.
108
+
109
+ ## Rollback
110
+
111
+ 1. Identify the last known healthy commit.
112
+ 2. Revert only the faulty change; do not copy old reference directories over the current backend.
113
+ 3. Push the revert.
114
+ 4. Wait for Space rebuild and `RUNNING` state.
115
+ 5. Repeat runtime audit and browser verification.
116
+ 6. Confirm deterministic thresholds, Telegram webhook-only mode, and port 7860 remain unchanged.
117
+
118
+ ## Release evidence to retain
119
+
120
+ - commit hash;
121
+ - serving Space revision;
122
+ - sanitized audit JSON;
123
+ - static/focused/regression test summary;
124
+ - runtime hash result;
125
+ - Console/Network findings;
126
+ - provider limitations such as Binance 451;
127
+ - explicit statement that no secret was exposed and no trade was executed.
docs/DEVELOPER_GUIDE.md ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hermes Futures Desk — Complete Developer Guide
2
+
3
+ ## 1. Purpose
4
+
5
+ Hermes Futures Desk is an authenticated Futures analysis and risk-management layer installed into the existing Hermes Agent runtime. It discovers markets, normalizes real market data, produces deterministic `LONG`, `SHORT`, or `NO_TRADE` outcomes, calculates a bounded trade plan, applies server-side risk controls, and optionally routes a fully revalidated plan to the existing Paper execution path.
6
+
7
+ The system is intentionally conservative:
8
+
9
+ - Datasource 4 is authoritative for Futures verification and safety.
10
+ - Binance public data may fill missing or unusable market fields but cannot override DS4 safety.
11
+ - Datasource 2 provides complementary context only.
12
+ - External AI provides advisory explanation only.
13
+ - The browser is never trusted to authorize execution.
14
+ - No new web server, FastAPI application, port, or trading engine is created.
15
+
16
+ ## 2. Runtime summary
17
+
18
+ ```text
19
+ Hugging Face Space / Docker container
20
+ └── /opt/hermes Upstream Hermes Agent source/runtime
21
+ ├── dashboard on 0.0.0.0:7860 Existing Hermes web application
22
+ ├── tools/futures_dashboard_api.py Installed repository overlay
23
+ ├── tools/templates/...html Installed Luxury dashboard template
24
+ ├── trading/* Installed Futures modules
25
+ └── .hermes_futures_overlay_manifest.json
26
+
27
+ /opt/data
28
+ ├── scripts/ Entrypoint, persistence, runtime audit
29
+ ├── hermes_overlay/ Restored/persisted copy; not preferred over image overlay
30
+ ├── futures_symbols_cache.json Optional symbol cache
31
+ ├── telegram_state.json Telegram owner/watchlist/alert state
32
+ └── persistent Hermes data
33
+
34
+ /opt/hermesface_overlay Immutable overlay copied from the current image
35
+ ```
36
+
37
+ The Docker image clones Hermes Agent into `/opt/hermes`, installs Python/Node dependencies, copies repository scripts into `/opt/data/scripts`, and copies the current overlay into both `/opt/data/hermes_overlay` and `/opt/hermesface_overlay`. At startup, `scripts/sync_hf.py` installs the overlay into `/opt/hermes`, writes a SHA-256 manifest, patches the existing Hermes dashboard to include the routers, and starts the dashboard on port `7860`.
38
+
39
+ ## 3. Main modules
40
+
41
+ | Module | Responsibility |
42
+ |---|---|
43
+ | `scripts/entrypoint.sh` | Runtime directory creation, dashboard auth configuration, and handoff to `sync_hf.py`. |
44
+ | `scripts/sync_hf.py` | Persistence restore/sync, overlay installation, manifest generation, router mounting, Telegram polling isolation, and process startup. |
45
+ | `hermes_overlay/tools/futures_dashboard_api.py` | Authenticated Futures HTTP routes, runtime file diagnostics, symbol catalog, market endpoint, analysis route, and Paper revalidation route. |
46
+ | `hermes_overlay/tools/templates/hermes_futures_desk_luxury.html` | Luxury Obsidian & Gold single-page dashboard. |
47
+ | `hermes_overlay/trading/dual_datasource_client.py` | DS4 → Binance → DS2 acquisition, normalization, provenance, health metadata, and `noTradeGuard` aggregation. |
48
+ | `hermes_overlay/trading/binance_public_client.py` | Unauthenticated Binance Futures fallback and explicit regional-restriction reporting. |
49
+ | `hermes_overlay/trading/trade_cycle.py` | Deterministic signal scoring, SL/TP construction, risk sizing, plan creation, and optional Paper orchestration. |
50
+ | `hermes_overlay/trading/risk.py` | Risk profiles, leverage caps/haircut, quantity sizing, and hard risk gates. |
51
+ | `hermes_overlay/trading/futures_execution.py` | Existing Paper account/position book and execution validation. |
52
+ | `hermes_overlay/trading/state.py` | Bounded in-memory dashboard state; no decision authority. |
53
+ | `hermes_overlay/trading/symbols.py` | Symbol normalization for DS4 and CCXT formats. |
54
+ | `hermes_overlay/external_ai/advisory.py` | Optional OpenRouter → Google → Hugging Face advisory chain. |
55
+ | `hermes_overlay/tools/telegram_bot.py` | Webhook-only, analysis-only Telegram adapter and owner bootstrap. |
56
+ | `scripts/verify_futures_runtime.py` | Read-only deployed runtime audit; never calls Paper Execute. |
57
+
58
+ ## 4. End-to-end request flow
59
+
60
+ ### 4.1 Market display
61
+
62
+ 1. Browser requests `GET /api/futures/market` with symbol, interval, and limit.
63
+ 2. Router normalizes the symbol and calls `get_market_context()`.
64
+ 3. Datasource client requests DS4 with bounded KuCoin-compatible millisecond `from`/`to` parameters.
65
+ 4. Missing, unusable, or stale fields are requested from Binance public fallback.
66
+ 5. Datasource 2 is queried for complementary context and may fill only still-missing legitimate fields.
67
+ 6. Each normalized field receives source, timestamp, freshness, validity, and fallback metadata.
68
+ 7. The endpoint returns only real normalized values or an explicit `partial`, `stale`, or `unavailable` state.
69
+ 8. The browser renders charts and diagnostics without modifying server decisions.
70
+
71
+ ### 4.2 Deterministic analysis
72
+
73
+ 1. Browser sends `POST /api/futures/analyze`.
74
+ 2. Server runs `run_futures_cycle(..., execute=False)`.
75
+ 3. DS4 safety state, Futures verification, required fields, and freshness gates are checked first.
76
+ 4. A deterministic score is calculated only from normalized real inputs.
77
+ 5. A directional plan is created only when score and confirmation thresholds pass.
78
+ 6. SL/TP are derived from ATR and configured reward-to-risk rules.
79
+ 7. Risk sizing calculates quantity from equity loss at Stop Loss.
80
+ 8. Slippage is estimated from the real order book.
81
+ 9. The result is stored as a bounded server-side plan and returned with a `planId`.
82
+
83
+ ### 4.3 Paper execution
84
+
85
+ Paper execution is not a continuation of browser state. The server performs all checks again:
86
+
87
+ - requested symbol is a verified Futures contract;
88
+ - `planId` matches the latest server plan;
89
+ - symbol and risk profile have not changed;
90
+ - plan was not already executed;
91
+ - plan has not expired;
92
+ - decision is `LONG` or `SHORT`;
93
+ - DS4 verification and trading readiness remain valid;
94
+ - `noTradeGuard` is false;
95
+ - plan is marked executable and risk-approved;
96
+ - runtime trading mode is `paper`;
97
+ - a fresh analysis-only cycle still authorizes the plan.
98
+
99
+ Only after these checks does the server invoke the existing Paper execution path.
100
+
101
+ ## 5. Datasource authority
102
+
103
+ ```text
104
+ Datasource 4 → Binance public fallback → Datasource 2
105
+ ```
106
+
107
+ Datasource 4 owns contract verification and all Futures safety semantics. The critical fields are:
108
+
109
+ ```text
110
+ contract
111
+ ticker
112
+ orderbook
113
+ funding
114
+ openInterest
115
+ ```
116
+
117
+ OHLCV, indicators, sentiment, and ATR are also normalized and attributed. Missing or non-fresh critical fields activate `noTradeGuard` and set `tradingReadiness=blocked`.
118
+
119
+ Binance public data is unauthenticated and field-level only. HTTP 451 is represented as `Regionally restricted`; it is never reported as healthy. Datasource 2 cannot verify Futures, clear `noTradeGuard`, or override DS4 data that is present and usable.
120
+
121
+ ## 6. Health model
122
+
123
+ The code deliberately separates:
124
+
125
+ - `transportStatus`: whether the HTTP request succeeded;
126
+ - `dataUsability`: whether parsed data is suitable for use;
127
+ - `freshness`: whether provider timestamp or authoritative DS4 state proves freshness;
128
+ - `completeness`: whether expected fields were supplied;
129
+ - `mergeStatus`: whether the combined context is complete;
130
+ - `tradingReadiness`: whether deterministic safety gates allow a plan.
131
+
132
+ A successful HTTP response does not make market data fresh. Fallback data without a provider timestamp remains `unknown` and cannot pass a Futures freshness gate.
133
+
134
+ ## 7. Analysis and plan states
135
+
136
+ ### Analysis states
137
+
138
+ ```text
139
+ NOT_ANALYZED
140
+ ANALYZING
141
+ LONG
142
+ SHORT
143
+ NO_TRADE
144
+ ANALYSIS_FAILED
145
+ STALE
146
+ API_UNAVAILABLE
147
+ ```
148
+
149
+ ### Plan types
150
+
151
+ - `directional_plan`: a valid directional plan before final execution checks.
152
+ - `non_executable_plan`: directional values exist but one or more safety/risk gates block execution.
153
+ - rejected/no-direction analysis: `NO_TRADE` with no executable plan geometry.
154
+
155
+ ### Market endpoint states
156
+
157
+ - `available`: real candles and required display fields are fresh and usable.
158
+ - `partial`: values exist but freshness or completeness is not fully proven.
159
+ - `stale`: required display data is stale or invalid.
160
+ - `unavailable`: real candles or a current price could not be obtained.
161
+
162
+ ## 8. Deterministic scoring and risk rules
163
+
164
+ Default analysis thresholds are environment-overridable:
165
+
166
+ | Setting | Default |
167
+ |---|---:|
168
+ | Minimum absolute signal score | `0.55` |
169
+ | Minimum signal components | `3` |
170
+ | Minimum direction confirmations | `2` |
171
+ | Stop ATR multiplier | `1.2` |
172
+ | Take Profit reward-to-risk | `1.8` |
173
+ | Minimum stop distance | `20` bps |
174
+ | Plan maximum age | `20` seconds |
175
+ | Requested leverage | `5x` |
176
+
177
+ Risk profiles:
178
+
179
+ | Profile | Equity risk | Maximum leverage |
180
+ |---|---:|---:|
181
+ | Conservative | 1% | 5x |
182
+ | Moderate | 3% | 10x |
183
+ | Aggressive | 5% | 15x |
184
+
185
+ Sizing is based on loss at Stop Loss:
186
+
187
+ ```text
188
+ risk_amount = account_equity × risk_percent
189
+ stop_distance = abs(entry_price - stop_loss)
190
+ quantity = risk_amount / stop_distance
191
+ ```
192
+
193
+ When ATR is at least 3% of price, effective leverage is reduced by 50% and never increased beyond the risk-profile cap.
194
+
195
+ ## 9. Frontend behavior
196
+
197
+ The dashboard is a single packaged HTML template with inline CSS and JavaScript. It uses the existing authenticated FastAPI origin and `fetch(..., credentials='same-origin', cache='no-store')`.
198
+
199
+ Major features:
200
+
201
+ - symbol search and verified/market-only catalog counts;
202
+ - local watchlist and recent markets;
203
+ - real candle/line chart, four intervals, three candle limits, volume, crosshair, and tooltip;
204
+ - market source, freshness, funding, Open Interest, best bid/ask/spread, and readiness;
205
+ - display-only diagnostics from returned candles;
206
+ - per-field provenance;
207
+ - deterministic analysis and Paper Execute controls;
208
+ - plan geometry and execution checklist;
209
+ - datasource detail cards and sanitized technical diagnostics;
210
+ - Paper account and positions;
211
+ - local activity/history, JSON export, copy summary, density/theme preferences;
212
+ - manual and automatic refresh controls.
213
+
214
+ Browser storage never grants server permission. Selecting a historical symbol or changing risk invalidates the current browser plan and requires a new server analysis.
215
+
216
+ ## 10. Authentication
217
+
218
+ The upstream Hermes dashboard is protected by its existing authentication middleware. The Futures router also supports local HTTP Basic enforcement when `HERMES_ADMIN_PASSWORD` is set:
219
+
220
+ ```text
221
+ username: HERMES_DASHBOARD_BASIC_AUTH_USERNAME (default: admin)
222
+ password: HERMES_ADMIN_PASSWORD
223
+ ```
224
+
225
+ `entrypoint.sh` writes a hashed credential into Hermes `config.yaml` before the server binds publicly. Secrets must be configured as Hugging Face Space secrets or injected environment variables, never committed.
226
+
227
+ ## 11. Telegram model
228
+
229
+ Telegram is webhook-only and analysis-only:
230
+
231
+ - `POST /api/telegram/webhook` validates `X-Telegram-Bot-Api-Secret-Token`.
232
+ - Owner bootstrap uses a one-time private-chat `/claim <secret>` command.
233
+ - Authorized users come from configured IDs or the persisted owner.
234
+ - Commands call `run_futures_cycle(..., execute=False)` only.
235
+ - Direct delivery may use a proxy; proactive delivery may use an HMAC relay.
236
+ - Polling must remain disabled.
237
+
238
+ No Telegram command can execute a Futures position.
239
+
240
+ ## 12. Runtime integrity
241
+
242
+ During overlay installation, `sync_hf.py` copies the current image overlay into `/opt/hermes` and writes `.hermes_futures_overlay_manifest.json`. The status endpoint compares repository/overlay/runtime/template/router hashes when those paths are available.
243
+
244
+ Runtime status semantics:
245
+
246
+ - `verified`: evidence exists and all expected hashes match;
247
+ - `mismatch`: evidence exists and one or more hashes differ;
248
+ - `unknown`: required evidence is unavailable.
249
+
250
+ Missing files must never be reported as verified.
251
+
252
+ ## 13. Development workflow
253
+
254
+ 1. Start from the current repository files under `asset-space/hermes_overlay`.
255
+ 2. Do not copy old loose reference files over the repository.
256
+ 3. Keep changes focused and additive to the API contract.
257
+ 4. Preserve the single server and port architecture.
258
+ 5. Add tests for normalization, provenance, state transitions, and server-side execution checks.
259
+ 6. Run static checks and focused Futures tests.
260
+ 7. Review secrets and generated files before commit.
261
+ 8. Deploy through the existing Space workflow.
262
+ 9. Verify the installed hashes and authenticated routes.
263
+ 10. Inspect browser Console and Network.
264
+ 11. Never click Paper Execute during deployment verification.
265
+
266
+ ## 14. Read-only runtime audit
267
+
268
+ ```bash
269
+ export HERMES_ADMIN_PASSWORD='...'
270
+ export HERMES_DASHBOARD_BASIC_AUTH_USERNAME='admin'
271
+ python scripts/verify_futures_runtime.py \
272
+ --base-url https://really-amin-asset.hf.space \
273
+ --symbol BTCUSDT \
274
+ --analyze \
275
+ --report .runtime_audit/futures_runtime_audit.json
276
+ ```
277
+
278
+ The utility checks `/futures`, status, symbols, positions, all market intervals, and optionally one analysis-only request. It never calls `/api/futures/paper/execute`.
279
+
280
+ ## 15. Known deployment limitations
281
+
282
+ - Binance public Futures endpoints may return HTTP 451 in the current Hugging Face region.
283
+ - Real DS4 payload names and provider timestamps must be verified against live deployed responses.
284
+ - The current package has static validation results but not a completed authenticated production verification cycle.
285
+ - The UI uses a single large HTML template; future refactoring must preserve runtime template installation and avoid introducing a second frontend server.
286
+
287
+ ## 16. Definition of done
288
+
289
+ A change is complete only when:
290
+
291
+ - the correct template and router are installed and hash-verified;
292
+ - authenticated routes return expected structured responses;
293
+ - real market data renders for 1m, 5m, 15m, and 1h or returns an explicit unavailable state;
294
+ - Console has no critical error and Network requests are authenticated;
295
+ - datasource health and attribution are truthful;
296
+ - deterministic safety logic is unchanged;
297
+ - Telegram remains webhook-only;
298
+ - no secret is exposed;
299
+ - no trade is executed during verification.
docs/ENVIRONMENT_CONFIGURATION.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment Configuration
2
+
3
+ Configure secrets in Hugging Face Space Settings or inject them at container runtime. Never commit a populated `.env` file.
4
+
5
+ ## Core persistence
6
+
7
+ | Variable | Default | Purpose |
8
+ |---|---|---|
9
+ | `HF_TOKEN` | none | Hugging Face token with required repository access. |
10
+ | `HERMES_DATASET_REPO` | derived/none | Private Dataset used to persist `/opt/data`. |
11
+ | `AUTO_CREATE_DATASET` | `true` | Create the private Dataset when missing. |
12
+ | `SYNC_INTERVAL` | `60` | Persistence sync interval in seconds. |
13
+ | `HF_HUB_DOWNLOAD_TIMEOUT` | implementation default | Hub download timeout. |
14
+ | `HF_HUB_UPLOAD_TIMEOUT` | implementation default | Hub upload timeout. |
15
+ | `HERMES_HOME` | `/opt/data` | Persistent Hermes data root. |
16
+ | `MAX_BACKUPS` | script default | Backup retention used by persistence helper. |
17
+
18
+ ## Dashboard authentication
19
+
20
+ | Variable | Default | Purpose |
21
+ |---|---|---|
22
+ | `HERMES_ADMIN_PASSWORD` | none | Required production dashboard password. |
23
+ | `HERMES_ADMIN_USERNAME` | `admin` | Username written to Hermes dashboard config by entrypoint. |
24
+ | `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | `admin` | Username checked by Futures router and runtime audit. |
25
+
26
+ For the audit tool only:
27
+
28
+ | Variable | Purpose |
29
+ |---|---|
30
+ | `HERMES_DASHBOARD_COOKIE` | Existing authenticated session cookie when Basic auth is not used. |
31
+ | `HERMES_FUTURES_BASE_URL` | Default audit target base URL. |
32
+
33
+ ## Datasources
34
+
35
+ | Variable | Default | Purpose |
36
+ |---|---|---|
37
+ | `DS4_BASE_URL` | DS4 Hugging Face Space URL | Authoritative Datasource 4 base. |
38
+ | `DS4_TIMEOUT_S` | `6` | DS4 request timeout. |
39
+ | `DS2_BASE_URL` | DS2 Hugging Face Space URL | Complementary Datasource 2 base. |
40
+ | `DS2_TIMEOUT_S` | `6` | DS2 request timeout. |
41
+ | `HERMES_SYMBOL_CACHE_PATH` | `/opt/data/futures_symbols_cache.json` | Symbol catalog cache. |
42
+
43
+ ## Binance fallback
44
+
45
+ | Variable | Default | Purpose |
46
+ |---|---|---|
47
+ | `BINANCE_PUBLIC_FALLBACK_ENABLED` | `true` | Enable unauthenticated field fallback. |
48
+ | `BINANCE_FUTURES_PUBLIC_BASE_URL` | `https://fapi.binance.com` | Binance Futures public base. |
49
+ | `BINANCE_FUTURES_TIMEOUT_S` | `5` | Request timeout. |
50
+ | `BINANCE_FUTURES_MAX_RETRIES` | `2` | Retry bound. |
51
+ | `BINANCE_KLINE_INTERVAL` | `5m` | Default fallback interval. |
52
+ | `BINANCE_KLINE_LIMIT` | `100` | Default kline count. |
53
+ | `BINANCE_ATR_PERIOD` | `14` | ATR lookback in fallback client. |
54
+ | `BINANCE_ORDERBOOK_LIMIT` | `20` | Depth level limit. |
55
+ | `BINANCE_OI_PERIOD` | `5m` | Open Interest history period. |
56
+
57
+ A regional HTTP 451 must be surfaced honestly. Do not use raw IP, DNS bypass, or TLS bypass.
58
+
59
+ ## Deterministic analysis
60
+
61
+ | Variable | Default | Purpose |
62
+ |---|---:|---|
63
+ | `FUTURES_MIN_SIGNAL_SCORE` | `0.55` | Minimum absolute deterministic score. |
64
+ | `FUTURES_MIN_SIGNAL_COMPONENTS` | `3` | Minimum present scoring components. |
65
+ | `FUTURES_MIN_DIRECTION_CONFIRMATIONS` | `2` | Minimum components confirming direction. |
66
+ | `FUTURES_STOP_ATR_MULTIPLIER` | `1.2` | ATR stop-distance multiplier. |
67
+ | `FUTURES_TAKE_PROFIT_RR` | `1.8` | Target reward-to-risk. |
68
+ | `FUTURES_MIN_STOP_BPS` | `20` | Minimum stop distance in basis points. |
69
+ | `FUTURES_PLAN_MAX_AGE_SECONDS` | `20` | Plan expiry window. |
70
+ | `FUTURES_DEFAULT_LEVERAGE` | `5` | Requested leverage before caps/haircut. |
71
+
72
+ Changing these variables changes deterministic behavior and requires explicit review, tests, and deployment evidence.
73
+
74
+ ## Execution and Paper account
75
+
76
+ | Variable | Default | Purpose |
77
+ |---|---|---|
78
+ | `PAPER_EQUITY_USDT` | implementation default | Initial Paper account equity. |
79
+ | `FUTURES_EXCHANGE_ID` | implementation default | Exchange adapter ID. |
80
+ | `FUTURES_API_KEY` | none | Exchange credential boundary. Do not set for routine Paper-only development. |
81
+ | `FUTURES_API_SECRET` | none | Exchange secret. |
82
+ | `FUTURES_API_PASSPHRASE` | none | Optional exchange passphrase. |
83
+
84
+ Do not introduce credentials into source, logs, diagnostics, screenshots, patches, or generated reports.
85
+
86
+ ## External advisory
87
+
88
+ | Variable | Default | Purpose |
89
+ |---|---|---|
90
+ | `EXTERNAL_AI_ENABLED` | `true` | Allow advisory when explicitly requested. |
91
+ | `EXTERNAL_AI_TIMEOUT_SECONDS` | `8` | Legacy/advisory timeout. |
92
+ | `EXTERNAL_AI_PROVIDER_TIMEOUT_SECONDS` | `8` | Per-provider timeout. |
93
+ | `EXTERNAL_AI_TOTAL_TIMEOUT_SECONDS` | `15` | Total advisory budget. |
94
+ | `OPENROUTER_ANALYSIS_MODEL` | configured model | OpenRouter model. |
95
+ | `GOOGLE_ANALYSIS_MODEL` | configured model | Google model. |
96
+ | `HF_ANALYSIS_MODEL` | configured model | Hugging Face model. |
97
+ | `OPENROUTER_API_KEY` | none | OpenRouter credential. |
98
+ | `GOOGLE_API_KEY` | none | Google credential. |
99
+
100
+ Provider order is OpenRouter → Google → Hugging Face. Advisory output cannot change the deterministic plan.
101
+
102
+ ## Telegram webhook
103
+
104
+ | Variable | Default | Purpose |
105
+ |---|---|---|
106
+ | `TELEGRAM_ENABLED` | `false` | Enable webhook adapter. |
107
+ | `TELEGRAM_MODE` | `webhook` | Must remain webhook mode. |
108
+ | `TELEGRAM_PUBLIC_BASE_URL` | Space URL | Webhook target base. |
109
+ | `TELEGRAM_WEBHOOK_PATH` | `/api/telegram/webhook` | Webhook path. |
110
+ | `TELEGRAM_WEBHOOK_SECRET` | none | Telegram secret-token header value. |
111
+ | `TELEGRAM_BOOTSTRAP_SECRET` | none | One-time owner claim secret. |
112
+ | `TELEGRAM_ALLOWED_USER_IDS` | empty | Comma-separated authorized IDs. |
113
+ | `TELEGRAM_BOT_TOKEN` | none | Telegram bot token. |
114
+ | `TELEGRAM_PROXY_URL` | empty | Optional direct Bot API proxy. |
115
+ | `TELEGRAM_RELAY_URL` | empty | Optional proactive relay. |
116
+ | `TELEGRAM_RELAY_SECRET` | empty | HMAC relay secret. |
117
+ | `TELEGRAM_STATE_PATH` | `/opt/data/telegram_state.json` | Persisted owner/watchlist state. |
118
+ | `TELEGRAM_ALERTS_ENABLED` | `false` | External-scheduler alert evaluation status. |
119
+ | `TELEGRAM_COMMAND_RATE_LIMIT` | `10` | Commands per user per minute. |
120
+ | `TELEGRAM_SCAN_MAX_SYMBOLS` | `300` | Maximum verified catalog candidates. |
121
+ | `TELEGRAM_SCAN_SHORTLIST_SIZE` | `20` | Deterministic shortlist size. |
122
+ | `TELEGRAM_SCAN_RESULT_COUNT` | `10` | Displayed result count. |
123
+ | `TELEGRAM_SCAN_MAX_CONCURRENCY` | `4` | Analysis concurrency. |
124
+
125
+ ## MCP isolation
126
+
127
+ | Variable | Default | Requirement |
128
+ |---|---|---|
129
+ | `LINEAR_MCP_ENABLED` | `false` | Keep unchanged unless separately approved. |
130
+ | `UNREAL_ENGINE_MCP_ENABLED` | `false` | Keep disabled unless separately approved. |
131
+
132
+ ## Runtime integrity paths
133
+
134
+ Advanced overrides used by diagnostics:
135
+
136
+ ```text
137
+ HERMES_FUTURES_OVERLAY_MANIFEST
138
+ HERMES_OVERLAY_SOURCE
139
+ HERMES_SYNC_SCRIPT
140
+ ```
141
+
142
+ These should normally use their runtime defaults.
docs/FRONTEND_GUIDE.md ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Frontend Guide
2
+
3
+ ## File and runtime
4
+
5
+ The entire Futures dashboard UI is packaged in:
6
+
7
+ ```text
8
+ hermes_overlay/tools/templates/hermes_futures_desk_luxury.html
9
+ ```
10
+
11
+ The router reads this file at request time from its installed `tools/templates` directory and serves it at `/futures`. Do not add a second frontend server, bundler process, or port.
12
+
13
+ ## Design system
14
+
15
+ The UI uses an Obsidian & Gold workstation theme with a light-theme option. Operational values use readable sans-serif/monospace styling. Decorative serif/italic styling is limited to headings and visual accents.
16
+
17
+ Responsive modes cover desktop, tablet, and mobile. `prefers-reduced-motion` is respected.
18
+
19
+ ## Main UI regions
20
+
21
+ - fixed/collapsible navigation and local market lists;
22
+ - command deck with symbol, risk, advisory, Analyze, and Paper Execute controls;
23
+ - selected-market header and chart;
24
+ - market diagnostics and field provenance;
25
+ - decision, score, risk approval, and execution mode;
26
+ - trade-plan geometry and execution checklist;
27
+ - signal reasons and components;
28
+ - Paper account and positions;
29
+ - datasource health and technical diagnostics;
30
+ - Telegram operational status;
31
+ - local activity/history and export actions.
32
+
33
+ ## API usage
34
+
35
+ The helper uses:
36
+
37
+ ```javascript
38
+ fetch(url, {
39
+ credentials: 'same-origin',
40
+ cache: 'no-store'
41
+ })
42
+ ```
43
+
44
+ Primary calls:
45
+
46
+ ```text
47
+ GET /api/futures/status
48
+ GET /api/futures/symbols
49
+ GET /api/futures/positions
50
+ GET /api/futures/market
51
+ POST /api/futures/analyze
52
+ POST /api/futures/paper/execute
53
+ GET /api/telegram/status
54
+ ```
55
+
56
+ No backend route is declared in the template.
57
+
58
+ ## Refresh behavior
59
+
60
+ Default intervals:
61
+
62
+ - clock and age labels: 1 second;
63
+ - status and positions: 5 seconds while auto-refresh is enabled and page is visible;
64
+ - market data: 15 seconds;
65
+ - Telegram status: 60 seconds.
66
+
67
+ When the page becomes visible again, status and market data refresh if automatic refresh is enabled.
68
+
69
+ ## Chart behavior
70
+
71
+ Supported intervals:
72
+
73
+ ```text
74
+ 1m 5m 15m 1h
75
+ ```
76
+
77
+ Supported candle limits:
78
+
79
+ ```text
80
+ 60 120 240
81
+ ```
82
+
83
+ Modes:
84
+
85
+ - Candles;
86
+ - Line;
87
+ - optional volume bars.
88
+
89
+ The chart is an inline SVG and uses only `candles` returned by the market endpoint. Crosshair, OHLCV legend, tooltip, current-price reference, price labels, visible high/low, range position, and last-candle age are derived from the returned series.
90
+
91
+ No visual interpolation or fallback is permitted to create production candles.
92
+
93
+ ## Display-only diagnostics
94
+
95
+ The UI calculates visible trend, average candle range, relative last-candle volume, realized variation, last-candle direction, and range position from the real returned candles. These values are explicitly informational and must never change:
96
+
97
+ ```text
98
+ LONG / SHORT / NO_TRADE
99
+ risk approval
100
+ noTradeGuard
101
+ Entry / SL / TP
102
+ leverage
103
+ quantity
104
+ execution eligibility
105
+ ```
106
+
107
+ ## Local browser state
108
+
109
+ The UI stores only convenience preferences/history in `localStorage`.
110
+
111
+ Known keys:
112
+
113
+ ```text
114
+ hermes_theme
115
+ hermes_auto_refresh
116
+ hermes_compact
117
+ ```
118
+
119
+ Watchlist, recent markets, local analysis history, and workspace activity use Hermes-prefixed local keys defined in the template. They are not synchronized to the server and are not trusted for execution.
120
+
121
+ ## Keyboard shortcuts
122
+
123
+ | Key | Action |
124
+ |---|---|
125
+ | `/` | Focus and select symbol search. |
126
+ | `A` | Run analysis when not already analyzing. |
127
+ | `R` | Manual refresh. |
128
+ | `D` | Toggle display density. |
129
+ | `T` | Toggle theme. |
130
+ | `?` | Open shortcut help. |
131
+ | `Escape` | Close overlays/help. |
132
+
133
+ There is deliberately no keyboard shortcut for Paper Execute.
134
+
135
+ ## Analysis state rendering
136
+
137
+ Use the server result to set one of:
138
+
139
+ ```text
140
+ NOT_ANALYZED
141
+ ANALYZING
142
+ LONG
143
+ SHORT
144
+ NO_TRADE
145
+ ANALYSIS_FAILED
146
+ STALE
147
+ API_UNAVAILABLE
148
+ ```
149
+
150
+ Important rules:
151
+
152
+ - initial state is “Waiting for analysis,” not `NO_TRADE`;
153
+ - HTTP/network failure is `ANALYSIS_FAILED` or `API_UNAVAILABLE`;
154
+ - score is “Unavailable” when components do not exist, not numeric zero;
155
+ - expiry is prominent only for a valid directional plan;
156
+ - rejected/incomplete analysis is not presented as executable;
157
+ - changing symbol or risk invalidates the current browser plan;
158
+ - server state remains authoritative.
159
+
160
+ ## Execute availability
161
+
162
+ The button is disabled unless the latest browser plan mirrors all required server fields. The UI displays a concrete disabled reason such as:
163
+
164
+ ```text
165
+ Run analysis first
166
+ No directional plan
167
+ Risk approval failed
168
+ noTradeGuard active
169
+ Market-only symbol
170
+ Plan expired
171
+ Symbol changed
172
+ Risk profile changed
173
+ Plan already executed
174
+ Required Futures fields unavailable
175
+ ```
176
+
177
+ These checks improve UX but do not replace backend revalidation.
178
+
179
+ ## Adding a UI feature safely
180
+
181
+ 1. Reuse existing API fields or add an additive backend field.
182
+ 2. Render missing values as `Unavailable`, never zero or fabricated content.
183
+ 3. Keep browser calculations labeled display-only.
184
+ 4. Do not add another Execute path or shortcut.
185
+ 5. Invalidate plan display when relevant controls change.
186
+ 6. Keep DOM IDs unique and update static ID checks.
187
+ 7. Preserve responsive and reduced-motion behavior.
188
+ 8. Do not display raw provider errors or secrets.
189
+ 9. Verify Console and Network in an authenticated deployed session.
docs/OPERATIONS_AND_TROUBLESHOOTING.md ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Operations and Troubleshooting
2
+
3
+ ## Diagnostic order
4
+
5
+ 1. Confirm Space is `RUNNING`.
6
+ 2. Fetch `/futures` and inspect response/hash headers.
7
+ 3. Check `/api/futures/status` with authentication.
8
+ 4. Inspect runtime file status and datasource metadata.
9
+ 5. Check Browser Console and Network.
10
+ 6. Inspect market endpoint for one symbol/interval.
11
+ 7. Compare DS4 raw/normalized fields and timestamps.
12
+ 8. Check provider-specific diagnostics.
13
+ 9. Run one analysis-only request.
14
+ 10. Do not test Paper Execute during diagnosis.
15
+
16
+ ## Common issues
17
+
18
+ ### Dashboard returns 401
19
+
20
+ Likely causes:
21
+
22
+ - missing/incorrect `HERMES_ADMIN_PASSWORD`;
23
+ - wrong Basic username;
24
+ - browser session expired;
25
+ - reverse proxy did not preserve auth.
26
+
27
+ Actions:
28
+
29
+ - confirm `HERMES_ADMIN_USERNAME` and `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` alignment;
30
+ - re-authenticate;
31
+ - verify `entrypoint.sh` logged successful auth configuration;
32
+ - never print the password in logs or reports.
33
+
34
+ ### `/futures` returns 200 but old UI appears
35
+
36
+ Possible causes:
37
+
38
+ - stale installed overlay;
39
+ - wrong template path;
40
+ - restored old overlay taking precedence;
41
+ - CDN/browser cache;
42
+ - duplicate old page implementation.
43
+
44
+ Actions:
45
+
46
+ - compare `X-Hermes-Template-SHA256` with body hash;
47
+ - inspect `application.runtimeFiles` in status;
48
+ - confirm `/opt/hermesface_overlay` is preferred;
49
+ - confirm installed `/opt/hermes/tools/templates/...` matches manifest;
50
+ - hard reload only after server-side evidence is checked.
51
+
52
+ ### Runtime status is `unknown`
53
+
54
+ `unknown` means evidence is absent, not that files match.
55
+
56
+ Actions:
57
+
58
+ - verify manifest path and permissions;
59
+ - verify overlay and runtime paths exist;
60
+ - check `HERMES_FUTURES_OVERLAY_MANIFEST`, `HERMES_OVERLAY_SOURCE`, and `HERMES_SYNC_SCRIPT` overrides;
61
+ - inspect overlay installation logs.
62
+
63
+ ### Runtime status is `mismatch`
64
+
65
+ Actions:
66
+
67
+ - identify exact mismatched file in status payload;
68
+ - compare repository/image overlay and `/opt/hermes` file;
69
+ - verify `sync_hf.py` installed after persistence restore;
70
+ - rebuild/redeploy from a clean commit.
71
+
72
+ ### Chart says market data unavailable
73
+
74
+ Check market endpoint payload:
75
+
76
+ - HTTP 503 and `API_UNAVAILABLE`: acquisition exception;
77
+ - `state=unavailable`: no real candles/current price;
78
+ - `state=stale`: provider timestamp invalid or stale;
79
+ - `state=partial`: freshness unknown or display fields incomplete.
80
+
81
+ Inspect:
82
+
83
+ ```text
84
+ warnings
85
+ missingFields
86
+ analysisRequiredFieldsMissing
87
+ staleRequiredFields
88
+ sourceMetadata
89
+ technicalDiagnostics
90
+ ```
91
+
92
+ Do not replace missing candles with mock data.
93
+
94
+ ### Binance shows HTTP 451
95
+
96
+ This is an expected regional limitation in some Hugging Face regions.
97
+
98
+ Correct behavior:
99
+
100
+ ```text
101
+ transportStatus=restricted
102
+ httpStatus=451
103
+ dataUsability=unavailable
104
+ reason=Regionally restricted
105
+ ```
106
+
107
+ Do not use raw-IP or TLS-bypass workarounds. DS4 safety remains authoritative.
108
+
109
+ ### KuCoin reports “Parameter 'from' must be milliseconds”
110
+
111
+ Verify DS4 request builder uses `build_kucoin_time_range()` and sends integer millisecond `from` and `to`. Check for upstream code that converts an already-millisecond value a second time.
112
+
113
+ ### Market data is HTTP 200 but readiness is blocked
114
+
115
+ Transport and readiness are separate. Inspect:
116
+
117
+ - DS4 Futures verification;
118
+ - DS4 `noTradeGuard`;
119
+ - missing critical fields;
120
+ - non-fresh critical fields;
121
+ - merge rejection reasons.
122
+
123
+ A provider may be reachable while its data is unusable.
124
+
125
+ ### `NO_TRADE` displayed before analysis
126
+
127
+ The initial state must be `NOT_ANALYZED`. Check frontend initialization and `/api/futures/status.analysisState`. A network error must be `ANALYSIS_FAILED` or `API_UNAVAILABLE`, not `NO_TRADE`.
128
+
129
+ ### Signal score shows zero with no components
130
+
131
+ The UI must show `Unavailable`. Check whether `latestSignalScore` is `null` and whether signal components are empty. Do not coerce null to zero.
132
+
133
+ ### Execute button is disabled
134
+
135
+ This is normally correct. Read the visible reason and inspect:
136
+
137
+ ```text
138
+ latest plan exists
139
+ planId matches
140
+ symbol and risk match
141
+ plan not expired
142
+ LONG/SHORT decision
143
+ verified Futures
144
+ risk approved
145
+ noTradeGuard false
146
+ tradingReadiness ready
147
+ executable true
148
+ not already executed
149
+ ```
150
+
151
+ ### Telegram says Owner setup required
152
+
153
+ No configured/persisted owner exists. Use the one-time private `/claim <TELEGRAM_BOOTSTRAP_SECRET>` flow. Remove/rotate the bootstrap secret after claim. Do not expose owner ID in dashboard status.
154
+
155
+ ### Telegram proactive alerts unavailable
156
+
157
+ Webhook responses can work without outbound connectivity, but proactive alerts need either:
158
+
159
+ - direct Telegram access with optional proxy; or
160
+ - configured relay URL and HMAC secret.
161
+
162
+ Keep polling disabled.
163
+
164
+ ## Logs and artifacts
165
+
166
+ Useful logs:
167
+
168
+ ```text
169
+ Space build log
170
+ entrypoint startup log
171
+ sync_hf overlay install/hash log
172
+ Hermes dashboard log under /opt/data/logs
173
+ sanitized runtime audit JSON
174
+ browser Console and Network export without credentials
175
+ ```
176
+
177
+ Never attach raw cookies, Authorization headers, tokens, or unredacted provider payloads.
docs/PROJECT_STATUS.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Status
2
+
3
+ ## Snapshot
4
+
5
+ This documentation describes the UI v3 implementation package prepared on 2026-07-21.
6
+
7
+ Repository base recorded by the implementation package:
8
+
9
+ ```text
10
+ 3ff79ee0fce31f8d09a7dac357904169d50d9f3e
11
+ ```
12
+
13
+ Last known deployed revision before this package:
14
+
15
+ ```text
16
+ 24d8dad11c0d7316446e9a26b0b074e8630de139
17
+ ```
18
+
19
+ The package itself was not committed, pushed, or deployed by the implementation environment.
20
+
21
+ ## Implemented backend/runtime work
22
+
23
+ - packaged Luxury template is the runtime source;
24
+ - overlay installation and SHA-256 manifest;
25
+ - runtime status `verified` / `mismatch` / `unknown`;
26
+ - no-cache Futures responses;
27
+ - KuCoin millisecond range construction;
28
+ - conservative DS4 Futures verification;
29
+ - DS4/Binance/DS2 normalization and priority;
30
+ - per-field provenance and truthful freshness;
31
+ - structured source health and merge readiness;
32
+ - real market endpoint with four intervals;
33
+ - explicit partial/stale/unavailable semantics;
34
+ - deterministic state machine and non-executable plan semantics;
35
+ - server-side plan/symbol/risk/expiry/readiness/risk revalidation;
36
+ - stronger diagnostic redaction;
37
+ - read-only runtime audit utility;
38
+ - Telegram webhook-only and Linear MCP isolation preserved.
39
+
40
+ ## Implemented UI v3 work
41
+
42
+ - watchlist and recent markets;
43
+ - manual/automatic refresh and density/theme controls;
44
+ - keyboard help without execution shortcut;
45
+ - Candles/Line chart, volume, crosshair, tooltip, four intervals, three limits;
46
+ - market header, source/freshness/readiness, order-book top values;
47
+ - display-only diagnostics;
48
+ - per-field provenance;
49
+ - plan geometry and execution checklist;
50
+ - analysis copy/export/history/activity;
51
+ - expanded datasource and technical diagnostics;
52
+ - responsive desktop/tablet/mobile layout.
53
+
54
+ ## Validation already recorded
55
+
56
+ Static validation reported:
57
+
58
+ - modified Python files compiled;
59
+ - dashboard JavaScript passed `node --check`;
60
+ - DOM ID and static reference checks passed;
61
+ - CSS custom-property checks passed;
62
+ - whitespace checks passed;
63
+ - package ZIP integrity passed.
64
+
65
+ Behavioral tests, Futures regression tests, authenticated production verification, and deployment were deferred.
66
+
67
+ ## Remaining production work
68
+
69
+ 1. Review documentation and final code diff.
70
+ 2. Run focused tests and existing Futures regression suite.
71
+ 3. Verify real DS4 field names and timestamps.
72
+ 4. Perform one safe KuCoin read-only request.
73
+ 5. Deploy through the repository/Hugging Face workflow.
74
+ 6. Run authenticated read-only audit.
75
+ 7. Inspect Browser Console and Network.
76
+ 8. Verify real market rendering for all intervals and UI features.
77
+ 9. Run one BTCUSDT analysis-only request.
78
+ 10. Confirm Telegram webhook-only mode and Linear MCP isolation.
79
+ 11. Record commit hash and serving Space revision.
80
+ 12. Confirm no secret exposure and no trade execution.
docs/README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hermes Futures Desk Developer Documentation
2
+
3
+ This directory is the canonical developer documentation for the repository snapshot.
4
+
5
+ ## Reading order
6
+
7
+ 1. [Complete Developer Guide](DEVELOPER_GUIDE.md) — single-document overview.
8
+ 2. [Architecture](ARCHITECTURE.md) — runtime, module boundaries, and control flow.
9
+ 3. [API Reference](API_REFERENCE.md) — authenticated routes and payload contracts.
10
+ 4. [Datasource Pipeline and Contracts](DATA_PIPELINE_AND_CONTRACTS.md) — DS4, Binance, DS2, normalization, provenance, and readiness.
11
+ 5. [Frontend Guide](FRONTEND_GUIDE.md) — UI state, charting, local workspace features, and execution controls.
12
+ 6. [Environment Configuration](ENVIRONMENT_CONFIGURATION.md) — supported variables and secret handling.
13
+ 7. [Deployment Runbook](DEPLOYMENT_RUNBOOK.md) — build, overlay installation, Space deployment, verification, and rollback.
14
+ 8. [Security and Safety](SECURITY_AND_SAFETY.md) — non-negotiable safety model and threat boundaries.
15
+ 9. [Operations and Troubleshooting](OPERATIONS_AND_TROUBLESHOOTING.md) — common failures and diagnostic workflow.
16
+ 10. [Testing and Verification](TESTING_AND_VERIFICATION.md) — focused tests, runtime audit, browser verification, and acceptance checklist.
17
+ 11. [Contributing](CONTRIBUTING.md) — change discipline and review checklist.
18
+ 12. [Project Status](PROJECT_STATUS.md) — implemented work and remaining production verification.
19
+
20
+ ## Documentation principles
21
+
22
+ - Source code is authoritative when documentation and implementation disagree.
23
+ - API examples are representative; clients must tolerate additive fields.
24
+ - Browser-derived values are informational and never authorize execution.
25
+ - Runtime verification requires evidence from hashes, authenticated API responses, and browser Console/Network inspection.
26
+ - “Application online” is not equivalent to “market data healthy” or “trading ready.”
docs/SECURITY_AND_SAFETY.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security and Safety
2
+
3
+ ## Non-negotiable invariants
4
+
5
+ Do not change or weaken:
6
+
7
+ - deterministic `LONG`, `SHORT`, and `NO_TRADE` decisions;
8
+ - Datasource 4 authority;
9
+ - `noTradeGuard`;
10
+ - Futures verification;
11
+ - provider timestamp and freshness checks;
12
+ - risk approval;
13
+ - Stop Loss and Take Profit rules;
14
+ - leverage caps and volatility haircut;
15
+ - quantity/sizing logic;
16
+ - Paper execution validation;
17
+ - Telegram webhook-only isolation;
18
+ - single FastAPI application and port 7860 architecture.
19
+
20
+ ## Browser trust model
21
+
22
+ The browser is untrusted for execution. It may display and calculate convenience diagnostics, but the server ignores browser-derived authorization.
23
+
24
+ Server-side Paper checks include plan reference, symbol/risk identity, expiry, executed flag, direction, DS4 verification, readiness, guard state, risk approval, executable flag, Paper mode, and fresh re-analysis.
25
+
26
+ ## Secret handling
27
+
28
+ Never expose or commit:
29
+
30
+ ```text
31
+ HF_TOKEN
32
+ HERMES_ADMIN_PASSWORD
33
+ FUTURES_API_KEY
34
+ FUTURES_API_SECRET
35
+ FUTURES_API_PASSPHRASE
36
+ OPENROUTER_API_KEY
37
+ GOOGLE_API_KEY
38
+ TELEGRAM_BOT_TOKEN
39
+ TELEGRAM_WEBHOOK_SECRET
40
+ TELEGRAM_BOOTSTRAP_SECRET
41
+ TELEGRAM_RELAY_SECRET
42
+ cookies or Authorization headers
43
+ ```
44
+
45
+ Diagnostics redact keys and text matching authorization, cookie, token, secret, password, or API key patterns. Continue to sanitize new error fields before they reach API responses or UI.
46
+
47
+ ## Market-data integrity
48
+
49
+ - No fabricated production candles, prices, funding, Open Interest, or order-book levels.
50
+ - Missing values are `null`/`Unavailable`, not zero.
51
+ - HTTP success is not data freshness.
52
+ - Provider errors in main UI are concise; detailed errors remain sanitized under Technical Diagnostics.
53
+ - Binance regional restriction must not be bypassed with raw IP, DNS override, or disabled TLS.
54
+
55
+ ## External AI boundary
56
+
57
+ External AI may return market bias, confidence, summary, and warnings. It must never modify:
58
+
59
+ ```text
60
+ decision
61
+ risk approval
62
+ noTradeGuard
63
+ Entry
64
+ Stop Loss
65
+ Take Profit
66
+ leverage
67
+ quantity
68
+ execution availability
69
+ ```
70
+
71
+ Bulk scans must not use advisory AI.
72
+
73
+ ## Telegram boundary
74
+
75
+ - Webhook secret-token validation is mandatory.
76
+ - Request size is bounded.
77
+ - Owner bootstrap is one-time, secret-checked, and private-chat only.
78
+ - Users are authorized by configured IDs or persisted owner.
79
+ - Commands are rate-limited.
80
+ - Callback nonces expire and are user-bound.
81
+ - Telegram performs analysis only and contains no order path.
82
+ - Polling remains disabled.
83
+
84
+ ## Development safety
85
+
86
+ During ordinary development and deployment verification:
87
+
88
+ - do not call Paper Execute;
89
+ - do not run Testnet or Live execution;
90
+ - use the read-only audit script;
91
+ - use Paper account endpoints only for display verification;
92
+ - do not add an execution keyboard shortcut;
93
+ - do not allow a UI feature to write plan or risk state directly.
94
+
95
+ ## Review checklist for security-sensitive changes
96
+
97
+ - Does the change alter a deterministic threshold or formula?
98
+ - Can fallback data override DS4 safety?
99
+ - Can a missing timestamp be treated as fresh?
100
+ - Can the browser enable execution without server state?
101
+ - Can a raw error include a secret?
102
+ - Can a Telegram request bypass authorization or webhook validation?
103
+ - Does the change introduce a second network service or port?
104
+ - Does it add an exchange credential requirement?
105
+ - Are failure states blocked by default?
docs/TESTING_AND_VERIFICATION.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Testing and Verification
2
+
3
+ ## Test locations
4
+
5
+ ```text
6
+ hermes_overlay/tests/
7
+ ```
8
+
9
+ Existing focused areas include:
10
+
11
+ - Binance public fallback;
12
+ - nested DS4 merge behavior;
13
+ - external advisory boundary;
14
+ - Futures dashboard/state;
15
+ - Futures integration;
16
+ - Luxury template markers;
17
+ - optional MCP runtime isolation;
18
+ - Telegram webhook;
19
+ - trade-cycle field paths.
20
+
21
+ ## Recommended validation layers
22
+
23
+ ### 1. Static validation
24
+
25
+ ```bash
26
+ python -m compileall hermes_overlay scripts
27
+ node --check /tmp/hermes_dashboard_script.js
28
+ ruff check hermes_overlay scripts
29
+ ```
30
+
31
+ Also check:
32
+
33
+ - duplicate DOM IDs;
34
+ - missing JavaScript DOM references;
35
+ - undefined CSS custom properties;
36
+ - `git diff --check`;
37
+ - absence of secrets/generated files.
38
+
39
+ ### 2. Focused unit tests
40
+
41
+ Required focus:
42
+
43
+ - seconds-to-milliseconds conversion;
44
+ - already-millisecond timestamps;
45
+ - ordered bounded KuCoin ranges;
46
+ - ticker/funding/Open Interest aliases;
47
+ - malformed and ambiguous provider shapes;
48
+ - per-field source/timestamp/freshness attribution;
49
+ - transport health versus usability/readiness;
50
+ - datasource-specific error attribution;
51
+ - market endpoint canonical shape;
52
+ - no fabricated values;
53
+ - safe rejection when required fields are missing or non-fresh;
54
+ - initial/failure UI states;
55
+ - Execute-disabled reasons and server safety gates.
56
+
57
+ ### 3. Futures regression suite
58
+
59
+ Run the existing Futures tests once after focused tests pass. Avoid repeatedly running unrelated broad suites while iterating on a narrow failure.
60
+
61
+ ### 4. Read-only deployed audit
62
+
63
+ ```bash
64
+ export HERMES_ADMIN_PASSWORD='...'
65
+ python scripts/verify_futures_runtime.py \
66
+ --base-url https://really-amin-asset.hf.space \
67
+ --symbol BTCUSDT \
68
+ --analyze \
69
+ --report .runtime_audit/futures_runtime_audit.json
70
+ ```
71
+
72
+ The audit never calls Paper Execute.
73
+
74
+ ### 5. Browser verification
75
+
76
+ Authenticated desktop and mobile verification must cover:
77
+
78
+ - Console free of critical errors;
79
+ - valid Network status and JSON payloads;
80
+ - chart rendering for all intervals;
81
+ - Candles/Line, volume, tooltip, crosshair;
82
+ - watchlist/recent/history/export/density/theme/auto-refresh controls;
83
+ - field provenance and datasource cards;
84
+ - state machine and score semantics;
85
+ - visible Execute-disabled reason;
86
+ - no Paper Execute click.
87
+
88
+ ## Acceptance matrix
89
+
90
+ | Area | Required result |
91
+ |---|---|
92
+ | Runtime files | `verified`, or documented investigation for `unknown`; never unexplained mismatch. |
93
+ | Status API | 200 authenticated, structured source/runtime fields. |
94
+ | Symbols API | Accurate total/verified/market-only counts. |
95
+ | Positions API | Deliberate empty state or formatted real Paper positions. |
96
+ | Market API | Real canonical candles or explicit structured unavailable state. |
97
+ | Analysis API | Deterministic result; `NO_TRADE` is allowed and expected when unsafe. |
98
+ | Paper Execute | Not called during verification. |
99
+ | Binance 451 | Clearly reported as regional restriction. |
100
+ | Telegram | Webhook-only; no polling adapter. |
101
+ | Secrets | None in source, logs, reports, screenshots, or package. |
102
+
103
+ ## Testing safety
104
+
105
+ Tests must not:
106
+
107
+ - place Paper/Testnet/Live orders;
108
+ - require real exchange credentials;
109
+ - fabricate production responses in deployed paths;
110
+ - weaken guards to make assertions pass;
111
+ - treat an unavailable score as zero;
112
+ - report missing runtime evidence as verified.
hermes_overlay/tests/test_binance_public_fallback.py CHANGED
@@ -21,8 +21,9 @@ import trading.trade_cycle as trade_cycle
21
 
22
 
23
  class _FakeResponse:
24
- def __init__(self, payload):
25
  self._payload = payload
 
26
 
27
  def raise_for_status(self):
28
  pass
@@ -41,7 +42,7 @@ def _make_fake_client(ds4_payload, ds2_payloads=None, ds4_raises=False):
41
  async def __aexit__(self, *a):
42
  return False
43
 
44
- async def get(self, url, timeout=None):
45
  if "short-hunter/snapshot" in url:
46
  if ds4_raises:
47
  raise RuntimeError("boom")
@@ -99,7 +100,7 @@ def test_ds4_fully_valid_binance_not_called(monkeypatch):
99
 
100
  monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _fake_binance)
101
  payload = _full_ds4_payload()
102
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
103
 
104
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
105
 
@@ -113,20 +114,26 @@ def test_ds4_fully_valid_binance_not_called(monkeypatch):
113
  # ---------------------------------------------------------------------------
114
 
115
  def test_ds4_missing_field_filled_by_binance(monkeypatch):
 
 
 
 
116
  calls = []
117
 
118
- async def _fake_binance(symbol, needed):
119
- calls.append(needed)
120
- assert needed == frozenset({"funding"})
121
- return {"funding": {"currentFundingRate": -0.0001, "source": "binance_public"}}, []
 
 
122
 
123
- monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _fake_binance)
124
  payload = _full_ds4_payload(funding=None) # DS4 genuinely missing funding
125
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
126
 
127
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
128
 
129
- assert calls == [frozenset({"funding"})]
130
  assert result["merged"]["funding"] == {"currentFundingRate": -0.0001, "source": "binance_public"}
131
  assert result["sources"]["funding"] == "binance_public"
132
  assert any("filled from Binance public fallback" in w for w in result["warnings"])
@@ -170,26 +177,30 @@ def test_oi_change_none_when_history_too_short():
170
 
171
 
172
  def test_ds4_missing_oi_history_filled_by_binance(monkeypatch):
 
 
173
  calls = []
174
 
175
- async def _fake_binance(symbol, needed):
176
- calls.append(needed)
177
- assert needed == frozenset({"openInterestChange"})
178
  return {
179
- "openInterestChange": {
180
- "changePercent": 0.1,
181
- "history": [
182
- {"sumOpenInterest": 1000.0, "timestamp": 1},
183
- {"sumOpenInterest": 1100.0, "timestamp": 2},
184
- ],
185
- "source": "binance_public",
186
- }
187
- }, []
188
-
189
- monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _fake_binance)
 
 
 
190
  payload = _full_ds4_payload()
191
  payload["data"]["openInterest"] = {"openInterest": 27000928.0, "history": []}
192
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
193
 
194
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
195
  assert calls == [frozenset({"openInterestChange"})]
@@ -203,19 +214,18 @@ def test_ds4_missing_oi_history_filled_by_binance(monkeypatch):
203
  # ---------------------------------------------------------------------------
204
 
205
  def test_notradeguard_enforced_even_when_binance_fills_all_gaps(monkeypatch):
206
- async def _fake_binance(symbol, needed):
207
- # Pretend Binance successfully supplies everything that was missing.
208
- filled = {}
209
- if "funding" in needed:
210
- filled["funding"] = {"currentFundingRate": -0.0001, "source": "binance_public"}
211
- if "atr" in needed:
212
- filled["atr"] = 42.0
213
- return filled, []
214
 
215
- monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _fake_binance)
216
  payload = _full_ds4_payload(funding=None)
217
  payload["noTradeGuard"] = True # DS4 itself says NO_TRADE
218
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
219
 
220
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
221
 
 
21
 
22
 
23
  class _FakeResponse:
24
+ def __init__(self, payload, status_code=200):
25
  self._payload = payload
26
+ self.status_code = status_code
27
 
28
  def raise_for_status(self):
29
  pass
 
42
  async def __aexit__(self, *a):
43
  return False
44
 
45
+ async def get(self, url, timeout=None, **_kwargs):
46
  if "short-hunter/snapshot" in url:
47
  if ds4_raises:
48
  raise RuntimeError("boom")
 
100
 
101
  monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _fake_binance)
102
  payload = _full_ds4_payload()
103
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
104
 
105
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
106
 
 
114
  # ---------------------------------------------------------------------------
115
 
116
  def test_ds4_missing_field_filled_by_binance(monkeypatch):
117
+ # "funding" is one of the market-data fields (ticker/ohlcv/funding/openInterest)
118
+ # that get_market_context() requests together via get_binance_public_market_data;
119
+ # only openInterestChange/atr/orderbook go through get_binance_public_result with
120
+ # an explicit `needed` set. See dual_datasource_client.get_market_context().
121
  calls = []
122
 
123
+ async def _fake_market_data(symbol, interval, limit):
124
+ calls.append(symbol)
125
+ return {
126
+ "data": {"funding": {"currentFundingRate": -0.0001, "source": "binance_public"}},
127
+ "errors": [], "warnings": [], "meta": {},
128
+ }
129
 
130
+ monkeypatch.setattr(ddc.binance_public, "get_binance_public_market_data", _fake_market_data)
131
  payload = _full_ds4_payload(funding=None) # DS4 genuinely missing funding
132
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
133
 
134
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
135
 
136
+ assert len(calls) == 1
137
  assert result["merged"]["funding"] == {"currentFundingRate": -0.0001, "source": "binance_public"}
138
  assert result["sources"]["funding"] == "binance_public"
139
  assert any("filled from Binance public fallback" in w for w in result["warnings"])
 
177
 
178
 
179
  def test_ds4_missing_oi_history_filled_by_binance(monkeypatch):
180
+ # openInterestChange is not one of the market-data fields, so it is
181
+ # requested via get_binance_public_result(symbol, needed_fields).
182
  calls = []
183
 
184
+ async def _fake_result(symbol, needed_fields=None):
185
+ calls.append(frozenset(needed_fields) if needed_fields is not None else frozenset())
 
186
  return {
187
+ "data": {
188
+ "openInterestChange": {
189
+ "changePercent": 0.1,
190
+ "history": [
191
+ {"sumOpenInterest": 1000.0, "timestamp": 1},
192
+ {"sumOpenInterest": 1100.0, "timestamp": 2},
193
+ ],
194
+ "source": "binance_public",
195
+ }
196
+ },
197
+ "errors": [], "warnings": [], "meta": {},
198
+ }
199
+
200
+ monkeypatch.setattr(ddc.binance_public, "get_binance_public_result", _fake_result)
201
  payload = _full_ds4_payload()
202
  payload["data"]["openInterest"] = {"openInterest": 27000928.0, "history": []}
203
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
204
 
205
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
206
  assert calls == [frozenset({"openInterestChange"})]
 
214
  # ---------------------------------------------------------------------------
215
 
216
  def test_notradeguard_enforced_even_when_binance_fills_all_gaps(monkeypatch):
217
+ async def _fake_market_data(symbol, interval, limit):
218
+ # Pretend Binance successfully supplies everything that was missing
219
+ # (here just "funding" -- the rest of the fixture's DS4 data is valid).
220
+ return {
221
+ "data": {"funding": {"currentFundingRate": -0.0001, "source": "binance_public"}},
222
+ "errors": [], "warnings": [], "meta": {},
223
+ }
 
224
 
225
+ monkeypatch.setattr(ddc.binance_public, "get_binance_public_market_data", _fake_market_data)
226
  payload = _full_ds4_payload(funding=None)
227
  payload["noTradeGuard"] = True # DS4 itself says NO_TRADE
228
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
229
 
230
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
231
 
hermes_overlay/tests/test_ds4_merge_nested_data.py CHANGED
@@ -10,6 +10,7 @@ Mocked HTTP only -- no live network calls.
10
  import asyncio
11
  import os
12
  import sys
 
13
  import pytest
14
 
15
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # hermes_overlay/
@@ -25,8 +26,9 @@ def _disable_live_binance(monkeypatch):
25
 
26
 
27
  class _FakeResponse:
28
- def __init__(self, payload):
29
  self._payload = payload
 
30
 
31
  def raise_for_status(self):
32
  pass
@@ -45,7 +47,7 @@ def _make_fake_client(ds4_payload, ds2_payloads=None, ds4_raises=False):
45
  async def __aexit__(self, *a):
46
  return False
47
 
48
- async def get(self, url, timeout=None):
49
  if "short-hunter/snapshot" in url:
50
  if ds4_raises:
51
  raise RuntimeError("boom")
@@ -64,11 +66,15 @@ def _nested_ds4_payload(**overrides):
64
  "symbol": "BTCUSDT",
65
  "dataState": "live",
66
  "noTradeGuard": False,
67
- "timestamp": "2026-07-20T11:08:19Z",
68
  "warnings": [],
69
  "errors": [],
70
  "data": {
71
- "contract": {"symbol": "XBTUSDTM"},
 
 
 
 
72
  "ticker": {"lastPrice": 64364.4, "change24h": None},
73
  "ohlcv": [
74
  {"timestamp": i, "open": 64360, "high": 64370, "low": 64350,
@@ -88,7 +94,7 @@ def _nested_ds4_payload(**overrides):
88
 
89
  def test_merge_reads_futures_fields_from_nested_data(monkeypatch):
90
  payload = _nested_ds4_payload()
91
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
92
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
93
  merged = result["merged"]
94
 
@@ -96,7 +102,11 @@ def test_merge_reads_futures_fields_from_nested_data(monkeypatch):
96
  assert merged["funding"] == {"currentFundingRate": -8.2e-05}
97
  assert merged["openInterest"] == {"openInterest": 27000928.0, "history": []}
98
  assert merged["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]}
99
- assert merged["contract"] == {"symbol": "XBTUSDTM"}
 
 
 
 
100
  assert result["sources"]["ticker"] == "datasource4"
101
  assert result["sources"]["funding"] == "datasource4"
102
  assert result["noTradeGuard"] is False
@@ -107,7 +117,7 @@ def test_ds2_fallback_fires_only_for_genuinely_missing_nested_field(monkeypatch)
107
  payload["data"]["orderbook"] = None # DS4 genuinely missing this field
108
  ds2_payloads = {"trading/orderbook": {"bids": [[1, 1]], "asks": [[1, 1]]}}
109
  monkeypatch.setattr(
110
- ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload, ds2_payloads)
111
  )
112
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
113
  merged = result["merged"]
@@ -123,7 +133,7 @@ def test_ds2_cannot_override_valid_nested_ds4_orderbook(monkeypatch):
123
  payload = _nested_ds4_payload() # DS4 orderbook present and valid
124
  ds2_payloads = {"trading/orderbook": {"bids": [[999, 1]], "asks": [[999, 1]]}}
125
  monkeypatch.setattr(
126
- ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload, ds2_payloads)
127
  )
128
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
129
  merged = result["merged"]
@@ -138,7 +148,7 @@ def test_ds4_data_key_missing_entirely_is_treated_as_no_data(monkeypatch):
138
  "noTradeGuard": False, "timestamp": "x", "warnings": [], "errors": [],
139
  # no "data" key at all -- malformed response
140
  }
141
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
142
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
143
 
144
  assert result["sources"]["ticker"] == "unavailable"
@@ -150,7 +160,7 @@ def test_ds4_data_key_missing_entirely_is_treated_as_no_data(monkeypatch):
150
  def test_top_level_metadata_unaffected_by_nesting_fix(monkeypatch):
151
  """dataState/noTradeGuard were never part of the bug -- must stay top-level reads."""
152
  payload = _nested_ds4_payload(dataState="PARTIAL")
153
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
154
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
155
 
156
  assert result["noTradeGuard"] is True
 
10
  import asyncio
11
  import os
12
  import sys
13
+ from datetime import datetime, timezone
14
  import pytest
15
 
16
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # hermes_overlay/
 
26
 
27
 
28
  class _FakeResponse:
29
+ def __init__(self, payload, status_code=200):
30
  self._payload = payload
31
+ self.status_code = status_code
32
 
33
  def raise_for_status(self):
34
  pass
 
47
  async def __aexit__(self, *a):
48
  return False
49
 
50
+ async def get(self, url, timeout=None, **_kwargs):
51
  if "short-hunter/snapshot" in url:
52
  if ds4_raises:
53
  raise RuntimeError("boom")
 
66
  "symbol": "BTCUSDT",
67
  "dataState": "live",
68
  "noTradeGuard": False,
69
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
70
  "warnings": [],
71
  "errors": [],
72
  "data": {
73
+ # contractType/status let _contract_verification() mark this a
74
+ # verified Futures instrument (see docs/DATA_PIPELINE_AND_CONTRACTS.md
75
+ # and docs/DEVELOPER_GUIDE.md: DS4 owns Futures verification, and a
76
+ # bare {"symbol": ...} is deliberately treated as unverified).
77
+ "contract": {"symbol": "XBTUSDTM", "contractType": "PERPETUAL", "status": "TRADING"},
78
  "ticker": {"lastPrice": 64364.4, "change24h": None},
79
  "ohlcv": [
80
  {"timestamp": i, "open": 64360, "high": 64370, "low": 64350,
 
94
 
95
  def test_merge_reads_futures_fields_from_nested_data(monkeypatch):
96
  payload = _nested_ds4_payload()
97
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
98
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
99
  merged = result["merged"]
100
 
 
102
  assert merged["funding"] == {"currentFundingRate": -8.2e-05}
103
  assert merged["openInterest"] == {"openInterest": 27000928.0, "history": []}
104
  assert merged["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]}
105
+ assert merged["contract"] == {
106
+ "symbol": "XBTUSDTM",
107
+ "contractType": "PERPETUAL",
108
+ "status": "TRADING",
109
+ }
110
  assert result["sources"]["ticker"] == "datasource4"
111
  assert result["sources"]["funding"] == "datasource4"
112
  assert result["noTradeGuard"] is False
 
117
  payload["data"]["orderbook"] = None # DS4 genuinely missing this field
118
  ds2_payloads = {"trading/orderbook": {"bids": [[1, 1]], "asks": [[1, 1]]}}
119
  monkeypatch.setattr(
120
+ ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload, ds2_payloads)
121
  )
122
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
123
  merged = result["merged"]
 
133
  payload = _nested_ds4_payload() # DS4 orderbook present and valid
134
  ds2_payloads = {"trading/orderbook": {"bids": [[999, 1]], "asks": [[999, 1]]}}
135
  monkeypatch.setattr(
136
+ ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload, ds2_payloads)
137
  )
138
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
139
  merged = result["merged"]
 
148
  "noTradeGuard": False, "timestamp": "x", "warnings": [], "errors": [],
149
  # no "data" key at all -- malformed response
150
  }
151
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
152
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
153
 
154
  assert result["sources"]["ticker"] == "unavailable"
 
160
  def test_top_level_metadata_unaffected_by_nesting_fix(monkeypatch):
161
  """dataState/noTradeGuard were never part of the bug -- must stay top-level reads."""
162
  payload = _nested_ds4_payload(dataState="PARTIAL")
163
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
164
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
165
 
166
  assert result["noTradeGuard"] is True
hermes_overlay/tests/test_futures_dashboard_and_state.py CHANGED
@@ -20,8 +20,9 @@ from tools.futures_dashboard_api import router as futures_router
20
 
21
 
22
  class _FakeResponse:
23
- def __init__(self, payload):
24
  self._payload = payload
 
25
 
26
  def raise_for_status(self):
27
  pass
@@ -38,7 +39,7 @@ def _make_fake_client(ds4_payload, ds4_raises=False):
38
  async def __aexit__(self, *a):
39
  return False
40
 
41
- async def get(self, url, timeout=None):
42
  if "short-hunter/snapshot" in url or "short-hunter/health" in url:
43
  if ds4_raises:
44
  raise RuntimeError("boom")
@@ -69,7 +70,10 @@ def test_state_updates_after_successful_get_market_context(monkeypatch):
69
  payload = {
70
  "noTradeGuard": False, "dataState": "live",
71
  "data": {
72
- "contract": {"x": 1}, "ticker": {"price": 42},
 
 
 
73
  "ohlcv": [
74
  {"timestamp": i, "open": 42, "high": 43, "low": 41,
75
  "close": 42, "volume": 1} for i in range(4)
@@ -80,16 +84,20 @@ def test_state_updates_after_successful_get_market_context(monkeypatch):
80
  "atr": 1.0,
81
  },
82
  }
83
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
84
  asyncio.run(ftt._h_get_market_context({"symbol": "BTCUSDT"}))
85
  snap = dash_state.snapshot()
86
  assert snap["primary_status"] == "ok"
87
- assert snap["current_signal"] in ("NO_TRADE", "LONG", "SHORT") # unchanged unless guard trips
 
 
 
 
88
  assert snap["field_sources"]["ticker"] == "datasource4"
89
 
90
 
91
  def test_state_updates_after_rejected_get_market_context(monkeypatch):
92
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client({}, ds4_raises=True))
93
  asyncio.run(ftt._h_get_market_context({"symbol": "BTCUSDT"}))
94
  snap = dash_state.snapshot()
95
  assert snap["primary_status"] == "unreachable"
 
20
 
21
 
22
  class _FakeResponse:
23
+ def __init__(self, payload, status_code=200):
24
  self._payload = payload
25
+ self.status_code = status_code
26
 
27
  def raise_for_status(self):
28
  pass
 
39
  async def __aexit__(self, *a):
40
  return False
41
 
42
+ async def get(self, url, timeout=None, **_kwargs):
43
  if "short-hunter/snapshot" in url or "short-hunter/health" in url:
44
  if ds4_raises:
45
  raise RuntimeError("boom")
 
70
  payload = {
71
  "noTradeGuard": False, "dataState": "live",
72
  "data": {
73
+ # contractType/status let _contract_verification() mark this a
74
+ # verified Futures instrument (see docs/DATA_PIPELINE_AND_CONTRACTS.md).
75
+ "contract": {"symbol": "BTCUSDT", "contractType": "PERPETUAL", "status": "TRADING"},
76
+ "ticker": {"price": 42},
77
  "ohlcv": [
78
  {"timestamp": i, "open": 42, "high": 43, "low": 41,
79
  "close": 42, "volume": 1} for i in range(4)
 
84
  "atr": 1.0,
85
  },
86
  }
87
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
88
  asyncio.run(ftt._h_get_market_context({"symbol": "BTCUSDT"}))
89
  snap = dash_state.snapshot()
90
  assert snap["primary_status"] == "ok"
91
+ # record_market_context() only ever forces NO_TRADE when noTradeGuard
92
+ # trips; a clean market-context call with no analysis performed leaves
93
+ # current_signal untouched, i.e. still NOT_ANALYZED (see state.py
94
+ # record_market_context fail-safe comment).
95
+ assert snap["current_signal"] in ("NOT_ANALYZED", "NO_TRADE", "LONG", "SHORT")
96
  assert snap["field_sources"]["ticker"] == "datasource4"
97
 
98
 
99
  def test_state_updates_after_rejected_get_market_context(monkeypatch):
100
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client({}, ds4_raises=True))
101
  asyncio.run(ftt._h_get_market_context({"symbol": "BTCUSDT"}))
102
  snap = dash_state.snapshot()
103
  assert snap["primary_status"] == "unreachable"
hermes_overlay/tests/test_futures_integration.py CHANGED
@@ -107,8 +107,9 @@ def test_calculate_futures_size_max_concurrent_positions_blocks_trade():
107
  # ---------------------------------------------------------------------------
108
 
109
  class _FakeResponse:
110
- def __init__(self, payload):
111
  self._payload = payload
 
112
 
113
  def raise_for_status(self):
114
  pass
@@ -125,7 +126,7 @@ def _make_fake_client(ds4_payload, ds4_raises=False):
125
  async def __aexit__(self, *a):
126
  return False
127
 
128
- async def get(self, url, timeout=None):
129
  if "short-hunter/snapshot" in url:
130
  if ds4_raises:
131
  raise RuntimeError("boom")
@@ -143,7 +144,7 @@ def _disable_live_binance(monkeypatch):
143
 
144
 
145
  def test_no_trade_guard_when_ds4_unreachable(monkeypatch):
146
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client({}, ds4_raises=True))
147
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
148
  assert result["noTradeGuard"] is True
149
  assert any("unreachable" in r for r in result["noTradeReasons"])
@@ -161,7 +162,7 @@ def test_no_trade_guard_honored_from_ds4(monkeypatch):
161
  "openInterest": {"openInterest": 100},
162
  },
163
  }
164
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
165
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
166
  assert result["noTradeGuard"] is True
167
  assert result["sources"]["ticker"] == "datasource4"
@@ -177,9 +178,11 @@ def test_ds2_cannot_override_ds4_field(monkeypatch):
177
  "openInterest": {"openInterest": 100},
178
  },
179
  }
180
- monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda: _make_fake_client(payload))
181
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
182
- assert result["merged"]["ticker"] == {"price": 42}
 
 
183
  assert result["sources"]["ticker"] == "datasource4"
184
 
185
 
 
107
  # ---------------------------------------------------------------------------
108
 
109
  class _FakeResponse:
110
+ def __init__(self, payload, status_code=200):
111
  self._payload = payload
112
+ self.status_code = status_code
113
 
114
  def raise_for_status(self):
115
  pass
 
126
  async def __aexit__(self, *a):
127
  return False
128
 
129
+ async def get(self, url, timeout=None, **_kwargs):
130
  if "short-hunter/snapshot" in url:
131
  if ds4_raises:
132
  raise RuntimeError("boom")
 
144
 
145
 
146
  def test_no_trade_guard_when_ds4_unreachable(monkeypatch):
147
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client({}, ds4_raises=True))
148
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
149
  assert result["noTradeGuard"] is True
150
  assert any("unreachable" in r for r in result["noTradeReasons"])
 
162
  "openInterest": {"openInterest": 100},
163
  },
164
  }
165
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
166
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
167
  assert result["noTradeGuard"] is True
168
  assert result["sources"]["ticker"] == "datasource4"
 
178
  "openInterest": {"openInterest": 100},
179
  },
180
  }
181
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
182
  result = asyncio.run(ddc.get_market_context("BTCUSDT"))
183
+ # _normalize_ticker() always adds a canonical lastPrice extracted from
184
+ # price/last/close/markPrice, in addition to preserving original fields.
185
+ assert result["merged"]["ticker"] == {"price": 42, "lastPrice": 42.0}
186
  assert result["sources"]["ticker"] == "datasource4"
187
 
188
 
hermes_overlay/tools/futures_dashboard_api.py CHANGED
@@ -1,47 +1,38 @@
1
- """Minimal Futures dashboard extension for the existing Hermes web server.
2
-
3
- Mounted onto the SAME FastAPI `app` (same host/port) that already serves
4
- HermesFace -- this is not a separate frontend. It adds:
5
-
6
- GET /api/futures/status -- primary/secondary datasource health,
7
- current signal, reasons, warnings,
8
- open position, PnL, last rejected trade
9
- GET /api/futures/positions -- open positions with live mark price
10
- GET /futures -- a small polling HTML page (real page,
11
- served by this same process)
12
-
13
- NOTE on scope: HermesFace's main dashboard is a compiled React/Vite SPA
14
- (`web/`) that depends on a private workspace package (`@nous-research/ui`)
15
- and other in-monorepo packages -- adding a native tab there means rebuilding
16
- that whole frontend workspace, which needs credentials/build infra this pass
17
- doesn't have. Serving a plain page from the same FastAPI app is the safe
18
- subset of "extend the existing dashboard" that doesn't risk breaking the SPA
19
- build. Swapping this for a real SPA tab later is a drop-in replacement --
20
- the JSON endpoints below don't change.
21
  """
22
  from __future__ import annotations
23
 
24
  import asyncio
25
  import calendar
 
26
  import json
 
27
  import os
 
28
  from pathlib import Path
29
  import secrets
30
  import time
 
31
 
32
  import httpx
33
- from fastapi import APIRouter, Depends, HTTPException, Request
34
  from fastapi.security import HTTPBasic, HTTPBasicCredentials
35
  from fastapi.responses import HTMLResponse, JSONResponse
36
  from pydantic import BaseModel, Field
37
 
38
  from trading import state as _dash_state
39
- from trading.dual_datasource_client import DS4_BASE, DS2_BASE
 
40
  from trading.futures_execution import get_futures_positions as _get_futures_positions
41
  from trading.futures_execution import get_futures_account as _get_futures_account
42
  from trading.futures_execution import get_trading_mode as _get_trading_mode
43
  from trading.trade_cycle import run_futures_cycle as _run_futures_cycle
44
- from trading.dual_datasource_client import get_market_context as _get_market_context
45
  from trading.symbols import normalize_symbol
46
 
47
  router = APIRouter()
@@ -57,7 +48,7 @@ _EMERGENCY_BASES = tuple("""BTC ETH USDT BNB SOL XRP USDC ADA AVAX DOGE DOT TRX
57
 
58
  class _CycleRequest(BaseModel):
59
  symbol: str
60
- risk_profile: str = "moderate"
61
  include_external_context: bool = False
62
 
63
  class Config:
@@ -66,7 +57,7 @@ class _CycleRequest(BaseModel):
66
 
67
  class _PaperExecutionRequest(BaseModel):
68
  symbol: str
69
- risk_profile: str = "moderate"
70
  plan_id: str = Field(alias="planId")
71
 
72
  class Config:
@@ -110,20 +101,13 @@ def _is_future_iso(value: object) -> bool:
110
  return False
111
 
112
 
113
- async def _probe(url: str, timeout: float = 3.0) -> dict:
114
- started = time.perf_counter()
115
  try:
116
  async with httpx.AsyncClient() as client:
117
  resp = await client.get(url, timeout=timeout)
118
- status = "healthy" if resp.status_code < 400 else "degraded"
119
- return {"status": status, "endpoint": url, "httpStatus": resp.status_code,
120
- "latencyMs": round((time.perf_counter()-started)*1000, 1),
121
- "lastSuccessful": time.time() if resp.status_code < 400 else None,
122
- "failureReason": None if resp.status_code < 400 else f"HTTP {resp.status_code}"}
123
- except Exception as exc:
124
- return {"status": "unavailable", "endpoint": url,
125
- "latencyMs": round((time.perf_counter()-started)*1000, 1),
126
- "lastSuccessful": None, "failureReason": type(exc).__name__}
127
 
128
 
129
  async def _mark_price(ccxt_perp_symbol: str) -> float | None:
@@ -135,10 +119,42 @@ async def _mark_price(ccxt_perp_symbol: str) -> float | None:
135
  resp = await client.get(f"{DS4_BASE}/api/short-hunter/snapshot/{norm.ds4}", timeout=3.0)
136
  resp.raise_for_status()
137
  data = resp.json()
138
- ticker = data.get("ticker") or {}
139
- return ticker.get("last") or ticker.get("markPrice") or ticker.get("close")
 
 
 
 
 
 
 
 
 
 
140
  except Exception:
141
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  def _symbol_items(payload: object, source: str, *, futures_default: bool = False) -> list[dict]:
@@ -166,11 +182,22 @@ def _symbol_items(payload: object, source: str, *, futures_default: bool = False
166
  continue
167
  if not isinstance(raw, dict):
168
  continue
169
- status = str(raw.get("status") or ("TRADING" if futures_default else "UNKNOWN")).upper()
170
- contract_type = str(raw.get("contractType") or raw.get("type") or "PERPETUAL").upper()
 
 
171
  symbol = str(raw.get("symbol") or raw.get("id") or "").upper()
172
- is_futures = bool(raw.get("isFutures") or raw.get("futuresVerified") or futures_default)
173
- if futures_default and (status not in {"TRADING", "ACTIVE", "OPEN"} or not is_futures):
 
 
 
 
 
 
 
 
 
174
  continue
175
  try:
176
  # KuCoin Futures uses symbols such as XBTUSDTM; use the verified
@@ -184,9 +211,10 @@ def _symbol_items(payload: object, source: str, *, futures_default: bool = False
184
  "quoteAsset": str(raw.get("quoteAsset") or norm.quote),
185
  "name": str(raw.get("name") or raw.get("baseAsset") or norm.base),
186
  "rank": raw.get("rank") or raw.get("market_cap_rank"),
187
- "contractType": "PERPETUAL" if is_futures else "UNKNOWN",
188
- "status": "TRADING" if is_futures and status in {"TRADING", "ACTIVE", "OPEN"} else "UNKNOWN",
189
- "futuresVerified": is_futures, "source": source})
 
190
  unique = {item["symbol"]: item for item in result}
191
  return sorted(unique.values(), key=lambda item: item["symbol"])
192
 
@@ -200,7 +228,11 @@ async def _load_symbols() -> tuple[list[dict], str]:
200
  try:
201
  cached = json.loads(_SYMBOL_CACHE_PATH.read_text(encoding="utf-8"))
202
  if isinstance(cached, dict) and isinstance(cached.get("items"), list):
203
- _symbol_cache.update(items=cached["items"], updatedAt=float(cached.get("updatedAt") or 0), source="cache")
 
 
 
 
204
  except (OSError, ValueError, TypeError):
205
  pass
206
  async with httpx.AsyncClient() as client:
@@ -209,7 +241,10 @@ async def _load_symbols() -> tuple[list[dict], str]:
209
  try:
210
  response = await client.get(f"{DS4_BASE}{path}", timeout=3.0)
211
  if response.status_code < 400:
212
- items = _symbol_items(response.json(), "datasource4", futures_default=True)
 
 
 
213
  if items:
214
  # Preserve verified Futures contracts, then enlarge with DS2 market assets.
215
  break
@@ -246,13 +281,16 @@ async def _load_symbols() -> tuple[list[dict], str]:
246
  "name": norm.base, "rank": None, "contractType": "UNKNOWN",
247
  "status": "UNKNOWN", "futuresVerified": False, "source": "seed"})
248
  seen.add(norm.ds4)
249
- _symbol_cache.update(items=items, updatedAt=now, source="datasource4+datasource2")
 
 
 
250
  try:
251
  _SYMBOL_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
252
  _SYMBOL_CACHE_PATH.write_text(json.dumps({"items": items, "updatedAt": now}), encoding="utf-8")
253
  except OSError:
254
  pass
255
- return items, "datasource4+datasource2"
256
  if _symbol_cache["items"]:
257
  return list(_symbol_cache["items"]), "cache"
258
  items = []
@@ -268,29 +306,365 @@ async def _load_symbols() -> tuple[list[dict], str]:
268
  return items, "seed"
269
 
270
 
271
- @router.get("/api/futures/status")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  async def futures_status() -> JSONResponse:
273
  state = _dash_state.snapshot()
274
- ds4_health, ds2_primary, ds2_fallback = await asyncio.gather(
275
  _probe(f"{DS4_BASE}/api/short-hunter/health"),
276
  _probe(f"{DS2_BASE}/real/api/market/tickers"),
277
- _probe(f"{DS2_BASE}/api/coins/top"),
278
  )
279
- def health_obj(value):
280
- return value if isinstance(value, dict) else {"status": value}
281
- ds4_health, ds2_primary, ds2_fallback = map(health_obj, (ds4_health, ds2_primary, ds2_fallback))
282
- ds2_health = ds2_primary if ds2_primary.get("status") in {"healthy", "ok"} else ds2_fallback
283
- ds2_health["fallbackEndpoint"] = ds2_fallback.get("endpoint")
284
- account = await _get_futures_account()
285
- positions = await _get_futures_positions()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  return JSONResponse({
287
- "primaryDatasource": {**ds4_health, "url": DS4_BASE},
288
- "secondaryDatasource": {**ds2_health, "url": DS2_BASE,
289
- "primaryEndpoint": ds2_primary.get("endpoint")},
 
 
 
 
 
 
 
 
 
 
290
  "fieldSources": state.get("field_sources", {}),
 
 
 
 
 
291
  "currentSignal": state.get("current_signal"),
292
  "signalReasons": state.get("signal_reasons", []),
293
  "warnings": state.get("warnings", []),
 
294
  "rejectedTradeReason": state.get("rejected_trade_reason"),
295
  "latestTradePlan": state.get("latest_trade_plan"),
296
  "latestPlanId": state.get("latest_plan_id"),
@@ -298,8 +672,11 @@ async def futures_status() -> JSONResponse:
298
  "signalComponents": state.get("signal_components", {}),
299
  "riskApproved": state.get("risk_approved", False),
300
  "rejectionReasons": state.get("rejection_reasons", []),
 
301
  "planCreatedAt": state.get("latest_plan_created_at"),
302
  "planExpiresAt": state.get("latest_plan_expires_at"),
 
 
303
  "latestPaperExecutionResult": state.get("latest_paper_execution_result"),
304
  "planExecuted": state.get("latest_plan_executed", False),
305
  "tradingMode": account.get("mode"),
@@ -308,77 +685,234 @@ async def futures_status() -> JSONResponse:
308
  "openPositionCount": len(positions.get("positions", [])),
309
  "updatedAt": state.get("updated_at"),
310
  "serverTime": time.time(),
311
- "marketDataStatus": "healthy" if ds4_health.get("status") == "healthy" else "degraded",
312
- })
313
 
314
 
315
  @router.get("/api/futures/symbols", dependencies=[Depends(_authenticated_dashboard_request)])
316
  async def futures_symbols() -> JSONResponse:
317
  items, source = await _load_symbols()
318
  updated = _symbol_cache.get("updatedAt") or time.time()
 
319
  for item in items:
320
  item.setdefault("source", source)
321
- item["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(updated)))
322
- verified = sum(1 for item in items if item.get("futuresVerified") is True)
323
- return JSONResponse({"symbols": items, "source": source,
324
- "catalogCount": len(items), "verifiedFuturesCount": verified,
325
- "marketOnlyCount": max(0, len(items)-verified),
326
- "updatedAt": items[0]["updatedAt"] if items else None})
327
 
328
 
329
- @router.get("/api/futures/positions")
330
  async def futures_positions() -> JSONResponse:
331
  positions = await _get_futures_positions()
332
- sem = asyncio.Semaphore(4)
333
- async def enrich(p):
334
- async with sem:
335
- try:
336
- mark = await asyncio.wait_for(_mark_price(p["symbol"]), timeout=4.0)
337
- except Exception:
338
- mark = None
339
  unrealized = None
340
  if mark:
341
- direction = 1 if p["side"] == "long" else -1
342
- unrealized = (mark - p["entryPrice"]) * p["size"] * direction
343
- return {**p, "markPrice": mark, "unrealizedPnl": unrealized}
344
- enriched = await asyncio.gather(*(enrich(p) for p in positions.get("positions", [])))
345
- return JSONResponse({"mode": positions.get("mode"), "positions": enriched})
 
 
346
 
347
 
348
  @router.get("/api/futures/market", dependencies=[Depends(_authenticated_dashboard_request)])
349
- async def futures_market(symbol: str = "BTCUSDT", interval: str = "5m", limit: int = 120) -> JSONResponse:
350
- """Return real merged OHLCV and market metrics for the chart."""
351
- if interval not in {"1m", "5m", "15m", "1h"}:
352
- raise HTTPException(status_code=400, detail="Unsupported interval")
353
- context = await _get_market_context(symbol, timeframe=interval)
354
- merged = context.get("merged") or {}
355
- candles = merged.get("ohlcv") or []
356
- if limit > 0:
357
- candles = candles[-min(limit, 500):]
358
- ticker = merged.get("ticker") or {}
359
- return JSONResponse({"symbol": context.get("symbol"), "interval": interval,
360
- "candles": candles, "ticker": ticker,
361
- "funding": merged.get("funding"),
362
- "openInterest": merged.get("openInterest"),
363
- "source": context.get("sources", {}).get("ohlcv", "unavailable"),
364
- "sources": context.get("sources", {}),
365
- "fetchedAt": context.get("fetchedAt"),
366
- "warnings": context.get("warnings", []),
367
- "noTradeGuard": context.get("noTradeGuard", True)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
 
369
 
370
  @router.post("/api/futures/analyze", dependencies=[Depends(_authenticated_dashboard_request)])
371
  async def futures_analyze(request: _CycleRequest) -> JSONResponse:
372
  """Run analysis only and return a server-referenced deterministic plan."""
 
373
  try:
374
  plan = await _run_futures_cycle(
375
- request.symbol, risk_profile=request.risk_profile, execute=False,
376
  include_external_context=request.include_external_context,
377
  )
378
  plan_id = _dash_state.record_trade_plan(plan)
379
- return JSONResponse(_plan_payload(plan, plan_id))
 
 
380
  except Exception as exc:
381
- raise HTTPException(status_code=400, detail=f"Futures analysis failed: {exc}") from exc
 
 
382
 
383
 
384
  @router.post("/api/futures/paper/execute", dependencies=[Depends(_authenticated_dashboard_request)])
@@ -386,13 +920,14 @@ async def futures_paper_execute(request: _PaperExecutionRequest) -> JSONResponse
386
  """Revalidate a server-held plan before invoking the existing Paper path."""
387
  async with _paper_cycle_lock:
388
  state = _dash_state.snapshot()
 
389
  supported_symbols, _ = await _load_symbols()
390
  verified = {item["symbol"] for item in supported_symbols if item.get("futuresVerified")}
391
- if normalize_symbol(request.symbol).ds4 not in verified:
392
  raise HTTPException(status_code=403, detail="Paper execution requires a verified Futures contract")
393
  if request.plan_id != state.get("latest_plan_id"):
394
  raise HTTPException(status_code=409, detail="Unknown or superseded planId")
395
- if request.symbol != state.get("latest_plan_symbol") \
396
  or request.risk_profile != state.get("latest_plan_risk_profile"):
397
  raise HTTPException(status_code=409, detail="Plan symbol or risk profile changed")
398
  if state.get("latest_plan_executed"):
@@ -400,17 +935,22 @@ async def futures_paper_execute(request: _PaperExecutionRequest) -> JSONResponse
400
  expires_at = state.get("latest_plan_expires_at")
401
  if not isinstance(expires_at, (int, float)) or time.time() >= expires_at:
402
  raise HTTPException(status_code=409, detail="Plan is expired")
403
- if state.get("latest_trade_plan", {}).get("decision") not in ("LONG", "SHORT"):
 
404
  raise HTTPException(status_code=409, detail="Only a directional plan can be executed")
 
 
 
 
 
 
405
  if not state.get("risk_approved"):
406
  raise HTTPException(status_code=409, detail="Plan is not risk-approved")
407
  if _get_trading_mode() != "paper":
408
  raise HTTPException(status_code=403, detail="Only Paper execution is enabled")
409
 
410
- # Re-run server-side; browser prices, SL/TP, leverage and quantity are
411
- # never accepted or forwarded.
412
  fresh = await _run_futures_cycle(
413
- request.symbol, risk_profile=request.risk_profile, execute=False,
414
  include_external_context=False,
415
  )
416
  if fresh.get("decision") not in ("LONG", "SHORT") or not fresh.get("risk_approved"):
@@ -419,84 +959,28 @@ async def futures_paper_execute(request: _PaperExecutionRequest) -> JSONResponse
419
  "message": "Fresh server-side validation rejected the plan",
420
  "reasons": fresh.get("rejection_reasons", []) + fresh.get("core_reasons", []),
421
  })
422
- if fresh.get("noTradeGuard") or fresh.get("executed"):
423
  raise HTTPException(status_code=409, detail="Fresh plan is not executable")
424
- fresh_expiry = fresh.get("expires_at")
425
- if not _is_future_iso(fresh_expiry):
426
  raise HTTPException(status_code=409, detail="Fresh plan has no valid expiry")
427
 
428
- result = await _run_futures_cycle(
429
- request.symbol, risk_profile=request.risk_profile, execute=True,
430
  include_external_context=False,
431
- )
432
- result = dict(result)
433
  _dash_state.record_trade_plan(result, plan_id=request.plan_id)
434
  _dash_state.record_paper_execution({
435
- "executed": bool(result.get("executed")),
436
- "symbol": result.get("symbol"),
437
- "side": result.get("decision"),
438
- "mode": "paper",
439
  "status": "executed" if result.get("executed") else "rejected",
440
  "reason": "; ".join(result.get("rejection_reasons", [])),
441
  })
442
- return JSONResponse(_plan_payload(result, request.plan_id))
443
-
444
-
445
- _PAGE = """<!doctype html>
446
- <html><head><meta charset="utf-8"><title>HermesFace — Futures Desk</title>
447
- <style>
448
- body{font-family:ui-monospace,Menlo,Consolas,monospace;background:#0b0f14;color:#d7e2ea;padding:24px;max-width:1100px;margin:auto}
449
- h1{font-size:16px;color:#9fd3ff;margin-bottom:16px}.card{background:#121821;border:1px solid #223042;border-radius:8px;padding:14px;margin-bottom:12px}
450
- .row{display:flex;justify-content:space-between;gap:18px;padding:5px 0;border-bottom:1px solid #1b2530;white-space:pre-wrap}
451
- input,select,button{background:#0d141c;color:#d7e2ea;border:1px solid #38516b;border-radius:4px;padding:7px;margin:3px}
452
- button:disabled{opacity:.45}.ok{color:#6bd48c}.degraded{color:#e8c14b}.unreachable{color:#e2665c}.LONG{color:#6bd48c}.SHORT{color:#e2665c}.NO_TRADE{color:#8896a3}small{color:#7c8896}
453
- </style></head><body>
454
- <h1>HermesFace — Futures Desk (DS4 → Binance → DS2)</h1>
455
- <div class="card"><label>Symbol <input id="symbol" value="BTCUSDT"></label>
456
- <label>Risk profile <select id="risk"><option>conservative</option><option selected>moderate</option><option>aggressive</option></select></label>
457
- <label><input type="checkbox" id="advisory"> Remote advisory</label>
458
- <button id="analyze">Analyze</button> <button id="execute" disabled>Execute Paper Trade</button>
459
- <div id="notice"><small>Analysis only until a server plan is directional, risk-approved, fresh, and unexecuted.</small></div></div>
460
- <div class="card" id="plan">No plan analyzed yet.</div>
461
- <div class="card" id="status">Loading status…</div><div class="card" id="positions"></div>
462
- <small>Paper execution only · browser never supplies entry, SL, TP, leverage, or quantity · auto-refresh status every 5s</small>
463
- <script>
464
- let latestPlan=null;
465
- const esc = x => String(x ?? '—').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
466
- function renderPlan(p){
467
- latestPlan=p; const directional=['LONG','SHORT'].includes(p.decision);
468
- const fresh=p.expires_at && Date.parse(p.expires_at)>Date.now();
469
- const canExecute=directional && !!p.risk_approved && fresh && !p.executed;
470
- document.getElementById('execute').disabled=!canExecute;
471
- document.getElementById('plan').innerHTML=`<div class="row"><b>Decision</b><b class="${esc(p.decision)}">${esc(p.decision)}</b></div>
472
- <div class="row"><span>Plan ID</span><span>${esc(p.planId)}</span></div><div class="row"><span>Score</span><span>${esc(p.score)}</span></div>
473
- <div class="row"><span>Signal components</span><span>${esc(JSON.stringify(p.components||{}))}</span></div>
474
- <div class="row"><span>Advisory</span><span>${esc(p.external_advisory?((p.external_advisory.provider||'unavailable')+' · '+(p.external_advisory.market_bias||'unknown')+' · '+(p.external_advisory.summary||p.external_advisory.error||'—')):'disabled')}</span></div>
475
- <div class="row"><span>Advisory risk warnings</span><span>${esc((p.external_advisory&&p.external_advisory.risk_warnings||[]).join('; ')||'—')}</span></div>
476
- <div class="row"><span>Reasons</span><span>${esc((p.core_reasons||[]).join('; ')||'—')}</span></div>
477
- <div class="row"><span>Warnings</span><span>${esc((p.warnings||[]).join('; ')||'—')}</span></div>
478
- <div class="row"><span>Entry / Stop Loss / Take Profit</span><span>${esc(p.entry)} / ${esc(p.stop_loss)} / ${esc(p.take_profit)}</span></div>
479
- <div class="row"><span>Leverage / Quantity / Slippage</span><span>${esc(p.effective_leverage??p.requested_leverage)}x / ${esc(p.quantity)} / ${esc(p.estimated_slippage_percent)}</span></div>
480
- <div class="row"><span>Risk approval</span><span>${esc(p.risk_approved)}</span></div>
481
- <div class="row"><span>Rejection reasons</span><span>${esc((p.rejection_reasons||[]).join('; ')||'—')}</span></div>
482
- <div class="row"><span>Created / Expires</span><span>${esc(p.created_at)} / ${esc(p.expires_at)}</span></div>`;
483
- document.getElementById('notice').innerHTML=`<small>${canExecute?'Paper execution is available after browser confirmation.':'Paper execution is disabled until all server safeguards pass.'}</small>`;
484
- }
485
- document.getElementById('analyze').onclick=async()=>{
486
- const r=await fetch('/api/futures/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol:document.getElementById('symbol').value,risk_profile:document.getElementById('risk').value,include_external_context:document.getElementById('advisory').checked})});
487
- const p=await r.json(); if(!r.ok){document.getElementById('notice').textContent=p.detail||'Analysis failed';return} renderPlan(p);
488
- };
489
- document.getElementById('execute').onclick=async()=>{
490
- if(!latestPlan||!confirm('Confirm server-side Paper execution for this plan?')) return;
491
- const r=await fetch('/api/futures/paper/execute',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol:document.getElementById('symbol').value,risk_profile:document.getElementById('risk').value,planId:latestPlan.planId})});
492
- const p=await r.json(); if(!r.ok){document.getElementById('notice').textContent=(p.detail&&p.detail.message)||p.detail||'Paper execution rejected';return} renderPlan(p);
493
- };
494
- async function refresh(){
495
- const s=await (await fetch('/api/futures/status')).json(); document.getElementById('status').innerHTML=`<div class="row"><span>Primary / Secondary</span><span>${esc(s.primaryDatasource.status)} / ${esc(s.secondaryDatasource.status)}</span></div><div class="row"><span>Current signal / score</span><span>${esc(s.currentSignal)} / ${esc(s.latestSignalScore)}</span></div><div class="row"><span>Trading mode / equity</span><span>${esc(s.tradingMode)} / ${esc(s.equity)}</span></div><div class="row"><span>Risk approved</span><span>${esc(s.riskApproved)}</span></div><div class="row"><span>Warnings / reasons</span><span>${esc((s.warnings||[]).join('; ')||'—')} / ${esc((s.signalReasons||[]).join('; ')||'—')}</span></div>`;
496
- const p=await (await fetch('/api/futures/positions')).json(); const rows=(p.positions||[]).map(x=>`<div class="row"><span>${esc(x.symbol)} · ${esc(x.side)} · ${esc(x.leverage)}x</span><span>entry ${esc(x.entryPrice)} · mark ${esc(x.markPrice)} · SL ${esc(x.stopLoss)} · TP ${esc(x.takeProfit)} · uPnL ${esc(x.unrealizedPnl)}</span></div>`).join('')||'<div class="row">No open positions</div>'; document.getElementById('positions').innerHTML=`<b>Open positions (${esc(p.mode)})</b>`+rows;
497
- }
498
- refresh(); setInterval(refresh,5000);
499
- </script></body></html>"""
500
 
501
 
502
  @router.get("/futures", response_class=HTMLResponse)
@@ -504,47 +988,11 @@ async def futures_page() -> HTMLResponse:
504
  try:
505
  page = _TEMPLATE_PATH.read_text(encoding="utf-8")
506
  except OSError:
507
- page = "<!doctype html><title>Futures Desk unavailable</title><p>The Futures dashboard template is not installed.</p>"
508
- return HTMLResponse(page)
509
-
510
-
511
- # The route above intentionally remains unchanged; this assignment replaces
512
- # only its presentation while keeping the same FastAPI process and endpoint.
513
- _PAGE = r'''<!doctype html>
514
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
515
- <title>HermesFace Futures Desk</title>
516
- <style>
517
- :root{color-scheme:dark;--bg:#080d14;--panel:#101923;--panel2:#0c141d;--line:#223243;--text:#e4edf5;--muted:#8ea1b2;--blue:#73b7ff;--green:#51d88a;--yellow:#f4c95d;--red:#ff7180;--shadow:0 12px 34px #0005}
518
- *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -10%,#14263a 0,#080d14 45%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui,sans-serif;min-height:100vh}main{max-width:1480px;margin:auto;padding:24px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}.eyebrow{color:var(--blue);font-size:12px;text-transform:uppercase;letter-spacing:.14em}.top h1{margin:5px 0;font-size:28px}.muted,small{color:var(--muted)}.badges{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end}.badge{border:1px solid var(--line);border-radius:999px;padding:7px 10px;background:#0e1823;color:var(--muted);font-size:12px}.badge.ok{color:var(--green);border-color:#286f4b}.badge.degraded{color:var(--yellow);border-color:#80652b}.badge.bad{color:var(--red);border-color:#783743}.badge.info{color:var(--blue)}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}.card{background:linear-gradient(145deg,var(--panel),var(--panel2));border:1px solid var(--line);border-radius:14px;padding:16px;box-shadow:var(--shadow);min-width:0}.controls{grid-column:span 12;display:flex;align-items:end;flex-wrap:wrap;gap:12px}.field{display:flex;flex-direction:column;gap:6px;min-width:180px}.field.symbol{min-width:270px;flex:1}label{color:var(--muted);font-size:12px}input,select,button{font:inherit;color:var(--text);background:#0a121b;border:1px solid #36516a;border-radius:9px;padding:10px 12px}input:focus,select:focus,button:focus{outline:2px solid #73b7ff66;outline-offset:1px}button{cursor:pointer;background:#1b4b78;border-color:#397bb4;font-weight:650}button.secondary{background:#16222e;border-color:var(--line)}button:disabled{opacity:.45;cursor:not-allowed}.toggle{display:flex;align-items:center;gap:8px;height:42px}.toggle input{accent-color:var(--blue)}.loading{display:none;color:var(--blue);align-items:center;gap:8px}.loading.show{display:flex}.spinner{width:14px;height:14px;border:2px solid #ffffff33;border-top-color:var(--blue);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.decision{grid-column:span 3}.metric{grid-column:span 1.5;min-height:92px}.metric h3,.section-title{margin:0 0 9px;font-size:12px;color:var(--muted);font-weight:600}.metric strong{font-size:20px}.decision strong{font-size:30px}.NO_TRADE{color:var(--yellow)}.LONG{color:var(--green)}.SHORT{color:var(--red)}.blocked{border-color:#7d5d27;background:linear-gradient(145deg,#211b12,#121820)}.blocked strong{color:var(--yellow)}.wide{grid-column:span 6}.full{grid-column:span 12}.rows{display:grid;gap:7px}.row{display:flex;justify-content:space-between;gap:15px;border-bottom:1px solid #ffffff0b;padding:7px 0;line-height:1.4}.row span:first-child{color:var(--muted)}.row span:last-child{text-align:right;overflow-wrap:anywhere}.source{grid-column:span 4}.source h3{margin:0 0 8px}.source .state{font-size:18px;margin-bottom:8px}.state.ok{color:var(--green)}.state.degraded{color:var(--yellow)}.state.unreachable,.state.bad{color:var(--red)}.advisory{border-color:#315a80}.advisory .summary{line-height:1.55;margin:10px 0}.pill{display:inline-block;border-radius:999px;padding:4px 8px;background:#17304a;color:var(--blue);font-size:12px;margin:2px}.pill.warn{background:#3b3018;color:var(--yellow)}.list{margin:8px 0 0;padding-left:18px;color:#d3dde6;line-height:1.55}.reasons{border-left:3px solid var(--yellow)}details{grid-column:span 12;background:#0b121a;border:1px solid var(--line);border-radius:12px;padding:12px}summary{cursor:pointer;color:var(--blue);font-weight:650}.tech{white-space:pre-wrap;overflow-wrap:anywhere;color:#aab8c4;line-height:1.5;margin-top:12px;max-height:320px;overflow:auto}.combo{position:relative}.combo-list{position:absolute;z-index:20;left:0;right:0;top:100%;max-height:260px;overflow:auto;background:#101a25;border:1px solid #36516a;border-radius:9px;display:none}.combo-list.open{display:block}.combo-item{padding:10px 12px;cursor:pointer}.combo-item:hover,.combo-item.active{background:#1a3852}.empty{color:var(--muted);padding:10px 0}.mobile-note{color:var(--muted);font-size:12px}.footer{grid-column:span 12;color:var(--muted);font-size:12px;text-align:center;padding:8px}
519
- @media(max-width:1050px){.metric{grid-column:span 3}.source{grid-column:span 6}.wide{grid-column:span 12}}@media(max-width:650px){main{padding:14px}.top{display:block}.badges{justify-content:flex-start;margin-top:12px}.controls,.field,.field.symbol{min-width:100%;width:100%}.controls button{flex:1}.decision,.metric,.source,.wide,.full{grid-column:span 12}.row{display:block}.row span:last-child{text-align:left;display:block;margin-top:3px}.top h1{font-size:23px}}
520
- </style><style>.combo-list{max-height:320px}.combo-item{display:flex;justify-content:space-between;gap:10px}.verified{color:var(--green)}.unverified{color:var(--yellow)}.market{grid-column:span 8;min-height:300px}.market-head{display:flex;justify-content:space-between;gap:14px}.market-head strong{font-size:24px}.chart{width:100%;height:210px;margin-top:12px;background:#09131d;border:1px solid #1c3142;border-radius:10px}.chart-empty{display:grid;place-items:center;height:100%;color:var(--muted)}</style></head><body><main>
521
- <header class="top"><div><div class="eyebrow">HermesFace · Deterministic Futures</div><h1>Futures Desk</h1><div id="refresh" class="muted">Last refresh —</div></div><div id="badges" class="badges"></div></header>
522
- <section class="grid">
523
- <div class="card controls"><div class="field symbol"><label for="symbolSearch">Symbol</label><div class="combo"><input id="symbolSearch" autocomplete="off" value="BTCUSDT" placeholder="Search symbol or asset" role="combobox" aria-expanded="false"><div id="symbolList" class="combo-list" role="listbox"></div></div><div id="symbolState" class="mobile-note">Loading active Futures pairs…</div></div><div class="field"><label for="risk">Risk profile</label><select id="risk"><option>conservative</option><option selected>moderate</option><option>aggressive</option></select></div><label class="toggle"><input type="checkbox" id="advisory"> Remote advisory</label><button id="analyze">Analyze</button><button id="execute" class="secondary" disabled>Execute Paper Trade</button><div id="loading" class="loading"><i class="spinner"></i><span>Analyzing… <b id="elapsed">0.0s</b></span></div></div>
524
- <div class="card market"><div class="market-head"><div><div class="eyebrow">Selected market</div><strong id="marketSymbol">BTCUSDT</strong><div id="marketMeta" class="muted">Market data appears after analysis.</div></div><div id="marketPrice">—</div></div><svg id="marketChart" class="chart" viewBox="0 0 800 210" preserveAspectRatio="none" aria-label="Market chart"><foreignObject x="0" y="0" width="800" height="210"><div xmlns="http://www.w3.org/1999/xhtml" class="chart-empty">OHLCV unavailable — no chart data fabricated.</div></foreignObject></svg></div>
525
- <div id="decisionCard" class="card decision blocked"><h3 class="section-title">Decision</h3><strong id="decision">NO_TRADE</strong><div id="decisionHint" class="muted">Trading is blocked until all safety gates pass.</div></div>
526
- <div class="card metric"><h3>Signal score</h3><strong id="score">—</strong></div><div class="card metric"><h3>Risk approval</h3><strong id="approval">—</strong></div><div class="card metric"><h3>Mode</h3><strong id="mode">paper</strong></div><div class="card metric"><h3>Expiry</h3><strong id="expiry">—</strong></div>
527
- <div class="card wide"><h2 class="section-title">Trade plan</h2><div id="planRows" class="rows"></div></div>
528
- <div class="card full"><h2 class="section-title">Signal analysis</h2><div id="signals" class="rows"><div class="empty">No valid signal components were available.</div></div></div>
529
- <div class="card wide advisory"><h2 class="section-title">Remote advisory</h2><div id="advisoryPanel" class="empty">Disabled until Analyze is run with Remote advisory enabled.</div></div>
530
- <div class="card source"><h2 class="section-title">Datasource 4 · Authoritative</h2><div id="ds4" class="empty">Loading…</div></div><div class="card source"><h2 class="section-title">Binance · Public fallback</h2><div id="binance" class="empty">Loading…</div></div><div class="card source"><h2 class="section-title">Datasource 2 · Complementary</h2><div id="ds2" class="empty">Loading…</div></div>
531
- <div class="card wide reasons"><h2 class="section-title">Trade rejection reasons</h2><div id="reasons" class="empty">No plan analyzed yet.</div></div><div class="card wide"><h2 class="section-title">Account & positions</h2><div id="account" class="rows"></div></div>
532
- <details><summary>Show technical details</summary><div id="technical" class="tech">No technical diagnostics available.</div></details><div class="footer">Paper mode only · server validates every execution request · updated <span id="footerTime">—</span></div>
533
- </section></main>
534
- <script>
535
- const $=id=>document.getElementById(id), esc=x=>String(x??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])), fmt=x=>x===null||x===undefined||x===''?'—':esc(x);
536
- let symbols=[],activeIndex=-1,analysisBusy=false;
537
- function unique(xs){return [...new Set((xs||[]).filter(Boolean))]};
538
- function groupedWarnings(xs){let raw=unique(xs),out=[],bin451=raw.filter(x=>/Binance .*HTTP 451/i.test(x));if(bin451.length)out.push('Binance public API is unavailable from this region (HTTP 451).');raw.filter(x=>!/Binance .*HTTP 451/i.test(x)&&!/Binance unusable_response at\s*:/i.test(x)).forEach(x=>{if(!out.includes(x))out.push(x)});return {summary:out,raw};}
539
- function row(a,b){return '<div class="row"><span>'+esc(a)+'</span><span>'+fmt(b)+'</span></div>'}
540
- function renderSources(p){let w=groupedWarnings(p.warnings||[]),text=w.summary.join(' · ')||'No source diagnostics.';$('ds4').innerHTML='<div class="state bad">Unavailable</div>'+row('Role','Authoritative')+row('Reason',text.match(/Datasource 4[^;]*/)?.[0]||'No current response');$('binance').innerHTML='<div class="state '+(text.includes('HTTP 451')?'bad':'degraded')+'">'+(text.includes('HTTP 451')?'Regionally restricted':'Fallback')+'</div>'+row('Role','Market-data repair')+row('Reason',text.includes('HTTP 451')?'Binance public API is unavailable from this region (HTTP 451).':'Used only for missing fields');$('ds2').innerHTML='<div class="state degraded">Degraded</div>'+row('Role','Complementary context')+row('Reason',text.match(/Datasource 2[^;]*/)?.[0]||'No current response');$('technical').textContent=w.raw.join('\n')||'No technical diagnostics available.';}
541
- function renderAdvisory(a){if(!a){$('advisoryPanel').innerHTML='<div class="empty">Disabled for this analysis.</div>';return}let status=a.status||((a.provider&&a.provider!=='external_ai_unavailable')?'available':'unavailable');$('advisoryPanel').innerHTML='<div><span class="pill">'+fmt(a.provider)+'</span><span class="pill">'+fmt(a.model)+'</span><span class="pill">'+fmt(a.market_bias)+'</span><span class="pill">'+fmt(status)+'</span></div><div class="summary">'+fmt(a.summary)+'</div>'+row('Confidence',a.confidence)+row('Latency',a.latency_ms==null?'—':a.latency_ms+' ms')+row('Fallback used',a.fallback_used?'Yes':'No')+(a.error?row('Provider error',a.error):'')+(a.risk_warnings?.length?'<ul class="list">'+a.risk_warnings.map(x=>'<li>'+esc(x)+'</li>').join('')+'</ul>':'')}
542
- function renderPlan(p){let d=p.decision||'NO_TRADE';$('decision').textContent=d;$('decision').className=d;$('decisionCard').className='card decision '+(d==='NO_TRADE'?'blocked':'');$('decisionHint').textContent=d==='NO_TRADE'?'Trading is blocked until all safety gates pass.':'Directional plan; server safeguards still control execution.';$('score').textContent=fmt(p.score);$('approval').textContent=p.risk_approved?'Approved':'Not approved';$('approval').style.color=p.risk_approved?'var(--green)':'var(--yellow)';$('expiry').textContent=fmt(p.expires_at);$('planRows').innerHTML=[row('Entry',p.entry),row('Stop Loss',p.stop_loss),row('Take Profit',p.take_profit),row('Reward-to-risk',p.reward_to_risk),row('Leverage',p.effective_leverage??p.requested_leverage),row('Quantity',p.quantity),row('Slippage',p.estimated_slippage_percent),row('Created',p.created_at)].join('');let comps=Object.entries(p.components||{});$('signals').innerHTML=comps.length?comps.map(x=>row(x[0],x[1])).join(''):'<div class="empty">No valid signal components were available.</div>';let rs=unique([...(p.core_reasons||[]),...(p.rejection_reasons||[])]);$('reasons').innerHTML=rs.length?'<ul class="list">'+rs.map(x=>'<li>'+esc(x)+'</li>').join('')+'</ul>':'No trade rejection reasons.';renderAdvisory(p.external_advisory);renderSources(p);if(p.external_advisory?.attempts?.length)$('technical').textContent+='\n\nAdvisory attempts:\n'+JSON.stringify(p.external_advisory.attempts,null,2);$('execute').disabled=!(d==='LONG'||d==='SHORT')||!p.risk_approved||!p.expires_at||Date.parse(p.expires_at)<=Date.now()||p.executed;}
543
- function renderStatus(s){$('mode').textContent=fmt(s.tradingMode);$('account').innerHTML=[row('Paper equity',s.equity),row('Daily PnL',s.realizedPnlToday),row('Open positions',s.openPositionCount||0),row('Latest execution',s.latestPaperExecutionResult?.status||'None')].join('');let sw=(s.warnings||[]).join(' '),bs=/HTTP 451/i.test(sw)?'unavailable':Object.values(s.fieldSources||{}).some(x=>x==='binance_public')?'degraded':'degraded';let b=[['DS4',s.primaryDatasource?.status],['Binance',bs],['DS2',s.secondaryDatasource?.status],['Remote Advisory','info']];$('badges').innerHTML=b.map(x=>'<span class="badge '+(x[1]==='ok'?'ok':x[1]==='info'?'info':x[1]==='unreachable'||x[1]==='unavailable'?'bad':'degraded')+'">'+x[0]+': '+esc(x[1]||'unknown')+'</span>').join('');$('refresh').textContent='Last refresh '+new Date().toLocaleTimeString();$('footerTime').textContent=new Date().toLocaleTimeString();renderSources({warnings:s.warnings||[]});}
544
- async function refresh(){try{let r=await fetch('/api/futures/status');if(r.ok)renderStatus(await r.json())}catch(e){$('technical').textContent='Status request failed.'}}
545
- function showList(){let q=$('symbolSearch').value.toUpperCase();let found=symbols.filter(x=>(x.symbol+' '+x.baseAsset+' '+x.quoteAsset).includes(q)).slice(0,80);$('symbolList').innerHTML=found.length?found.map((x,i)=>'<div class="combo-item" role="option" data-symbol="'+esc(x.symbol)+'">'+esc(x.symbol)+' <small>'+esc(x.baseAsset)+' / '+esc(x.quoteAsset)+'</small></div>').join(''):'<div class="empty">No active pair found. Validated manual entry is allowed.</div>';$('symbolList').classList.add('open');$('symbolSearch').setAttribute('aria-expanded','true');activeIndex=-1;document.querySelectorAll('.combo-item').forEach(el=>el.onclick=()=>{ $('symbolSearch').value=el.dataset.symbol;$('symbolList').classList.remove('open')});}
546
- $('symbolSearch').oninput=showList;$('symbolSearch').onfocus=showList;$('symbolSearch').onkeydown=e=>{let opts=[...document.querySelectorAll('.combo-item')];if(e.key==='ArrowDown'){activeIndex=Math.min(activeIndex+1,opts.length-1);opts[activeIndex]?.classList.add('active');e.preventDefault()}else if(e.key==='Enter'&&opts[activeIndex]){opts[activeIndex].click()}else if(e.key==='Escape')$('symbolList').classList.remove('open')};document.addEventListener('click',e=>{if(!e.target.closest('.combo'))$('symbolList').classList.remove('open')});
547
- async function loadSymbols(){try{let r=await fetch('/api/futures/symbols');let p=await r.json();symbols=p.symbols||[];$('symbolState').textContent=symbols.length?symbols.length+' active pairs · source '+p.source:'Symbol service unavailable; validated manual entry enabled';}catch(e){$('symbolState').textContent='Symbol service unavailable; validated manual entry enabled';}}
548
- $('analyze').onclick=async()=>{if(analysisBusy)return;analysisBusy=true;$('analyze').disabled=true;$('execute').disabled=true;$('loading').classList.add('show');let started=performance.now(),timer=setInterval(()=>{$('elapsed').textContent=((performance.now()-started)/1000).toFixed(1)+'s'},100);try{let r=await fetch('/api/futures/analyze',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol:$('symbolSearch').value,risk_profile:$('risk').value,include_external_context:$('advisory').checked})});let p=await r.json();if(!r.ok)throw new Error(p.detail||'Analysis failed');renderPlan(p);}catch(e){$('reasons').textContent=e.message;$('decision').textContent='NO_TRADE';}finally{clearInterval(timer);$('loading').classList.remove('show');analysisBusy=false;$('analyze').disabled=false;}};
549
- $('execute').onclick=async()=>{if($('execute').disabled||!confirm('Confirm server-side Paper execution for this plan?'))return};loadSymbols();refresh();setInterval(refresh,5000);
550
- </script><script>function showList(){let q=$('symbolSearch').value.toUpperCase();let found=symbols.filter(x=>(x.symbol+' '+x.baseAsset+' '+x.quoteAsset+' '+(x.name||'')+' '+(x.rank||'')).toUpperCase().includes(q)).slice(0,120);$('symbolList').innerHTML=found.length?found.map(x=>'<div class="combo-item" role="option" data-symbol="'+esc(x.symbol)+'"><span><b>'+esc(x.symbol)+'</b> <small>'+esc(x.name||x.baseAsset)+' · '+esc(x.baseAsset)+'/'+esc(x.quoteAsset)+'</small></span><span class="'+(x.futuresVerified?'verified':'unverified')+'">'+(x.futuresVerified?'✓ Futures':'Market only')+'</span></div>').join(''):'<div class="empty">No pair found. Validated manual entry is allowed.</div>';$('symbolList').classList.add('open');$('symbolSearch').setAttribute('aria-expanded','true');activeIndex=-1;document.querySelectorAll('.combo-item').forEach(el=>el.onclick=()=>{ $('symbolSearch').value=el.dataset.symbol;$('marketSymbol').textContent=el.dataset.symbol;$('symbolList').classList.remove('open')});}$('symbolSearch').oninput=showList;$('symbolSearch').onfocus=showList;</script></body></html>'''
 
1
+ """Authenticated Futures dashboard routes for the existing Hermes server.
2
+
3
+ The router is mounted into the same FastAPI application and port already used
4
+ by Hermes. It exposes read-only market/status routes, deterministic analysis,
5
+ and the existing server-revalidated Paper execution path. The Luxury template
6
+ is loaded from the packaged templates directory; no second frontend, backend,
7
+ port, or decision engine is created here.
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
  from __future__ import annotations
10
 
11
  import asyncio
12
  import calendar
13
+ import hashlib
14
  import json
15
+ import math
16
  import os
17
+ import re
18
  from pathlib import Path
19
  import secrets
20
  import time
21
+ from typing import Literal
22
 
23
  import httpx
24
+ from fastapi import APIRouter, Depends, HTTPException, Query
25
  from fastapi.security import HTTPBasic, HTTPBasicCredentials
26
  from fastapi.responses import HTMLResponse, JSONResponse
27
  from pydantic import BaseModel, Field
28
 
29
  from trading import state as _dash_state
30
+ from trading.dual_datasource_client import DS4_BASE, DS2_BASE, get_market_context
31
+ import trading.binance_public_client as binance_public
32
  from trading.futures_execution import get_futures_positions as _get_futures_positions
33
  from trading.futures_execution import get_futures_account as _get_futures_account
34
  from trading.futures_execution import get_trading_mode as _get_trading_mode
35
  from trading.trade_cycle import run_futures_cycle as _run_futures_cycle
 
36
  from trading.symbols import normalize_symbol
37
 
38
  router = APIRouter()
 
48
 
49
  class _CycleRequest(BaseModel):
50
  symbol: str
51
+ risk_profile: Literal["conservative", "moderate", "aggressive"] = "moderate"
52
  include_external_context: bool = False
53
 
54
  class Config:
 
57
 
58
  class _PaperExecutionRequest(BaseModel):
59
  symbol: str
60
+ risk_profile: Literal["conservative", "moderate", "aggressive"] = "moderate"
61
  plan_id: str = Field(alias="planId")
62
 
63
  class Config:
 
101
  return False
102
 
103
 
104
+ async def _probe(url: str, timeout: float = 3.0) -> str:
 
105
  try:
106
  async with httpx.AsyncClient() as client:
107
  resp = await client.get(url, timeout=timeout)
108
+ return "ok" if resp.status_code < 400 else "degraded"
109
+ except Exception:
110
+ return "unreachable"
 
 
 
 
 
 
111
 
112
 
113
  async def _mark_price(ccxt_perp_symbol: str) -> float | None:
 
119
  resp = await client.get(f"{DS4_BASE}/api/short-hunter/snapshot/{norm.ds4}", timeout=3.0)
120
  resp.raise_for_status()
121
  data = resp.json()
122
+ candidates = [data] if isinstance(data, dict) else []
123
+ for wrapper in ("data", "snapshot", "market", "marketData", "futures", "result"):
124
+ for candidate in list(candidates):
125
+ nested = candidate.get(wrapper) if isinstance(candidate, dict) else None
126
+ if isinstance(nested, dict) and nested not in candidates:
127
+ candidates.append(nested)
128
+ for candidate in candidates:
129
+ ticker = candidate.get("ticker") or candidate.get("marketTicker") or candidate.get("quote")
130
+ if isinstance(ticker, dict):
131
+ price = _finite(_first(ticker, "markPrice", "lastPrice", "last", "close", "price"))
132
+ if price is not None and price > 0:
133
+ return price
134
  except Exception:
135
  return None
136
+ return None
137
+
138
+
139
+ def _boolean_flag(value: object) -> bool | None:
140
+ if isinstance(value, bool):
141
+ return value
142
+ if isinstance(value, (int, float)) and value in (0, 1):
143
+ return bool(value)
144
+ if isinstance(value, str):
145
+ normalized = value.strip().lower()
146
+ if normalized in {"true", "yes", "1", "verified", "futures"}:
147
+ return True
148
+ if normalized in {"false", "no", "0", "unverified", "market_only"}:
149
+ return False
150
+ return None
151
+
152
+
153
+ def _normalized_symbol_or_422(symbol: str):
154
+ try:
155
+ return normalize_symbol(symbol)
156
+ except ValueError as exc:
157
+ raise HTTPException(status_code=422, detail="Invalid market symbol") from exc
158
 
159
 
160
  def _symbol_items(payload: object, source: str, *, futures_default: bool = False) -> list[dict]:
 
182
  continue
183
  if not isinstance(raw, dict):
184
  continue
185
+ raw_status = raw.get("status") or raw.get("contractStatus") or raw.get("state")
186
+ status = str(raw_status or ("TRADING" if futures_default else "UNKNOWN")).upper()
187
+ raw_contract_type = raw.get("contractType") or raw.get("type") or raw.get("marketType") or raw.get("instrumentType")
188
+ contract_type = str(raw_contract_type or "UNKNOWN").upper()
189
  symbol = str(raw.get("symbol") or raw.get("id") or "").upper()
190
+ explicit_flag = _boolean_flag(raw.get("futuresVerified"))
191
+ if explicit_flag is None:
192
+ explicit_flag = _boolean_flag(raw.get("isFutures"))
193
+ if explicit_flag is None:
194
+ explicit_flag = _boolean_flag(raw.get("isFuture"))
195
+ type_is_futures = any(token in contract_type for token in ("FUTURE", "PERPETUAL", "SWAP"))
196
+ is_futures = explicit_flag is True or (explicit_flag is None and futures_default and type_is_futures)
197
+ active = status in {"TRADING", "ACTIVE", "OPEN"} or (
198
+ raw_status is None and (futures_default or explicit_flag is True)
199
+ )
200
+ if futures_default and (not active or not is_futures):
201
  continue
202
  try:
203
  # KuCoin Futures uses symbols such as XBTUSDTM; use the verified
 
211
  "quoteAsset": str(raw.get("quoteAsset") or norm.quote),
212
  "name": str(raw.get("name") or raw.get("baseAsset") or norm.base),
213
  "rank": raw.get("rank") or raw.get("market_cap_rank"),
214
+ "contractType": contract_type if is_futures else "UNKNOWN",
215
+ "status": status if active else "UNKNOWN",
216
+ "futuresVerified": bool(is_futures and active),
217
+ "source": str(raw.get("source") or source)})
218
  unique = {item["symbol"]: item for item in result}
219
  return sorted(unique.values(), key=lambda item: item["symbol"])
220
 
 
228
  try:
229
  cached = json.loads(_SYMBOL_CACHE_PATH.read_text(encoding="utf-8"))
230
  if isinstance(cached, dict) and isinstance(cached.get("items"), list):
231
+ cached_items = _symbol_items(cached["items"], "cache", futures_default=False)
232
+ if cached_items:
233
+ _symbol_cache.update(
234
+ items=cached_items, updatedAt=float(cached.get("updatedAt") or 0), source="cache"
235
+ )
236
  except (OSError, ValueError, TypeError):
237
  pass
238
  async with httpx.AsyncClient() as client:
 
241
  try:
242
  response = await client.get(f"{DS4_BASE}{path}", timeout=3.0)
243
  if response.status_code < 400:
244
+ items = _symbol_items(
245
+ response.json(), "datasource4",
246
+ futures_default=path == "/api/short-hunter/universe",
247
+ )
248
  if items:
249
  # Preserve verified Futures contracts, then enlarge with DS2 market assets.
250
  break
 
281
  "name": norm.base, "rank": None, "contractType": "UNKNOWN",
282
  "status": "UNKNOWN", "futuresVerified": False, "source": "seed"})
283
  seen.add(norm.ds4)
284
+ aggregate_source = "+".join(dict.fromkeys(
285
+ str(item.get("source") or "unknown") for item in items
286
+ ))
287
+ _symbol_cache.update(items=items, updatedAt=now, source=aggregate_source)
288
  try:
289
  _SYMBOL_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
290
  _SYMBOL_CACHE_PATH.write_text(json.dumps({"items": items, "updatedAt": now}), encoding="utf-8")
291
  except OSError:
292
  pass
293
+ return items, aggregate_source
294
  if _symbol_cache["items"]:
295
  return list(_symbol_cache["items"]), "cache"
296
  items = []
 
306
  return items, "seed"
307
 
308
 
309
+ def _epoch_to_iso(value: object) -> str | None:
310
+ if not isinstance(value, (int, float)):
311
+ return value if isinstance(value, str) else None
312
+ seconds = float(value)
313
+ if seconds > 100_000_000_000:
314
+ seconds /= 1000.0
315
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(seconds))
316
+
317
+
318
+ def _file_sha256(path: Path) -> str | None:
319
+ try:
320
+ return hashlib.sha256(path.read_bytes()).hexdigest()
321
+ except OSError:
322
+ return None
323
+
324
+
325
+ def _path_info(path: Path) -> dict:
326
+ resolved = path.resolve(strict=False)
327
+ return {
328
+ "path": str(resolved),
329
+ "exists": resolved.is_file(),
330
+ "sha256": _file_sha256(resolved),
331
+ "size": resolved.stat().st_size if resolved.is_file() else None,
332
+ }
333
+
334
+
335
+ def _runtime_files() -> dict:
336
+ router_path = Path(__file__).resolve()
337
+ try:
338
+ router_source = router_path.read_text(encoding="utf-8")
339
+ except OSError:
340
+ router_source = ""
341
+ inline_page_count = len(re.findall(r"(?m)^\s*_PAGE\s*=", router_source))
342
+ runtime_template = _path_info(_TEMPLATE_PATH)
343
+ runtime_router = _path_info(router_path)
344
+ overlay_root = Path(os.environ.get("HERMES_OVERLAY_SOURCE", "/opt/hermesface_overlay"))
345
+ persisted_overlay_root = Path("/opt/data/hermes_overlay")
346
+ overlay_template = _path_info(overlay_root / "tools" / "templates" / "hermes_futures_desk_luxury.html")
347
+ overlay_router = _path_info(overlay_root / "tools" / "futures_dashboard_api.py")
348
+ persisted_overlay_template = _path_info(
349
+ persisted_overlay_root / "tools" / "templates" / "hermes_futures_desk_luxury.html"
350
+ )
351
+ persisted_overlay_router = _path_info(
352
+ persisted_overlay_root / "tools" / "futures_dashboard_api.py"
353
+ )
354
+ sync_script = _path_info(Path(os.environ.get("HERMES_SYNC_SCRIPT", "/opt/data/scripts/sync_hf.py")))
355
+ manifest_path = Path(os.environ.get(
356
+ "HERMES_FUTURES_OVERLAY_MANIFEST", "/opt/hermes/.hermes_futures_overlay_manifest.json"
357
+ ))
358
+ manifest = None
359
+ try:
360
+ loaded = json.loads(manifest_path.read_text(encoding="utf-8"))
361
+ if isinstance(loaded, dict):
362
+ manifest = loaded
363
+ except (OSError, ValueError, TypeError):
364
+ manifest = None
365
+ router_matches_overlay = bool(
366
+ runtime_router["sha256"] and runtime_router["sha256"] == overlay_router["sha256"]
367
+ ) if overlay_router["exists"] else None
368
+ template_matches_overlay = bool(
369
+ runtime_template["sha256"] and runtime_template["sha256"] == overlay_template["sha256"]
370
+ ) if overlay_template["exists"] else None
371
+ router_matches_persisted = bool(
372
+ runtime_router["sha256"] and runtime_router["sha256"] == persisted_overlay_router["sha256"]
373
+ ) if persisted_overlay_router["exists"] else None
374
+ template_matches_persisted = bool(
375
+ runtime_template["sha256"] and runtime_template["sha256"] == persisted_overlay_template["sha256"]
376
+ ) if persisted_overlay_template["exists"] else None
377
+ manifest_files = manifest.get("files") if isinstance(manifest, dict) else None
378
+ manifest_matches_runtime = None
379
+ if isinstance(manifest_files, dict):
380
+ router_manifest = manifest_files.get("router") or {}
381
+ template_manifest = manifest_files.get("template") or {}
382
+ manifest_matches_runtime = bool(
383
+ router_manifest.get("destinationSha256") == runtime_router["sha256"]
384
+ and template_manifest.get("destinationSha256") == runtime_template["sha256"]
385
+ and router_manifest.get("matches") is True
386
+ and template_manifest.get("matches") is True
387
+ )
388
+ runtime_mismatch = (
389
+ not runtime_template["exists"]
390
+ or inline_page_count != 0
391
+ or router_matches_overlay is False
392
+ or template_matches_overlay is False
393
+ or manifest_matches_runtime is False
394
+ )
395
+ runtime_evidence = (
396
+ (router_matches_overlay is True and template_matches_overlay is True)
397
+ or manifest_matches_runtime is True
398
+ )
399
+ runtime_consistent = False if runtime_mismatch else (True if runtime_evidence else None)
400
+ return {
401
+ "routerPath": str(router_path),
402
+ "routerSha256": runtime_router["sha256"],
403
+ "templatePath": str(_TEMPLATE_PATH.resolve()),
404
+ "templateSha256": runtime_template["sha256"],
405
+ "templateExists": _TEMPLATE_PATH.is_file(),
406
+ "templateSource": "packaged_file" if _TEMPLATE_PATH.is_file() else "safe_fallback",
407
+ "inlinePageCount": inline_page_count,
408
+ "duplicateInlinePage": inline_page_count > 1,
409
+ "runtimeConsistent": runtime_consistent,
410
+ "runtimeRouter": runtime_router,
411
+ "runtimeTemplate": runtime_template,
412
+ "overlayRouter": overlay_router,
413
+ "overlayTemplate": overlay_template,
414
+ "persistedOverlayRouter": persisted_overlay_router,
415
+ "persistedOverlayTemplate": persisted_overlay_template,
416
+ "syncScript": sync_script,
417
+ "installManifest": {
418
+ "path": str(manifest_path.resolve(strict=False)),
419
+ "exists": manifest_path.is_file(),
420
+ "matchesRuntime": manifest_matches_runtime,
421
+ "content": manifest,
422
+ },
423
+ "routerMatchesOverlay": router_matches_overlay,
424
+ "templateMatchesOverlay": template_matches_overlay,
425
+ "routerMatchesPersistedOverlay": router_matches_persisted,
426
+ "templateMatchesPersistedOverlay": template_matches_persisted,
427
+ }
428
+
429
+
430
+ def _sanitize_text(value: object, limit: int = 500) -> str:
431
+ text = re.sub(r"[\r\n\t]+", " ", str(value or "")).strip()
432
+ # Redact both header-style credentials and JSON/query-style secret values.
433
+ text = re.sub(
434
+ r"(?i)\b(authorization|proxy-authorization)\b\s*[:=]\s*(?:bearer|basic)?\s*[^\s,;]+",
435
+ r"\1=[redacted]",
436
+ text,
437
+ )
438
+ text = re.sub(
439
+ r"(?ix)\b(token|secret|password|api[_-]?key|webhook[_-]?secret|bootstrap[_-]?secret)\b[\s\"']*[:=][\s\"']*[^\s,;&}\"']+",
440
+ r"\1=[redacted]",
441
+ text,
442
+ )
443
+ text = re.sub(r"https?://[^\s]+", lambda m: m.group(0).split("?", 1)[0], text)
444
+ return text[:limit]
445
+
446
+
447
+ def _sanitized_diagnostics(value: object) -> object:
448
+ if isinstance(value, dict):
449
+ return {str(k): _sanitized_diagnostics(v) for k, v in value.items()}
450
+ if isinstance(value, list):
451
+ return [_sanitized_diagnostics(v) for v in value[:100]]
452
+ return _sanitize_text(value) if isinstance(value, str) else value
453
+
454
+
455
+ def _source_status(meta: dict) -> str:
456
+ transport = str(meta.get("transportStatus") or "unknown").lower()
457
+ usability = str(meta.get("dataUsability") or "unknown").lower()
458
+ freshness = str(meta.get("freshness") or "unknown").lower()
459
+ completeness = str(meta.get("completeness") or "unknown").lower()
460
+ if transport in {"unavailable", "failed"}:
461
+ return "unreachable"
462
+ if transport == "restricted" or usability == "unavailable":
463
+ return "unavailable"
464
+ if transport == "healthy" and usability in {"healthy", "usable"} \
465
+ and freshness == "fresh" and completeness == "complete":
466
+ return "ok"
467
+ return "degraded"
468
+
469
+
470
+ def _normalize_source_meta(name: str, url: str, meta: object) -> dict:
471
+ normalized = dict(meta) if isinstance(meta, dict) else {}
472
+ normalized.setdefault("name", name)
473
+ normalized.setdefault("url", url)
474
+ normalized.setdefault("endpoint", url)
475
+ normalized.setdefault("transportStatus", "unknown")
476
+ normalized.setdefault("dataUsability", "unknown")
477
+ normalized.setdefault("freshness", "unknown")
478
+ normalized.setdefault("completeness", "unknown")
479
+ normalized.setdefault("httpStatus", None)
480
+ normalized.setdefault("latencyMs", None)
481
+ normalized.setdefault("lastSuccess", None)
482
+ normalized.setdefault("suppliedFields", [])
483
+ normalized.setdefault("missingFields", [])
484
+ normalized.setdefault("reason", f"{name} has not produced structured market metadata yet")
485
+ normalized["status"] = normalized.get("status") or _source_status(normalized)
486
+ return normalized
487
+
488
+
489
+ def _source_fallback(name: str, status: str, url: str) -> dict:
490
+ transport = "healthy" if status == "ok" else ("unavailable" if status == "unreachable" else "degraded")
491
+ return _normalize_source_meta(name, url, {
492
+ "transportStatus": transport,
493
+ "dataUsability": "unknown",
494
+ "freshness": "unknown",
495
+ "completeness": "unknown",
496
+ "reason": f"{name} has not produced structured market metadata yet",
497
+ })
498
+
499
+
500
+ def _market_health(state: dict, source_metadata: dict) -> str:
501
+ field_metadata = state.get("field_metadata") or {}
502
+ required = ("ticker", "funding", "openInterest")
503
+ required_meta = [field_metadata.get(name) or {} for name in required]
504
+ if state.get("merge_status") == "complete" and required_meta \
505
+ and all(item.get("validity") == "valid" and item.get("freshness") == "fresh" for item in required_meta):
506
+ return "healthy"
507
+ if any((item.get("validity") == "valid") for item in field_metadata.values() if isinstance(item, dict)):
508
+ return "degraded"
509
+ if any(str(meta.get("transportStatus")).lower() == "healthy" for meta in source_metadata.values()):
510
+ return "degraded"
511
+ return "unavailable"
512
+
513
+
514
+ def _freshness_from_timestamp(value: object, interval: str) -> str:
515
+ if value is None:
516
+ return "unknown"
517
+ try:
518
+ if isinstance(value, str) and not value.isdigit():
519
+ parsed = calendar.timegm(time.strptime(value, "%Y-%m-%dT%H:%M:%SZ"))
520
+ else:
521
+ number = float(value)
522
+ parsed = number / 1000.0 if number > 100_000_000_000 else number
523
+ except (TypeError, ValueError, OverflowError):
524
+ return "unknown"
525
+ interval_seconds = {"1m": 60, "5m": 300, "15m": 900, "1h": 3600}.get(interval, 300)
526
+ age = time.time() - parsed
527
+ if age < -60:
528
+ return "invalid"
529
+ return "fresh" if age <= max(180, interval_seconds * 3) else "stale"
530
+
531
+
532
+ def _finite(value: object) -> float | None:
533
+ try:
534
+ number = float(value)
535
+ except (TypeError, ValueError):
536
+ return None
537
+ return number if math.isfinite(number) else None
538
+
539
+
540
+ def _first(mapping: object, *keys: str) -> object:
541
+ if not isinstance(mapping, dict):
542
+ return None
543
+ for key in keys:
544
+ if mapping.get(key) is not None:
545
+ return mapping[key]
546
+ return None
547
+
548
+
549
+ def _normalize_candles(raw: object, limit: int) -> list[dict]:
550
+ if not isinstance(raw, list):
551
+ return []
552
+ candles: list[dict] = []
553
+ for item in raw[-limit:]:
554
+ if isinstance(item, dict):
555
+ ts = _first(item, "timestamp", "time", "ts", "openTime")
556
+ values = [_finite(_first(item, key, short)) for key, short in (
557
+ ("open", "o"), ("high", "h"), ("low", "l"), ("close", "c"), ("volume", "v")
558
+ )]
559
+ source = item.get("source")
560
+ elif isinstance(item, (list, tuple)) and len(item) >= 6:
561
+ ts, values, source = item[0], [_finite(v) for v in item[1:6]], None
562
+ else:
563
+ continue
564
+ open_, high, low, close, volume = values
565
+ if ts is None or None in (open_, high, low, close, volume):
566
+ continue
567
+ if min(open_, high, low, close) <= 0 or volume < 0 or high < max(open_, close, low) or low > min(open_, close, high):
568
+ continue
569
+ candle = {"timestamp": ts, "open": open_, "high": high, "low": low, "close": close, "volume": volume}
570
+ if source:
571
+ candle["source"] = source
572
+ candles.append(candle)
573
+ return candles
574
+
575
+
576
+ def _catalog_counts(items: list[dict]) -> dict:
577
+ verified = sum(1 for item in items if item.get("futuresVerified") is True)
578
+ total = len(items)
579
+ return {"total": total, "verifiedFutures": verified, "marketOnly": total - verified}
580
+
581
+
582
+ def _public_field_metadata(metadata: object) -> dict:
583
+ """Bound large market fields while preserving value/source/freshness semantics."""
584
+ if not isinstance(metadata, dict):
585
+ return {}
586
+ public: dict[str, dict] = {}
587
+ for field, raw in metadata.items():
588
+ if not isinstance(raw, dict):
589
+ continue
590
+ item = {key: raw.get(key) for key in (
591
+ "source", "timestamp", "freshness", "validity", "fallbackStatus"
592
+ ) if key in raw}
593
+ value = raw.get("value")
594
+ if field == "ohlcv" and isinstance(value, list):
595
+ item["value"] = {"count": len(value), "latest": value[-1] if value else None}
596
+ elif field == "orderbook" and isinstance(value, dict):
597
+ bids, asks = value.get("bids") or [], value.get("asks") or []
598
+ item["value"] = {
599
+ "bidLevels": len(bids), "askLevels": len(asks),
600
+ "bestBid": bids[0] if bids else None, "bestAsk": asks[0] if asks else None,
601
+ }
602
+ else:
603
+ item["value"] = value
604
+ public[str(field)] = item
605
+ return public
606
+
607
+
608
+ @router.get("/api/futures/status", dependencies=[Depends(_authenticated_dashboard_request)])
609
  async def futures_status() -> JSONResponse:
610
  state = _dash_state.snapshot()
611
+ ds4_probe, ds2_probe = await asyncio.gather(
612
  _probe(f"{DS4_BASE}/api/short-hunter/health"),
613
  _probe(f"{DS2_BASE}/real/api/market/tickers"),
 
614
  )
615
+ account, positions = await asyncio.gather(_get_futures_account(), _get_futures_positions())
616
+ raw_source_metadata = dict(state.get("source_metadata") or {})
617
+ restricted = any("HTTP 451" in str(w) for w in state.get("warnings", []))
618
+ source_metadata = {
619
+ "datasource4": _normalize_source_meta(
620
+ "Datasource 4", DS4_BASE,
621
+ raw_source_metadata.get("datasource4") or _source_fallback("Datasource 4", ds4_probe, DS4_BASE),
622
+ ),
623
+ "binance": _normalize_source_meta(
624
+ "Binance public", binance_public.BASE_URL,
625
+ raw_source_metadata.get("binance") or {
626
+ "transportStatus": "restricted" if restricted else "standby",
627
+ "dataUsability": "unavailable" if restricted else "not_used",
628
+ "freshness": "unknown", "completeness": "unknown",
629
+ "httpStatus": 451 if restricted else None,
630
+ "reason": "Regionally restricted" if restricted else "Fallback available on demand",
631
+ },
632
+ ),
633
+ "datasource2": _normalize_source_meta(
634
+ "Datasource 2", DS2_BASE,
635
+ raw_source_metadata.get("datasource2") or _source_fallback("Datasource 2", ds2_probe, DS2_BASE),
636
+ ),
637
+ }
638
+ market_health = _market_health(state, source_metadata)
639
+ runtime_files = _runtime_files()
640
+ runtime_consistent = runtime_files.get("runtimeConsistent")
641
+ runtime_status = "verified" if runtime_consistent is True else (
642
+ "mismatch" if runtime_consistent is False else "unknown"
643
+ )
644
  return JSONResponse({
645
+ "application": {
646
+ "status": "online",
647
+ "runtimeStatus": runtime_status,
648
+ "runtimeFiles": runtime_files,
649
+ },
650
+ "marketData": {"status": market_health},
651
+ "tradingReadiness": state.get("trading_readiness", "blocked"),
652
+ "mergeStatus": state.get("merge_status", "unknown"),
653
+ "analysisState": state.get("analysis_state", "NOT_ANALYZED"),
654
+ "primaryDatasource": source_metadata["datasource4"],
655
+ "binanceDatasource": source_metadata["binance"],
656
+ "secondaryDatasource": source_metadata["datasource2"],
657
+ "sourceMetadata": source_metadata,
658
  "fieldSources": state.get("field_sources", {}),
659
+ "fieldMetadata": _public_field_metadata(state.get("field_metadata", {})),
660
+ "verifiedFutures": state.get("verified_futures", False),
661
+ "futuresVerification": state.get("futures_verification", {}),
662
+ "missingRequiredFields": state.get("missing_required_fields", []),
663
+ "staleRequiredFields": state.get("stale_required_fields", []),
664
  "currentSignal": state.get("current_signal"),
665
  "signalReasons": state.get("signal_reasons", []),
666
  "warnings": state.get("warnings", []),
667
+ "technicalDiagnostics": _sanitized_diagnostics(state.get("technical_diagnostics", {})),
668
  "rejectedTradeReason": state.get("rejected_trade_reason"),
669
  "latestTradePlan": state.get("latest_trade_plan"),
670
  "latestPlanId": state.get("latest_plan_id"),
 
672
  "signalComponents": state.get("signal_components", {}),
673
  "riskApproved": state.get("risk_approved", False),
674
  "rejectionReasons": state.get("rejection_reasons", []),
675
+ "marketRejectionReasons": state.get("market_rejection_reasons", []),
676
  "planCreatedAt": state.get("latest_plan_created_at"),
677
  "planExpiresAt": state.get("latest_plan_expires_at"),
678
+ "planCreatedAtIso": _epoch_to_iso(state.get("latest_plan_created_at")),
679
+ "planExpiresAtIso": _epoch_to_iso(state.get("latest_plan_expires_at")),
680
  "latestPaperExecutionResult": state.get("latest_paper_execution_result"),
681
  "planExecuted": state.get("latest_plan_executed", False),
682
  "tradingMode": account.get("mode"),
 
685
  "openPositionCount": len(positions.get("positions", [])),
686
  "updatedAt": state.get("updated_at"),
687
  "serverTime": time.time(),
688
+ }, headers={"Cache-Control": "no-store"})
 
689
 
690
 
691
  @router.get("/api/futures/symbols", dependencies=[Depends(_authenticated_dashboard_request)])
692
  async def futures_symbols() -> JSONResponse:
693
  items, source = await _load_symbols()
694
  updated = _symbol_cache.get("updatedAt") or time.time()
695
+ updated_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(updated)))
696
  for item in items:
697
  item.setdefault("source", source)
698
+ item["updatedAt"] = updated_iso
699
+ item["marketOnly"] = not bool(item.get("futuresVerified"))
700
+ return JSONResponse({
701
+ "symbols": items, "source": source, "updatedAt": updated_iso if items else None,
702
+ "counts": _catalog_counts(items),
703
+ }, headers={"Cache-Control": "no-store"})
704
 
705
 
706
+ @router.get("/api/futures/positions", dependencies=[Depends(_authenticated_dashboard_request)])
707
  async def futures_positions() -> JSONResponse:
708
  positions = await _get_futures_positions()
709
+ raw_positions = list(positions.get("positions", []))
710
+ marks = await asyncio.gather(*(
711
+ _mark_price(position["symbol"]) for position in raw_positions
712
+ )) if raw_positions else []
713
+ enriched = []
714
+ for position, mark in zip(raw_positions, marks):
 
715
  unrealized = None
716
  if mark:
717
+ direction = 1 if position["side"] == "long" else -1
718
+ unrealized = (mark - position["entryPrice"]) * position["size"] * direction
719
+ enriched.append({**position, "markPrice": mark, "unrealizedPnl": unrealized})
720
+ return JSONResponse(
721
+ {"mode": positions.get("mode"), "positions": enriched},
722
+ headers={"Cache-Control": "no-store"},
723
+ )
724
 
725
 
726
  @router.get("/api/futures/market", dependencies=[Depends(_authenticated_dashboard_request)])
727
+ async def futures_market(
728
+ symbol: str = Query("BTCUSDT", min_length=3, max_length=32),
729
+ interval: Literal["1m", "5m", "15m", "1h"] = Query("5m"),
730
+ limit: int = Query(120, ge=20, le=500),
731
+ ) -> JSONResponse:
732
+ """Return real read-only market data with explicit partial/unavailable semantics."""
733
+ norm = _normalized_symbol_or_422(symbol)
734
+ try:
735
+ context = await get_market_context(norm.ds4, timeframe=interval, limit=limit)
736
+ _dash_state.record_market_context(context)
737
+ except Exception as exc:
738
+ return JSONResponse({
739
+ "state": "unavailable",
740
+ "analysisState": "API_UNAVAILABLE",
741
+ "dataUsability": "unavailable",
742
+ "reason": "Market data request failed",
743
+ "symbol": norm.ds4,
744
+ "interval": interval,
745
+ "limit": limit,
746
+ "candles": [],
747
+ "currentPrice": None,
748
+ "markPrice": None,
749
+ "change24h": None,
750
+ "volume24h": None,
751
+ "fundingRate": None,
752
+ "openInterest": None,
753
+ "source": "unavailable",
754
+ "sourcesUsed": [],
755
+ "fieldSources": {},
756
+ "fieldMetadata": {},
757
+ "freshness": "unknown",
758
+ "timestamp": None,
759
+ "verifiedFutures": False,
760
+ "futuresVerification": {
761
+ "verified": False,
762
+ "status": "unavailable",
763
+ "reason": "Market data request failed",
764
+ },
765
+ "contractStatus": "unavailable",
766
+ "warnings": ["Market data provider request failed"],
767
+ "missingFields": ["candles", "price", "funding", "openInterest"],
768
+ "analysisRequiredFieldsMissing": [
769
+ "contract", "ticker", "orderbook", "funding", "openInterest"
770
+ ],
771
+ "staleRequiredFields": [],
772
+ "mergeStatus": "unavailable",
773
+ "tradingReadiness": "blocked",
774
+ "rejectionReasons": ["Market data request failed"],
775
+ "sourceMetadata": {},
776
+ "technicalDiagnostics": {"market": [_sanitize_text(type(exc).__name__)]},
777
+ "fetchedAt": time.time(),
778
+ }, status_code=503, headers={"Cache-Control": "no-store"})
779
+
780
+ merged = dict(context.get("merged") or {})
781
+ sources = dict(context.get("sources") or {})
782
+ warnings = [_sanitize_text(item) for item in (context.get("warnings") or [])]
783
+ diagnostics = _sanitized_diagnostics(context.get("technicalDiagnostics") or {})
784
+ field_metadata = dict(context.get("fieldMetadata") or {})
785
+ source_metadata = {
786
+ key: _normalize_source_meta(
787
+ {"datasource4": "Datasource 4", "binance": "Binance public", "datasource2": "Datasource 2"}.get(key, key),
788
+ {"datasource4": DS4_BASE, "binance": binance_public.BASE_URL, "datasource2": DS2_BASE}.get(key, ""),
789
+ value,
790
+ )
791
+ for key, value in (context.get("sourceMetadata") or {}).items()
792
+ }
793
+ candles = _normalize_candles(merged.get("ohlcv"), limit)
794
+ ticker = merged.get("ticker") if isinstance(merged.get("ticker"), dict) else {}
795
+ funding = merged.get("funding") if isinstance(merged.get("funding"), dict) else {}
796
+ open_interest = merged.get("openInterest") if isinstance(merged.get("openInterest"), dict) else {}
797
+
798
+ price = _finite(_first(ticker, "markPrice", "lastPrice", "last", "price", "close"))
799
+ if price is None and candles:
800
+ price = candles[-1]["close"]
801
+ mark_price = _finite(_first(ticker, "markPrice"))
802
+ change_24h = _finite(_first(
803
+ ticker, "change24h", "changePercent", "priceChangePercent", "percentage", "price24hPcnt"
804
+ ))
805
+ volume = _finite(_first(
806
+ ticker, "volume24h", "quoteVolume", "volume", "volValue", "turnover24h", "turnover"
807
+ ))
808
+ funding_rate = _finite(_first(
809
+ funding, "currentFundingRate", "fundingRate", "lastFundingRate", "rate"
810
+ ))
811
+ open_interest_value = _finite(_first(
812
+ open_interest, "openInterest", "open_interest", "sumOpenInterest", "oi"
813
+ ))
814
+
815
+ display_missing = [name for name, value in (
816
+ ("candles", candles), ("price", price), ("funding", funding_rate),
817
+ ("openInterest", open_interest_value),
818
+ ) if value in (None, [], {})]
819
+ display_freshness = []
820
+ for field in ("ohlcv", "ticker", "funding", "openInterest"):
821
+ label = str((field_metadata.get(field) or {}).get("freshness") or "unknown").lower()
822
+ if label:
823
+ display_freshness.append(label)
824
+ aggregate_freshness = (
825
+ "invalid" if "invalid" in display_freshness else
826
+ "stale" if "stale" in display_freshness else
827
+ "fresh" if display_freshness and all(label == "fresh" for label in display_freshness) else
828
+ "unknown"
829
+ )
830
+ if not candles or price is None:
831
+ state = "unavailable"
832
+ usability = "unavailable"
833
+ reason = "Real OHLCV candles or a current price are unavailable"
834
+ elif aggregate_freshness in {"stale", "invalid"}:
835
+ state = "stale"
836
+ usability = "degraded"
837
+ reason = "Market data freshness is invalid or stale"
838
+ elif aggregate_freshness != "fresh":
839
+ state = "partial"
840
+ usability = "degraded"
841
+ reason = "Market data freshness could not be verified"
842
+ elif display_missing:
843
+ state = "partial"
844
+ usability = "degraded"
845
+ reason = "Required Futures display fields are incomplete"
846
+ else:
847
+ state = "available"
848
+ usability = "usable"
849
+ reason = None
850
+
851
+ used_sources = sorted({source for source in sources.values() if source not in {None, "", "unavailable"}})
852
+ primary_source = sources.get("ohlcv") or sources.get("ticker") or "unavailable"
853
+ if len(used_sources) > 1:
854
+ primary_source = "mixed"
855
+ verification = context.get("futuresVerification") or {}
856
+ verified = bool(context.get("verifiedFutures"))
857
+ rejection_reasons = list(context.get("noTradeReasons") or [])
858
+ payload = {
859
+ "state": state,
860
+ "analysisState": "API_UNAVAILABLE" if state == "unavailable" else (
861
+ "STALE" if state == "stale" else "NOT_ANALYZED"
862
+ ),
863
+ "dataUsability": usability,
864
+ "reason": reason,
865
+ "symbol": norm.ds4,
866
+ "interval": interval,
867
+ "limit": limit,
868
+ "candles": candles,
869
+ "currentPrice": price,
870
+ "markPrice": mark_price,
871
+ "change24h": change_24h,
872
+ "volume24h": volume,
873
+ "fundingRate": funding_rate,
874
+ "openInterest": open_interest_value,
875
+ "source": primary_source,
876
+ "sourcesUsed": used_sources,
877
+ "fieldSources": sources,
878
+ "fieldMetadata": _public_field_metadata(field_metadata),
879
+ "freshness": aggregate_freshness,
880
+ "timestamp": (field_metadata.get("ohlcv") or {}).get("timestamp")
881
+ or (field_metadata.get("ticker") or {}).get("timestamp"),
882
+ "verifiedFutures": verified,
883
+ "futuresVerification": verification,
884
+ "contractStatus": verification.get("status") or ("verified" if verified else "unverified"),
885
+ "warnings": warnings,
886
+ "missingFields": display_missing,
887
+ "analysisRequiredFieldsMissing": list(context.get("missingRequiredFields") or []),
888
+ "staleRequiredFields": list(context.get("staleRequiredFields") or []),
889
+ "mergeStatus": context.get("mergeStatus") or "unknown",
890
+ "tradingReadiness": context.get("tradingReadiness") or "blocked",
891
+ "rejectionReasons": rejection_reasons,
892
+ "sourceMetadata": source_metadata,
893
+ "technicalDiagnostics": diagnostics,
894
+ "fetchedAt": context.get("fetchedAt"),
895
+ }
896
+ return JSONResponse(payload, headers={"Cache-Control": "no-store"})
897
 
898
 
899
  @router.post("/api/futures/analyze", dependencies=[Depends(_authenticated_dashboard_request)])
900
  async def futures_analyze(request: _CycleRequest) -> JSONResponse:
901
  """Run analysis only and return a server-referenced deterministic plan."""
902
+ normalized = _normalized_symbol_or_422(request.symbol)
903
  try:
904
  plan = await _run_futures_cycle(
905
+ normalized.ds4, risk_profile=request.risk_profile, execute=False,
906
  include_external_context=request.include_external_context,
907
  )
908
  plan_id = _dash_state.record_trade_plan(plan)
909
+ return JSONResponse(
910
+ _plan_payload(plan, plan_id), headers={"Cache-Control": "no-store"}
911
+ )
912
  except Exception as exc:
913
+ public_message = "Futures analysis failed"
914
+ _dash_state.record_analysis_failure(public_message, _sanitize_text(type(exc).__name__))
915
+ raise HTTPException(status_code=503, detail=public_message) from exc
916
 
917
 
918
  @router.post("/api/futures/paper/execute", dependencies=[Depends(_authenticated_dashboard_request)])
 
920
  """Revalidate a server-held plan before invoking the existing Paper path."""
921
  async with _paper_cycle_lock:
922
  state = _dash_state.snapshot()
923
+ normalized = _normalized_symbol_or_422(request.symbol)
924
  supported_symbols, _ = await _load_symbols()
925
  verified = {item["symbol"] for item in supported_symbols if item.get("futuresVerified")}
926
+ if normalized.ds4 not in verified:
927
  raise HTTPException(status_code=403, detail="Paper execution requires a verified Futures contract")
928
  if request.plan_id != state.get("latest_plan_id"):
929
  raise HTTPException(status_code=409, detail="Unknown or superseded planId")
930
+ if normalized.ds4 != state.get("latest_plan_symbol") \
931
  or request.risk_profile != state.get("latest_plan_risk_profile"):
932
  raise HTTPException(status_code=409, detail="Plan symbol or risk profile changed")
933
  if state.get("latest_plan_executed"):
 
935
  expires_at = state.get("latest_plan_expires_at")
936
  if not isinstance(expires_at, (int, float)) or time.time() >= expires_at:
937
  raise HTTPException(status_code=409, detail="Plan is expired")
938
+ stored_plan = state.get("latest_trade_plan") or {}
939
+ if stored_plan.get("decision") not in ("LONG", "SHORT"):
940
  raise HTTPException(status_code=409, detail="Only a directional plan can be executed")
941
+ if stored_plan.get("futuresVerified") is not True:
942
+ raise HTTPException(status_code=409, detail="Plan is not attached to a verified Futures contract")
943
+ if stored_plan.get("noTradeGuard") or stored_plan.get("trading_readiness") != "ready":
944
+ raise HTTPException(status_code=409, detail="Plan is blocked by current trading readiness")
945
+ if stored_plan.get("executable") is not True:
946
+ raise HTTPException(status_code=409, detail="Stored plan is not executable")
947
  if not state.get("risk_approved"):
948
  raise HTTPException(status_code=409, detail="Plan is not risk-approved")
949
  if _get_trading_mode() != "paper":
950
  raise HTTPException(status_code=403, detail="Only Paper execution is enabled")
951
 
 
 
952
  fresh = await _run_futures_cycle(
953
+ normalized.ds4, risk_profile=request.risk_profile, execute=False,
954
  include_external_context=False,
955
  )
956
  if fresh.get("decision") not in ("LONG", "SHORT") or not fresh.get("risk_approved"):
 
959
  "message": "Fresh server-side validation rejected the plan",
960
  "reasons": fresh.get("rejection_reasons", []) + fresh.get("core_reasons", []),
961
  })
962
+ if fresh.get("noTradeGuard") or fresh.get("executed") or not fresh.get("executable"):
963
  raise HTTPException(status_code=409, detail="Fresh plan is not executable")
964
+ if not _is_future_iso(fresh.get("expires_at")):
 
965
  raise HTTPException(status_code=409, detail="Fresh plan has no valid expiry")
966
 
967
+ result = dict(await _run_futures_cycle(
968
+ normalized.ds4, risk_profile=request.risk_profile, execute=True,
969
  include_external_context=False,
970
+ ))
 
971
  _dash_state.record_trade_plan(result, plan_id=request.plan_id)
972
  _dash_state.record_paper_execution({
973
+ "executed": bool(result.get("executed")), "symbol": result.get("symbol"),
974
+ "side": result.get("decision"), "mode": "paper",
 
 
975
  "status": "executed" if result.get("executed") else "rejected",
976
  "reason": "; ".join(result.get("rejection_reasons", [])),
977
  })
978
+ return JSONResponse(
979
+ _plan_payload(result, request.plan_id), headers={"Cache-Control": "no-store"}
980
+ )
981
+
982
+
983
+ _FALLBACK_PAGE = """<!doctype html><html lang=en><meta charset=utf-8><title>Hermes Futures Desk</title><body><h1>Futures Desk</h1><p>Dashboard template unavailable. Trading remains blocked.</p></body></html>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
984
 
985
 
986
  @router.get("/futures", response_class=HTMLResponse)
 
988
  try:
989
  page = _TEMPLATE_PATH.read_text(encoding="utf-8")
990
  except OSError:
991
+ page = _FALLBACK_PAGE
992
+ template_hash = hashlib.sha256(page.encode("utf-8")).hexdigest()
993
+ return HTMLResponse(page, headers={
994
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
995
+ "Pragma": "no-cache",
996
+ "X-Hermes-Template-SHA256": template_hash,
997
+ "X-Hermes-Router-SHA256": _file_sha256(Path(__file__).resolve()) or "unavailable",
998
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hermes_overlay/tools/templates/hermes_futures_desk_luxury.html CHANGED
The diff for this file is too large to render. See raw diff
 
hermes_overlay/trading/binance_public_client.py CHANGED
@@ -11,6 +11,7 @@ import math
11
  import os
12
  import socket
13
  import ssl
 
14
  from dataclasses import dataclass
15
  from typing import Any, Iterable, Optional
16
 
@@ -171,6 +172,19 @@ async def _get_json(
171
  return _RequestResult(issue=_issue("internal_error", path, "Unexpected retry termination"))
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  async def _fetch_premium_index(client: httpx.AsyncClient, symbol: str) -> _RequestResult:
175
  return await _get_json(client, "/fapi/v1/premiumIndex", {"symbol": symbol})
176
 
@@ -236,14 +250,37 @@ def _normalize_ticker_and_funding(raw: Any) -> tuple[Optional[dict], Optional[di
236
  ticker = {"markPrice": mark, "source": "binance_public"}
237
  if index is not None and index > 0:
238
  ticker["indexPrice"] = index
 
 
239
  funding = None
240
  if funding_rate is not None:
241
  funding = {"currentFundingRate": funding_rate, "source": "binance_public"}
 
 
242
  if raw.get("nextFundingTime") is not None:
243
  funding["nextFundingTime"] = raw["nextFundingTime"]
244
  return ticker, funding
245
 
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  def _normalize_depth(raw: Any) -> Optional[dict]:
248
  if not isinstance(raw, dict):
249
  return None
@@ -264,7 +301,12 @@ def _normalize_depth(raw: Any) -> Optional[dict]:
264
  bids, asks = levels(raw.get("bids")), levels(raw.get("asks"))
265
  if not bids or not asks:
266
  return None
267
- return {"bids": bids, "asks": asks, "source": "binance_public"}
 
 
 
 
 
268
 
269
 
270
  def _calculate_atr(mark_candles: Any, period: int = ATR_PERIOD) -> Optional[float]:
@@ -325,7 +367,10 @@ def _normalize_open_interest(raw: Any) -> Optional[dict]:
325
  current = _to_float(raw.get("openInterest"))
326
  if current is None or current < 0:
327
  return None
328
- return {"openInterest": current, "source": "binance_public"}
 
 
 
329
 
330
 
331
  def _format_issue(issue: dict) -> str:
@@ -344,16 +389,27 @@ async def get_binance_public_result(
344
  ) -> dict:
345
  """Return data plus structured warnings/errors for requested logical fields."""
346
  needed = frozenset(needed_fields) & SUPPORTED_FIELDS if needed_fields is not None else SUPPORTED_FIELDS
 
347
  result = {
348
  "source": "binance_public", "symbol": symbol, "requestedFields": sorted(needed),
349
- "data": {}, "warnings": [], "errors": [],
350
  }
351
  if not FALLBACK_ENABLED:
352
  result["warnings"].append(_issue(
353
  "disabled", "", "Binance public fallback disabled by configuration", retryable=False,
354
  ))
 
 
 
 
 
355
  return result
356
  if not needed:
 
 
 
 
 
357
  return result
358
 
359
  timeout = httpx.Timeout(TIMEOUT_S, connect=min(TIMEOUT_S, 3.0))
@@ -414,7 +470,8 @@ async def get_binance_public_result(
414
  change = _calculate_oi_change(history)
415
  if history is not None and change is not None:
416
  data["openInterestChange"] = {
417
- "changePercent": change, "history": history, "source": "binance_public",
 
418
  }
419
 
420
  for field in needed:
@@ -423,6 +480,81 @@ async def get_binance_public_result(
423
  "unusable_response", "", f"Binance did not provide usable data for {field}",
424
  field=field, retryable=False,
425
  ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  return result
427
 
428
 
 
11
  import os
12
  import socket
13
  import ssl
14
+ import time
15
  from dataclasses import dataclass
16
  from typing import Any, Iterable, Optional
17
 
 
172
  return _RequestResult(issue=_issue("internal_error", path, "Unexpected retry termination"))
173
 
174
 
175
+
176
+
177
+ async def _fetch_24h_ticker(client: httpx.AsyncClient, symbol: str) -> _RequestResult:
178
+ return await _get_json(client, "/fapi/v1/ticker/24hr", {"symbol": symbol})
179
+
180
+
181
+ async def _fetch_klines_for(
182
+ client: httpx.AsyncClient, symbol: str, interval: str, limit: int
183
+ ) -> _RequestResult:
184
+ return await _get_json(client, "/fapi/v1/klines", {
185
+ "symbol": symbol, "interval": interval, "limit": limit,
186
+ })
187
+
188
  async def _fetch_premium_index(client: httpx.AsyncClient, symbol: str) -> _RequestResult:
189
  return await _get_json(client, "/fapi/v1/premiumIndex", {"symbol": symbol})
190
 
 
250
  ticker = {"markPrice": mark, "source": "binance_public"}
251
  if index is not None and index > 0:
252
  ticker["indexPrice"] = index
253
+ if raw.get("time") is not None:
254
+ ticker["timestamp"] = raw["time"]
255
  funding = None
256
  if funding_rate is not None:
257
  funding = {"currentFundingRate": funding_rate, "source": "binance_public"}
258
+ if raw.get("time") is not None:
259
+ funding["timestamp"] = raw["time"]
260
  if raw.get("nextFundingTime") is not None:
261
  funding["nextFundingTime"] = raw["nextFundingTime"]
262
  return ticker, funding
263
 
264
 
265
+ def _normalize_24h_ticker(raw: Any) -> Optional[dict]:
266
+ if not isinstance(raw, dict):
267
+ return None
268
+ last = _to_float(raw.get("lastPrice"))
269
+ if last is None or last <= 0:
270
+ return None
271
+ result = {"lastPrice": last, "source": "binance_public"}
272
+ change = _to_float(raw.get("priceChangePercent"))
273
+ volume = _to_float(raw.get("quoteVolume"))
274
+ if change is not None:
275
+ result["change24h"] = change
276
+ result["change24hFraction"] = change / 100.0
277
+ if volume is not None and volume >= 0:
278
+ result["volume24h"] = volume
279
+ if raw.get("closeTime") is not None:
280
+ result["timestamp"] = raw["closeTime"]
281
+ return result
282
+
283
+
284
  def _normalize_depth(raw: Any) -> Optional[dict]:
285
  if not isinstance(raw, dict):
286
  return None
 
301
  bids, asks = levels(raw.get("bids")), levels(raw.get("asks"))
302
  if not bids or not asks:
303
  return None
304
+ result = {"bids": bids, "asks": asks, "source": "binance_public"}
305
+ if raw.get("E") is not None:
306
+ result["timestamp"] = raw["E"]
307
+ elif raw.get("lastUpdateId") is not None:
308
+ result["lastUpdateId"] = raw["lastUpdateId"]
309
+ return result
310
 
311
 
312
  def _calculate_atr(mark_candles: Any, period: int = ATR_PERIOD) -> Optional[float]:
 
367
  current = _to_float(raw.get("openInterest"))
368
  if current is None or current < 0:
369
  return None
370
+ result = {"openInterest": current, "source": "binance_public"}
371
+ if raw.get("time") is not None:
372
+ result["timestamp"] = raw["time"]
373
+ return result
374
 
375
 
376
  def _format_issue(issue: dict) -> str:
 
389
  ) -> dict:
390
  """Return data plus structured warnings/errors for requested logical fields."""
391
  needed = frozenset(needed_fields) & SUPPORTED_FIELDS if needed_fields is not None else SUPPORTED_FIELDS
392
+ started = time.perf_counter()
393
  result = {
394
  "source": "binance_public", "symbol": symbol, "requestedFields": sorted(needed),
395
+ "data": {}, "warnings": [], "errors": [], "meta": {},
396
  }
397
  if not FALLBACK_ENABLED:
398
  result["warnings"].append(_issue(
399
  "disabled", "", "Binance public fallback disabled by configuration", retryable=False,
400
  ))
401
+ result["meta"] = {
402
+ "transportStatus": "disabled", "httpStatus": None,
403
+ "latencyMs": round((time.perf_counter() - started) * 1000, 2),
404
+ "lastSuccess": None,
405
+ }
406
  return result
407
  if not needed:
408
+ result["meta"] = {
409
+ "transportStatus": "standby", "httpStatus": None,
410
+ "latencyMs": round((time.perf_counter() - started) * 1000, 2),
411
+ "lastSuccess": None,
412
+ }
413
  return result
414
 
415
  timeout = httpx.Timeout(TIMEOUT_S, connect=min(TIMEOUT_S, 3.0))
 
470
  change = _calculate_oi_change(history)
471
  if history is not None and change is not None:
472
  data["openInterestChange"] = {
473
+ "changePercent": change, "changeFraction": change,
474
+ "history": history, "source": "binance_public",
475
  }
476
 
477
  for field in needed:
 
480
  "unusable_response", "", f"Binance did not provide usable data for {field}",
481
  field=field, retryable=False,
482
  ))
483
+ statuses = [item.get("status") for item in result["errors"] if isinstance(item, dict)]
484
+ restricted = 451 in statuses
485
+ result["meta"] = {
486
+ "transportStatus": "restricted" if restricted else ("healthy" if data else "degraded"),
487
+ "httpStatus": 451 if restricted else next((status for status in statuses if status is not None), None),
488
+ "latencyMs": round((time.perf_counter() - started) * 1000, 2),
489
+ "lastSuccess": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if data else None,
490
+ }
491
+ return result
492
+
493
+
494
+ async def get_binance_public_market_data(
495
+ symbol: str, interval: str = "5m", limit: int = 120,
496
+ ) -> dict:
497
+ """Read-only market payload for the dashboard endpoint. No values are synthesized."""
498
+ allowed_intervals = {"1m", "5m", "15m", "1h"}
499
+ if interval not in allowed_intervals:
500
+ raise ValueError(f"Unsupported Binance interval: {interval}")
501
+ bounded_limit = max(1, min(int(limit), 500))
502
+ started = time.perf_counter()
503
+ result = {
504
+ "source": "binance_public", "symbol": symbol, "interval": interval,
505
+ "requestedLimit": bounded_limit, "data": {}, "warnings": [], "errors": [], "meta": {},
506
+ }
507
+ if not FALLBACK_ENABLED:
508
+ result["warnings"].append(_issue(
509
+ "disabled", "", "Binance public fallback disabled by configuration", retryable=False,
510
+ ))
511
+ result["meta"] = {
512
+ "transportStatus": "disabled", "httpStatus": None,
513
+ "latencyMs": round((time.perf_counter() - started) * 1000, 2),
514
+ "lastSuccess": None,
515
+ }
516
+ return result
517
+ timeout = httpx.Timeout(TIMEOUT_S, connect=min(TIMEOUT_S, 3.0))
518
+ async with httpx.AsyncClient(timeout=timeout, headers={"Accept": "application/json"}) as client:
519
+ responses = await asyncio.gather(
520
+ _fetch_premium_index(client, symbol),
521
+ _fetch_24h_ticker(client, symbol),
522
+ _fetch_klines_for(client, symbol, interval, bounded_limit),
523
+ _fetch_open_interest(client, symbol),
524
+ )
525
+ premium_r, ticker_r, candles_r, oi_r = responses
526
+ for response in responses:
527
+ if isinstance(response, _RequestResult) and response.issue:
528
+ result["errors"].append(response.issue)
529
+ premium = _response_data(premium_r)
530
+ premium_ticker, funding = _normalize_ticker_and_funding(premium)
531
+ ticker = _normalize_24h_ticker(_response_data(ticker_r)) or premium_ticker
532
+ if ticker is not None and premium_ticker:
533
+ ticker = {**premium_ticker, **ticker}
534
+ candles = _normalize_klines(_response_data(candles_r))
535
+ open_interest = _normalize_open_interest(_response_data(oi_r))
536
+ if ticker is not None:
537
+ result["data"]["ticker"] = ticker
538
+ if funding is not None:
539
+ result["data"]["funding"] = funding
540
+ if candles is not None:
541
+ result["data"]["ohlcv"] = candles
542
+ if open_interest is not None:
543
+ result["data"]["openInterest"] = open_interest
544
+ for field in ("ticker", "funding", "ohlcv", "openInterest"):
545
+ if field not in result["data"]:
546
+ result["warnings"].append(_issue(
547
+ "unusable_response", "", f"Binance did not provide usable data for {field}",
548
+ field=field, retryable=False,
549
+ ))
550
+ statuses = [item.get("status") for item in result["errors"] if isinstance(item, dict)]
551
+ restricted = 451 in statuses
552
+ result["meta"] = {
553
+ "transportStatus": "restricted" if restricted else ("healthy" if result["data"] else "degraded"),
554
+ "httpStatus": 451 if restricted else next((status for status in statuses if status is not None), None),
555
+ "latencyMs": round((time.perf_counter() - started) * 1000, 2),
556
+ "lastSuccess": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if result["data"] else None,
557
+ }
558
  return result
559
 
560
 
hermes_overlay/trading/dual_datasource_client.py CHANGED
@@ -12,8 +12,10 @@ this module only calls their existing HTTP endpoints and merges the responses.
12
  from __future__ import annotations
13
 
14
  import asyncio
 
15
  import os
16
  import time
 
17
  from typing import Any, Optional
18
 
19
  import httpx
@@ -37,28 +39,103 @@ DS4_OWNED_FIELDS = (
37
  # must not be executed (NO_TRADE), regardless of what DS2 supplied.
38
  FUTURES_CRITICAL_FIELDS = ("contract", "ticker", "orderbook", "funding", "openInterest")
39
  DS4_FRESH_STATES = {"live", "fresh", "ok"}
 
 
40
 
41
 
42
- async def _get_json(client: httpx.AsyncClient, url: str, timeout: float) -> Optional[dict]:
 
43
  try:
44
- resp = await client.get(url, timeout=timeout)
45
- resp.raise_for_status()
46
- return resp.json()
47
- except Exception:
48
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
- async def _fetch_ds4_snapshot(client: httpx.AsyncClient, ds4_symbol: str) -> tuple[Optional[dict], list[str]]:
 
 
52
  warnings: list[str] = []
53
- data = await _get_json(
54
- client, f"{DS4_BASE}/api/short-hunter/snapshot/{ds4_symbol}", DS4_TIMEOUT_S
 
 
 
 
 
55
  )
56
  if data is None:
57
  warnings.append("Datasource 4 (Short Hunter) unreachable or errored")
58
- return data, warnings
59
 
60
 
61
- async def _fetch_ds2_context(client: httpx.AsyncClient, ds2_symbol: str) -> tuple[dict, list[str]]:
 
 
62
  """Best-effort complementary context; never blocks a NO_TRADE decision."""
63
  warnings: list[str] = []
64
  endpoints = {
@@ -72,15 +149,43 @@ async def _fetch_ds2_context(client: httpx.AsyncClient, ds2_symbol: str) -> tupl
72
  "correlations": f"{DS2_BASE}/api/correlations",
73
  }
74
  results = await asyncio.gather(
75
- *(_get_json(client, url, DS2_TIMEOUT_S) for url in endpoints.values())
76
  )
77
- context = {}
78
- for key, value in zip(endpoints.keys(), results):
 
 
79
  if value is None:
80
  warnings.append(f"Datasource 2 field '{key}' unavailable")
81
  else:
82
  context[key] = value
83
- return context, warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  def _finite_number(value: Any) -> Optional[float]:
@@ -155,7 +260,7 @@ def _is_usable(field: str, value: Any) -> bool:
155
  def _has_oi_change(value: Any) -> bool:
156
  if not isinstance(value, dict):
157
  return False
158
- for key in ("change24h", "changePercent", "oiChangePercent"):
159
  if _finite_number(value.get(key)) is not None:
160
  return True
161
  return _oi_change_from_history(value.get("history")) is not None
@@ -176,7 +281,423 @@ def _oi_change_from_history(history: Any) -> Optional[float]:
176
  return (samples[-1] - samples[0]) / samples[0]
177
 
178
 
179
- async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  """Fetch and merge Futures market context for `symbol` from both datasources.
181
 
182
  Returns the standard merged envelope: primary, secondary, merged, sources,
@@ -187,22 +708,39 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
187
  no_trade_reasons: list[str] = []
188
 
189
  async with httpx.AsyncClient() as client:
190
- (ds4, ds4_warnings), (ds2_context, ds2_warnings) = await asyncio.gather(
191
- _fetch_ds4_snapshot(client, norm.ds4),
192
  _fetch_ds2_context(client, norm.ds2),
193
  )
194
  warnings.extend(ds4_warnings)
195
  warnings.extend(ds2_warnings)
196
 
197
- if isinstance(ds4, dict):
198
- warnings.extend(str(item) for item in (ds4.get("warnings") or []))
199
- warnings.extend(f"Datasource 4 error: {item}" for item in (ds4.get("errors") or []))
 
 
 
200
 
201
- ds4_state = str((ds4 or {}).get("dataState") or "").strip().lower()
 
 
 
 
202
  ds4_stale = ds4 is not None and ds4_state not in DS4_FRESH_STATES
203
- ds4_data = (ds4 or {}).get("data")
204
- if not isinstance(ds4_data, dict):
205
- ds4_data = {}
 
 
 
 
 
 
 
 
 
 
206
 
207
  sources: dict[str, str] = {}
208
  merged: dict[str, Any] = {}
@@ -211,6 +749,8 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
211
  # A stale/partial DS4 snapshot remains available in `primary`, but its
212
  # Futures values are not treated as current merged market data.
213
  usable = _is_usable(field, value) and not (ds4_stale and field in binance_public.SUPPORTED_FIELDS)
 
 
214
  merged[field] = value if usable else None
215
  sources[field] = "datasource4" if usable else "unavailable"
216
  if value not in (None, {}, [], "") and not usable:
@@ -227,9 +767,12 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
227
  if ds4_oi_change is not None:
228
  merged["openInterest"] = dict(merged["openInterest"])
229
  merged["openInterest"]["changePercent"] = ds4_oi_change
 
230
  sources["openInterest.changePercent"] = "datasource4"
231
 
232
  # ---- Secondary priority: Binance public Futures data -------------
 
 
233
  needed = {
234
  field for field in ("ticker", "ohlcv", "orderbook", "funding", "openInterest", "atr")
235
  if not _is_usable(field, merged.get(field))
@@ -237,11 +780,36 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
237
  if not _has_oi_change(merged.get("openInterest")):
238
  needed.add("openInterestChange")
239
 
 
 
240
  if needed:
241
- binance_snapshot, binance_warnings = await binance_public.get_binance_public_snapshot(
242
- norm.ds4, frozenset(needed)
243
- )
244
- warnings.extend(binance_warnings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  for field in needed - {"openInterestChange"}:
246
  value = binance_snapshot.get(field)
247
  if _is_usable(field, value):
@@ -257,6 +825,9 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
257
  if isinstance(existing_oi, dict):
258
  merged["openInterest"] = dict(existing_oi)
259
  merged["openInterest"]["changePercent"] = oi_change["changePercent"]
 
 
 
260
  merged["openInterest"]["history"] = oi_change.get("history", [])
261
  sources["openInterest.changePercent"] = "binance_public"
262
  sources["openInterest.history"] = "binance_public"
@@ -264,9 +835,16 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
264
 
265
  # ---- Lowest priority: Datasource 2 complementary/fallback data ----
266
  # It may fill only a field still unavailable after DS4 and Binance.
 
 
 
 
 
 
 
267
  ds2_equivalents = {
268
- "orderbook": ds2_context.get("orderbook"),
269
- "indicators": ds2_context.get("indicators"),
270
  }
271
  for field, value in ds2_equivalents.items():
272
  if not _is_usable(field, merged.get(field)) and _is_usable(field, value):
@@ -292,13 +870,17 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
292
  no_trade_guard = True
293
  no_trade_reasons.append("Datasource 4 unreachable — Futures-critical data unavailable")
294
  else:
295
- if ds4.get("noTradeGuard"):
296
  no_trade_guard = True
297
  no_trade_reasons.append("Datasource 4 noTradeGuard=true (authoritative, not overridable)")
298
  if ds4_stale:
299
- no_trade_reasons.append(f"Datasource 4 dataState={ds4.get('dataState')!r} (stale)")
300
  no_trade_guard = True
301
 
 
 
 
 
302
  missing_critical = [f for f in FUTURES_CRITICAL_FIELDS if not _is_usable(f, merged.get(f))]
303
  if missing_critical:
304
  no_trade_guard = True
@@ -306,15 +888,179 @@ async def get_market_context(symbol: str, timeframe: str = "5m") -> dict:
306
  f"Missing required Futures fields after merge: {', '.join(missing_critical)}"
307
  )
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  return {
310
  "symbol": norm.ds4,
 
 
311
  "primary": ds4 or {},
312
  "secondary": ds2_context,
313
  "merged": merged,
314
  "sources": sources,
 
 
 
 
 
 
 
 
315
  "warnings": warnings,
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  "noTradeGuard": no_trade_guard,
317
  "noTradeReasons": no_trade_reasons,
 
 
318
  "timeframe": timeframe,
319
  "fetchedAt": time.time(),
320
  }
 
12
  from __future__ import annotations
13
 
14
  import asyncio
15
+ import math
16
  import os
17
  import time
18
+ from datetime import datetime, timezone
19
  from typing import Any, Optional
20
 
21
  import httpx
 
39
  # must not be executed (NO_TRADE), regardless of what DS2 supplied.
40
  FUTURES_CRITICAL_FIELDS = ("contract", "ticker", "orderbook", "funding", "openInterest")
41
  DS4_FRESH_STATES = {"live", "fresh", "ok"}
42
+ SUPPORTED_INTERVALS = {"1m": 60, "5m": 300, "15m": 900, "1h": 3600}
43
+ MAX_MARKET_LIMIT = 500
44
 
45
 
46
+ def normalize_epoch_milliseconds(value: Any) -> int:
47
+ """Return a positive epoch timestamp in milliseconds without double conversion."""
48
  try:
49
+ number = float(value)
50
+ except (TypeError, ValueError) as exc:
51
+ raise ValueError("timestamp must be numeric") from exc
52
+ if not math.isfinite(number) or number <= 0:
53
+ raise ValueError("timestamp must be a positive finite value")
54
+ # Contemporary epoch seconds are ~1e9; milliseconds are ~1e12.
55
+ if number < 100_000_000_000:
56
+ number *= 1000
57
+ return int(number)
58
+
59
+
60
+ def build_kucoin_time_range(
61
+ interval: str = "5m", limit: int = 120, *, end: Any | None = None
62
+ ) -> tuple[int, int]:
63
+ """Build a bounded, ordered KuCoin-compatible millisecond range."""
64
+ if interval not in SUPPORTED_INTERVALS:
65
+ raise ValueError(f"unsupported interval: {interval}")
66
+ bounded_limit = max(1, min(int(limit), MAX_MARKET_LIMIT))
67
+ end_ms = normalize_epoch_milliseconds(end if end is not None else time.time())
68
+ start_ms = end_ms - SUPPORTED_INTERVALS[interval] * bounded_limit * 1000
69
+ if start_ms <= 0 or start_ms >= end_ms:
70
+ raise ValueError("invalid historical time range")
71
+ return start_ms, end_ms
72
+
73
+
74
+ def _iso_now() -> str:
75
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
76
+
77
+
78
+ async def _get_json_with_meta(
79
+ client: httpx.AsyncClient, url: str, timeout: float, params: Optional[dict] = None
80
+ ) -> tuple[Optional[dict], dict]:
81
+ started = time.perf_counter()
82
+ meta = {
83
+ "transportStatus": "unavailable", "endpoint": url, "httpStatus": None,
84
+ "latencyMs": None, "lastSuccess": None, "reason": "No response",
85
+ }
86
+ try:
87
+ try:
88
+ resp = await client.get(url, timeout=timeout, params=params)
89
+ except TypeError:
90
+ # Compatibility with the repository's minimal mocked clients.
91
+ resp = await client.get(url, timeout=timeout)
92
+ meta["latencyMs"] = round((time.perf_counter() - started) * 1000, 2)
93
+ status = int(getattr(resp, "status_code", 200))
94
+ meta["httpStatus"] = status
95
+ if status >= 400:
96
+ meta["transportStatus"] = "degraded"
97
+ meta["reason"] = f"HTTP {status}"
98
+ return None, meta
99
+ payload = resp.json()
100
+ if not isinstance(payload, dict):
101
+ meta["transportStatus"] = "degraded"
102
+ meta["reason"] = "Invalid JSON payload shape"
103
+ return None, meta
104
+ meta.update(transportStatus="healthy", lastSuccess=_iso_now(), reason="Response received")
105
+ return payload, meta
106
+ except Exception as exc:
107
+ meta["latencyMs"] = round((time.perf_counter() - started) * 1000, 2)
108
+ meta["reason"] = type(exc).__name__
109
+ return None, meta
110
+
111
+
112
+ async def _get_json(
113
+ client: httpx.AsyncClient, url: str, timeout: float, params: Optional[dict] = None
114
+ ) -> Optional[dict]:
115
+ data, _ = await _get_json_with_meta(client, url, timeout, params=params)
116
+ return data
117
 
118
 
119
+ async def _fetch_ds4_snapshot(
120
+ client: httpx.AsyncClient, ds4_symbol: str, timeframe: str = "5m", limit: int = 120
121
+ ) -> tuple[Optional[dict], list[str], dict]:
122
  warnings: list[str] = []
123
+ bounded_limit = max(1, min(int(limit), MAX_MARKET_LIMIT))
124
+ start_ms, end_ms = build_kucoin_time_range(timeframe, bounded_limit)
125
+ endpoint = f"{DS4_BASE}/api/short-hunter/snapshot/{ds4_symbol}"
126
+ data, meta = await _get_json_with_meta(
127
+ client, endpoint, DS4_TIMEOUT_S,
128
+ params={"interval": timeframe, "limit": bounded_limit,
129
+ "from": start_ms, "to": end_ms},
130
  )
131
  if data is None:
132
  warnings.append("Datasource 4 (Short Hunter) unreachable or errored")
133
+ return data, warnings, meta
134
 
135
 
136
+ async def _fetch_ds2_context(
137
+ client: httpx.AsyncClient, ds2_symbol: str
138
+ ) -> tuple[dict, list[str], dict]:
139
  """Best-effort complementary context; never blocks a NO_TRADE decision."""
140
  warnings: list[str] = []
141
  endpoints = {
 
149
  "correlations": f"{DS2_BASE}/api/correlations",
150
  }
151
  results = await asyncio.gather(
152
+ *(_get_json_with_meta(client, url, DS2_TIMEOUT_S) for url in endpoints.values())
153
  )
154
+ context: dict[str, Any] = {}
155
+ metas: dict[str, dict] = {}
156
+ for key, (value, meta) in zip(endpoints.keys(), results):
157
+ metas[key] = meta
158
  if value is None:
159
  warnings.append(f"Datasource 2 field '{key}' unavailable")
160
  else:
161
  context[key] = value
162
+ successes = [m for m in metas.values() if m.get("transportStatus") == "healthy"]
163
+ source_meta = {
164
+ "transportStatus": "healthy" if successes else "unavailable",
165
+ "endpoint": DS2_BASE,
166
+ "httpStatus": next((m.get("httpStatus") for m in metas.values() if m.get("httpStatus") is not None), None),
167
+ "latencyMs": round(sum(float(m.get("latencyMs") or 0) for m in metas.values()), 2),
168
+ "lastSuccess": max((m.get("lastSuccess") for m in successes if m.get("lastSuccess")), default=None),
169
+ "reason": "Complementary endpoints partially available" if len(successes) < len(metas) else "Complementary endpoints available",
170
+ "suppliedFields": sorted(context.keys()),
171
+ "missingFields": sorted(key for key in endpoints if key not in context),
172
+ "endpointDetails": metas,
173
+ }
174
+ return context, warnings, source_meta
175
+
176
+
177
+ def _boolean_flag(value: Any) -> Optional[bool]:
178
+ if isinstance(value, bool):
179
+ return value
180
+ if isinstance(value, (int, float)) and value in (0, 1):
181
+ return bool(value)
182
+ if isinstance(value, str):
183
+ normalized = value.strip().lower()
184
+ if normalized in {"true", "yes", "1", "verified", "futures"}:
185
+ return True
186
+ if normalized in {"false", "no", "0", "unverified", "market_only"}:
187
+ return False
188
+ return None
189
 
190
 
191
  def _finite_number(value: Any) -> Optional[float]:
 
260
  def _has_oi_change(value: Any) -> bool:
261
  if not isinstance(value, dict):
262
  return False
263
+ for key in ("changeFraction", "change24h", "changePercent", "oiChangePercent"):
264
  if _finite_number(value.get(key)) is not None:
265
  return True
266
  return _oi_change_from_history(value.get("history")) is not None
 
281
  return (samples[-1] - samples[0]) / samples[0]
282
 
283
 
284
+ def _first_value(mapping: Any, *keys: str) -> Any:
285
+ if not isinstance(mapping, dict):
286
+ return None
287
+ for key in keys:
288
+ if key in mapping and mapping[key] is not None:
289
+ return mapping[key]
290
+ return None
291
+
292
+
293
+ def _candidate_sections(payload: dict) -> list[dict]:
294
+ """Return only known API envelope sections, bounded to avoid broad guessing."""
295
+ sections: list[dict] = []
296
+ queue: list[tuple[dict, int]] = [(payload, 0)]
297
+ wrapper_keys = ("data", "snapshot", "market", "marketData", "futures", "result", "payload")
298
+ while queue:
299
+ candidate, depth = queue.pop(0)
300
+ if candidate in sections:
301
+ continue
302
+ sections.append(candidate)
303
+ if depth >= 3:
304
+ continue
305
+ for key in wrapper_keys:
306
+ nested = candidate.get(key)
307
+ if isinstance(nested, dict):
308
+ queue.append((nested, depth + 1))
309
+ return sections
310
+
311
+
312
+ def _find_section(payload: dict, *keys: str) -> Any:
313
+ for section in _candidate_sections(payload):
314
+ for key in keys:
315
+ if key in section and section[key] not in (None, {}, [], ""):
316
+ return section[key]
317
+ return None
318
+
319
+
320
+ def _find_mapping_with_any(payload: dict, *keys: str) -> Optional[dict]:
321
+ for section in _candidate_sections(payload):
322
+ if any(section.get(key) is not None for key in keys):
323
+ return section
324
+ return None
325
+
326
+
327
+ def _normalize_timestamp(value: Any) -> Any:
328
+ """Normalize common timestamp wrappers while preserving the original epoch unit."""
329
+ if isinstance(value, dict):
330
+ value = _first_value(value, "timestamp", "time", "ts", "updatedAt", "lastUpdateTime", "closeTime")
331
+ if value in (None, ""):
332
+ return None
333
+ if isinstance(value, str):
334
+ stripped = value.strip()
335
+ if not stripped:
336
+ return None
337
+ if stripped.isdigit():
338
+ value = stripped
339
+ else:
340
+ try:
341
+ datetime.fromisoformat(stripped.replace("Z", "+00:00"))
342
+ return stripped
343
+ except ValueError:
344
+ return None
345
+ try:
346
+ number = float(value)
347
+ except (TypeError, ValueError):
348
+ return None
349
+ if not math.isfinite(number) or number <= 0:
350
+ return None
351
+ return int(number)
352
+
353
+
354
+ def _normalize_contract(value: Any) -> Optional[dict]:
355
+ if isinstance(value, str):
356
+ return {"symbol": value}
357
+ if not isinstance(value, dict):
358
+ return None
359
+ out = dict(value)
360
+ aliases = {
361
+ "symbol": ("symbol", "contractSymbol", "instrument", "instrumentId", "instId"),
362
+ "status": ("status", "contractStatus", "state", "tradingStatus"),
363
+ "contractType": ("contractType", "type", "marketType", "instrumentType", "instType"),
364
+ "quoteAsset": ("quoteAsset", "quote", "quoteCurrency", "quoteCoin"),
365
+ "baseAsset": ("baseAsset", "base", "baseCurrency", "baseCoin"),
366
+ }
367
+ for target, keys in aliases.items():
368
+ found = _first_value(value, *keys)
369
+ if found is not None:
370
+ out[target] = found
371
+ for target, keys in {
372
+ "futuresVerified": ("futuresVerified", "verifiedFutures", "isFutures", "isFuture", "verified"),
373
+ "perpetual": ("perpetual", "isPerpetual", "isSwap"),
374
+ }.items():
375
+ found = _boolean_flag(_first_value(value, *keys))
376
+ if found is not None:
377
+ out[target] = found
378
+ return out or None
379
+
380
+
381
+ def _contract_verification(contract: Any, ds4: Optional[dict], *, stale: bool) -> dict:
382
+ """Derive a conservative verification result without allowing a fallback source to authorize execution."""
383
+ if not isinstance(ds4, dict):
384
+ return {"verified": False, "status": "unavailable", "reason": "Datasource 4 unavailable"}
385
+ if stale:
386
+ return {"verified": False, "status": "stale", "reason": "Datasource 4 verification is stale"}
387
+ if ds4.get("noTradeGuard") is True:
388
+ return {"verified": False, "status": "blocked", "reason": "Datasource 4 noTradeGuard is active"}
389
+
390
+ candidates: list[Any] = [contract, ds4]
391
+ for section in _candidate_sections(ds4):
392
+ candidates.append(section)
393
+ explicit_false = False
394
+ for candidate in candidates:
395
+ if not isinstance(candidate, dict):
396
+ continue
397
+ flag = _boolean_flag(_first_value(
398
+ candidate, "futuresVerified", "verifiedFutures", "isFutures", "isFuture"
399
+ ))
400
+ if flag is True:
401
+ return {"verified": True, "status": "verified", "reason": "Verified by Datasource 4"}
402
+ if flag is False:
403
+ explicit_false = True
404
+ if explicit_false:
405
+ return {"verified": False, "status": "market_only", "reason": "Datasource 4 marked the market unverified"}
406
+
407
+ if isinstance(contract, dict):
408
+ contract_type = str(_first_value(contract, "contractType", "type", "marketType", "instrumentType") or "").lower()
409
+ status = str(_first_value(contract, "status", "contractStatus", "state", "tradingStatus") or "").lower()
410
+ inactive = status in {"inactive", "disabled", "delisted", "closed", "offline", "suspended"}
411
+ futures_type = any(token in contract_type for token in ("future", "perpetual", "swap"))
412
+ if futures_type and not inactive:
413
+ return {"verified": True, "status": "verified", "reason": "Datasource 4 contract metadata identifies a Futures market"}
414
+ if inactive:
415
+ return {"verified": False, "status": "inactive", "reason": "Datasource 4 contract is not active"}
416
+ return {"verified": False, "status": "unverified", "reason": "Datasource 4 did not provide verifiable contract metadata"}
417
+
418
+
419
+ def _normalize_ohlcv(value: Any) -> Optional[list[dict]]:
420
+ if isinstance(value, dict):
421
+ value = _first_value(value, "candles", "ohlcv", "klines", "items", "data", "result")
422
+ if not isinstance(value, list):
423
+ return None
424
+ candles: list[dict] = []
425
+ for item in value:
426
+ if isinstance(item, dict):
427
+ timestamp = _first_value(item, "timestamp", "time", "ts", "openTime", "startTime")
428
+ open_ = _finite_number(_first_value(item, "open", "o"))
429
+ high = _finite_number(_first_value(item, "high", "h"))
430
+ low = _finite_number(_first_value(item, "low", "l"))
431
+ close = _finite_number(_first_value(item, "close", "c"))
432
+ volume = _finite_number(_first_value(item, "volume", "v", "baseVolume"))
433
+ elif isinstance(item, (list, tuple)) and len(item) >= 6:
434
+ timestamp = item[0]
435
+ open_, high, low, close, volume = (_finite_number(v) for v in item[1:6])
436
+ else:
437
+ continue
438
+ timestamp = _normalize_timestamp(timestamp)
439
+ if timestamp is None or None in (open_, high, low, close, volume):
440
+ continue
441
+ if min(open_, high, low, close) <= 0 or volume < 0 or high < max(open_, close, low) or low > min(open_, close, high):
442
+ continue
443
+ candles.append({
444
+ "timestamp": timestamp,
445
+ "open": open_, "high": high, "low": low, "close": close, "volume": volume,
446
+ })
447
+ return candles or None
448
+
449
+
450
+ def _normalize_orderbook(value: Any) -> Optional[dict]:
451
+ if isinstance(value, dict) and not ("bids" in value or "asks" in value):
452
+ nested = _first_value(value, "data", "depth", "orderbook", "orderBook", "result")
453
+ if isinstance(nested, dict):
454
+ value = nested
455
+ if not isinstance(value, dict):
456
+ return None
457
+ out: dict[str, Any] = {}
458
+ for side in ("bids", "asks"):
459
+ raw_levels = value.get(side)
460
+ if not isinstance(raw_levels, list):
461
+ return None
462
+ levels: list[list[float]] = []
463
+ for level in raw_levels:
464
+ if isinstance(level, dict):
465
+ price = _finite_number(_first_value(level, "price", "p", "0"))
466
+ quantity = _finite_number(_first_value(level, "quantity", "qty", "size", "amount", "q", "1"))
467
+ elif isinstance(level, (list, tuple)) and len(level) >= 2:
468
+ price, quantity = _finite_number(level[0]), _finite_number(level[1])
469
+ else:
470
+ continue
471
+ if price is None or quantity is None or price <= 0 or quantity < 0:
472
+ continue
473
+ levels.append([price, quantity])
474
+ if not levels:
475
+ return None
476
+ out[side] = levels
477
+ timestamp = _normalize_timestamp(_first_value(value, "timestamp", "time", "ts", "eventTime", "updatedAt"))
478
+ if timestamp is not None:
479
+ out["timestamp"] = timestamp
480
+ return out
481
+
482
+
483
+ def _normalize_ticker(value: Any) -> Optional[dict]:
484
+ if isinstance(value, (int, float, str)):
485
+ price = _finite_number(value)
486
+ return {"lastPrice": price} if price is not None and price > 0 else None
487
+ if not isinstance(value, dict):
488
+ return None
489
+ price = _finite_number(_first_value(value, "lastPrice", "last", "price", "close", "markPrice", "mark_price"))
490
+ if price is None or price <= 0:
491
+ return None
492
+ out = dict(value)
493
+ out["lastPrice"] = price
494
+ aliases = {
495
+ "markPrice": ("markPrice", "mark_price"),
496
+ "indexPrice": ("indexPrice", "index_price"),
497
+ "volume24h": (
498
+ "volume24h", "quoteVolume", "volume", "volValue", "turnover24h",
499
+ "quoteVolume24h", "turnover", "volume_24h",
500
+ ),
501
+ "timestamp": ("timestamp", "time", "ts", "updatedAt", "closeTime", "lastUpdateTime"),
502
+ }
503
+ for target, keys in aliases.items():
504
+ found = _first_value(value, *keys)
505
+ if found is not None:
506
+ out[target] = _normalize_timestamp(found) if target == "timestamp" else found
507
+ fraction = _finite_number(_first_value(value, "change24hFraction", "changeRate", "price24hPcnt"))
508
+ if fraction is None:
509
+ percent_value = _finite_number(_first_value(
510
+ value, "changePercent", "priceChangePercent", "percentage"
511
+ ))
512
+ if percent_value is not None:
513
+ fraction = percent_value / 100.0
514
+ if fraction is None:
515
+ ambiguous = _finite_number(_first_value(value, "change24h", "change_24h"))
516
+ if ambiguous is not None:
517
+ fraction = ambiguous / 100.0 if abs(ambiguous) > 1 else ambiguous
518
+ if fraction is not None:
519
+ out["change24hFraction"] = fraction
520
+ out["change24h"] = fraction * 100.0
521
+ return out
522
+
523
+
524
+ def _normalize_funding(value: Any) -> Optional[dict]:
525
+ if isinstance(value, (int, float, str)):
526
+ rate = _finite_number(value)
527
+ return {"currentFundingRate": rate} if rate is not None else None
528
+ if not isinstance(value, dict):
529
+ return None
530
+ rate = _finite_number(_first_value(value, "currentFundingRate", "fundingRate", "lastFundingRate", "rate", "funding_rate"))
531
+ if rate is None:
532
+ return None
533
+ out = dict(value)
534
+ out["currentFundingRate"] = rate
535
+ timestamp = _normalize_timestamp(_first_value(
536
+ value, "timestamp", "time", "ts", "updatedAt", "fundingTime", "lastUpdateTime"
537
+ ))
538
+ if timestamp is not None:
539
+ out["timestamp"] = timestamp
540
+ next_time = _normalize_timestamp(_first_value(value, "nextFundingTime", "nextFundingTimestamp"))
541
+ if next_time is not None:
542
+ out["nextFundingTime"] = next_time
543
+ return out
544
+
545
+
546
+ def _normalize_atr(value: Any) -> Optional[float]:
547
+ if isinstance(value, dict):
548
+ value = _first_value(value, "value", "atr", "ATR", "current", "latest")
549
+ number = _finite_number(value)
550
+ return number if number is not None and number > 0 else None
551
+
552
+
553
+ def _normalize_open_interest(value: Any) -> Optional[dict]:
554
+ if isinstance(value, (int, float, str)):
555
+ current = _finite_number(value)
556
+ return {"openInterest": current} if current is not None and current >= 0 else None
557
+ if not isinstance(value, dict):
558
+ return None
559
+ current = _finite_number(_first_value(
560
+ value, "openInterest", "open_interest", "openInterestAmount", "openInterestValue",
561
+ "sumOpenInterest", "openInterestQty", "openInterestQuantity", "oi",
562
+ ))
563
+ if current is None or current < 0:
564
+ return None
565
+ out = dict(value)
566
+ out["openInterest"] = current
567
+ fraction = _finite_number(_first_value(value, "changeFraction", "oiChangeFraction"))
568
+ if fraction is None:
569
+ percent_value = _finite_number(_first_value(value, "changePercent", "oiChangePercent"))
570
+ if percent_value is not None:
571
+ fraction = percent_value / 100.0
572
+ if fraction is None:
573
+ ambiguous = _finite_number(_first_value(
574
+ value, "change24h", "openInterestChange24h", "change_24h"
575
+ ))
576
+ if ambiguous is not None:
577
+ fraction = ambiguous / 100.0 if abs(ambiguous) > 1 else ambiguous
578
+ if fraction is not None:
579
+ out["changeFraction"] = fraction
580
+ out["changePercent"] = fraction
581
+ timestamp = _normalize_timestamp(_first_value(
582
+ value, "timestamp", "time", "ts", "updatedAt", "lastUpdateTime"
583
+ ))
584
+ if timestamp is not None:
585
+ out["timestamp"] = timestamp
586
+ return out
587
+
588
+
589
+ def _normalize_ds4_data(payload: Optional[dict]) -> dict:
590
+ if not isinstance(payload, dict):
591
+ return {}
592
+
593
+ contract_raw = _find_section(
594
+ payload, "contract", "futuresContract", "instrument", "contractInfo", "marketInfo"
595
+ )
596
+ contract_mapping = _find_mapping_with_any(
597
+ payload, "contractType", "marketType", "instrumentType",
598
+ "futuresVerified", "verifiedFutures", "isFutures", "isFuture",
599
+ )
600
+ if contract_raw is None or (not isinstance(contract_raw, dict) and contract_mapping is not None):
601
+ contract_raw = contract_mapping
602
+ ticker_raw = _find_section(payload, "ticker", "marketTicker", "ticker24h", "quote", "price")
603
+ ticker_mapping = _find_mapping_with_any(
604
+ payload, "lastPrice", "markPrice", "indexPrice", "priceChangePercent", "price24hPcnt"
605
+ )
606
+ if ticker_raw is None or (not isinstance(ticker_raw, dict) and ticker_mapping is not None):
607
+ ticker_raw = ticker_mapping
608
+ funding_raw = _find_section(
609
+ payload, "funding", "fundingRate", "funding_rate", "fundingInfo", "premiumIndex"
610
+ )
611
+ funding_mapping = _find_mapping_with_any(
612
+ payload, "currentFundingRate", "lastFundingRate", "fundingRate", "funding_rate"
613
+ )
614
+ if funding_raw is None or (not isinstance(funding_raw, dict) and funding_mapping is not None):
615
+ funding_raw = funding_mapping
616
+ oi_raw = _find_section(
617
+ payload, "openInterest", "open_interest", "openInterestData", "oi", "openInterestInfo"
618
+ )
619
+ oi_mapping = _find_mapping_with_any(
620
+ payload, "openInterest", "open_interest", "sumOpenInterest", "openInterestValue"
621
+ )
622
+ if oi_raw is None or (not isinstance(oi_raw, dict) and oi_mapping is not None):
623
+ oi_raw = oi_mapping
624
+
625
+ result = {
626
+ "contract": _normalize_contract(contract_raw),
627
+ "ticker": _normalize_ticker(ticker_raw),
628
+ "ohlcv": _normalize_ohlcv(_find_section(
629
+ payload, "ohlcv", "candles", "klines", "kline", "history"
630
+ )),
631
+ "orderbook": _normalize_orderbook(_find_section(
632
+ payload, "orderbook", "orderBook", "depth"
633
+ )),
634
+ "funding": _normalize_funding(funding_raw),
635
+ "openInterest": _normalize_open_interest(oi_raw),
636
+ "indicators": _find_section(payload, "indicators", "technicalIndicators"),
637
+ "sentiment": _find_section(payload, "sentiment", "marketSentiment"),
638
+ }
639
+ result["atr"] = _normalize_atr(_find_section(payload, "atr", "ATR"))
640
+ if result["atr"] is None and isinstance(result["indicators"], dict):
641
+ result["atr"] = _normalize_atr(result["indicators"])
642
+ return result
643
+
644
+
645
+ def _field_timestamp(value: Any, fallback: Any = None) -> Any:
646
+ if isinstance(value, dict):
647
+ candidate = _first_value(value, "timestamp", "time", "ts", "updatedAt", "lastUpdateTime")
648
+ if candidate is not None:
649
+ return candidate
650
+ if isinstance(value, list) and value:
651
+ last = value[-1]
652
+ if isinstance(last, dict):
653
+ return _first_value(last, "timestamp", "time", "ts", "openTime") or fallback
654
+ if isinstance(last, (list, tuple)) and last:
655
+ return last[0]
656
+ return fallback
657
+
658
+
659
+ def _freshness_label(timestamp: Any, timeframe: str) -> str:
660
+ if timestamp is None:
661
+ return "unknown"
662
+ try:
663
+ if isinstance(timestamp, str) and not timestamp.isdigit():
664
+ parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
665
+ else:
666
+ parsed = normalize_epoch_milliseconds(timestamp) / 1000
667
+ except (ValueError, TypeError, OverflowError):
668
+ return "unknown"
669
+ max_age = max(180, SUPPORTED_INTERVALS.get(timeframe, 300) * 3)
670
+ age = time.time() - parsed
671
+ if age < -60:
672
+ return "invalid"
673
+ return "fresh" if age <= max_age else "stale"
674
+
675
+
676
+ def _concise_reason(source: str, missing: list[str], stale: bool = False) -> str:
677
+ if stale:
678
+ return f"{source} data is stale"
679
+ if missing:
680
+ return f"Required fields unavailable: {', '.join(missing)}"
681
+ return "Data is usable"
682
+
683
+
684
+ def _provider_issue_text(issue: Any) -> str:
685
+ if not isinstance(issue, dict):
686
+ return str(issue)
687
+ provider = str(issue.get("provider") or issue.get("source") or "Binance")
688
+ category = str(issue.get("category") or "provider_error")
689
+ status = issue.get("status")
690
+ field = issue.get("field")
691
+ parts = [provider, category]
692
+ if status is not None:
693
+ parts.append(f"HTTP {status}")
694
+ if field:
695
+ parts.append(f"field={field}")
696
+ message = str(issue.get("message") or "request unavailable")
697
+ return " · ".join(parts) + f": {message}"
698
+
699
+
700
+ async def get_market_context(symbol: str, timeframe: str = "5m", limit: int = 120) -> dict:
701
  """Fetch and merge Futures market context for `symbol` from both datasources.
702
 
703
  Returns the standard merged envelope: primary, secondary, merged, sources,
 
708
  no_trade_reasons: list[str] = []
709
 
710
  async with httpx.AsyncClient() as client:
711
+ (ds4, ds4_warnings, ds4_transport), (ds2_context, ds2_warnings, ds2_transport) = await asyncio.gather(
712
+ _fetch_ds4_snapshot(client, norm.ds4, timeframe, limit),
713
  _fetch_ds2_context(client, norm.ds2),
714
  )
715
  warnings.extend(ds4_warnings)
716
  warnings.extend(ds2_warnings)
717
 
718
+ ds4_warnings_payload = _find_section(ds4, "warnings") if isinstance(ds4, dict) else None
719
+ ds4_errors_payload = _find_section(ds4, "errors") if isinstance(ds4, dict) else None
720
+ if isinstance(ds4_warnings_payload, list):
721
+ warnings.extend(str(item) for item in ds4_warnings_payload)
722
+ if ds4_errors_payload:
723
+ warnings.append("Datasource 4 reported an upstream provider error")
724
 
725
+ ds4_state_raw = _find_section(ds4, "dataState", "data_state") if isinstance(ds4, dict) else None
726
+ ds4_state = str(ds4_state_raw or "").strip().lower()
727
+ ds4_guard = _boolean_flag(
728
+ _find_section(ds4, "noTradeGuard", "no_trade_guard") if isinstance(ds4, dict) else None
729
+ ) is True
730
  ds4_stale = ds4 is not None and ds4_state not in DS4_FRESH_STATES
731
+ ds4_data = _normalize_ds4_data(ds4)
732
+ ds4_authority = dict(ds4 or {})
733
+ ds4_authority["dataState"] = ds4_state_raw
734
+ ds4_authority["noTradeGuard"] = ds4_guard
735
+ futures_verification = _contract_verification(
736
+ ds4_data.get("contract"), ds4_authority if ds4 is not None else None, stale=ds4_stale,
737
+ )
738
+ if futures_verification.get("verified") and not ds4_data.get("contract"):
739
+ ds4_data["contract"] = {
740
+ "symbol": norm.ds4,
741
+ "futuresVerified": True,
742
+ "verificationSource": "datasource4",
743
+ }
744
 
745
  sources: dict[str, str] = {}
746
  merged: dict[str, Any] = {}
 
749
  # A stale/partial DS4 snapshot remains available in `primary`, but its
750
  # Futures values are not treated as current merged market data.
751
  usable = _is_usable(field, value) and not (ds4_stale and field in binance_public.SUPPORTED_FIELDS)
752
+ if field == "contract":
753
+ usable = usable and futures_verification.get("verified") is True
754
  merged[field] = value if usable else None
755
  sources[field] = "datasource4" if usable else "unavailable"
756
  if value not in (None, {}, [], "") and not usable:
 
767
  if ds4_oi_change is not None:
768
  merged["openInterest"] = dict(merged["openInterest"])
769
  merged["openInterest"]["changePercent"] = ds4_oi_change
770
+ merged["openInterest"]["changeFraction"] = ds4_oi_change
771
  sources["openInterest.changePercent"] = "datasource4"
772
 
773
  # ---- Secondary priority: Binance public Futures data -------------
774
+ binance_diagnostics: list[str] = []
775
+ binance_snapshot: dict[str, Any] = {}
776
  needed = {
777
  field for field in ("ticker", "ohlcv", "orderbook", "funding", "openInterest", "atr")
778
  if not _is_usable(field, merged.get(field))
 
780
  if not _has_oi_change(merged.get("openInterest")):
781
  needed.add("openInterestChange")
782
 
783
+ binance_issues: list[dict] = []
784
+ binance_meta_runtime: dict[str, Any] = {}
785
  if needed:
786
+ market_needed = needed & {"ticker", "ohlcv", "funding", "openInterest"}
787
+ snapshot_needed = needed - market_needed
788
+ results = []
789
+ if market_needed:
790
+ results.append(await binance_public.get_binance_public_market_data(
791
+ norm.ds4, timeframe, limit,
792
+ ))
793
+ if snapshot_needed:
794
+ results.append(await binance_public.get_binance_public_result(
795
+ norm.ds4, frozenset(snapshot_needed),
796
+ ))
797
+ for result in results:
798
+ if not isinstance(result, dict):
799
+ continue
800
+ binance_snapshot.update(result.get("data") or {})
801
+ binance_issues.extend(
802
+ item for item in (list(result.get("errors") or []) + list(result.get("warnings") or []))
803
+ if isinstance(item, dict)
804
+ )
805
+ runtime_meta = result.get("meta")
806
+ if isinstance(runtime_meta, dict):
807
+ binance_meta_runtime.update(runtime_meta)
808
+ binance_diagnostics = [_provider_issue_text(item) for item in binance_issues]
809
+ if any(item.get("status") == 451 for item in binance_issues):
810
+ warnings.append("Binance public API is regionally restricted (HTTP 451)")
811
+ elif binance_diagnostics:
812
+ warnings.append("Binance public fallback could not supply one or more requested fields")
813
  for field in needed - {"openInterestChange"}:
814
  value = binance_snapshot.get(field)
815
  if _is_usable(field, value):
 
825
  if isinstance(existing_oi, dict):
826
  merged["openInterest"] = dict(existing_oi)
827
  merged["openInterest"]["changePercent"] = oi_change["changePercent"]
828
+ merged["openInterest"]["changeFraction"] = oi_change.get(
829
+ "changeFraction", oi_change["changePercent"]
830
+ )
831
  merged["openInterest"]["history"] = oi_change.get("history", [])
832
  sources["openInterest.changePercent"] = "binance_public"
833
  sources["openInterest.history"] = "binance_public"
 
835
 
836
  # ---- Lowest priority: Datasource 2 complementary/fallback data ----
837
  # It may fill only a field still unavailable after DS4 and Binance.
838
+ ds2_indicator_payload = ds2_context.get("indicators")
839
+ if isinstance(ds2_indicator_payload, dict):
840
+ ds2_indicators = _find_section(
841
+ ds2_indicator_payload, "indicators", "technicalIndicators", "data", "result"
842
+ ) or ds2_indicator_payload
843
+ else:
844
+ ds2_indicators = ds2_indicator_payload
845
  ds2_equivalents = {
846
+ "orderbook": _normalize_orderbook(ds2_context.get("orderbook")),
847
+ "indicators": ds2_indicators,
848
  }
849
  for field, value in ds2_equivalents.items():
850
  if not _is_usable(field, merged.get(field)) and _is_usable(field, value):
 
870
  no_trade_guard = True
871
  no_trade_reasons.append("Datasource 4 unreachable — Futures-critical data unavailable")
872
  else:
873
+ if ds4_guard:
874
  no_trade_guard = True
875
  no_trade_reasons.append("Datasource 4 noTradeGuard=true (authoritative, not overridable)")
876
  if ds4_stale:
877
+ no_trade_reasons.append(f"Datasource 4 dataState={ds4_state_raw!r} (stale)")
878
  no_trade_guard = True
879
 
880
+ if not futures_verification.get("verified"):
881
+ no_trade_guard = True
882
+ no_trade_reasons.append(futures_verification.get("reason") or "Futures verification unavailable")
883
+
884
  missing_critical = [f for f in FUTURES_CRITICAL_FIELDS if not _is_usable(f, merged.get(f))]
885
  if missing_critical:
886
  no_trade_guard = True
 
888
  f"Missing required Futures fields after merge: {', '.join(missing_critical)}"
889
  )
890
 
891
+ primary_timestamp = (
892
+ (ds4 or {}).get("timestamp") or (ds4 or {}).get("updatedAt")
893
+ or (_find_section(ds4, "timestamp", "updatedAt", "lastUpdateTime") if isinstance(ds4, dict) else None)
894
+ )
895
+ observed_at = _iso_now()
896
+ field_metadata: dict[str, dict] = {}
897
+ for field in DS4_OWNED_FIELDS:
898
+ value = merged.get(field)
899
+ source = sources.get(field, "unavailable")
900
+ timestamp = _field_timestamp(value, primary_timestamp if source == "datasource4" else None)
901
+ freshness_basis = "field_timestamp"
902
+ if timestamp is not None:
903
+ freshness = _freshness_label(timestamp, timeframe)
904
+ elif source == "datasource4" and ds4_state in DS4_FRESH_STATES:
905
+ freshness = "fresh"
906
+ freshness_basis = "datasource4_dataState"
907
+ else:
908
+ # A successful HTTP response proves transport health, not market-data
909
+ # freshness. Fallback data without its own provider timestamp remains
910
+ # unknown and therefore cannot pass a Futures freshness gate.
911
+ freshness = "unknown"
912
+ freshness_basis = "missing_provider_timestamp" if source not in {"unavailable", None} else "unavailable"
913
+ valid = _is_usable(field, value)
914
+ field_metadata[field] = {
915
+ "value": value, "source": source, "timestamp": timestamp,
916
+ "freshness": freshness, "validity": "valid" if valid else "unavailable",
917
+ "observedAt": observed_at, "freshnessBasis": freshness_basis,
918
+ "fallbackStatus": "primary" if source == "datasource4" else ("fallback" if source not in {"unavailable", None} else "not_filled"),
919
+ }
920
+
921
+ stale_critical = [
922
+ field for field in FUTURES_CRITICAL_FIELDS
923
+ if (field_metadata.get(field) or {}).get("freshness") in {"stale", "invalid", "unknown"}
924
+ ]
925
+ if stale_critical:
926
+ no_trade_guard = True
927
+ no_trade_reasons.append(
928
+ f"Required Futures fields are not fresh: {', '.join(stale_critical)}"
929
+ )
930
+
931
+ ds4_missing = [f for f in FUTURES_CRITICAL_FIELDS if sources.get(f) != "datasource4"]
932
+ ds4_usability = "unavailable" if ds4 is None else (
933
+ "degraded" if ds4_stale or ds4_missing or str(ds4_state_raw or "").upper() == "PARTIAL" else "usable"
934
+ )
935
+ restricted = any(item.get("status") == 451 for item in binance_issues)
936
+ binance_supplied = sorted(f for f, source in sources.items() if source == "binance_public")
937
+ binance_missing = sorted(
938
+ f for f in needed if f not in binance_snapshot and f != "openInterestChange"
939
+ )
940
+ binance_freshness_values = [
941
+ (field_metadata.get(field) or {}).get("freshness") for field in binance_supplied
942
+ ]
943
+ binance_freshness = (
944
+ "stale" if any(value in {"stale", "invalid"} for value in binance_freshness_values) else
945
+ "fresh" if binance_freshness_values and all(value == "fresh" for value in binance_freshness_values) else
946
+ "unknown"
947
+ )
948
+ binance_attempted = bool(needed)
949
+ binance_transport = (
950
+ "restricted" if restricted else
951
+ "healthy" if binance_supplied or binance_meta_runtime.get("lastSuccess") else
952
+ "degraded" if binance_attempted else "standby"
953
+ )
954
+ binance_meta = {
955
+ "name": "Binance public", "url": binance_public.BASE_URL,
956
+ "transportStatus": binance_transport,
957
+ "dataUsability": "unavailable" if restricted or (binance_attempted and not binance_supplied) else (
958
+ "usable" if binance_supplied and binance_freshness == "fresh" else
959
+ "degraded" if binance_supplied else "not_used"
960
+ ),
961
+ "endpoint": binance_public.BASE_URL,
962
+ "httpStatus": 451 if restricted else binance_meta_runtime.get("httpStatus"),
963
+ "latencyMs": binance_meta_runtime.get("latencyMs"),
964
+ "lastSuccess": binance_meta_runtime.get("lastSuccess"),
965
+ "freshness": binance_freshness,
966
+ "completeness": "partial" if binance_missing else ("complete" if binance_supplied else "unknown"),
967
+ "suppliedFields": binance_supplied,
968
+ "missingFields": binance_missing,
969
+ "reason": "Regionally restricted" if restricted else (
970
+ "Fallback supplied fields" if binance_supplied else "Fallback not used or no usable response"
971
+ ),
972
+ }
973
+ binance_meta["status"] = "unavailable" if restricted else (
974
+ "ok" if binance_supplied and not binance_missing and binance_freshness == "fresh" else
975
+ "degraded" if binance_attempted else "standby"
976
+ )
977
+ ds2_supplied = sorted(f for f, source in sources.items() if source == "datasource2")
978
+ ds4_supplied = sorted(f for f, source in sources.items() if source == "datasource4")
979
+ ds4_freshness_values = [
980
+ (field_metadata.get(field) or {}).get("freshness") for field in ds4_supplied
981
+ ]
982
+ ds4_freshness = (
983
+ "stale" if ds4_stale or any(value in {"stale", "invalid"} for value in ds4_freshness_values) else
984
+ "fresh" if ds4_freshness_values and all(value == "fresh" for value in ds4_freshness_values) else
985
+ "unknown"
986
+ )
987
+ ds2_freshness_values = [
988
+ (field_metadata.get(field) or {}).get("freshness") for field in ds2_supplied
989
+ ]
990
+ ds2_freshness = (
991
+ "stale" if any(value in {"stale", "invalid"} for value in ds2_freshness_values) else
992
+ "fresh" if ds2_freshness_values and all(value == "fresh" for value in ds2_freshness_values) else
993
+ "unknown"
994
+ )
995
+ ds4_meta = {
996
+ **ds4_transport, "name": "Datasource 4", "url": DS4_BASE,
997
+ "dataUsability": ds4_usability,
998
+ "freshness": ds4_freshness,
999
+ "completeness": "complete" if not ds4_missing else "partial",
1000
+ "suppliedFields": ds4_supplied,
1001
+ "missingFields": ds4_missing,
1002
+ "reason": _concise_reason("Datasource 4", ds4_missing, ds4_stale),
1003
+ }
1004
+ ds4_meta["status"] = "unreachable" if ds4 is None else (
1005
+ "ok" if ds4_usability == "usable" else "degraded"
1006
+ )
1007
+ ds2_meta = {
1008
+ **ds2_transport, "name": "Datasource 2", "url": DS2_BASE,
1009
+ "dataUsability": "usable" if ds2_context and not ds2_supplied else (
1010
+ "usable" if ds2_supplied and ds2_freshness == "fresh" else
1011
+ "degraded" if ds2_context else "unavailable"
1012
+ ),
1013
+ "freshness": ds2_freshness,
1014
+ "completeness": "partial" if ds2_warnings else "complete",
1015
+ "suppliedFields": sorted(set(ds2_supplied) | set(ds2_transport.get("suppliedFields") or [])),
1016
+ "missingFields": list(ds2_transport.get("missingFields") or []),
1017
+ "reason": "Complementary context available" if ds2_context else "Complementary context unavailable",
1018
+ }
1019
+ ds2_meta["status"] = "ok" if ds2_context and not ds2_warnings and (
1020
+ not ds2_supplied or ds2_freshness == "fresh"
1021
+ ) else ("degraded" if ds2_context else "unreachable")
1022
+ source_metadata = {
1023
+ "datasource4": ds4_meta,
1024
+ "binance": binance_meta,
1025
+ "datasource2": ds2_meta,
1026
+ }
1027
+ trading_readiness = "ready" if not no_trade_guard else "blocked"
1028
+ merge_status = "complete" if not missing_critical and not stale_critical else "partial"
1029
+
1030
  return {
1031
  "symbol": norm.ds4,
1032
+ "verifiedFutures": bool(futures_verification.get("verified")),
1033
+ "futuresVerification": futures_verification,
1034
  "primary": ds4 or {},
1035
  "secondary": ds2_context,
1036
  "merged": merged,
1037
  "sources": sources,
1038
+ "fieldMetadata": field_metadata,
1039
+ "sourceMetadata": source_metadata,
1040
+ "transportStatus": {k: v.get("transportStatus") for k, v in source_metadata.items()},
1041
+ "dataUsability": {k: v.get("dataUsability") for k, v in source_metadata.items()},
1042
+ "freshness": {k: v.get("freshness") for k, v in source_metadata.items()},
1043
+ "completeness": {k: v.get("completeness") for k, v in source_metadata.items()},
1044
+ "mergeStatus": merge_status,
1045
+ "tradingReadiness": trading_readiness,
1046
  "warnings": warnings,
1047
+ "technicalDiagnostics": {
1048
+ "datasource4": list(ds4_errors_payload) if isinstance(ds4_errors_payload, list) else (
1049
+ [ds4_errors_payload] if ds4_errors_payload else []
1050
+ ),
1051
+ "binance": binance_diagnostics,
1052
+ "datasource2": ds2_warnings,
1053
+ "merge": {
1054
+ "status": merge_status,
1055
+ "missingCriticalFields": missing_critical,
1056
+ "tradingReadiness": trading_readiness,
1057
+ "rejectionReasons": no_trade_reasons,
1058
+ },
1059
+ },
1060
  "noTradeGuard": no_trade_guard,
1061
  "noTradeReasons": no_trade_reasons,
1062
+ "missingRequiredFields": missing_critical,
1063
+ "staleRequiredFields": stale_critical,
1064
  "timeframe": timeframe,
1065
  "fetchedAt": time.time(),
1066
  }
hermes_overlay/trading/state.py CHANGED
@@ -1,10 +1,9 @@
1
  """In-memory dashboard state for the Futures desk.
2
 
3
- The LLM (Hermes) is the one that actually decides LONG / SHORT / NO_TRADE by
4
- calling the registered tools; this module just remembers the *last* outcome
5
- of those calls so the dashboard has something to show. No new decision logic
6
- lives here -- it only mirrors what dual_datasource_client / futures_execution
7
- already produced.
8
  """
9
  from __future__ import annotations
10
 
@@ -19,13 +18,43 @@ _PLAN_FIELDS = (
19
  "warnings", "entry", "stop_loss", "take_profit", "reward_to_risk",
20
  "risk_profile", "risk_percent", "requested_leverage", "effective_leverage",
21
  "quantity", "estimated_slippage_percent", "risk_approved", "rejection_reasons",
22
- "created_at", "expires_at", "external_advisory",
 
 
 
23
  )
24
 
25
 
26
  def _safe_plan_view(plan: dict) -> dict:
27
  """Keep analytical plan fields, excluding external/raw exchange payloads."""
28
- return {key: plan.get(key) for key in _PLAN_FIELDS if key in plan}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def _plan_epoch(plan: dict, key: str) -> Optional[float]:
@@ -46,7 +75,18 @@ class DashboardState:
46
  primary_status: str = "unknown" # ok | degraded | unreachable | unknown
47
  secondary_status: str = "unknown"
48
  field_sources: dict = field(default_factory=dict)
49
- current_signal: str = "NO_TRADE" # LONG | SHORT | NO_TRADE
 
 
 
 
 
 
 
 
 
 
 
50
  signal_reasons: list = field(default_factory=list)
51
  warnings: list = field(default_factory=list)
52
  rejected_trade_reason: Optional[str] = None
@@ -72,15 +112,36 @@ def record_market_context(context: dict) -> None:
72
  """Called after every get_market_context()/get_alpha_signals() so the
73
  dashboard reflects the latest datasource health + guard state."""
74
  _state.symbol = context.get("symbol", _state.symbol)
75
- _state.primary_status = "unreachable" if not context.get("primary") else (
76
- "degraded" if context.get("noTradeGuard") else "ok"
 
 
 
 
 
 
 
77
  )
78
- _state.secondary_status = "degraded" if context.get("warnings") else "ok"
79
  _state.field_sources = context.get("sources", _state.field_sources)
 
 
 
 
 
 
 
 
 
80
  _state.warnings = context.get("warnings", [])
 
 
 
 
 
81
  if context.get("noTradeGuard"):
82
  _state.current_signal = "NO_TRADE"
83
- _state.signal_reasons = context.get("noTradeReasons", [])
 
84
  _state.updated_at = time.time()
85
 
86
 
@@ -107,8 +168,14 @@ def record_trade_plan(plan: dict, plan_id: Optional[str] = None) -> str:
107
  safe = _safe_plan_view(plan if isinstance(plan, dict) else {})
108
  reference = plan_id or uuid.uuid4().hex
109
  _state.latest_trade_plan = safe
 
 
 
110
  _state.latest_plan_id = reference
111
  _state.latest_plan_symbol = safe.get("symbol")
 
 
 
112
  _state.latest_plan_risk_profile = safe.get("risk_profile", "moderate")
113
  _state.latest_plan_created_at = _plan_epoch(safe, "created_at") or time.time()
114
  _state.latest_plan_expires_at = _plan_epoch(safe, "expires_at")
@@ -140,5 +207,29 @@ def record_paper_execution(result: dict) -> None:
140
  _state.updated_at = time.time()
141
 
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def snapshot() -> dict:
144
  return asdict(_state)
 
1
  """In-memory dashboard state for the Futures desk.
2
 
3
+ The deterministic Futures engine decides LONG / SHORT / NO_TRADE. This
4
+ module only remembers the last bounded outcome so the dashboard can display
5
+ it. No decision logic lives here; it mirrors the datasource, risk, and
6
+ execution layers without granting the browser additional authority.
 
7
  """
8
  from __future__ import annotations
9
 
 
18
  "warnings", "entry", "stop_loss", "take_profit", "reward_to_risk",
19
  "risk_profile", "risk_percent", "requested_leverage", "effective_leverage",
20
  "quantity", "estimated_slippage_percent", "risk_approved", "rejection_reasons",
21
+ "created_at", "expires_at", "external_advisory", "noTradeGuard",
22
+ "plan_type", "executable", "analysis_state", "futuresVerified",
23
+ "futuresVerification", "field_sources", "field_metadata",
24
+ "missing_required_fields", "stale_required_fields", "merge_status", "trading_readiness",
25
  )
26
 
27
 
28
  def _safe_plan_view(plan: dict) -> dict:
29
  """Keep analytical plan fields, excluding external/raw exchange payloads."""
30
+ safe = {key: plan.get(key) for key in _PLAN_FIELDS if key in plan}
31
+ metadata = safe.get("field_metadata")
32
+ if isinstance(metadata, dict):
33
+ bounded: dict[str, dict] = {}
34
+ for field_name, item in metadata.items():
35
+ if not isinstance(item, dict):
36
+ continue
37
+ entry = {key: item.get(key) for key in (
38
+ "source", "timestamp", "freshness", "validity", "fallbackStatus"
39
+ ) if key in item}
40
+ if isinstance(item.get("valueSummary"), dict):
41
+ entry["valueSummary"] = dict(item["valueSummary"])
42
+ bounded[str(field_name)] = entry
43
+ continue
44
+ value = item.get("value")
45
+ if field_name == "ohlcv" and isinstance(value, list):
46
+ entry["value"] = {"count": len(value), "latest": value[-1] if value else None}
47
+ elif field_name == "orderbook" and isinstance(value, dict):
48
+ bids, asks = value.get("bids") or [], value.get("asks") or []
49
+ entry["value"] = {
50
+ "bidLevels": len(bids), "askLevels": len(asks),
51
+ "bestBid": bids[0] if bids else None, "bestAsk": asks[0] if asks else None,
52
+ }
53
+ else:
54
+ entry["value"] = value
55
+ bounded[str(field_name)] = entry
56
+ safe["field_metadata"] = bounded
57
+ return safe
58
 
59
 
60
  def _plan_epoch(plan: dict, key: str) -> Optional[float]:
 
75
  primary_status: str = "unknown" # ok | degraded | unreachable | unknown
76
  secondary_status: str = "unknown"
77
  field_sources: dict = field(default_factory=dict)
78
+ field_metadata: dict = field(default_factory=dict)
79
+ source_metadata: dict = field(default_factory=dict)
80
+ merge_status: str = "unknown"
81
+ trading_readiness: str = "blocked"
82
+ technical_diagnostics: dict = field(default_factory=dict)
83
+ market_rejection_reasons: list = field(default_factory=list)
84
+ verified_futures: bool = False
85
+ futures_verification: dict = field(default_factory=dict)
86
+ missing_required_fields: list = field(default_factory=list)
87
+ stale_required_fields: list = field(default_factory=list)
88
+ analysis_state: str = "NOT_ANALYZED"
89
+ current_signal: str = "NOT_ANALYZED" # NOT_ANALYZED | LONG | SHORT | NO_TRADE | ANALYSIS_FAILED
90
  signal_reasons: list = field(default_factory=list)
91
  warnings: list = field(default_factory=list)
92
  rejected_trade_reason: Optional[str] = None
 
112
  """Called after every get_market_context()/get_alpha_signals() so the
113
  dashboard reflects the latest datasource health + guard state."""
114
  _state.symbol = context.get("symbol", _state.symbol)
115
+ source_metadata = context.get("sourceMetadata") or {}
116
+ ds4_meta = source_metadata.get("datasource4") or {}
117
+ ds2_meta = source_metadata.get("datasource2") or {}
118
+ _state.primary_status = ds4_meta.get("status") or (
119
+ "unreachable" if not context.get("primary") else "degraded"
120
+ if context.get("noTradeGuard") else "ok"
121
+ )
122
+ _state.secondary_status = ds2_meta.get("status") or (
123
+ "degraded" if context.get("warnings") else "ok"
124
  )
 
125
  _state.field_sources = context.get("sources", _state.field_sources)
126
+ _state.field_metadata = context.get("fieldMetadata", _state.field_metadata)
127
+ _state.source_metadata = context.get("sourceMetadata", _state.source_metadata)
128
+ _state.merge_status = context.get("mergeStatus", _state.merge_status)
129
+ _state.trading_readiness = context.get("tradingReadiness", _state.trading_readiness)
130
+ _state.technical_diagnostics = context.get("technicalDiagnostics", _state.technical_diagnostics)
131
+ _state.verified_futures = bool(context.get("verifiedFutures"))
132
+ _state.futures_verification = dict(context.get("futuresVerification") or {})
133
+ _state.missing_required_fields = list(context.get("missingRequiredFields") or [])
134
+ _state.stale_required_fields = list(context.get("staleRequiredFields") or [])
135
  _state.warnings = context.get("warnings", [])
136
+ _state.market_rejection_reasons = list(context.get("noTradeReasons") or [])
137
+ # Fail-safe: if the guard trips on the market-context call itself (e.g.
138
+ # Datasource 4 unreachable, unverified contract, stale data), the
139
+ # dashboard must show NO_TRADE rather than leave a stale/ambiguous
140
+ # signal from a previous request -- never silently imply tradability.
141
  if context.get("noTradeGuard"):
142
  _state.current_signal = "NO_TRADE"
143
+ if _state.market_rejection_reasons:
144
+ _state.signal_reasons = list(_state.market_rejection_reasons)
145
  _state.updated_at = time.time()
146
 
147
 
 
168
  safe = _safe_plan_view(plan if isinstance(plan, dict) else {})
169
  reference = plan_id or uuid.uuid4().hex
170
  _state.latest_trade_plan = safe
171
+ _state.analysis_state = str(safe.get("analysis_state") or (
172
+ safe.get("decision") if safe.get("decision") in {"LONG", "SHORT", "NO_TRADE"} else "ANALYSIS_FAILED"
173
+ ))
174
  _state.latest_plan_id = reference
175
  _state.latest_plan_symbol = safe.get("symbol")
176
+ if safe.get("decision") in {"LONG", "SHORT", "NO_TRADE"}:
177
+ _state.current_signal = safe["decision"]
178
+ _state.signal_reasons = list(safe.get("core_reasons") or [])
179
  _state.latest_plan_risk_profile = safe.get("risk_profile", "moderate")
180
  _state.latest_plan_created_at = _plan_epoch(safe, "created_at") or time.time()
181
  _state.latest_plan_expires_at = _plan_epoch(safe, "expires_at")
 
207
  _state.updated_at = time.time()
208
 
209
 
210
+ def record_analysis_failure(reason: str, diagnostic: Optional[str] = None) -> None:
211
+ """Record a failed request without presenting the previous plan as current."""
212
+ _state.analysis_state = "ANALYSIS_FAILED"
213
+ _state.current_signal = "ANALYSIS_FAILED"
214
+ _state.signal_reasons = [reason]
215
+ _state.latest_trade_plan = None
216
+ _state.latest_plan_id = None
217
+ _state.latest_plan_symbol = None
218
+ _state.latest_plan_risk_profile = None
219
+ _state.latest_plan_created_at = None
220
+ _state.latest_plan_expires_at = None
221
+ _state.latest_signal_score = None
222
+ _state.signal_components = {}
223
+ _state.risk_approved = False
224
+ _state.rejection_reasons = [reason]
225
+ _state.market_rejection_reasons = []
226
+ if diagnostic:
227
+ diagnostics = dict(_state.technical_diagnostics or {})
228
+ diagnostics["analysis"] = [diagnostic]
229
+ _state.technical_diagnostics = diagnostics
230
+ _state.latest_plan_executed = False
231
+ _state.updated_at = time.time()
232
+
233
+
234
  def snapshot() -> dict:
235
  return asdict(_state)
hermes_overlay/trading/trade_cycle.py CHANGED
@@ -80,8 +80,9 @@ def _to_float(x: Any) -> Optional[float]:
80
  def _extract_ohlcv_returns(merged: dict):
81
  """(short_return, medium_return, volume_ratio) from merged['ohlcv'].
82
 
83
- Expects a list of candles, each [ts, open, high, low, close, volume] or
84
- an equivalent dict. TODO: confirm against a live DS4 snapshot.
 
85
  """
86
  ohlcv = merged.get("ohlcv")
87
  if not isinstance(ohlcv, list) or len(ohlcv) < 4:
@@ -140,6 +141,9 @@ def _extract_orderbook_imbalance(merged: dict, depth: int = 10) -> Optional[floa
140
 
141
 
142
  def _extract_price_change(merged: dict) -> Optional[float]:
 
 
 
143
  val = _to_float(_first_present(
144
  merged.get("ticker"), "change24h", "percentage", "changePercent", "priceChangePercent"
145
  ))
@@ -147,6 +151,9 @@ def _extract_price_change(merged: dict) -> Optional[float]:
147
 
148
 
149
  def _extract_open_interest_change(merged: dict) -> Optional[float]:
 
 
 
150
  val = _to_float(_first_present(merged.get("openInterest"), "change24h", "changePercent", "oiChangePercent"))
151
  return (val / 100.0 if abs(val) > 1 else val) if val is not None else None
152
 
@@ -276,25 +283,92 @@ def _compute_signal(merged: dict) -> SignalResult:
276
 
277
  def _empty_plan(symbol: str, decision: str, reasons: list, warnings: list,
278
  components: dict, external_context: dict, created_at: float,
279
- external_advisory: Optional[dict] = None) -> dict:
 
 
 
280
  return {
281
- "symbol": symbol, "decision": decision, "score": 0.0, "confidence": 0.0,
 
282
  "components": components, "core_reasons": reasons, "warnings": warnings,
283
  "entry": None, "stop_loss": None, "take_profit": None, "reward_to_risk": None,
284
- "risk_profile": None, "risk_percent": None, "requested_leverage": None,
285
  "effective_leverage": None, "quantity": None, "estimated_slippage_percent": None,
286
- "risk_approved": False, "rejection_reasons": [], "external_context": external_context,
287
- "external_advisory": external_advisory,
 
288
  "executed": False, "execution_result": None,
289
- "created_at": _iso(created_at), "expires_at": _iso(created_at + PLAN_MAX_AGE_SECONDS),
290
  }
291
 
292
 
293
  def _finish_empty_plan(symbol: str, decision: str, reasons: list, warnings: list,
294
  components: dict, external_context: dict, created_at: float,
295
- external_advisory: Optional[dict]) -> dict:
296
- return _empty_plan(symbol, decision, reasons, warnings, components,
297
- external_context, created_at, external_advisory)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
 
300
  async def run_futures_cycle(
@@ -327,18 +401,24 @@ async def run_futures_cycle(
327
 
328
  # ---- Step 3: hard NO_TRADE gates --------------------------------------
329
  if context.get("noTradeGuard"):
330
- return _finish_empty_plan(norm.ds4, "NO_TRADE", context.get("noTradeReasons", []),
331
- context.get("warnings", []), {}, external_context, created_at,
332
- external_advisory)
 
 
333
  if not merged.get("orderbook"):
334
- return _finish_empty_plan(norm.ds4, "NO_TRADE", ["Order book unavailable"],
335
- context.get("warnings", []), {}, external_context, created_at,
336
- external_advisory)
 
 
337
  atr_check = _to_float(merged.get("atr"))
338
  if atr_check is None or atr_check <= 0:
339
- return _finish_empty_plan(norm.ds4, "NO_TRADE", ["ATR unavailable or invalid"],
340
- context.get("warnings", []), {}, external_context, created_at,
341
- external_advisory)
 
 
342
 
343
  # ---- Steps 4-5: deterministic signal ----------------------------------
344
  signal = _compute_signal(merged)
@@ -348,24 +428,36 @@ async def run_futures_cycle(
348
  if signal.non_missing_count < MIN_SIGNAL_COMPONENTS:
349
  reasons.append(f"Only {signal.non_missing_count} signal components available "
350
  f"(need {MIN_SIGNAL_COMPONENTS}) — NO_TRADE")
351
- return _finish_empty_plan(norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
352
- external_context, created_at, external_advisory)
 
 
 
353
  if abs(signal.score) < MIN_SIGNAL_SCORE:
354
  reasons.append(f"Score {signal.score:+.2f} below minimum {MIN_SIGNAL_SCORE} — NO_TRADE")
355
- return _finish_empty_plan(norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
356
- external_context, created_at, external_advisory)
 
 
 
357
  if signal.direction_confirmations < MIN_DIRECTION_CONFIRMATIONS:
358
  reasons.append(f"Only {signal.direction_confirmations} components confirm the direction "
359
  f"(need {MIN_DIRECTION_CONFIRMATIONS}) — NO_TRADE")
360
- return _finish_empty_plan(norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
361
- external_context, created_at, external_advisory)
 
 
 
362
 
363
  decision = "LONG" if signal.score > 0 else "SHORT"
364
  entry, atr = signal.reference_price, signal.atr
365
  if not entry or not atr:
366
  reasons.append("Missing entry reference price or ATR after signal pass — NO_TRADE")
367
- return _finish_empty_plan(norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
368
- external_context, created_at, external_advisory)
 
 
 
369
 
370
  # ---- Step 6: Stop Loss / Take Profit ----------------------------------
371
  stop_distance = max(atr * STOP_ATR_MULTIPLIER, entry * (MIN_STOP_BPS / 10000.0))
@@ -378,7 +470,10 @@ async def run_futures_cycle(
378
 
379
  if not valid or stop_loss <= 0 or take_profit <= 0:
380
  reasons.append("Invalid Stop Loss / Take Profit price relationship — NO_TRADE")
381
- return _empty_plan(norm.ds4, "NO_TRADE", reasons, warnings, signal.components, external_context, created_at)
 
 
 
382
 
383
  plan = {
384
  "symbol": norm.ds4, "decision": decision, "score": signal.score,
@@ -390,10 +485,12 @@ async def run_futures_cycle(
390
  "requested_leverage": DEFAULT_REQUESTED_LEVERAGE, "effective_leverage": None,
391
  "quantity": None, "estimated_slippage_percent": None, "risk_approved": False,
392
  "rejection_reasons": [], "external_context": external_context,
393
- "external_advisory": external_advisory,
 
394
  "executed": False, "execution_result": None,
395
  "created_at": _iso(created_at), "expires_at": _iso(created_at + PLAN_MAX_AGE_SECONDS),
396
  }
 
397
 
398
  # ---- Step 7: risk approval (reuses trading.risk, not duplicated) ------
399
  try:
@@ -432,8 +529,16 @@ async def run_futures_cycle(
432
  except Exception:
433
  plan["warnings"].append("Could not estimate slippage from order book")
434
 
 
 
 
 
 
 
 
 
435
  # ---- Step 8-10: Paper-only execution ----------------------------------
436
- if execute and plan["risk_approved"] and get_trading_mode() == "paper":
437
  if time.time() - created_at > PLAN_MAX_AGE_SECONDS:
438
  plan["rejection_reasons"].append("Plan expired before execution")
439
  else:
@@ -456,5 +561,17 @@ async def run_futures_cycle(
456
  f"execute=true requested but trading mode is {get_trading_mode()!r} — "
457
  "only Paper execution is enabled in this phase"
458
  )
 
 
 
 
459
 
 
 
 
 
 
 
 
 
460
  return plan
 
80
  def _extract_ohlcv_returns(merged: dict):
81
  """(short_return, medium_return, volume_ratio) from merged['ohlcv'].
82
 
83
+ Expects normalized candles from the datasource layer. Both the historical
84
+ list form and the canonical dictionary form remain accepted for backward
85
+ compatibility with older cached snapshots.
86
  """
87
  ohlcv = merged.get("ohlcv")
88
  if not isinstance(ohlcv, list) or len(ohlcv) < 4:
 
141
 
142
 
143
  def _extract_price_change(merged: dict) -> Optional[float]:
144
+ canonical = _to_float(_first_present(merged.get("ticker"), "change24hFraction"))
145
+ if canonical is not None:
146
+ return canonical
147
  val = _to_float(_first_present(
148
  merged.get("ticker"), "change24h", "percentage", "changePercent", "priceChangePercent"
149
  ))
 
151
 
152
 
153
  def _extract_open_interest_change(merged: dict) -> Optional[float]:
154
+ canonical = _to_float(_first_present(merged.get("openInterest"), "changeFraction"))
155
+ if canonical is not None:
156
+ return canonical
157
  val = _to_float(_first_present(merged.get("openInterest"), "change24h", "changePercent", "oiChangePercent"))
158
  return (val / 100.0 if abs(val) > 1 else val) if val is not None else None
159
 
 
283
 
284
  def _empty_plan(symbol: str, decision: str, reasons: list, warnings: list,
285
  components: dict, external_context: dict, created_at: float,
286
+ risk_profile: str, *, no_trade_guard: bool = False,
287
+ external_advisory: Optional[dict] = None,
288
+ score: Optional[float] = None) -> dict:
289
+ """Return a completed, non-executable analysis record with no fake expiry."""
290
  return {
291
+ "symbol": symbol, "decision": decision, "score": score,
292
+ "confidence": abs(score) if score is not None else None,
293
  "components": components, "core_reasons": reasons, "warnings": warnings,
294
  "entry": None, "stop_loss": None, "take_profit": None, "reward_to_risk": None,
295
+ "risk_profile": risk_profile, "risk_percent": None, "requested_leverage": None,
296
  "effective_leverage": None, "quantity": None, "estimated_slippage_percent": None,
297
+ "risk_approved": False, "rejection_reasons": list(reasons), "external_context": external_context,
298
+ "external_advisory": external_advisory, "noTradeGuard": bool(no_trade_guard),
299
+ "plan_type": "rejected_analysis", "executable": False, "analysis_state": "NO_TRADE",
300
  "executed": False, "execution_result": None,
301
+ "created_at": _iso(created_at), "expires_at": None,
302
  }
303
 
304
 
305
  def _finish_empty_plan(symbol: str, decision: str, reasons: list, warnings: list,
306
  components: dict, external_context: dict, created_at: float,
307
+ risk_profile: str, *, no_trade_guard: bool = False,
308
+ external_advisory: Optional[dict] = None,
309
+ score: Optional[float] = None) -> dict:
310
+ return _empty_plan(
311
+ symbol, decision, reasons, warnings, components, external_context, created_at,
312
+ risk_profile, no_trade_guard=no_trade_guard, external_advisory=external_advisory,
313
+ score=score,
314
+ )
315
+
316
+
317
+ def _public_field_metadata(metadata: dict) -> dict:
318
+ """Keep field provenance useful without embedding large raw market payloads."""
319
+ public: dict[str, dict] = {}
320
+ for name, raw in (metadata or {}).items():
321
+ if not isinstance(raw, dict):
322
+ continue
323
+ item = {
324
+ key: raw.get(key)
325
+ for key in ("source", "timestamp", "freshness", "validity", "fallbackStatus")
326
+ if raw.get(key) is not None
327
+ }
328
+ value = raw.get("value")
329
+ if name == "ohlcv" and isinstance(value, list):
330
+ item["valueSummary"] = {
331
+ "count": len(value),
332
+ "latestTimestamp": value[-1].get("timestamp") if value and isinstance(value[-1], dict) else None,
333
+ }
334
+ elif name == "orderbook" and isinstance(value, dict):
335
+ bids, asks = value.get("bids") or [], value.get("asks") or []
336
+ item["valueSummary"] = {
337
+ "bidLevels": len(bids),
338
+ "askLevels": len(asks),
339
+ "bestBid": bids[0][0] if bids and isinstance(bids[0], (list, tuple)) and bids[0] else None,
340
+ "bestAsk": asks[0][0] if asks and isinstance(asks[0], (list, tuple)) and asks[0] else None,
341
+ }
342
+ else:
343
+ item["value"] = value
344
+ public[name] = item
345
+ return public
346
+
347
+
348
+ def _attach_context_metadata(plan: dict, context: dict) -> dict:
349
+ """Attach bounded provenance/readiness without changing a deterministic decision."""
350
+ enriched = dict(plan)
351
+ enriched["futuresVerified"] = bool(context.get("verifiedFutures"))
352
+ enriched["futuresVerification"] = context.get("futuresVerification") or {}
353
+ enriched["field_sources"] = dict(context.get("sources") or {})
354
+ enriched["field_metadata"] = _public_field_metadata(context.get("fieldMetadata") or {})
355
+ enriched["missing_required_fields"] = list(context.get("missingRequiredFields") or [])
356
+ enriched["stale_required_fields"] = list(context.get("staleRequiredFields") or [])
357
+ enriched["merge_status"] = context.get("mergeStatus") or "unknown"
358
+ enriched["trading_readiness"] = context.get("tradingReadiness") or "blocked"
359
+ return enriched
360
+
361
+
362
+ def _rejected_plan(context: dict, symbol: str, decision: str, reasons: list, warnings: list,
363
+ components: dict, external_context: dict, created_at: float,
364
+ risk_profile: str, *, no_trade_guard: bool = False,
365
+ external_advisory: Optional[dict] = None,
366
+ score: Optional[float] = None) -> dict:
367
+ return _attach_context_metadata(_finish_empty_plan(
368
+ symbol, decision, reasons, warnings, components, external_context, created_at,
369
+ risk_profile, no_trade_guard=no_trade_guard,
370
+ external_advisory=external_advisory, score=score,
371
+ ), context)
372
 
373
 
374
  async def run_futures_cycle(
 
401
 
402
  # ---- Step 3: hard NO_TRADE gates --------------------------------------
403
  if context.get("noTradeGuard"):
404
+ return _rejected_plan(context,
405
+ norm.ds4, "NO_TRADE", context.get("noTradeReasons", []),
406
+ context.get("warnings", []), {}, external_context, created_at, risk_profile,
407
+ no_trade_guard=True, external_advisory=external_advisory,
408
+ )
409
  if not merged.get("orderbook"):
410
+ return _rejected_plan(context,
411
+ norm.ds4, "NO_TRADE", ["Order book unavailable"], context.get("warnings", []),
412
+ {}, external_context, created_at, risk_profile,
413
+ external_advisory=external_advisory,
414
+ )
415
  atr_check = _to_float(merged.get("atr"))
416
  if atr_check is None or atr_check <= 0:
417
+ return _rejected_plan(context,
418
+ norm.ds4, "NO_TRADE", ["ATR unavailable or invalid"], context.get("warnings", []),
419
+ {}, external_context, created_at, risk_profile,
420
+ external_advisory=external_advisory,
421
+ )
422
 
423
  # ---- Steps 4-5: deterministic signal ----------------------------------
424
  signal = _compute_signal(merged)
 
428
  if signal.non_missing_count < MIN_SIGNAL_COMPONENTS:
429
  reasons.append(f"Only {signal.non_missing_count} signal components available "
430
  f"(need {MIN_SIGNAL_COMPONENTS}) — NO_TRADE")
431
+ return _rejected_plan(context,
432
+ norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
433
+ external_context, created_at, risk_profile,
434
+ external_advisory=external_advisory, score=signal.score,
435
+ )
436
  if abs(signal.score) < MIN_SIGNAL_SCORE:
437
  reasons.append(f"Score {signal.score:+.2f} below minimum {MIN_SIGNAL_SCORE} — NO_TRADE")
438
+ return _rejected_plan(context,
439
+ norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
440
+ external_context, created_at, risk_profile,
441
+ external_advisory=external_advisory, score=signal.score,
442
+ )
443
  if signal.direction_confirmations < MIN_DIRECTION_CONFIRMATIONS:
444
  reasons.append(f"Only {signal.direction_confirmations} components confirm the direction "
445
  f"(need {MIN_DIRECTION_CONFIRMATIONS}) — NO_TRADE")
446
+ return _rejected_plan(context,
447
+ norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
448
+ external_context, created_at, risk_profile,
449
+ external_advisory=external_advisory, score=signal.score,
450
+ )
451
 
452
  decision = "LONG" if signal.score > 0 else "SHORT"
453
  entry, atr = signal.reference_price, signal.atr
454
  if not entry or not atr:
455
  reasons.append("Missing entry reference price or ATR after signal pass — NO_TRADE")
456
+ return _rejected_plan(context,
457
+ norm.ds4, "NO_TRADE", reasons, warnings, signal.components,
458
+ external_context, created_at, risk_profile,
459
+ external_advisory=external_advisory, score=signal.score,
460
+ )
461
 
462
  # ---- Step 6: Stop Loss / Take Profit ----------------------------------
463
  stop_distance = max(atr * STOP_ATR_MULTIPLIER, entry * (MIN_STOP_BPS / 10000.0))
 
470
 
471
  if not valid or stop_loss <= 0 or take_profit <= 0:
472
  reasons.append("Invalid Stop Loss / Take Profit price relationship — NO_TRADE")
473
+ return _rejected_plan(context,
474
+ norm.ds4, "NO_TRADE", reasons, warnings, signal.components, external_context,
475
+ created_at, risk_profile, external_advisory=external_advisory, score=signal.score,
476
+ )
477
 
478
  plan = {
479
  "symbol": norm.ds4, "decision": decision, "score": signal.score,
 
485
  "requested_leverage": DEFAULT_REQUESTED_LEVERAGE, "effective_leverage": None,
486
  "quantity": None, "estimated_slippage_percent": None, "risk_approved": False,
487
  "rejection_reasons": [], "external_context": external_context,
488
+ "external_advisory": external_advisory, "noTradeGuard": False,
489
+ "plan_type": "directional_plan", "executable": False, "analysis_state": decision,
490
  "executed": False, "execution_result": None,
491
  "created_at": _iso(created_at), "expires_at": _iso(created_at + PLAN_MAX_AGE_SECONDS),
492
  }
493
+ plan = _attach_context_metadata(plan, context)
494
 
495
  # ---- Step 7: risk approval (reuses trading.risk, not duplicated) ------
496
  try:
 
529
  except Exception:
530
  plan["warnings"].append("Could not estimate slippage from order book")
531
 
532
+ plan["executable"] = bool(
533
+ plan["risk_approved"] and plan["decision"] in {"LONG", "SHORT"}
534
+ and not plan["rejection_reasons"] and plan.get("futuresVerified") is True
535
+ and plan.get("trading_readiness") == "ready"
536
+ )
537
+ if not plan["executable"]:
538
+ plan["plan_type"] = "non_executable_plan"
539
+
540
  # ---- Step 8-10: Paper-only execution ----------------------------------
541
+ if execute and plan["executable"] and get_trading_mode() == "paper":
542
  if time.time() - created_at > PLAN_MAX_AGE_SECONDS:
543
  plan["rejection_reasons"].append("Plan expired before execution")
544
  else:
 
561
  f"execute=true requested but trading mode is {get_trading_mode()!r} — "
562
  "only Paper execution is enabled in this phase"
563
  )
564
+ elif execute and not plan["executable"]:
565
+ plan["rejection_reasons"].append(
566
+ "Execution blocked because the directional plan is not server-authorized"
567
+ )
568
 
569
+ plan["executable"] = bool(
570
+ plan["risk_approved"] and plan["decision"] in {"LONG", "SHORT"}
571
+ and not plan["rejection_reasons"] and not plan["executed"]
572
+ and plan.get("futuresVerified") is True
573
+ and plan.get("trading_readiness") == "ready"
574
+ )
575
+ if not plan["executable"] and plan["plan_type"] == "directional_plan":
576
+ plan["plan_type"] = "non_executable_plan"
577
  return plan
scripts/sync_hf.py CHANGED
@@ -14,6 +14,8 @@ as-is to/from a Hugging Face Dataset repo.
14
  import os
15
  import sys
16
  import time
 
 
17
  import threading
18
  import subprocess
19
  import signal
@@ -572,6 +574,49 @@ class HermesFullSync:
572
  templates_src = tools_src / "templates"
573
  if templates_src.exists():
574
  shutil.copytree(templates_src, APP_DIR / "tools" / "templates", dirs_exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  print("[SYNC] Futures trading overlay installed into /opt/hermes")
576
  except Exception as e:
577
  print(f"[SYNC] Futures overlay install failed (non-fatal): {e}")
 
14
  import os
15
  import sys
16
  import time
17
+ import json
18
+ import hashlib
19
  import threading
20
  import subprocess
21
  import signal
 
574
  templates_src = tools_src / "templates"
575
  if templates_src.exists():
576
  shutil.copytree(templates_src, APP_DIR / "tools" / "templates", dirs_exist_ok=True)
577
+ tracked = {
578
+ "router": (
579
+ tools_src / "futures_dashboard_api.py",
580
+ APP_DIR / "tools" / "futures_dashboard_api.py",
581
+ ),
582
+ "template": (
583
+ templates_src / "hermes_futures_desk_luxury.html",
584
+ APP_DIR / "tools" / "templates" / "hermes_futures_desk_luxury.html",
585
+ ),
586
+ }
587
+
588
+ def _sha256(path):
589
+ if not path.is_file():
590
+ return None
591
+ digest = hashlib.sha256()
592
+ with path.open("rb") as handle:
593
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
594
+ digest.update(chunk)
595
+ return digest.hexdigest()
596
+
597
+ manifest = {
598
+ "installedAt": datetime.now().astimezone().isoformat(),
599
+ "sourceRoot": str(overlay_src),
600
+ "destinationRoot": str(APP_DIR),
601
+ "files": {},
602
+ }
603
+ for name, (source_path, destination_path) in tracked.items():
604
+ source_hash = _sha256(source_path)
605
+ destination_hash = _sha256(destination_path)
606
+ manifest["files"][name] = {
607
+ "sourcePath": str(source_path),
608
+ "destinationPath": str(destination_path),
609
+ "sourceSha256": source_hash,
610
+ "destinationSha256": destination_hash,
611
+ "matches": bool(source_hash and source_hash == destination_hash),
612
+ }
613
+ manifest_path = APP_DIR / ".hermes_futures_overlay_manifest.json"
614
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
615
+ mismatch = [name for name, item in manifest["files"].items() if not item["matches"]]
616
+ if mismatch:
617
+ print(f"[SYNC] WARNING: Futures overlay hash mismatch: {', '.join(mismatch)}")
618
+ else:
619
+ print("[SYNC] Futures overlay hashes verified")
620
  print("[SYNC] Futures trading overlay installed into /opt/hermes")
621
  except Exception as e:
622
  print(f"[SYNC] Futures overlay install failed (non-fatal): {e}")
scripts/verify_futures_runtime.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Read-only deployed-runtime audit for Hermes Futures Desk.
3
+
4
+ This utility intentionally never calls the Paper Execute endpoint. Credentials
5
+ are read only from environment variables and are never written to the report.
6
+ It can verify the served dashboard hash, authenticated Futures API response
7
+ shapes, all supported chart intervals, and an optional analysis-only request.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ DEFAULT_BASE_URL = "https://really-amin-asset.hf.space"
25
+ SUPPORTED_INTERVALS = ("1m", "5m", "15m", "1h")
26
+
27
+
28
+ def _redact(value: Any) -> Any:
29
+ if isinstance(value, dict):
30
+ result = {}
31
+ for key, item in value.items():
32
+ if re.search(r"(?i)(authorization|cookie|token|secret|password|api[_-]?key)", str(key)):
33
+ result[str(key)] = "[redacted]"
34
+ else:
35
+ result[str(key)] = _redact(item)
36
+ return result
37
+ if isinstance(value, list):
38
+ return [_redact(item) for item in value[:200]]
39
+ if isinstance(value, str):
40
+ text = re.sub(
41
+ r"(?i)\b(authorization|cookie|token|secret|password|api[_-]?key)\b\s*[:=]\s*[^\s,;]+",
42
+ r"\1=[redacted]",
43
+ value,
44
+ )
45
+ return text[:2000]
46
+ return value
47
+
48
+
49
+ def _response_summary(response: httpx.Response, payload: Any = None) -> dict[str, Any]:
50
+ return {
51
+ "statusCode": response.status_code,
52
+ "contentType": response.headers.get("content-type"),
53
+ "elapsedMs": round(response.elapsed.total_seconds() * 1000, 2),
54
+ "payload": _redact(payload),
55
+ }
56
+
57
+
58
+ def _market_shape(payload: Any) -> dict[str, Any]:
59
+ if not isinstance(payload, dict):
60
+ return {"valid": False, "reason": "Payload is not an object"}
61
+ candles = payload.get("candles")
62
+ required_keys = {
63
+ "state", "symbol", "interval", "candles", "currentPrice", "fundingRate",
64
+ "openInterest", "source", "freshness", "tradingReadiness",
65
+ }
66
+ missing = sorted(required_keys - set(payload))
67
+ real_candles = isinstance(candles, list) and all(
68
+ isinstance(item, dict)
69
+ and all(key in item for key in ("timestamp", "open", "high", "low", "close", "volume"))
70
+ for item in candles
71
+ )
72
+ return {
73
+ "valid": not missing and isinstance(candles, list) and real_candles,
74
+ "missingKeys": missing,
75
+ "candleCount": len(candles) if isinstance(candles, list) else None,
76
+ "state": payload.get("state"),
77
+ "freshness": payload.get("freshness"),
78
+ "source": payload.get("source"),
79
+ "verifiedFutures": payload.get("verifiedFutures"),
80
+ "tradingReadiness": payload.get("tradingReadiness"),
81
+ }
82
+
83
+
84
+ def _client_auth() -> tuple[httpx.BasicAuth | None, dict[str, str]]:
85
+ username = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
86
+ password = os.environ.get("HERMES_ADMIN_PASSWORD", "")
87
+ cookie = os.environ.get("HERMES_DASHBOARD_COOKIE", "")
88
+ auth = httpx.BasicAuth(username, password) if password else None
89
+ headers = {"Accept": "application/json", "Cache-Control": "no-cache"}
90
+ if cookie:
91
+ headers["Cookie"] = cookie
92
+ return auth, headers
93
+
94
+
95
+ async def _json_request(
96
+ client: httpx.AsyncClient,
97
+ method: str,
98
+ path: str,
99
+ *,
100
+ json_body: dict[str, Any] | None = None,
101
+ ) -> tuple[httpx.Response, Any]:
102
+ response = await client.request(method, path, json=json_body)
103
+ try:
104
+ payload = response.json()
105
+ except ValueError:
106
+ payload = {"error": "Response was not valid JSON", "bodyPreview": response.text[:500]}
107
+ return response, payload
108
+
109
+
110
+ async def audit(args: argparse.Namespace) -> dict[str, Any]:
111
+ auth, headers = _client_auth()
112
+ report: dict[str, Any] = {
113
+ "generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
114
+ "baseUrl": args.base_url.rstrip("/"),
115
+ "symbol": args.symbol.upper(),
116
+ "analysisRequested": bool(args.analyze),
117
+ "paperExecuteCalled": False,
118
+ "checks": {},
119
+ }
120
+ timeout = httpx.Timeout(args.timeout, connect=min(args.timeout, 10.0))
121
+ async with httpx.AsyncClient(
122
+ base_url=args.base_url.rstrip("/"),
123
+ auth=auth,
124
+ headers=headers,
125
+ follow_redirects=True,
126
+ timeout=timeout,
127
+ ) as client:
128
+ dashboard = await client.get("/futures", headers={**headers, "Accept": "text/html"})
129
+ body_hash = hashlib.sha256(dashboard.content).hexdigest()
130
+ header_hash = dashboard.headers.get("x-hermes-template-sha256")
131
+ report["checks"]["dashboard"] = {
132
+ "statusCode": dashboard.status_code,
133
+ "bodySha256": body_hash,
134
+ "headerTemplateSha256": header_hash,
135
+ "headerRouterSha256": dashboard.headers.get("x-hermes-router-sha256"),
136
+ "hashMatchesHeader": bool(header_hash and header_hash == body_hash),
137
+ "cacheControl": dashboard.headers.get("cache-control"),
138
+ "containsMarketEndpoint": "/api/futures/market" in dashboard.text,
139
+ "containsPaperExecuteEndpoint": "/api/futures/paper/execute" in dashboard.text,
140
+ }
141
+
142
+ for name, path in (
143
+ ("status", "/api/futures/status"),
144
+ ("symbols", "/api/futures/symbols"),
145
+ ("positions", "/api/futures/positions"),
146
+ ):
147
+ response, payload = await _json_request(client, "GET", path)
148
+ report["checks"][name] = _response_summary(response, payload)
149
+
150
+ market_checks: dict[str, Any] = {}
151
+ for interval in SUPPORTED_INTERVALS:
152
+ path = (
153
+ f"/api/futures/market?symbol={args.symbol.upper()}"
154
+ f"&interval={interval}&limit={args.limit}"
155
+ )
156
+ response, payload = await _json_request(client, "GET", path)
157
+ market_checks[interval] = {
158
+ **_response_summary(response),
159
+ "shape": _market_shape(payload),
160
+ "payload": _redact(payload),
161
+ }
162
+ report["checks"]["marketIntervals"] = market_checks
163
+
164
+ if args.analyze:
165
+ response, payload = await _json_request(
166
+ client,
167
+ "POST",
168
+ "/api/futures/analyze",
169
+ json_body={
170
+ "symbol": args.symbol.upper(),
171
+ "risk_profile": args.risk_profile,
172
+ "include_external_context": False,
173
+ },
174
+ )
175
+ report["checks"]["analysis"] = _response_summary(response, payload)
176
+
177
+ statuses: list[bool] = []
178
+ dashboard_check = report["checks"]["dashboard"]
179
+ statuses.append(dashboard_check["statusCode"] == 200)
180
+ statuses.append(dashboard_check["hashMatchesHeader"] is True)
181
+ for name in ("status", "symbols", "positions"):
182
+ statuses.append(report["checks"][name]["statusCode"] == 200)
183
+ for interval in SUPPORTED_INTERVALS:
184
+ check = report["checks"]["marketIntervals"][interval]
185
+ statuses.append(check["statusCode"] in {200, 503})
186
+ statuses.append(check["shape"]["valid"] is True)
187
+ if args.analyze:
188
+ statuses.append(report["checks"]["analysis"]["statusCode"] == 200)
189
+ report["summary"] = {
190
+ "passed": all(statuses),
191
+ "checkCount": len(statuses),
192
+ "failedCheckCount": sum(1 for value in statuses if not value),
193
+ "credentialsProvided": bool(
194
+ os.environ.get("HERMES_ADMIN_PASSWORD") or os.environ.get("HERMES_DASHBOARD_COOKIE")
195
+ ),
196
+ "note": "No Paper, Testnet, or Live execution endpoint was called.",
197
+ }
198
+ return report
199
+
200
+
201
+ def parse_args() -> argparse.Namespace:
202
+ parser = argparse.ArgumentParser(description=__doc__)
203
+ parser.add_argument(
204
+ "--base-url",
205
+ default=os.environ.get("HERMES_FUTURES_BASE_URL", DEFAULT_BASE_URL),
206
+ )
207
+ parser.add_argument("--symbol", default="BTCUSDT")
208
+ parser.add_argument("--limit", type=int, default=120, choices=range(20, 501), metavar="20..500")
209
+ parser.add_argument(
210
+ "--risk-profile",
211
+ choices=("conservative", "moderate", "aggressive"),
212
+ default="moderate",
213
+ )
214
+ parser.add_argument(
215
+ "--analyze",
216
+ action="store_true",
217
+ help="Also perform one analysis-only POST. Paper Execute is still never called.",
218
+ )
219
+ parser.add_argument("--timeout", type=float, default=30.0)
220
+ parser.add_argument("--report", type=Path, default=Path("futures_runtime_audit.json"))
221
+ return parser.parse_args()
222
+
223
+
224
+ def main() -> int:
225
+ args = parse_args()
226
+ try:
227
+ report = asyncio.run(audit(args))
228
+ except Exception as exc: # Audit failure must still avoid leaking credentials.
229
+ report = {
230
+ "generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
231
+ "baseUrl": args.base_url,
232
+ "paperExecuteCalled": False,
233
+ "summary": {"passed": False, "error": type(exc).__name__},
234
+ }
235
+ args.report.parent.mkdir(parents=True, exist_ok=True)
236
+ args.report.write_text(json.dumps(_redact(report), indent=2, ensure_ascii=False), encoding="utf-8")
237
+ print(f"Runtime audit report written to {args.report}")
238
+ print("Paper Execute was not called.")
239
+ return 0 if report.get("summary", {}).get("passed") else 1
240
+
241
+
242
+ if __name__ == "__main__":
243
+ sys.exit(main())