Spaces:
Paused
Paused
Your Name commited on
Commit ·
848e6c4
1
Parent(s): 24d8dad
Implement Hermes Futures Desk: dual-datasource trading engine, dashboard UI, tests, and documentation
Browse filesAdds 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 +14 -0
- DEVELOPER_DOCUMENTATION.md +2048 -0
- README.md +51 -1
- docs/API_REFERENCE.md +287 -0
- docs/ARCHITECTURE.md +110 -0
- docs/CONTRIBUTING.md +103 -0
- docs/DATA_PIPELINE_AND_CONTRACTS.md +249 -0
- docs/DEPLOYMENT_RUNBOOK.md +127 -0
- docs/DEVELOPER_GUIDE.md +299 -0
- docs/ENVIRONMENT_CONFIGURATION.md +142 -0
- docs/FRONTEND_GUIDE.md +189 -0
- docs/OPERATIONS_AND_TROUBLESHOOTING.md +177 -0
- docs/PROJECT_STATUS.md +80 -0
- docs/README.md +26 -0
- docs/SECURITY_AND_SAFETY.md +105 -0
- docs/TESTING_AND_VERIFICATION.md +112 -0
- hermes_overlay/tests/test_binance_public_fallback.py +45 -35
- hermes_overlay/tests/test_ds4_merge_nested_data.py +20 -10
- hermes_overlay/tests/test_futures_dashboard_and_state.py +14 -6
- hermes_overlay/tests/test_futures_integration.py +9 -6
- hermes_overlay/tools/futures_dashboard_api.py +672 -224
- hermes_overlay/tools/templates/hermes_futures_desk_luxury.html +0 -0
- hermes_overlay/trading/binance_public_client.py +136 -4
- hermes_overlay/trading/dual_datasource_client.py +780 -34
- hermes_overlay/trading/state.py +103 -12
- hermes_overlay/trading/trade_cycle.py +148 -31
- scripts/sync_hf.py +45 -0
- scripts/verify_futures_runtime.py +243 -0
.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
|
| 119 |
-
calls.append(
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
|
| 123 |
-
monkeypatch.setattr(ddc.binance_public, "
|
| 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 ==
|
| 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
|
| 176 |
-
calls.append(
|
| 177 |
-
assert needed == frozenset({"openInterestChange"})
|
| 178 |
return {
|
| 179 |
-
"
|
| 180 |
-
"
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 207 |
-
# Pretend Binance successfully supplies everything that was missing
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
return filled, []
|
| 214 |
|
| 215 |
-
monkeypatch.setattr(ddc.binance_public, "
|
| 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": "
|
| 68 |
"warnings": [],
|
| 69 |
"errors": [],
|
| 70 |
"data": {
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"] == {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 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,
|
| 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:
|
| 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:
|
| 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) ->
|
| 114 |
-
started = time.perf_counter()
|
| 115 |
try:
|
| 116 |
async with httpx.AsyncClient() as client:
|
| 117 |
resp = await client.get(url, timeout=timeout)
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 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 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
symbol = str(raw.get("symbol") or raw.get("id") or "").upper()
|
| 172 |
-
|
| 173 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 188 |
-
"status":
|
| 189 |
-
"futuresVerified": is_futures
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
async def futures_status() -> JSONResponse:
|
| 273 |
state = _dash_state.snapshot()
|
| 274 |
-
|
| 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 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
return JSONResponse({
|
| 287 |
-
"
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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"] =
|
| 322 |
-
|
| 323 |
-
return JSONResponse({
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
|
| 328 |
|
| 329 |
-
@router.get("/api/futures/positions")
|
| 330 |
async def futures_positions() -> JSONResponse:
|
| 331 |
positions = await _get_futures_positions()
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
mark = None
|
| 339 |
unrealized = None
|
| 340 |
if mark:
|
| 341 |
-
direction = 1 if
|
| 342 |
-
unrealized = (mark -
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
| 346 |
|
| 347 |
|
| 348 |
@router.get("/api/futures/market", dependencies=[Depends(_authenticated_dashboard_request)])
|
| 349 |
-
async def futures_market(
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 376 |
include_external_context=request.include_external_context,
|
| 377 |
)
|
| 378 |
plan_id = _dash_state.record_trade_plan(plan)
|
| 379 |
-
return JSONResponse(
|
|
|
|
|
|
|
| 380 |
except Exception as exc:
|
| 381 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 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(
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
<
|
| 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 =
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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, "
|
|
|
|
| 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 |
-
|
|
|
|
| 43 |
try:
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
-
async def _fetch_ds4_snapshot(
|
|
|
|
|
|
|
| 52 |
warnings: list[str] = []
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
*(
|
| 76 |
)
|
| 77 |
-
context = {}
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
if value is None:
|
| 80 |
warnings.append(f"Datasource 2 field '{key}' unavailable")
|
| 81 |
else:
|
| 82 |
context[key] = value
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
ds4_stale = ds4 is not None and ds4_state not in DS4_FRESH_STATES
|
| 203 |
-
ds4_data = (ds4
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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
|
| 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={
|
| 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
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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
|
| 84 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 280 |
return {
|
| 281 |
-
"symbol": symbol, "decision": decision, "score":
|
|
|
|
| 282 |
"components": components, "core_reasons": reasons, "warnings": warnings,
|
| 283 |
"entry": None, "stop_loss": None, "take_profit": None, "reward_to_risk": None,
|
| 284 |
-
"risk_profile":
|
| 285 |
"effective_leverage": None, "quantity": None, "estimated_slippage_percent": None,
|
| 286 |
-
"risk_approved": False, "rejection_reasons":
|
| 287 |
-
"external_advisory": external_advisory,
|
|
|
|
| 288 |
"executed": False, "execution_result": None,
|
| 289 |
-
"created_at": _iso(created_at), "expires_at":
|
| 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 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
| 333 |
if not merged.get("orderbook"):
|
| 334 |
-
return
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
| 337 |
atr_check = _to_float(merged.get("atr"))
|
| 338 |
if atr_check is None or atr_check <= 0:
|
| 339 |
-
return
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
| 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
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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["
|
| 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())
|