Commit 6132f5c0 authored by xeron56's avatar xeron56

Add CUDA device compatibility checks to TimesFM backend

- Implemented a function to check if the current CUDA device is supported by the installed PyTorch version.
- Added warnings for unsupported CUDA devices, informing users that the CPU will be used instead.
- Updated the TimesFMBackend class to utilize the new compatibility checks and handle GPU names appropriately.
- Introduced unit tests to validate the behavior of the CUDA compatibility function with various simulated CUDA environments.
parent 02b92e82
......@@ -86,7 +86,7 @@ DSE_PASSWORD=
#TRADINGAGENTS_SOCIAL_MEDIA_ENABLED=false
#TRADINGAGENTS_MACRO_DATA_ENABLED=false
#TRADINGAGENTS_PREDICTION_MARKETS_ENABLED=false
#TRADINGAGENTS_DSE_GATEWAY_URL=https://gateway.dohasecurities.com.bd
# TRADINGAGENTS_DSE_GATEWAY_URL=https://gateway.dohasecurities.com.bd
#TRADINGAGENTS_DSE_REQUEST_TIMEOUT=30
#TRADINGAGENTS_DSE_VERIFY_SSL=true
#TRADINGAGENTS_DSE_BENCHMARK_TICKER=DSEX
......
......@@ -3476,14 +3476,14 @@
}
},
"node_modules/@jsonjoy.com/fs-core": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.68.0.tgz",
"integrity": "sha512-OAioDU3UGV34ECVTB4pt641OfUzuyDuyUmgrlVM+Fp9g4YyavZrB4xxWuupYrU66+81+um9D0DAzYhjXto5ysQ==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.68.1.tgz",
"integrity": "sha512-V5oZ4Gt9WJKyQef0n9cAd0N9qjSkIBm3E4MYsgNIWBk5aINCDPKxMPo1i29rBxqiT4Ixf1epklqV9VJMKIxwlw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-node-builtins": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.0",
"@jsonjoy.com/fs-node-builtins": "4.68.1",
"@jsonjoy.com/fs-node-utils": "4.68.1",
"thingies": "^2.5.0"
},
"engines": {
......@@ -3498,15 +3498,15 @@
}
},
"node_modules/@jsonjoy.com/fs-fsa": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.68.0.tgz",
"integrity": "sha512-dC6VeW8uqXMF9Hkqb7qYST/t165XCHG+bNwwMzRetoNZRxJC8anr5XCJSAk5B+YLHGn6SP+noNltU6OFg/oa7A==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.68.1.tgz",
"integrity": "sha512-HCG72UioncuO7Gw09XNVG+S85e3cq2hrUC/mexBrsWsa3mI7eePkkqWie3uVYbtsb64OR9YGQs5SqaufDRYBcg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-core": "4.68.0",
"@jsonjoy.com/fs-node-builtins": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.0",
"@jsonjoy.com/fs-core": "4.68.1",
"@jsonjoy.com/fs-node-builtins": "4.68.1",
"@jsonjoy.com/fs-node-utils": "4.68.1",
"thingies": "^2.5.0"
},
"engines": {
......@@ -3521,17 +3521,17 @@
}
},
"node_modules/@jsonjoy.com/fs-node": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.68.0.tgz",
"integrity": "sha512-xYDlpSk3UDHjcI0kiz6kNJ/oVTEzlS7kUzAoAsgJh2/+yOtYV/eZW7M8O9Fj+ED3BDONYKt7+8OiPlWl6xpFeA==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.68.1.tgz",
"integrity": "sha512-R5D9mWtqdURzcOWj1vdXr3APCwX0xchtFT+kmW7fXLNDifWdDrnh26jSID8pdnUfFBxTyfHtFtTL/NWKzIH7kQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-core": "4.68.0",
"@jsonjoy.com/fs-node-builtins": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.0",
"@jsonjoy.com/fs-print": "4.68.0",
"@jsonjoy.com/fs-snapshot": "4.68.0",
"@jsonjoy.com/fs-core": "4.68.1",
"@jsonjoy.com/fs-node-builtins": "4.68.1",
"@jsonjoy.com/fs-node-utils": "4.68.1",
"@jsonjoy.com/fs-print": "4.68.1",
"@jsonjoy.com/fs-snapshot": "4.68.1",
"glob-to-regex.js": "^1.0.0",
"thingies": "^2.5.0"
},
......@@ -3547,9 +3547,9 @@
}
},
"node_modules/@jsonjoy.com/fs-node-builtins": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.68.0.tgz",
"integrity": "sha512-j3bb+k4NuqpqXQzinCLeagS7LGLCUvPqA6TnpaBkBqhnPjbzETYiQFpD7pWaV8cGOhhZcAv9dmpzk5mxNb1qsg==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.68.1.tgz",
"integrity": "sha512-HK1BTksysokNZxNspqDH0yPaqN9YgR/AYIlYiIaU2Ys4BOk5CdybI7r6BgiZuiiPiV8n4sK/kZdice7Znpy2Kw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
......@@ -3564,15 +3564,15 @@
}
},
"node_modules/@jsonjoy.com/fs-node-to-fsa": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.68.0.tgz",
"integrity": "sha512-dtLpmLQxw3IBcGxoK9tVvftwmQLl9qmQyivt8DZpeEwDUcQkxk2CLMZERXtmOlF1mWXfT9CqtjjMiswkb03rKw==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.68.1.tgz",
"integrity": "sha512-lpKmU4X9e/oh8GIuAI7EXaS5QiLNM3KD15CkdhfS6PYmrGvoJqKQcyEfnLgnnaGslh/PFUMYSIZBCf2ejJGw8g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-fsa": "4.68.0",
"@jsonjoy.com/fs-node-builtins": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.0"
"@jsonjoy.com/fs-fsa": "4.68.1",
"@jsonjoy.com/fs-node-builtins": "4.68.1",
"@jsonjoy.com/fs-node-utils": "4.68.1"
},
"engines": {
"node": ">=10.0"
......@@ -3586,13 +3586,13 @@
}
},
"node_modules/@jsonjoy.com/fs-node-utils": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.68.0.tgz",
"integrity": "sha512-Q84pogSfRaz0WNrBBy+HzBmhNu4m6tWoM0po8bLwW4pKvrwrSadOgFpm1sku08sfg0QAtUubkWuZEBDpNI3toQ==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.68.1.tgz",
"integrity": "sha512-/GxfW1DWm9SCdkfbvqevLO/P5duobQfmKkHXxdMIDbcZMQeAgooAstIfZhkXpATzq9QbCQsnoWFM/dGHdZfndw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-node-builtins": "4.68.0",
"@jsonjoy.com/fs-node-builtins": "4.68.1",
"glob-to-regex.js": "^1.0.1"
},
"engines": {
......@@ -3607,13 +3607,13 @@
}
},
"node_modules/@jsonjoy.com/fs-print": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.68.0.tgz",
"integrity": "sha512-sdy9F6N9QEcyAcU8OxEQmB91mlgiyuMphTFeijQ+qFgigCOeT16lW3X1dCWNzD7wwKLOKXnnYqsFyeq4LWSlYQ==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.68.1.tgz",
"integrity": "sha512-oGeZOGPYKK9v1CgeVeEDsLomH1lCnslSpqUN5GmPzrmAVGQlsmsdcXNA2O4lV8Y4xkuSuynx2ITBkUHJVaTbow==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-node-utils": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.1",
"tree-dump": "^1.1.0"
},
"engines": {
......@@ -3628,14 +3628,14 @@
}
},
"node_modules/@jsonjoy.com/fs-snapshot": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.68.0.tgz",
"integrity": "sha512-eV44t9KY9LH47aPR1a4Nv4xYxM1vQ+OKEw9Mx/3zkuxGOILwmtFddlh5HakdMFpFHwuFNPw1s+NGq2eVYFpQRA==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.68.1.tgz",
"integrity": "sha512-XZfP0FDZN32bbc4t2bZN2qRrYHg5AktJnzk22HRoKGK4BprrbNRH2k5ceSNS/kupKYcofCs+O841+xaAbjnxwQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/buffers": "^17.65.0",
"@jsonjoy.com/fs-node-utils": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.1",
"@jsonjoy.com/json-pack": "^17.65.0",
"@jsonjoy.com/util": "^17.65.0"
},
......@@ -4449,9 +4449,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
"integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
"integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==",
"dev": true,
"license": "MIT",
"optional": true,
......@@ -4466,8 +4466,8 @@
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@ngtools/webpack": {
......@@ -5865,9 +5865,9 @@
"license": "MIT"
},
"node_modules/@stomp/stompjs": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.1.1.tgz",
"integrity": "sha512-chcDs6YkAnKp1FqzwhGvh3i7v0+/ytzqWdKYw6XzINEKAzke/iD00dNgFPWSZEqktHOK+C1gSzXhLkLbARIaZw==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz",
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0"
},
"node_modules/@tufjs/canonical-json": {
......@@ -7799,9 +7799,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.403",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz",
"integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==",
"version": "1.5.405",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
"integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
"dev": true,
"license": "ISC"
},
......@@ -8116,9 +8116,9 @@
}
},
"node_modules/eventsource-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"dev": true,
"license": "MIT",
"engines": {
......@@ -8923,9 +8923,9 @@
}
},
"node_modules/ip-address": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"dev": true,
"license": "MIT",
"engines": {
......@@ -9802,20 +9802,20 @@
}
},
"node_modules/memfs": {
"version": "4.68.0",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.68.0.tgz",
"integrity": "sha512-qlU8XrIfXUuZcmXk/GODL47KXXnhSbbaWtW5z+L7cLFfIQbgAOWzL5lqUYn39kfJh+BHLaD/siaM94/UTweGow==",
"version": "4.68.1",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-4.68.1.tgz",
"integrity": "sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jsonjoy.com/fs-core": "4.68.0",
"@jsonjoy.com/fs-fsa": "4.68.0",
"@jsonjoy.com/fs-node": "4.68.0",
"@jsonjoy.com/fs-node-builtins": "4.68.0",
"@jsonjoy.com/fs-node-to-fsa": "4.68.0",
"@jsonjoy.com/fs-node-utils": "4.68.0",
"@jsonjoy.com/fs-print": "4.68.0",
"@jsonjoy.com/fs-snapshot": "4.68.0",
"@jsonjoy.com/fs-core": "4.68.1",
"@jsonjoy.com/fs-fsa": "4.68.1",
"@jsonjoy.com/fs-node": "4.68.1",
"@jsonjoy.com/fs-node-builtins": "4.68.1",
"@jsonjoy.com/fs-node-to-fsa": "4.68.1",
"@jsonjoy.com/fs-node-utils": "4.68.1",
"@jsonjoy.com/fs-print": "4.68.1",
"@jsonjoy.com/fs-snapshot": "4.68.1",
"@jsonjoy.com/json-pack": "^1.11.0",
"@jsonjoy.com/util": "^1.9.0",
"glob-to-regex.js": "^1.0.1",
......@@ -12900,9 +12900,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz",
"integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
"integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
"dev": true,
"funding": [
{
......
......@@ -79,24 +79,88 @@
<section class="chart-card">
<header>
<div>
<p class="eyebrow">HISTORICAL BACKTEST + FORWARD PATH</p>
<h2>Actual candles against {{ report.model.name }}</h2>
<p class="eyebrow">{{ chartMode() === 'future' ? 'FORWARD FORECAST' : 'HISTORICAL BACKTEST' }}</p>
<h2>
{{ chartMode() === 'future'
? 'Recent actual candles + ' + report.model.name + ' future'
: 'Hidden actuals against ' + report.model.name + ' backtest' }}
</h2>
</div>
<div class="legend" aria-label="Chart legend">
<span><i class="candle"></i>Actual OHLC</span>
<span><i class="backtest"></i>Held-out prediction</span>
<span><i class="future"></i>Future forecast</span>
<span><i class="live"></i>Live actual</span>
<div class="chart-controls">
<div class="chart-mode-switch" role="group" aria-label="Forecast chart view">
<button
type="button"
[attr.aria-pressed]="chartMode() === 'future'"
[class.active]="chartMode() === 'future'"
(click)="selectChartMode('future')"
>Future forecast</button>
<button
type="button"
[attr.aria-pressed]="chartMode() === 'backtest'"
[class.active]="chartMode() === 'backtest'"
(click)="selectChartMode('backtest')"
>Backtest</button>
</div>
<div class="legend" aria-label="Chart legend">
@if (chartMode() === 'future') {
<span><i class="candle"></i>Actual OHLC</span>
<span><i class="future"></i>Future forecast</span>
@if (showFutureInterval()) {
<span><i class="interval future-interval"></i>Q10–Q90</span>
}
<span><i class="live"></i>Live actual</span>
} @else {
<span><i class="actual-line"></i>Actual close</span>
<span><i class="backtest"></i>Backtest prediction</span>
@if (showBacktestInterval()) {
<span><i class="interval"></i>Q10–Q90</span>
}
}
</div>
</div>
</header>
<div #predictionChart class="prediction-chart" role="img" [attr.aria-label]="'DSE actual and ' + report.model.name + ' forecast chart'"></div>
<div class="chart-context-note">
<p>
@if (chartMode() === 'future') {
Uses the latest real prices. The separate historical backtest is excluded from this scale.
} @else {
The model uses the first {{ report.data.context_points | number }} bars as context, then predicts all
{{ report.data.holdout_points | number }} hidden bars. Compare predicted and actual closing prices directly.
}
</p>
<button
type="button"
class="interval-toggle"
[class.active]="intervalVisible()"
[attr.aria-pressed]="intervalVisible()"
(click)="toggleInterval()"
>{{ intervalVisible() ? 'Hide' : 'Show' }} 80% uncertainty</button>
</div>
<div
#predictionChart
id="prediction-chart"
class="prediction-chart"
role="img"
[attr.aria-label]="chartMode() === 'future'
? 'Recent DSE actual prices and ' + report.model.name + ' future forecast chart'
: 'DSE hidden actual prices and ' + report.model.name + ' historical backtest chart'"
></div>
<footer>
<span>Context</span>
<strong>{{ report.data.first_timestamp | date: 'mediumDate' }} → {{ report.history[report.data.context_points - 1].time | date: 'mediumDate' }}</strong>
<span>Hidden test</span>
<strong>{{ report.backtest[0].time | date: 'mediumDate' }} → {{ report.data.last_timestamp | date: 'mediumDate' }}</strong>
<span>Next forecast</span>
<strong>{{ report.future.length }} bars</strong>
@if (chartMode() === 'future') {
<span>Recent actual</span>
<strong>{{ futureHistoryStart(report) | date: 'mediumDate' }} → {{ report.data.last_timestamp | date: 'mediumDate' }}</strong>
<span>Forecast window</span>
<strong>{{ report.future[0].time | date: 'mediumDate' }} → {{ report.future[report.future.length - 1].time | date: 'mediumDate' }}</strong>
<span>Horizon</span>
<strong>{{ report.future.length }} bars</strong>
} @else {
<span>Model context</span>
<strong>{{ report.data.first_timestamp | date: 'mediumDate' }} → {{ report.history[report.data.context_points - 1].time | date: 'mediumDate' }}</strong>
<span>Line comparison</span>
<strong>{{ report.backtest[0].time | date: 'mediumDate' }} → {{ report.data.last_timestamp | date: 'mediumDate' }}</strong>
<span>Scored bars</span>
<strong>{{ report.backtest.length }}</strong>
}
</footer>
</section>
......
......@@ -179,8 +179,73 @@
.legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
.legend i { width: 1.25rem; height: 0.16rem; border-radius: 1rem; background: #34d399; }
.legend .backtest { background: var(--amber); }
.legend .actual-line { background: #34d399; }
.legend .future { background: var(--violet); }
.legend .live { background: var(--cyan); }
.legend .interval {
height: 0;
border-top: 2px dashed rgb(245 158 11 / 65%);
border-radius: 0;
background: transparent;
}
.legend .future-interval { border-color: rgb(167 139 250 / 70%); }
.chart-controls { display: grid; justify-items: end; gap: 0.7rem; }
.chart-mode-switch {
display: inline-grid;
grid-template-columns: repeat(2, 1fr);
padding: 0.2rem;
border: 1px solid #334155;
border-radius: 0.7rem;
background: #0b1220;
}
.chart-mode-switch button,
.interval-toggle {
border: 0;
color: var(--muted);
background: transparent;
font: inherit;
font-size: 0.76rem;
font-weight: 700;
cursor: pointer;
}
.chart-mode-switch button {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
}
.chart-mode-switch button.active {
color: #f8fafc;
background: #253146;
box-shadow: 0 0 0 1px rgb(148 163 184 / 12%);
}
.chart-mode-switch button:focus-visible,
.interval-toggle:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
.chart-context-note {
display: flex;
min-height: 3rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 1.25rem;
border-bottom: 1px solid var(--line);
color: var(--muted);
background: #0d1420;
font-size: 0.76rem;
}
.chart-context-note p { margin: 0; line-height: 1.5; }
.interval-toggle {
flex: 0 0 auto;
padding: 0.45rem 0.7rem;
border: 1px solid #475569;
border-radius: 0.55rem;
}
.interval-toggle.active {
border-color: #7c3aed;
color: #ddd6fe;
background: rgb(124 58 237 / 14%);
}
.prediction-chart { width: 100%; min-height: 520px; }
.chart-card > footer {
display: grid;
......@@ -239,7 +304,9 @@ td { color: #dbe5f3; font-size: 0.82rem; }
.accuracy-card { flex-basis: auto; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-card > header, .table-card > header, .prediction-footer { align-items: flex-start; flex-direction: column; }
.chart-controls { justify-items: start; }
.legend { justify-content: flex-start; }
.chart-context-note { align-items: flex-start; flex-direction: column; }
.prediction-chart { min-height: 420px; }
.live-comparison { grid-template-columns: 1fr; }
.table-card > header > span { text-align: left; }
......
......@@ -32,6 +32,10 @@ import {
} from './timesfm-prediction.model';
import { TimesFmPredictionService } from './timesfm-prediction.service';
type PredictionChartMode = 'future' | 'backtest';
const FUTURE_HISTORY_BARS = 30;
@Component({
selector: 'app-timesfm-prediction',
standalone: true,
......@@ -51,13 +55,15 @@ export class TimesFmPredictionComponent
);
readonly livePrice = signal<number | null>(null);
readonly liveMatches = signal<LiveForecastMatch[]>([]);
readonly chartMode = signal<PredictionChartMode>('future');
readonly showFutureInterval = signal(false);
readonly showBacktestInterval = signal(false);
readonly latestLiveMatch = computed(() => this.liveMatches().at(-1) ?? null);
readonly recentBacktest = computed(() => this.result()?.backtest.slice(-12) ?? []);
readonly subscriptions: Subscription[] = [];
private chart: IChartApi | null = null;
private liveSeries: ISeriesApi<'Line'> | null = null;
private resizeObserver: ResizeObserver | null = null;
private stompClient: Client | null = null;
private chartReady = false;
......@@ -107,7 +113,6 @@ export class TimesFmPredictionComponent
ngOnDestroy(): void {
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
void this.stompClient?.deactivate();
this.resizeObserver?.disconnect();
this.chart?.remove();
}
......@@ -133,13 +138,38 @@ export class TimesFmPredictionComponent
return point.time;
}
selectChartMode(mode: PredictionChartMode): void {
if (this.chartMode() === mode) return;
this.chartMode.set(mode);
this.renderChart();
}
toggleInterval(): void {
if (this.chartMode() === 'future') {
this.showFutureInterval.update((visible) => !visible);
} else {
this.showBacktestInterval.update((visible) => !visible);
}
this.renderChart();
}
intervalVisible(): boolean {
return this.chartMode() === 'future'
? this.showFutureInterval()
: this.showBacktestInterval();
}
futureHistoryStart(result: TimesFmPredictionResult): string {
return result.history.at(-FUTURE_HISTORY_BARS)?.time ?? result.data.first_timestamp;
}
private renderChart(): void {
const result = this.result();
const container = this.chartContainer?.nativeElement;
if (!result || !container || !this.chartReady) return;
this.resizeObserver?.disconnect();
this.chart?.remove();
this.liveSeries = null;
this.chart = createChart(container, {
autoSize: true,
height: 520,
......@@ -164,15 +194,24 @@ export class TimesFmPredictionComponent
},
});
const candles = this.chart.addCandlestickSeries({
if (this.chartMode() === 'future') {
this.renderFutureSeries(result);
} else {
this.renderBacktestSeries(result);
}
this.chart.timeScale().fitContent();
}
private renderFutureSeries(result: TimesFmPredictionResult): void {
const candles = this.chart?.addCandlestickSeries({
upColor: '#34d399',
downColor: '#f87171',
borderVisible: false,
wickUpColor: '#34d399',
wickDownColor: '#f87171',
});
candles.setData(
result.history.map((point) => ({
candles?.setData(
result.history.slice(-FUTURE_HISTORY_BARS).map((point) => ({
time: this.chartTime(point.time),
open: point.open,
high: point.high,
......@@ -181,49 +220,7 @@ export class TimesFmPredictionComponent
})),
);
const predicted = this.chart.addLineSeries({
color: '#f59e0b',
lineWidth: 2,
title: 'Held-out prediction',
priceLineVisible: false,
});
predicted.setData(
result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.predicted,
})),
);
const lower = this.chart.addLineSeries({
color: 'rgba(245, 158, 11, 0.48)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
title: 'Q10',
priceLineVisible: false,
lastValueVisible: false,
});
const upper = this.chart.addLineSeries({
color: 'rgba(245, 158, 11, 0.48)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
title: 'Q90',
priceLineVisible: false,
lastValueVisible: false,
});
lower.setData(
result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.q10,
})),
);
upper.setData(
result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.q90,
})),
);
const future = this.chart.addLineSeries({
const future = this.chart?.addLineSeries({
color: '#a78bfa',
lineWidth: 3,
lineStyle: LineStyle.Dashed,
......@@ -231,7 +228,7 @@ export class TimesFmPredictionComponent
priceLineVisible: false,
});
const futureStart = result.history.at(-1);
future.setData([
future?.setData([
...(futureStart
? [
{
......@@ -246,16 +243,91 @@ export class TimesFmPredictionComponent
})),
]);
this.liveSeries = this.chart.addLineSeries({
color: '#22d3ee',
lineWidth: 2,
title: 'Live actual',
priceLineVisible: true,
lastValueVisible: true,
if (this.showFutureInterval()) {
this.addIntervalSeries(result.future, '#a78bfa');
}
this.liveSeries =
this.chart?.addLineSeries({
color: '#22d3ee',
lineWidth: 2,
title: 'Live actual',
priceLineVisible: true,
lastValueVisible: true,
}) ?? null;
const latestMatch = this.latestLiveMatch();
if (latestMatch) {
this.liveSeries?.update({
time: this.chartTime(latestMatch.targetTime),
value: latestMatch.actual,
});
}
}
private renderBacktestSeries(result: TimesFmPredictionResult): void {
const contextEnd = result.history[result.data.context_points - 1];
const anchor = contextEnd
? [{ time: this.chartTime(contextEnd.time), value: contextEnd.close }]
: [];
const actual = this.chart?.addLineSeries({
color: '#34d399',
lineWidth: 3,
title: 'Actual close',
priceLineVisible: false,
});
actual?.setData([
...anchor,
...result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.actual,
})),
]);
const predicted = this.chart?.addLineSeries({
color: '#f59e0b',
lineWidth: 3,
lineStyle: LineStyle.Dashed,
title: 'Backtest prediction',
priceLineVisible: false,
});
this.chart.timeScale().fitContent();
this.resizeObserver = new ResizeObserver(() => this.chart?.timeScale().fitContent());
this.resizeObserver.observe(container);
predicted?.setData([
...anchor,
...result.backtest.map((point) => ({
time: this.chartTime(point.time),
value: point.predicted,
})),
]);
if (this.showBacktestInterval()) {
this.addIntervalSeries(result.backtest, '#f59e0b');
}
}
private addIntervalSeries(
points: Array<{ time: string; q10: number; q90: number }>,
color: string,
): void {
const intervalOptions = {
color,
lineWidth: 1 as const,
lineStyle: LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
};
const lower = this.chart?.addLineSeries({ ...intervalOptions, title: 'Q10' });
const upper = this.chart?.addLineSeries({ ...intervalOptions, title: 'Q90' });
lower?.setData(
points.map((point) => ({
time: this.chartTime(point.time),
value: point.q10,
})),
);
upper?.setData(
points.map((point) => ({
time: this.chartTime(point.time),
value: point.q90,
})),
);
}
private connectLiveFeed(result: TimesFmPredictionResult): void {
......
{
"surface": "report",
"manifest": {
"version": 1,
"surface": "report",
"title": "Why GP's TimesFM backtest went flat",
"description": "Technical diagnosis of the nearly flat GP daily forecast, its accuracy metrics, GPU status, and model-improvement options.",
"generatedAt": "2026-08-12T06:30:00Z",
"cards": [
{
"id": "headline_accuracy",
"description": "The displayed score is 100 minus sMAPE; it is not the probability that the forecast direction or price is correct.",
"dataset": "run_summary",
"sourceId": "run_summary_sql",
"metrics": [
{ "label": "Displayed accuracy", "field": "accuracy_score_pct", "format": "number", "unit": "%" },
{ "label": "sMAPE", "field": "smape_pct", "format": "number", "unit": "%" }
]
},
{
"id": "baseline_skill",
"description": "Negative skill means TimesFM's MAE is worse than repeating the last observed close for every holdout bar.",
"dataset": "run_summary",
"sourceId": "run_summary_sql",
"metrics": [
{ "label": "Skill vs last price", "field": "skill_vs_naive_pct", "format": "number", "unit": "%", "signed": true },
{ "label": "TimesFM MAE", "field": "timesfm_mae_bdt", "format": "number", "unit": "BDT" },
{ "label": "Baseline MAE", "field": "naive_mae_bdt", "format": "number", "unit": "BDT" }
]
},
{
"id": "direction_accuracy",
"description": "Share of holdout bars for which the predicted up, down, or unchanged sign matched the actual sign.",
"dataset": "run_summary",
"sourceId": "run_summary_sql",
"metrics": [
{ "label": "Direction matched", "field": "direction_accuracy_pct", "format": "number", "unit": "%" }
]
},
{
"id": "interval_coverage",
"description": "Only a small fraction of actual closes fell inside the model's nominal Q10-Q90 interval.",
"dataset": "run_summary",
"sourceId": "run_summary_sql",
"metrics": [
{ "label": "80% band coverage", "field": "coverage_80_pct", "format": "number", "unit": "%" },
{ "label": "Nominal target", "field": "coverage_nominal_pct", "format": "number", "unit": "%" }
]
},
{
"id": "variance_ratio",
"description": "The forecast variance is almost zero relative to the actual holdout variance, which is why the orange path looks flat.",
"dataset": "run_summary",
"sourceId": "run_summary_sql",
"metrics": [
{ "label": "Predicted / actual variance", "field": "variance_ratio", "format": "percent" },
{ "label": "Predicted standard deviation", "field": "predicted_std_bdt", "format": "number", "unit": "BDT" },
{ "label": "Actual standard deviation", "field": "actual_std_bdt", "format": "number", "unit": "BDT" }
]
},
{
"id": "r_squared",
"description": "A negative value means the forecast explains less holdout variation than predicting the holdout mean in the R-squared calculation.",
"dataset": "run_summary",
"sourceId": "run_summary_sql",
"metrics": [
{ "label": "Holdout R-squared", "field": "r_squared", "format": "number", "signed": true }
]
}
],
"charts": [
{
"id": "horizon_mae_chart",
"title": "MAE by evaluation horizon",
"subtitle": "Prefixes of one open-loop holdout forecast; BDT per bar, lower is better.",
"intent": "trend",
"question": "How does TimesFM MAE compare with the constant last-price baseline as the evaluated horizon grows?",
"rationale": "Ten ordered horizon prefixes reveal whether relative forecast error improves or deteriorates without treating the prefixes as independent backtest folds.",
"comparisonContext": {
"baseline": "Constant last-context close of BDT 286.60",
"grain": "Prefix horizon of one 598-bar forecast",
"unit": "BDT MAE per bar"
},
"type": "line",
"dataset": "horizon_mae",
"sourceId": "horizon_sql",
"encodings": {
"x": { "field": "horizon_bars", "type": "quantitative", "label": "Evaluated holdout bars" },
"y": { "field": "mae_bdt", "type": "quantitative", "label": "MAE", "unit": "BDT" },
"color": { "field": "series", "type": "nominal", "label": "Forecast" },
"lineStyle": { "field": "line_style", "type": "nominal", "label": "Line style" },
"tooltip": [
{ "field": "skill_vs_naive_pct", "type": "quantitative", "label": "Skill vs baseline", "format": "number", "unit": "%" }
]
},
"xAxisTitle": "Evaluated holdout bars",
"yAxisTitle": "MAE (BDT)",
"valueFormat": "number",
"unit": "BDT",
"layout": "full",
"labels": { "values": "endpoints" },
"legend": { "position": "bottom", "sort": "spec" },
"palette": { "kind": "semantic", "name": "forecast-vs-baseline" },
"settings": { "showPoints": "always" },
"surface": { "viewMode": "visualization", "interactiveLegend": true }
}
],
"tables": [
{
"id": "metric_audit_table",
"title": "Full-holdout metric audit",
"subtitle": "GP daily close, January 23, 2024 through August 11, 2026; 598 scored bars.",
"dataset": "metric_audit",
"sourceId": "metric_audit_sql",
"defaultSort": { "field": "rank", "direction": "asc" },
"density": "spacious",
"layout": "full",
"columns": [
{ "field": "rank", "label": "#", "format": "number" },
{ "field": "metric", "label": "Metric", "type": "text" },
{ "field": "observed", "label": "Observed", "type": "text" },
{ "field": "benchmark", "label": "Benchmark or meaning", "type": "text" },
{ "field": "diagnosis", "label": "Diagnosis", "type": "text" }
]
}
],
"sources": [
{
"id": "run_record",
"label": "Saved GP prediction run gp-1d-20260812T053147Z-f9ed7068",
"path": "prediction-runs/gp-1d-20260812T053147Z-f9ed7068.json"
},
{
"id": "run_summary_sql",
"label": "GP run summary calculation",
"path": "diagnostics/gp_run_summary.sql"
},
{
"id": "horizon_sql",
"label": "GP horizon sensitivity calculation",
"path": "diagnostics/gp_horizon_mae.sql"
},
{
"id": "metric_audit_sql",
"label": "GP metric audit rows",
"path": "diagnostics/gp_metric_audit.sql"
},
{
"id": "implementation",
"label": "Local TimesFM forecasting implementation",
"path": "dohasecuritiesstockai/timesfm_forecasting/pipeline.py"
}
],
"blocks": [
{
"id": "report_title",
"type": "markdown",
"body": "# Why GP's TimesFM backtest went flat"
},
{
"id": "technical_summary",
"type": "markdown",
"sourceId": "run_record",
"body": "## Technical summary\n\n**The flat orange line is genuine model output, and this GP configuration should not be treated as a useful trading forecast.** Across 598 hidden daily closes, TimesFM's MAE was BDT 32.27 versus BDT 31.81 for simply repeating the last known close, so skill was **-1.46%**. Direction matched only **46.82%** of bars, R-squared was **-0.028**, and the nominal 80% interval covered only **4.52%** of actual closes.\n\n**The GPU did not cause the poor shape.** This saved run used the CPU because the Tesla P100 was incompatible with the installed PyTorch kernels. GPU execution would primarily change speed; it would not add information, retrain the weights, or turn this long-horizon path into a more accurate one."
},
{
"id": "headline_metrics",
"type": "metric-strip",
"cardIds": [
"headline_accuracy",
"baseline_skill",
"direction_accuracy",
"interval_coverage",
"variance_ratio",
"r_squared"
]
},
{
"id": "flatness_finding",
"type": "markdown",
"sourceId": "run_record",
"body": "## The forecast collapsed toward a level instead of tracking price movement\n\nThe hidden actual close ranged from **BDT 222.20 to BDT 379.20**, a BDT 157.00 span. The prediction stayed between **BDT 286.56 and BDT 293.51**, only a BDT 6.96 span. Its variance was **0.095% of actual variance**, and predicted-versus-actual correlation was **-0.30**. Zooming in reveals small model movements; zooming out correctly shows that those movements are tiny relative to the stock's realized range."
},
{
"id": "horizon_explanation",
"type": "markdown",
"sourceId": "run_record",
"body": "## Longer evaluation does not reveal durable skill\n\nThe comparison below scores prefixes of the same one-origin forecast. TimesFM stays extremely close to the last-price baseline and finishes worse at 598 bars. This is a sensitivity check, not independent rolling validation, but it confirms that the large displayed accuracy score is not evidence of baseline-beating performance."
},
{
"id": "horizon_chart_block",
"type": "chart",
"chartId": "horizon_mae_chart",
"layout": "full"
},
{
"id": "metric_definition_finding",
"type": "markdown",
"sourceId": "run_record",
"body": "## The 88.63% headline is a percentage-error transform, not forecast reliability\n\nThe application defines `accuracy` as **100 minus sMAPE**. Prices around BDT 250-300 can therefore produce a visually high score even when the model misses path direction and volatility. For model selection, use baseline skill, MAE or MASE, directional performance, and interval calibration together; do not promote this score as a probability of correctness."
},
{
"id": "metric_table_block",
"type": "table",
"tableId": "metric_audit_table",
"layout": "full"
},
{
"id": "scope_and_design",
"type": "markdown",
"body": "## The test design is much harsher than the command suggests\n\nThe command loaded **1,195 daily bars** and the default 50/50 split gave the model 597 context bars, then asked it for all **598 hidden bars in one open-loop path** from January 23, 2024 to August 11, 2026. The `--future-steps 300` option controls the separate forward forecast; it does **not** limit the backtest to 300 bars. The implementation calls the backtest with the entire holdout length and never feeds hidden actuals back into later predictions.\n\nThat design is leakage-safe, but it is not representative of a strategy that refreshes a 5-, 12-, 20-, or 30-day forecast whenever a new close arrives. A very long raw-price forecast tends to accumulate error and regress toward a central level, making an almost-flat path unsurprising."
},
{
"id": "model_and_gpu",
"type": "markdown",
"body": "## CPU explains runtime, not accuracy\n\nThe saved run used `google/timesfm-2.0-500m-pytorch` on CPU with normalization enabled and the median quantile as the point forecast. The Tesla P100 has CUDA capability `sm_60`, while the installed PyTorch build contains kernels starting at `sm_70`, so the application correctly fell back to CPU. A compatible GPU would make inference faster and is useful for training, but it would not fix this forecast's information, target, horizon, or evaluation design.\n\nThe project also uses archived TimesFM 2.0. Google's current [TimesFM repository](https://github.com/google-research/timesfm) documents TimesFM 2.5, including longer context, improved quantile support, exogenous-regressor support, and a LoRA fine-tuning path. Upgrade and benchmark it; do not assume the newer checkpoint automatically fixes GP. Google's [TimesFM model overview](https://research.google/blog/a-decoder-only-foundation-model-for-time-series-forecasting/) also explains that long forecasts are generated in output patches, so later predictions depend on earlier generated values and error can accumulate."
},
{
"id": "limitations",
"type": "markdown",
"body": "## What this diagnosis establishes—and what it does not\n\nThis result establishes that **this saved GP run and evaluation configuration failed useful baseline and calibration checks**. It does not establish that TimesFM is universally poor, nor that another horizon, target, checkpoint, or rolling test will fail. The evidence is one stock and one forecast origin; the current target is the raw close; no sector, index, volume, corporate-action, or macro regressors were supplied; and no transaction-cost trading strategy was evaluated."
},
{
"id": "recommended_actions",
"type": "markdown",
"body": "## Fix evaluation before fine-tuning\n\n1. **Add rolling-origin backtesting.** At each historical cutoff, forecast the horizon actually used in decisions—start with 5, 12, 20, and 30 trading days—then advance the cutoff and aggregate results. Keep a final untouched period. A new `--backtest-horizon` option would make this explicit; `--future-steps` currently does not.\n2. **Benchmark simple forecasts.** Compare TimesFM with last price, drift, and a suitable statistical baseline. Report MAE or MASE, skill versus baseline, direction, and Q10-Q90 coverage by horizon and market regime. Demote `100 - sMAPE` from the main accuracy label.\n3. **Test a stationary target.** Compare raw close with log returns or price deltas, reconstruct prices for scoring, and preserve chronological splits. Long raw price levels are non-stationary and encourage level forecasts.\n4. **Benchmark TimesFM 2.5 zero-shot before training.** It is the maintained line and supports longer context and exogenous regressors. Require repeated out-of-sample improvement rather than relying on one graph.\n5. **Use covariates without leakage.** Candidate inputs include DSEX or DS30, sector movement, volume and turnover, calendar features, corporate-action flags, rates, and FX. Any future covariate must be known in advance or separately forecast as a scenario.\n6. **Fine-tune only with pooled data.** One GP history of 1,195 observations is too small for a convincing 500M-model adaptation. Use sliding windows across many DSE equities and regimes, match training horizons to production, and use LoRA or QLoRA with strict ticker/time holdouts. Google's official [fine-tuning guide](https://github.com/google-research/timesfm/blob/master/timesfm-forecasting/examples/finetuning/README.md) documents this path for TimesFM 2.5."
},
{
"id": "promotion_gate",
"type": "markdown",
"body": "## Promote a model only after it beats the baseline repeatedly\n\nA replacement should show positive out-of-sample skill across multiple rolling folds and several DSE symbols, direction performance that is stable rather than a one-run fluctuation, and uncertainty coverage reasonably close to its nominal level. If the intended use is trading, the last gate is a transaction-cost- and slippage-aware strategy test. Better-looking forecast lines are not enough."
},
{
"id": "further_questions",
"type": "markdown",
"body": "## Further questions\n\n- What holding period will the forecast support: 1 day, 5 days, 12 days, or a longer allocation horizon?\n- Are historical prices adjusted for dividends, splits, rights issues, and other corporate actions?\n- Which DSE symbols and market regimes should form the pooled training and final evaluation universe?\n- Which exogenous variables are available at prediction time without future leakage?"
}
]
},
"snapshot": {
"version": 1,
"generatedAt": "2026-08-12T06:30:00Z",
"status": "ready",
"datasets": {
"run_summary": [
{
"accuracy_score_pct": 88.6306,
"smape_pct": 11.3694,
"skill_vs_naive_pct": -1.4551,
"timesfm_mae_bdt": 32.273934,
"naive_mae_bdt": 31.811037,
"direction_accuracy_pct": 46.8227,
"coverage_80_pct": 4.5151,
"coverage_nominal_pct": 80.0,
"variance_ratio": 0.0009519820964956403,
"predicted_std_bdt": 1.1219044008063683,
"actual_std_bdt": 36.36147241581861,
"r_squared": -0.02803
}
],
"horizon_mae": [
{ "horizon_bars": 30, "series": "TimesFM", "mae_bdt": 5.24998, "line_style": "solid", "skill_vs_naive_pct": -4.0286 },
{ "horizon_bars": 30, "series": "Last-price baseline", "mae_bdt": 5.04667, "line_style": "dashed", "skill_vs_naive_pct": -4.0286 },
{ "horizon_bars": 60, "series": "TimesFM", "mae_bdt": 26.4866, "line_style": "solid", "skill_vs_naive_pct": -1.1906 },
{ "horizon_bars": 60, "series": "Last-price baseline", "mae_bdt": 26.175, "line_style": "dashed", "skill_vs_naive_pct": -1.1906 },
{ "horizon_bars": 96, "series": "TimesFM", "mae_bdt": 38.0453, "line_style": "solid", "skill_vs_naive_pct": -1.0918 },
{ "horizon_bars": 96, "series": "Last-price baseline", "mae_bdt": 37.6344, "line_style": "dashed", "skill_vs_naive_pct": -1.0918 },
{ "horizon_bars": 128, "series": "TimesFM", "mae_bdt": 39.5413, "line_style": "solid", "skill_vs_naive_pct": -1.0399 },
{ "horizon_bars": 128, "series": "Last-price baseline", "mae_bdt": 39.1344, "line_style": "dashed", "skill_vs_naive_pct": -1.0399 },
{ "horizon_bars": 192, "series": "TimesFM", "mae_bdt": 41.3634, "line_style": "solid", "skill_vs_naive_pct": -0.0739 },
{ "horizon_bars": 192, "series": "Last-price baseline", "mae_bdt": 41.3328, "line_style": "dashed", "skill_vs_naive_pct": -0.0739 },
{ "horizon_bars": 256, "series": "TimesFM", "mae_bdt": 41.3333, "line_style": "solid", "skill_vs_naive_pct": 0.4739 },
{ "horizon_bars": 256, "series": "Last-price baseline", "mae_bdt": 41.5301, "line_style": "dashed", "skill_vs_naive_pct": 0.4739 },
{ "horizon_bars": 300, "series": "TimesFM", "mae_bdt": 39.9547, "line_style": "solid", "skill_vs_naive_pct": 0.7968 },
{ "horizon_bars": 300, "series": "Last-price baseline", "mae_bdt": 40.2757, "line_style": "dashed", "skill_vs_naive_pct": 0.7968 },
{ "horizon_bars": 384, "series": "TimesFM", "mae_bdt": 34.3531, "line_style": "solid", "skill_vs_naive_pct": 1.2341 },
{ "horizon_bars": 384, "series": "Last-price baseline", "mae_bdt": 34.7823, "line_style": "dashed", "skill_vs_naive_pct": 1.2341 },
{ "horizon_bars": 512, "series": "TimesFM", "mae_bdt": 30.8351, "line_style": "solid", "skill_vs_naive_pct": 0.1903 },
{ "horizon_bars": 512, "series": "Last-price baseline", "mae_bdt": 30.8939, "line_style": "dashed", "skill_vs_naive_pct": 0.1903 },
{ "horizon_bars": 598, "series": "TimesFM", "mae_bdt": 32.273934, "line_style": "solid", "skill_vs_naive_pct": -1.4551 },
{ "horizon_bars": 598, "series": "Last-price baseline", "mae_bdt": 31.811037, "line_style": "dashed", "skill_vs_naive_pct": -1.4551 }
],
"metric_audit": [
{ "rank": 1, "metric": "Displayed accuracy", "observed": "88.63%", "benchmark": "Defined as 100 - sMAPE", "diagnosis": "Not a probability of a correct price or direction" },
{ "rank": 2, "metric": "Mean absolute error", "observed": "BDT 32.27", "benchmark": "Last-price MAE: BDT 31.81", "diagnosis": "Worse than the naive forecast" },
{ "rank": 3, "metric": "Skill vs last price", "observed": "-1.46%", "benchmark": "Positive is better", "diagnosis": "No baseline-beating skill" },
{ "rank": 4, "metric": "Directional accuracy", "observed": "46.82%", "benchmark": "50% is a rough binary reference", "diagnosis": "No useful directional evidence in this run" },
{ "rank": 5, "metric": "R-squared", "observed": "-0.028", "benchmark": "Zero is the holdout-mean reference", "diagnosis": "Explains no useful holdout variation" },
{ "rank": 6, "metric": "Q10-Q90 coverage", "observed": "4.52%", "benchmark": "Nominal: 80%", "diagnosis": "Severely under-dispersed uncertainty" },
{ "rank": 7, "metric": "Price range", "observed": "Predicted BDT 6.96", "benchmark": "Actual BDT 157.00", "diagnosis": "Forecast path is genuinely flat at the full scale" }
]
}
},
"sources": [
{
"id": "run_record",
"label": "Saved GP prediction run gp-1d-20260812T053147Z-f9ed7068",
"path": "prediction-runs/gp-1d-20260812T053147Z-f9ed7068.json"
},
{
"id": "run_summary_sql",
"query": {
"engine": "duckdb",
"id": "gp-run-summary-20260812",
"language": "sql",
"description": "Loads the saved run's headline metrics and calculates actual-versus-predicted dispersion from its backtest rows.",
"executed_at": "2026-08-12T06:15:00Z",
"sql": "WITH run AS (SELECT * FROM read_json_auto('prediction-runs/gp-1d-20260812T053147Z-f9ed7068.json')), dispersion AS (SELECT var_samp(b.predicted::DOUBLE) / var_samp(b.actual::DOUBLE) AS variance_ratio, stddev_samp(b.predicted::DOUBLE) AS predicted_std_bdt, stddev_samp(b.actual::DOUBLE) AS actual_std_bdt FROM run, UNNEST(backtest) AS t(b)) SELECT r.metrics.accuracy_score AS accuracy_score_pct, r.metrics.smape_percent AS smape_pct, r.metrics.skill_vs_naive_percent AS skill_vs_naive_pct, r.metrics.mae AS timesfm_mae_bdt, r.metrics.naive_mae AS naive_mae_bdt, r.metrics.directional_accuracy_percent AS direction_accuracy_pct, r.metrics.interval_80_coverage_percent AS coverage_80_pct, 80.0 AS coverage_nominal_pct, d.variance_ratio, d.predicted_std_bdt, d.actual_std_bdt, r.metrics.r_squared FROM run r CROSS JOIN dispersion d"
}
},
{
"id": "horizon_sql",
"query": {
"engine": "duckdb",
"id": "gp-horizon-mae-20260812",
"language": "sql",
"description": "Calculates TimesFM and constant-last-price MAE over selected prefixes of the same ordered holdout forecast.",
"executed_at": "2026-08-12T06:15:00Z",
"sql": "WITH run AS (SELECT * FROM read_json_auto('prediction-runs/gp-1d-20260812T053147Z-f9ed7068.json')), ordered AS (SELECT row_number() OVER (ORDER BY b.time) AS horizon_bars, b.actual::DOUBLE AS actual, b.predicted::DOUBLE AS predicted FROM run, UNNEST(backtest) AS t(b)), prefixes AS (SELECT horizon_bars, avg(abs(predicted - actual)) OVER (ORDER BY horizon_bars ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS timesfm_mae_bdt, avg(abs(286.6 - actual)) OVER (ORDER BY horizon_bars ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS last_price_mae_bdt FROM ordered), selected AS (SELECT *, (1 - timesfm_mae_bdt / NULLIF(last_price_mae_bdt, 0)) * 100 AS skill_vs_naive_pct FROM prefixes WHERE horizon_bars IN (30,60,96,128,192,256,300,384,512,598)) SELECT horizon_bars, 'TimesFM' AS series, timesfm_mae_bdt AS mae_bdt, 'solid' AS line_style, skill_vs_naive_pct FROM selected UNION ALL SELECT horizon_bars, 'Last-price baseline' AS series, last_price_mae_bdt AS mae_bdt, 'dashed' AS line_style, skill_vs_naive_pct FROM selected ORDER BY horizon_bars, series DESC"
}
},
{
"id": "metric_audit_sql",
"query": {
"engine": "duckdb",
"id": "gp-metric-audit-20260812",
"language": "sql",
"description": "Shapes the reviewed full-holdout metrics and benchmark interpretations into an audit table.",
"executed_at": "2026-08-12T06:15:00Z",
"sql": "SELECT * FROM (VALUES (1, 'Displayed accuracy', '88.63%', 'Defined as 100 - sMAPE', 'Not a probability of a correct price or direction'), (2, 'Mean absolute error', 'BDT 32.27', 'Last-price MAE: BDT 31.81', 'Worse than the naive forecast'), (3, 'Skill vs last price', '-1.46%', 'Positive is better', 'No baseline-beating skill'), (4, 'Directional accuracy', '46.82%', '50% is a rough binary reference', 'No useful directional evidence in this run'), (5, 'R-squared', '-0.028', 'Zero is the holdout-mean reference', 'Explains no useful holdout variation'), (6, 'Q10-Q90 coverage', '4.52%', 'Nominal: 80%', 'Severely under-dispersed uncertainty'), (7, 'Price range', 'Predicted BDT 6.96', 'Actual BDT 157.00', 'Forecast path is genuinely flat at the full scale')) AS t(rank, metric, observed, benchmark, diagnosis) ORDER BY rank"
}
},
{
"id": "implementation",
"query": {
"engine": "source-code",
"language": "python",
"description": "Local forecasting pipeline, TimesFM backend, and metric definitions inspected for split, horizon, device, normalization, and scoring behavior."
}
}
]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -8,6 +8,7 @@ import io
import math
import os
import sys
import warnings
from pathlib import Path
from types import ModuleType
from typing import Any
......@@ -82,6 +83,44 @@ def _load_archived_timesfm() -> ModuleType:
return module
def _cuda_device_is_supported(torch: ModuleType, device_index: int = 0) -> bool:
"""Return whether this PyTorch build contains code usable by the CUDA device."""
if not torch.cuda.is_available():
return False
compiled_arches = torch.cuda.get_arch_list()
if not compiled_arches:
# ROCm builds and some custom CUDA builds do not expose an architecture
# list. In that case availability is the best signal PyTorch provides.
return True
device_major, device_minor = torch.cuda.get_device_capability(device_index)
device_arch = device_major * 10 + device_minor
sm_arches: list[int] = []
ptx_arches: list[int] = []
for arch in compiled_arches:
kind, separator, version = arch.partition("_")
if not separator:
continue
numeric_version = "".join(character for character in version if character.isdigit())
if not numeric_version:
continue
if kind == "sm":
sm_arches.append(int(numeric_version))
elif kind == "compute":
ptx_arches.append(int(numeric_version))
# CUDA cubins are compatible with later minor revisions in the same major
# architecture. PTX can be JIT-compiled for newer CUDA architectures.
if any(
compiled // 10 == device_arch // 10 and compiled <= device_arch
for compiled in sm_arches
):
return True
return any(compiled <= device_arch for compiled in ptx_arches)
class TimesFMBackend:
"""One TimesFM 2.0 500M model instance configured for one prediction run."""
......@@ -111,7 +150,24 @@ class TimesFMBackend:
) from exc
timesfm = _load_archived_timesfm()
use_cuda = torch.cuda.is_available()
gpu_name = None
with warnings.catch_warnings():
# PyTorch emits several long compatibility warnings while querying
# an unsupported device. Replace them with one actionable warning.
warnings.simplefilter("ignore", UserWarning)
cuda_available = torch.cuda.is_available()
if cuda_available:
gpu_name = torch.cuda.get_device_name(0)
use_cuda = _cuda_device_is_supported(torch)
capability = torch.cuda.get_device_capability(0) if cuda_available else None
if cuda_available and not use_cuda:
device_arch = f"sm_{capability[0]}{capability[1]}" if capability else "unknown"
warnings.warn(
f"{gpu_name or 'CUDA device'} ({device_arch}) is not supported by this "
"PyTorch build; TimesFM is using the CPU instead.",
RuntimeWarning,
stacklevel=2,
)
torch.set_float32_matmul_precision("high")
desired_context = max(INPUT_PATCH, min(context_points, MODEL_CONTEXT_LIMIT))
......@@ -138,7 +194,7 @@ class TimesFMBackend:
)
if use_cuda:
self.device = "cuda:0"
self.gpu_name = torch.cuda.get_device_name(0)
self.gpu_name = gpu_name
else:
self.device = "cpu"
self.gpu_name = None
......
......@@ -6,6 +6,7 @@ import numpy as np
import pytest
from dohasecuritiesstockai.timesfm_forecasting.cli import _lookback_start
from dohasecuritiesstockai.timesfm_forecasting.forecaster import _cuda_device_is_supported
from dohasecuritiesstockai.timesfm_forecasting.market_data import (
MarketCandle,
fetch_dse_candles,
......@@ -48,6 +49,27 @@ class LinearForecastBackend:
return point, quantiles, 1
class FakeCuda:
def __init__(self, *, available: bool, capability=(0, 0), arches=()):
self.available = available
self.capability = capability
self.arches = list(arches)
def is_available(self):
return self.available
def get_device_capability(self, _device_index):
return self.capability
def get_arch_list(self):
return self.arches
class FakeTorch:
def __init__(self, cuda):
self.cuda = cuda
def _linear_candles(count: int) -> list[MarketCandle]:
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
return [
......@@ -91,6 +113,36 @@ def test_lookback_is_separate_from_candle_resolution():
assert _lookback_start(datetime(2026, 8, 10).date(), "max") is None
def test_timesfm_rejects_cuda_device_older_than_compiled_pytorch_arches():
torch = FakeTorch(
FakeCuda(
available=True,
capability=(6, 0),
arches=("sm_70", "sm_75", "sm_80", "sm_86", "sm_90"),
)
)
assert not _cuda_device_is_supported(torch)
def test_timesfm_accepts_compatible_cuda_cubin_or_forward_compatible_ptx():
same_major = FakeTorch(
FakeCuda(available=True, capability=(8, 9), arches=("sm_80", "sm_86"))
)
forward_ptx = FakeTorch(
FakeCuda(available=True, capability=(9, 0), arches=("sm_86", "compute_86"))
)
assert _cuda_device_is_supported(same_major)
assert _cuda_device_is_supported(forward_ptx)
def test_timesfm_treats_cuda_as_unavailable_when_pytorch_does():
torch = FakeTorch(FakeCuda(available=False))
assert not _cuda_device_is_supported(torch)
def test_fetch_dse_candles_normalizes_envelope_and_ohlcv_arrays():
first = int(datetime(2025, 1, 1, tzinfo=timezone.utc).timestamp())
client = FakeDSEClient(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment