diff --git a/.github/workflows/tradernet-chart-causal-followups.yml b/.github/workflows/tradernet-chart-causal-followups.yml new file mode 100644 index 00000000..9fdc1ec8 --- /dev/null +++ b/.github/workflows/tradernet-chart-causal-followups.yml @@ -0,0 +1,129 @@ +name: Tradernet Chart Causal Follow-ups + +on: + workflow_dispatch: + push: + branches: + - agent/tradernet-chart-quote-audit + paths: + - .github/workflows/tradernet-chart-causal-followups.yml + - audits/tradernet/chart-quote-public.json + - scripts/tradernet_public_hloc_integrity.mjs + - scripts/tradernet_chart_route_matrix.mjs + pull_request: + branches: + - agent/tradernet-lighthouse-audit + paths: + - .github/workflows/tradernet-chart-causal-followups.yml + - audits/tradernet/chart-quote-public.json + - scripts/tradernet_public_hloc_integrity.mjs + - scripts/tradernet_chart_route_matrix.mjs + +permissions: + contents: read + +concurrency: + group: tradernet-chart-causal-followups-${{ github.ref }} + cancel-in-progress: true + +jobs: + hloc-integrity: + name: Naturally loaded HLOC integrity + runs-on: ubuntu-latest + timeout-minutes: 12 + env: + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + steps: + - uses: actions/checkout@v6 + - name: Validate bounded target + shell: bash + run: | + set -euo pipefail + jq -e ' + (.target_url == "https://tradernet.ru/charts/MICEXINDEXCF") and + (.ticker == "MICEXINDEXCF") and + (.boundaries.direct_api_testing == false) and + (.boundaries.authenticated_testing == false) and + (.boundaries.financial_operations == false) + ' audits/tradernet/chart-quote-public.json >/dev/null + - name: Install pinned browser driver + run: npm install --no-save --package-lock=false puppeteer-core@24.16.0 + - name: Locate Chrome and validate script + id: runtime + shell: bash + run: | + set -euo pipefail + node --check scripts/tradernet_public_hloc_integrity.mjs + chrome="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || command -v chromium-browser || true)" + test -n "${chrome}" + echo "chrome=${chrome}" >> "${GITHUB_OUTPUT}" + - name: Capture naturally requested candle history + shell: bash + run: | + set -euo pipefail + rm -rf reports/tradernet-hloc-integrity + node scripts/tradernet_public_hloc_integrity.mjs \ + --config audits/tradernet/chart-quote-public.json \ + --chrome "${{ steps.runtime.outputs.chrome }}" \ + --output-dir reports/tradernet-hloc-integrity + cat reports/tradernet-hloc-integrity/hloc-integrity-summary.md >> "${GITHUB_STEP_SUMMARY}" + - name: Upload HLOC evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: tradernet-hloc-integrity-${{ github.run_id }} + path: reports/tradernet-hloc-integrity/ + if-no-files-found: error + retention-days: 14 + + route-matrix: + name: User-agent and viewport route matrix + runs-on: ubuntu-latest + timeout-minutes: 12 + env: + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + steps: + - uses: actions/checkout@v6 + - name: Validate bounded matrix + shell: bash + run: | + set -euo pipefail + jq -e ' + (.target_url == "https://tradernet.ru/charts/MICEXINDEXCF") and + ((.profiles | length) == 2) and + (.boundaries.public_page_only == true) and + (.boundaries.authenticated_testing == false) and + (.boundaries.direct_api_testing == false) and + (.boundaries.load_testing == false) + ' audits/tradernet/chart-quote-public.json >/dev/null + - name: Install pinned browser driver + run: npm install --no-save --package-lock=false puppeteer-core@24.16.0 + - name: Locate Chrome and validate script + id: runtime + shell: bash + run: | + set -euo pipefail + node --check scripts/tradernet_chart_route_matrix.mjs + chrome="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || command -v chromium-browser || true)" + test -n "${chrome}" + echo "chrome=${chrome}" >> "${GITHUB_OUTPUT}" + - name: Run four-way public route matrix + shell: bash + run: | + set -euo pipefail + rm -rf reports/tradernet-chart-route-matrix + node scripts/tradernet_chart_route_matrix.mjs \ + --config audits/tradernet/chart-quote-public.json \ + --chrome "${{ steps.runtime.outputs.chrome }}" \ + --output-dir reports/tradernet-chart-route-matrix + cat reports/tradernet-chart-route-matrix/route-matrix-summary.md >> "${GITHUB_STEP_SUMMARY}" + - name: Upload route evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: tradernet-chart-route-matrix-${{ github.run_id }} + path: reports/tradernet-chart-route-matrix/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/tradernet-chart-quote-public.yml b/.github/workflows/tradernet-chart-quote-public.yml new file mode 100644 index 00000000..9b0f08d1 --- /dev/null +++ b/.github/workflows/tradernet-chart-quote-public.yml @@ -0,0 +1,106 @@ +name: Tradernet Public Chart and Quote Audit + +on: + workflow_dispatch: + push: + branches: + - agent/tradernet-chart-quote-audit + paths: + - .github/workflows/tradernet-chart-quote-public.yml + - audits/tradernet/chart-quote-public.json + - scripts/tradernet_chart_quote_observer.mjs + pull_request: + branches: + - agent/tradernet-lighthouse-audit + paths: + - .github/workflows/tradernet-chart-quote-public.yml + - audits/tradernet/chart-quote-public.json + - scripts/tradernet_chart_quote_observer.mjs + +permissions: + contents: read + +concurrency: + group: tradernet-public-chart-quote-${{ github.ref }} + cancel-in-progress: true + +jobs: + observe: + name: Passive public chart and quote observation + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + + steps: + - name: Checkout exact workflow revision + uses: actions/checkout@v6 + + - name: Validate exact safety boundary + shell: bash + run: | + set -euo pipefail + config="audits/tradernet/chart-quote-public.json" + python3 -m json.tool "${config}" >/dev/null + jq -e ' + (.target_url == "https://tradernet.ru/charts/MICEXINDEXCF") and + (.ticker == "MICEXINDEXCF") and + (.observation_ms <= 30000) and + ((.profiles | length) == 2) + ' "${config}" >/dev/null + jq -e ' + (.boundaries.public_page_only == true) and + (.boundaries.one_ticker_only == true) and + (.boundaries.one_timeframe_switch_only == true) and + (.boundaries.authenticated_testing == false) and + (.boundaries.direct_api_testing == false) and + (.boundaries.financial_operations == false) and + (.boundaries.order_entry == false) and + (.boundaries.market_depth_subscription == false) and + (.boundaries.fuzzing == false) and + (.boundaries.load_testing == false) and + (.boundaries.active_security_testing == false) + ' "${config}" >/dev/null + + - name: Install pinned browser driver + shell: bash + run: | + set -euo pipefail + npm install --no-save --package-lock=false puppeteer-core@24.16.0 + + - name: Validate observer and locate Chrome + id: runtime + shell: bash + run: | + set -euo pipefail + node --version + npm --version + node --check scripts/tradernet_chart_quote_observer.mjs + chrome="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || command -v chromium-browser || true)" + if [[ -z "${chrome}" ]]; then + echo "Chrome executable was not found" >&2 + exit 1 + fi + "${chrome}" --version + echo "chrome=${chrome}" >> "${GITHUB_OUTPUT}" + + - name: Observe desktop and mobile public chart + shell: bash + run: | + set -euo pipefail + rm -rf reports/tradernet-chart-quote + node scripts/tradernet_chart_quote_observer.mjs \ + --config audits/tradernet/chart-quote-public.json \ + --chrome "${{ steps.runtime.outputs.chrome }}" \ + --output-dir reports/tradernet-chart-quote + cat reports/tradernet-chart-quote/result/chart-quote-summary.md >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload exact evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: tradernet-public-chart-quote-${{ github.run_id }} + path: reports/tradernet-chart-quote/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/tradernet-chart-timeframe-probe.yml b/.github/workflows/tradernet-chart-timeframe-probe.yml new file mode 100644 index 00000000..beffde66 --- /dev/null +++ b/.github/workflows/tradernet-chart-timeframe-probe.yml @@ -0,0 +1,80 @@ +name: Tradernet Chart Timeframe Probe + +on: + workflow_dispatch: + push: + branches: + - agent/tradernet-chart-quote-audit + paths: + - .github/workflows/tradernet-chart-timeframe-probe.yml + - audits/tradernet/chart-quote-public.json + - scripts/tradernet_chart_timeframe_real_click.mjs + pull_request: + branches: + - agent/tradernet-lighthouse-audit + paths: + - .github/workflows/tradernet-chart-timeframe-probe.yml + - audits/tradernet/chart-quote-public.json + - scripts/tradernet_chart_timeframe_real_click.mjs + +permissions: + contents: read + +concurrency: + group: tradernet-chart-timeframe-probe-${{ github.ref }} + cancel-in-progress: true + +jobs: + probe: + name: Trusted-click D1 to H1 public chart transition + runs-on: ubuntu-latest + timeout-minutes: 12 + env: + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + steps: + - uses: actions/checkout@v6 + - name: Validate exact boundary + shell: bash + run: | + set -euo pipefail + jq -e ' + (.target_url == "https://tradernet.ru/charts/MICEXINDEXCF") and + (.ticker == "MICEXINDEXCF") and + (.boundaries.one_ticker_only == true) and + (.boundaries.one_timeframe_switch_only == true) and + (.boundaries.direct_api_testing == false) and + (.boundaries.authenticated_testing == false) and + (.boundaries.financial_operations == false) and + (.boundaries.order_entry == false) and + (.boundaries.load_testing == false) + ' audits/tradernet/chart-quote-public.json >/dev/null + - name: Install pinned browser driver + run: npm install --no-save --package-lock=false puppeteer-core@24.16.0 + - name: Locate Chrome and validate trusted-click script + id: runtime + shell: bash + run: | + set -euo pipefail + node --check scripts/tradernet_chart_timeframe_real_click.mjs + chrome="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium || command -v chromium-browser || true)" + test -n "${chrome}" + echo "chrome=${chrome}" >> "${GITHUB_OUTPUT}" + - name: Probe trusted-click D1 to H1 transition + shell: bash + run: | + set -euo pipefail + rm -rf reports/tradernet-chart-timeframe + node scripts/tradernet_chart_timeframe_real_click.mjs \ + --config audits/tradernet/chart-quote-public.json \ + --chrome "${{ steps.runtime.outputs.chrome }}" \ + --output-dir reports/tradernet-chart-timeframe + cat reports/tradernet-chart-timeframe/timeframe-transition-summary.md >> "${GITHUB_STEP_SUMMARY}" + - name: Upload exact evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: tradernet-chart-timeframe-${{ github.run_id }} + path: reports/tradernet-chart-timeframe/ + if-no-files-found: error + retention-days: 14 diff --git a/audits/tradernet/chart-quote-causality-result.json b/audits/tradernet/chart-quote-causality-result.json new file mode 100644 index 00000000..4816d341 --- /dev/null +++ b/audits/tradernet/chart-quote-causality-result.json @@ -0,0 +1,163 @@ +{ + "schema_version": "liminalqa-tradernet-chart-quote-causality-v1", + "target": { + "url": "https://tradernet.ru/charts/MICEXINDEXCF", + "ticker": "MICEXINDEXCF", + "scope": "public chart page only" + }, + "overall_verdict": "CONFIRMED_MOBILE_ROUTE_DEFECT_WITH_HEALTHY_TESTED_DESKTOP_HISTORY", + "confirmed_findings": [ + { + "id": "TN-CHART-001", + "title": "Public chart route returns 404 for mobile user-agents", + "severity": "high_product_impact", + "status": "CONFIRMED", + "causal_verdict": "USER_AGENT_ROUTING_CONFIRMED", + "evidence": { + "workflow_run": 29662815487, + "exact_head": "0a4e364bc311b548c551a33b30adf9302872ab2b", + "artifact": "tradernet-chart-route-matrix-29662815487", + "artifact_sha256": "223d10431960d0ca5fe181fbf22fe238e408ff72188ddb8daaca2d7e67d71395" + }, + "matrix": [ + { + "user_agent": "desktop", + "viewport": "desktop", + "http_status": 200, + "chart_visible": true + }, + { + "user_agent": "desktop", + "viewport": "mobile", + "http_status": 200, + "chart_visible": true + }, + { + "user_agent": "mobile", + "viewport": "desktop", + "http_status": 404, + "chart_visible": false + }, + { + "user_agent": "mobile", + "viewport": "mobile", + "http_status": 404, + "chart_visible": false + } + ], + "interpretation": "The route failure follows the mobile user-agent independently of viewport width, isolating user-agent routing rather than responsive layout as the dominant cause." + } + ], + "passed_checks": [ + { + "id": "TN-CHART-PASS-001", + "title": "Daily historical candle integrity", + "workflow_run": 29662815487, + "exact_head": "0a4e364bc311b548c551a33b30adf9302872ab2b", + "artifact": "tradernet-hloc-integrity-29662815487", + "artifact_sha256": "591f9c5f286478eec7d037e59f1c9d58e17187193520796287c2a474d47359a4", + "request": { + "timeframe": 1440, + "interval": "D1", + "date_from": "31.12.2025 00:00", + "date_to": "19.07.2026 00:00" + }, + "candles": 137, + "timestamps": 137, + "volumes": 137, + "violations": 0 + }, + { + "id": "TN-CHART-PASS-002", + "title": "Trusted UI transition from daily to hourly candles", + "workflow_run": 29663112769, + "exact_head": "b8e228aa2273a9e8f01b4d4c4c105c0781d8e63b", + "artifact": "tradernet-chart-timeframe-29663112769", + "artifact_sha256": "ae749e087bbeac5d98ae63c7452835692bb2ee8f91a0862f3abfa04723fdb92f", + "ui_transition": "D1 -> H1", + "request_transition": { + "from": { + "timeframe": 1440, + "interval": "D1", + "candles": 137, + "violations": 0 + }, + "to": { + "timeframe": 60, + "interval": "H1", + "candles": 698, + "violations": 0 + } + } + } + ], + "signals_requiring_follow_up": [ + { + "id": "TN-CHART-SIGNAL-001", + "title": "Desktop chart first became visibly renderable after approximately 7.48 seconds", + "status": "SINGLE_RUN_LATENCY_SIGNAL", + "workflow_run": 29662674360, + "value_ms": 7476.123, + "next_experiment": "Repeat three cold runs and separate document/runtime/history-response/canvas-render timing." + }, + { + "id": "TN-CHART-SIGNAL-002", + "title": "Console emitted 'ERROR: No context for render' while the chart remained visible", + "status": "IMPACT_NOT_PROVEN", + "workflow_run": 29662674360, + "next_experiment": "Correlate the message with specific canvas layers, resize events, multi-chart layout and timeframe transitions." + }, + { + "id": "TN-CHART-SIGNAL-003", + "title": "Narrow viewport with desktop user-agent exposes toolbar and axis crowding", + "status": "LATENT_RESPONSIVE_VISUAL_CANDIDATE", + "workflow_run": 29662815487, + "next_experiment": "After mobile routing is fixed, test actual mobile route for toolbar overlap, clipped price-axis labels and usable plot width." + }, + { + "id": "TN-CHART-SIGNAL-004", + "title": "Minute interval labels contain malformed text in the rendered dropdown DOM", + "status": "VISUAL_CONFIRMATION_REQUIRED", + "workflow_run": 29662973725, + "observed_labels": [ + "минутный > Д", + "5 минутный > 3Д", + "15 минутный > Н" + ], + "next_experiment": "Open the dropdown with a trusted click, capture a screenshot and verify the text visible to a user." + } + ], + "not_assessed": [ + { + "area": "live quote liveness and update cadence", + "reason": "The evidence run occurred during a UTC weekend; absence of live frames was deliberately not classified as a defect." + }, + { + "area": "market depth", + "reason": "No direct or authenticated subscription was allowed in this public passive scope." + }, + { + "area": "order, portfolio and execution consistency", + "reason": "Requires explicit written authorization, isolated accounts and a separate scope." + } + ], + "rejected_false_positives": [ + { + "title": "Generic observer labeled an aborted Google Analytics request as market-data failure", + "reason": "The failed URL was analytics.google.com, not a quote or candle dependency." + }, + { + "title": "First timeframe probe reported no new HLOC after a synthetic DOM click", + "reason": "The broad selector and untrusted event were invalid. A later visible trusted mouse click produced a clean D1-to-H1 transition." + } + ], + "next_authorized_experiments": [ + "During an open market session, measure page-ready to first quote, update cadence, stale timeout and market-closed behavior.", + "Interrupt and restore network locally, then verify reconnect without duplicate, regressing or out-of-order quote timestamps.", + "Switch symbols A-to-B and verify that a late A response cannot overwrite the selected B chart or quote.", + "Switch intervals rapidly within a bounded public scenario and verify late D1/H1 responses cannot overwrite the current selection.", + "Compare chart last value, quote tile and latest completed candle only when their timestamps and session semantics are aligned.", + "Validate timezone, holidays, session boundaries and absence of phantom weekend candles across several instrument classes.", + "With written authorization, test bid <= ask, last within daily high/low, percentage change versus previous close and order-book monotonicity." + ] +} diff --git a/audits/tradernet/chart-quote-public.json b/audits/tradernet/chart-quote-public.json new file mode 100644 index 00000000..9b0b9a20 --- /dev/null +++ b/audits/tradernet/chart-quote-public.json @@ -0,0 +1,65 @@ +{ + "schema_version": "liminalqa-public-market-data-audit-v1", + "name": "Tradernet public chart and quote observability audit", + "target_url": "https://tradernet.ru/charts/MICEXINDEXCF", + "ticker": "MICEXINDEXCF", + "observation_ms": 30000, + "timeframe_probe": "1Y", + "profiles": [ + { + "id": "desktop_broadband", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "viewport": { + "width": 1440, + "height": 900, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false + }, + "network": { + "latency_ms": 40, + "download_bytes_per_second": 1250000, + "upload_bytes_per_second": 625000, + "connection_type": "ethernet" + }, + "cpu_throttling_rate": 1 + }, + { + "id": "mobile_4g", + "user_agent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36", + "viewport": { + "width": 412, + "height": 823, + "deviceScaleFactor": 2.625, + "isMobile": true, + "hasTouch": true + }, + "network": { + "latency_ms": 85, + "download_bytes_per_second": 500000, + "upload_bytes_per_second": 187500, + "connection_type": "cellular4g" + }, + "cpu_throttling_rate": 2 + } + ], + "boundaries": { + "public_page_only": true, + "one_ticker_only": true, + "one_timeframe_switch_only": true, + "authenticated_testing": false, + "direct_api_testing": false, + "financial_operations": false, + "order_entry": false, + "market_depth_subscription": false, + "fuzzing": false, + "load_testing": false, + "active_security_testing": false + }, + "limitations": [ + "The run observes only traffic naturally initiated by one public chart page.", + "No absence of live quote frames is treated as a defect during a weekend or closed market session.", + "Cross-surface price consistency requires a later authorized or public multi-surface experiment.", + "Detected JSON invariants are heuristics and must be reviewed against the exact response schema." + ] +} diff --git a/docs/audits/TRADERNET_CHART_QUOTE_CAUSALITY.md b/docs/audits/TRADERNET_CHART_QUOTE_CAUSALITY.md new file mode 100644 index 00000000..4ecac2c4 --- /dev/null +++ b/docs/audits/TRADERNET_CHART_QUOTE_CAUSALITY.md @@ -0,0 +1,245 @@ +# Tradernet public chart and quote causal audit + +## Scope + +This audit examines one public chart page and one reviewed index: + +```text +https://tradernet.ru/charts/MICEXINDEXCF +``` + +It is intentionally bounded to passive browser behavior and one visible interval transition. It does not authenticate, call the application API directly, subscribe to market depth, access a portfolio, or submit financial operations. + +## Executive result + +The audit confirmed one high-impact product defect: + +> The public chart route succeeds for desktop user-agents but returns the Tradernet 404 page for mobile user-agents, regardless of viewport width. + +The tested desktop chart path otherwise loaded structurally valid daily and hourly candle history. The trusted `D1 → H1` transition changed both the visible UI state and the naturally initiated `getHloc` request. + +## Space-time causal graph + +```mermaid +flowchart LR + A[Public chart URL] --> B{User-Agent branch} + + B -->|Desktop UA| C[HTTP 200 chart document] + C --> D[Shared runtime] + D --> E[getHloc D1 / 1440] + E --> F[137 daily candles] + F --> G[Visible chart] + G --> H[Trusted click: H1] + H --> I[getHloc H1 / 60] + I --> J[698 hourly candles] + J --> K[UI remains visible and selected H1] + + B -->|Mobile UA| L[HTTP 404 experience] + L --> M[No chart surface] + L --> N[No historical-data path] + + O[Viewport width] -. does not determine route .-> B +``` + +## Confirmed defect: mobile user-agent route + +Evidence run: `29662815487` +Exact head: `0a4e364bc311b548c551a33b30adf9302872ab2b` + +| User-agent | Viewport | Result | +|---|---|---| +| Desktop | Desktop | HTTP 200; chart visible | +| Desktop | Mobile-sized | HTTP 200; chart visible | +| Mobile | Desktop-sized | HTTP 404; no chart | +| Mobile | Mobile-sized | HTTP 404; no chart | + +**Causal verdict:** `USER_AGENT_ROUTING_CONFIRMED` + +The failure follows the user-agent, not the viewport. This makes a pure responsive-layout explanation unlikely. The likely defect lies in server-side or early client-side device routing for the public chart route. + +### User impact + +A phone browser cannot open the public chart link even though the same resource is available to a desktop browser. The failure happens before chart rendering and before historical candles are loaded. + +### Recommended fix + +1. Route mobile user-agents to the same public chart document or an explicit supported mobile chart route. +2. Preserve the ticker during any redirect. +3. Return a product-level unsupported-state explanation only when mobile charts are intentionally unavailable; do not use the generic 404 page. +4. After the route is corrected, execute a real mobile responsive regression for chart width, price-axis labels, controls and drawing tools. + +## Passed: daily historical-data integrity + +Evidence run: `29662815487` +Artifact: `tradernet-hloc-integrity-29662815487` + +The public chart naturally requested: + +```text +interval: D1 +timeframe: 1440 +ticker: MICEXINDEXCF +``` + +Observed response: + +| Candles | Timestamps | Volumes | Violations | +|---:|---:|---:|---:| +| 137 | 137 | 137 | 0 | + +Checked invariants: + +- `high >= low`; +- open and close within the candle range; +- finite positive OHLC values; +- equal HLOC/timestamp/volume lengths; +- unique, strictly increasing timestamps; +- finite, non-negative volume. + +No historical-data integrity defect was confirmed for this ticker and interval. + +## Passed: daily-to-hourly transition + +Evidence run: `29663112769` +Exact head: `b8e228aa2273a9e8f01b4d4c4c105c0781d8e63b` + +The audit used one visible Puppeteer mouse click on the exact public option: + +```text +.js-selectInterval .js-chart-click[data-value="H1"] +``` + +| Phase | UI | Timeframe | Interval | Candles | Violations | +|---|---|---:|---|---:|---:| +| Initial | Дневной | 1440 | D1 | 137 | 0 | +| After click | Часовой | 60 | H1 | 698 | 0 | + +**Verdict:** `TRANSITION_PASS` + +This rejects the earlier synthetic-click false positive. Under the tested desktop path, the interval control, request parameters, data response and visible chart state remained consistent. + +## Signals that require another experiment + +### 1. Slow first visible chart + +In the first bounded desktop run, a visible chart surface was detected after approximately `7.48 s`. + +This is a single-run latency signal, not a stable performance verdict. The next experiment should measure three cold runs and split the timeline into: + +```text +document response +→ runtime ready +→ getHloc request +→ getHloc response +→ first non-empty chart canvas +``` + +### 2. `ERROR: No context for render` + +The console emitted: + +```text +ERROR: No context for render +``` + +The chart still rendered. Therefore the user impact is not established. The message should be correlated with canvas layers, resizes, multiple-chart layouts and interval transitions before being filed as a separate defect. + +### 3. Narrow-view visual crowding + +With a desktop user-agent and mobile-sized viewport, the chart technically rendered, but the vertical drawing toolbar consumed a significant part of the plot and crowded the right price axis. This is a latent responsive candidate, not the primary real-mobile behavior, because an actual mobile user-agent currently receives the 404 route. + +### 4. Malformed minute labels + +The rendered dropdown DOM contained these option labels: + +```text +минутный > Д +5 минутный > 3Д +15 минутный > Н +``` + +The entries look truncated or incorrectly templated. A trusted-click screenshot with the menu visibly open is required before classifying this as a user-visible localization bug. + +## Live quote loading: not assessed yet + +The evidence was collected during a UTC weekend. No real-time quote frames were observed, but absence of updates outside an active market session is not a defect. + +A market-open experiment should measure: + +1. document ready → first quote; +2. historical chart ready → first live update; +3. update cadence and timestamp monotonicity; +4. stale-data threshold and visual stale state; +5. market-close transition; +6. reconnect after a local offline/online cycle; +7. absence of duplicate or out-of-order updates after reconnect. + +## Other high-value causal cases + +### Symbol-switch race + +```text +select A +→ A history request starts +→ select B +→ B history request finishes +→ late A response arrives +``` + +Expected: B remains selected and A cannot overwrite the chart, headline price or instrument metadata. + +### Interval-switch race + +```text +D1 request +→ select H1 +→ select D1 +→ responses arrive out of order +``` + +Expected: only the response matching the current selected interval may update the chart. + +### Cross-surface consistency + +Compare only observations with aligned timestamps and session semantics: + +```text +headline last price +↔ chart latest value +↔ latest completed candle close +↔ bid / ask +↔ market status +``` + +Potential invariants include `bid <= ask`, last price within day high/low, and percentage change matching previous close. Authenticated or direct quote work requires a separate authorized scope. + +### Session and timezone boundaries + +Test several instrument classes around: + +- exchange open and close; +- premarket and aftermarket; +- holidays; +- daylight-saving transitions; +- weekend boundaries. + +Expected: no phantom candles, duplicated timestamps, missing session segments or false “live” state. + +## Rejected false positives + +Two audit-side false positives were explicitly rejected: + +1. An aborted Google Analytics request was initially grouped with data failures. It is not a market-data dependency. +2. A broad synthetic click failed to change the interval. A later exact trusted mouse click completed the `D1 → H1` transition successfully. + +This separation is part of the LiminalQA contract: detector errors and product defects must not be merged into one verdict. + +## Evidence index + +| Evidence | Run | SHA-256 | +|---|---:|---| +| Initial chart/quote observation | 29662674360 | `4a93f7c93a668d88369414500ff3e860b433b91eb9a32256ee69b12541b1e305` | +| Route causal matrix | 29662815487 | `223d10431960d0ca5fe181fbf22fe238e408ff72188ddb8daaca2d7e67d71395` | +| Daily HLOC integrity | 29662815487 | `591f9c5f286478eec7d037e59f1c9d58e17187193520796287c2a474d47359a4` | +| Interval DOM discovery | 29662973725 | `77f03dc492eda0c2ce4e323dd0a1bc57269c53d5b43f99f9b8d5af1bb490ff99` | +| Trusted D1 → H1 transition | 29663112769 | `ae749e087bbeac5d98ae63c7452835692bb2ee8f91a0862f3abfa04723fdb92f` | diff --git a/scripts/tradernet_chart_interval_dom_discovery.mjs b/scripts/tradernet_chart_interval_dom_discovery.mjs new file mode 100644 index 00000000..98ea810d --- /dev/null +++ b/scripts/tradernet_chart_interval_dom_discovery.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import puppeteer from "puppeteer-core"; + +function args(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 2) out[argv[i].replace(/^--/, "")] = argv[i + 1]; + return out; +} + +async function main() { + const input = args(process.argv.slice(2)); + const config = JSON.parse(await fs.readFile(input.config, "utf8")); + const profile = config.profiles.find((item) => item.id === "desktop_broadband"); + await fs.mkdir(input["output-dir"], { recursive: true }); + + const browser = await puppeteer.launch({ + executablePath: input.chrome, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setUserAgent(profile.user_agent); + await page.setViewport(profile.viewport); + await page.goto(config.target_url, { waitUntil: "domcontentloaded", timeout: 90_000 }); + await new Promise((resolve) => setTimeout(resolve, 12_000)); + + const discovery = await page.evaluate(() => { + const trim = (value, limit = 4000) => { + const text = String(value || "").replace(/\s+/g, " ").trim(); + return text.length <= limit ? text : `${text.slice(0, limit)}…`; + }; + const exact = [...document.querySelectorAll("*")].find( + (element) => element.textContent?.replace(/\s+/g, " ").trim() === "Дневной" + ); + const ancestors = []; + let current = exact; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + const rect = current.getBoundingClientRect(); + ancestors.push({ + depth, + tag: current.tagName, + id: current.id || null, + class: typeof current.className === "string" ? current.className : null, + role: current.getAttribute("role"), + aria_expanded: current.getAttribute("aria-expanded"), + data_attributes: Object.fromEntries( + [...current.attributes] + .filter((attribute) => attribute.name.startsWith("data-")) + .map((attribute) => [attribute.name, attribute.value]) + ), + width: Math.round(rect.width), + height: Math.round(rect.height), + outer_html: trim(current.outerHTML, 2500), + }); + } + const templates = [...document.querySelectorAll('script[id*="interval" i], script[id*="chart" i]')] + .slice(0, 40) + .map((element) => ({ id: element.id, type: element.type, content: trim(element.textContent, 8000) })); + const related = [...document.querySelectorAll('[class*="interval" i], [id*="interval" i], [class*="dropdown" i], [class*="select" i]')] + .slice(0, 100) + .map((element) => ({ + tag: element.tagName, + id: element.id || null, + class: typeof element.className === "string" ? element.className : null, + text: trim(element.textContent, 500), + html: trim(element.outerHTML, 1500), + })); + return { ancestors, templates, related }; + }); + + await fs.writeFile( + path.join(input["output-dir"], "interval-dom-discovery.json"), + `${JSON.stringify(discovery, null, 2)}\n` + ); + await page.screenshot({ path: path.join(input["output-dir"], "interval-dom-page.png"), fullPage: true }); + console.log(JSON.stringify({ ancestor_count: discovery.ancestors.length, template_count: discovery.templates.length, related_count: discovery.related.length }, null, 2)); + await context.close(); + await browser.close(); +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/tradernet_chart_quote_observer.mjs b/scripts/tradernet_chart_quote_observer.mjs new file mode 100644 index 00000000..ee8313aa --- /dev/null +++ b/scripts/tradernet_chart_quote_observer.mjs @@ -0,0 +1,637 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import puppeteer from "puppeteer-core"; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument near ${key ?? ""}`); + } + args[key.slice(2)] = value; + } + return args; +} + +function round(value, digits = 3) { + if (!Number.isFinite(value)) return null; + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function clip(value, limit = 500) { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text.length <= limit ? text : `${text.slice(0, limit)}…`; +} + +function numberFrom(object, keys) { + for (const key of keys) { + const value = object?.[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} + +function timeFrom(object) { + const rawKeys = ["timestamp", "time", "datetime", "date", "t", "x", "ts"]; + for (const key of rawKeys) { + const value = object?.[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value < 10_000_000_000 ? value * 1000 : value; + } + if (typeof value === "string") { + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric < 10_000_000_000 ? numeric * 1000 : numeric; + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} + +function scanJson(root) { + const result = { + arrays_scanned: 0, + candle_arrays: 0, + candles_seen: 0, + quote_objects: 0, + violations: [], + observed_keys: new Set(), + }; + + const addViolation = (type, jsonPath, details = {}) => { + if (result.violations.length < 30) result.violations.push({ type, path: jsonPath, ...details }); + }; + + const inspectArray = (items, jsonPath) => { + result.arrays_scanned += 1; + const objects = items.filter((item) => item && typeof item === "object" && !Array.isArray(item)); + if (objects.length === 0) return; + + const candleRows = objects + .map((item, index) => ({ + index, + item, + open: numberFrom(item, ["open", "o"]), + high: numberFrom(item, ["high", "h"]), + low: numberFrom(item, ["low"]), + close: numberFrom(item, ["close", "c"]), + timestamp: timeFrom(item), + })) + .filter( + (row) => + Number.isFinite(row.open) && + Number.isFinite(row.high) && + Number.isFinite(row.low) && + Number.isFinite(row.close) + ); + + if (candleRows.length >= 2) { + result.candle_arrays += 1; + result.candles_seen += candleRows.length; + let previousTimestamp = null; + const timestamps = new Set(); + for (const row of candleRows) { + const rowPath = `${jsonPath}[${row.index}]`; + if (row.high < row.low) addViolation("CANDLE_HIGH_BELOW_LOW", rowPath); + if (row.open < row.low || row.open > row.high) addViolation("CANDLE_OPEN_OUTSIDE_RANGE", rowPath); + if (row.close < row.low || row.close > row.high) addViolation("CANDLE_CLOSE_OUTSIDE_RANGE", rowPath); + if ([row.open, row.high, row.low, row.close].some((value) => value <= 0)) { + addViolation("CANDLE_NON_POSITIVE_PRICE", rowPath); + } + if (Number.isFinite(row.timestamp)) { + if (timestamps.has(row.timestamp)) addViolation("CANDLE_DUPLICATE_TIMESTAMP", rowPath); + timestamps.add(row.timestamp); + if (Number.isFinite(previousTimestamp) && row.timestamp < previousTimestamp) { + addViolation("CANDLE_TIMESTAMP_REGRESSION", rowPath); + } + previousTimestamp = row.timestamp; + } + } + } + }; + + const visit = (value, jsonPath, depth) => { + if (depth > 14 || value === null || value === undefined) return; + if (Array.isArray(value)) { + inspectArray(value, jsonPath); + value.forEach((item, index) => visit(item, `${jsonPath}[${index}]`, depth + 1)); + return; + } + if (typeof value !== "object") return; + + Object.keys(value).slice(0, 100).forEach((key) => result.observed_keys.add(key)); + + const bid = numberFrom(value, ["bid", "bestBid", "bidPrice"]); + const ask = numberFrom(value, ["ask", "bestAsk", "askPrice"]); + const last = numberFrom(value, ["last", "lastPrice", "ltp", "price"]); + const high = numberFrom(value, ["dayHigh", "high", "hi"]); + const low = numberFrom(value, ["dayLow", "low", "lo"]); + const previousClose = numberFrom(value, ["prevClose", "previousClose", "closePrev"]); + const changePercent = numberFrom(value, ["changePercent", "changePct", "percentChange", "pc"]); + + if ([bid, ask, last].some(Number.isFinite)) { + result.quote_objects += 1; + if (Number.isFinite(bid) && Number.isFinite(ask) && bid > ask) { + addViolation("QUOTE_BID_ABOVE_ASK", jsonPath); + } + if (Number.isFinite(last) && Number.isFinite(low) && last < low) { + addViolation("QUOTE_LAST_BELOW_DAY_LOW", jsonPath); + } + if (Number.isFinite(last) && Number.isFinite(high) && last > high) { + addViolation("QUOTE_LAST_ABOVE_DAY_HIGH", jsonPath); + } + if ( + Number.isFinite(last) && + Number.isFinite(previousClose) && + previousClose !== 0 && + Number.isFinite(changePercent) + ) { + const computed = ((last - previousClose) / previousClose) * 100; + if (Math.abs(computed - changePercent) > 0.2) { + addViolation("QUOTE_CHANGE_PERCENT_MISMATCH", jsonPath, { + delta_percentage_points: round(Math.abs(computed - changePercent), 4), + }); + } + } + } + + for (const [key, child] of Object.entries(value)) visit(child, `${jsonPath}.${key}`, depth + 1); + }; + + visit(root, "$", 0); + return { + arrays_scanned: result.arrays_scanned, + candle_arrays: result.candle_arrays, + candles_seen: result.candles_seen, + quote_objects: result.quote_objects, + violations: result.violations, + observed_keys: [...result.observed_keys].sort().slice(0, 150), + }; +} + +async function captureDom(page) { + return page.evaluate(() => { + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 2 && rect.height > 2 && style.display !== "none" && style.visibility !== "hidden"; + }; + const serialize = (element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName, + id: element.id || null, + class: typeof element.className === "string" ? element.className.slice(0, 300) : null, + width: Math.round(rect.width), + height: Math.round(rect.height), + visible: visible(element), + }; + }; + const canvases = [...document.querySelectorAll("canvas")].map((canvas) => ({ + ...serialize(canvas), + pixel_width: canvas.width, + pixel_height: canvas.height, + })); + const svgs = [...document.querySelectorAll("svg")].map(serialize); + const chartCandidates = [ + ...document.querySelectorAll( + '[id*="chart" i], [class*="chart" i], [id*="graph" i], [class*="graph" i], [class*="highcharts" i], [class*="tradingview" i]' + ), + ] + .slice(0, 100) + .map(serialize); + const frames = [...document.querySelectorAll("iframe")].map((frame) => ({ + ...serialize(frame), + src: frame.src || null, + })); + const timeframeLabels = [...document.querySelectorAll("button, a, [role=button], span, div")] + .filter((element) => ["1m", "5m", "3M", "YTD", "1Y", "5Y", "MAX"].includes(element.textContent?.trim())) + .slice(0, 50) + .map((element) => ({ text: element.textContent.trim(), ...serialize(element) })); + const bodyText = document.body?.innerText || ""; + const statusMatches = bodyText + .split(/\n+/) + .map((line) => line.trim()) + .filter((line) => /ошиб|error|нет данных|no data|загруз|loading|market closed|рынок закрыт/i.test(line)) + .slice(0, 30); + const visibleChartSurface = [...canvases, ...svgs, ...chartCandidates].some( + (item) => item.visible && item.width >= 120 && item.height >= 80 + ); + return { + url: location.href, + title: document.title, + ready_state: document.readyState, + canvases, + svgs, + chart_candidates: chartCandidates, + iframes: frames, + timeframe_labels: timeframeLabels, + status_messages: statusMatches, + visible_chart_surface: visibleChartSurface, + body_text_sha256_input: bodyText.slice(0, 100_000), + }; + }); +} + +async function clickTimeframe(page, label) { + return page.evaluate((target) => { + const elements = [...document.querySelectorAll("button, a, [role=button], span, div")]; + const candidate = elements.find((element) => { + if (element.textContent?.trim() !== target) return false; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 1 && rect.height > 1 && style.display !== "none" && style.visibility !== "hidden"; + }); + if (!candidate) return { clicked: false, reason: "not_found" }; + candidate.click(); + return { + clicked: true, + tag: candidate.tagName, + id: candidate.id || null, + class: typeof candidate.className === "string" ? candidate.className.slice(0, 200) : null, + }; + }, label); +} + +async function runProfile(browser, config, profile, outputDir) { + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setUserAgent(profile.user_agent); + await page.setViewport(profile.viewport); + await page.setCacheEnabled(false); + + const consoleMessages = []; + const pageErrors = []; + const failedRequests = []; + const responses = []; + const parsedBodies = []; + const responseTasks = []; + const websocket = { + created: [], + closed: [], + received_frames: 0, + sent_frames: 0, + ticker_matching_frames: 0, + received_bytes: 0, + first_received_at: null, + last_received_at: null, + }; + + page.on("console", (message) => { + if (consoleMessages.length < 200) { + consoleMessages.push({ type: message.type(), text: clip(message.text(), 1000) }); + } + }); + page.on("pageerror", (error) => { + if (pageErrors.length < 100) pageErrors.push(clip(error?.stack || error, 2000)); + }); + page.on("requestfailed", (request) => { + if (failedRequests.length < 200) { + failedRequests.push({ + url: request.url(), + resource_type: request.resourceType(), + error: request.failure()?.errorText || null, + }); + } + }); + page.on("response", (response) => { + const request = response.request(); + const headers = response.headers(); + const entry = { + url: response.url(), + status: response.status(), + resource_type: request.resourceType(), + content_type: headers["content-type"] || null, + content_length: headers["content-length"] || null, + }; + if (responses.length < 500) responses.push(entry); + + const sameTradernet = /^https:\/\/[^/]*tradernet\.(ru|com|global|am)\//i.test(response.url()); + const bodyCandidate = ["xhr", "fetch"].includes(request.resourceType()) && sameTradernet; + if (!bodyCandidate || parsedBodies.length >= 80) return; + + responseTasks.push( + (async () => { + try { + const contentType = headers["content-type"] || ""; + if (!/json|javascript|text\/plain/i.test(contentType)) return; + const text = await response.text(); + if (text.length > 2_000_000) return; + const record = { + url: response.url(), + status: response.status(), + bytes: Buffer.byteLength(text), + sha256: sha256(text), + parsed_json: false, + json_scan: null, + }; + try { + const json = JSON.parse(text); + record.parsed_json = true; + record.json_scan = scanJson(json); + } catch { + // Public responses can be JavaScript or newline-delimited payloads. + } + parsedBodies.push(record); + } catch (error) { + parsedBodies.push({ + url: response.url(), + status: response.status(), + body_read_error: clip(error?.message || error), + }); + } + })() + ); + }); + + const client = await page.createCDPSession(); + await client.send("Network.enable"); + await client.send("Network.setCacheDisabled", { cacheDisabled: true }); + await client.send("Network.emulateNetworkConditions", { + offline: false, + latency: profile.network.latency_ms, + downloadThroughput: profile.network.download_bytes_per_second, + uploadThroughput: profile.network.upload_bytes_per_second, + connectionType: profile.network.connection_type, + }); + await client.send("Emulation.setCPUThrottlingRate", { rate: profile.cpu_throttling_rate }); + client.on("Network.webSocketCreated", (event) => { + if (websocket.created.length < 50) websocket.created.push({ request_id: event.requestId, url: event.url }); + }); + client.on("Network.webSocketClosed", (event) => { + if (websocket.closed.length < 50) websocket.closed.push({ request_id: event.requestId, at: event.timestamp }); + }); + client.on("Network.webSocketFrameReceived", (event) => { + const payload = event.response?.payloadData || ""; + websocket.received_frames += 1; + websocket.received_bytes += Buffer.byteLength(payload); + websocket.first_received_at ??= new Date().toISOString(); + websocket.last_received_at = new Date().toISOString(); + if (payload.includes(config.ticker)) websocket.ticker_matching_frames += 1; + }); + client.on("Network.webSocketFrameSent", () => { + websocket.sent_frames += 1; + }); + + const startedAt = new Date().toISOString(); + const startedMonotonic = performance.now(); + let navigationError = null; + try { + await page.goto(config.target_url, { waitUntil: "domcontentloaded", timeout: 90_000 }); + } catch (error) { + navigationError = clip(error?.stack || error, 3000); + } + + let chartVisibleAtMs = null; + if (!navigationError) { + const deadline = Date.now() + config.observation_ms; + while (Date.now() < deadline) { + const visible = await page.evaluate(() => { + const elements = document.querySelectorAll( + 'canvas, svg, [id*="chart" i], [class*="chart" i], [id*="graph" i], [class*="graph" i]' + ); + return [...elements].some((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width >= 120 && rect.height >= 80 && style.display !== "none" && style.visibility !== "hidden"; + }); + }); + if (visible) { + chartVisibleAtMs = round(performance.now() - startedMonotonic); + break; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + + await new Promise((resolve) => setTimeout(resolve, Math.min(config.observation_ms, 15_000))); + const before = await captureDom(page); + before.body_text_sha256 = sha256(before.body_text_sha256_input); + delete before.body_text_sha256_input; + await page.screenshot({ path: path.join(outputDir, `${profile.id}-before.png`), fullPage: true }); + + const timeframeAction = await clickTimeframe(page, config.timeframe_probe); + if (timeframeAction.clicked) await new Promise((resolve) => setTimeout(resolve, 8_000)); + const after = await captureDom(page); + after.body_text_sha256 = sha256(after.body_text_sha256_input); + delete after.body_text_sha256_input; + await page.screenshot({ path: path.join(outputDir, `${profile.id}-after-${config.timeframe_probe}.png`), fullPage: true }); + + await Promise.allSettled(responseTasks); + const navigationTiming = await page.evaluate(() => { + const navigation = performance.getEntriesByType("navigation")[0]; + if (!navigation) return null; + return { + response_start_ms: navigation.responseStart, + response_end_ms: navigation.responseEnd, + dom_content_loaded_ms: navigation.domContentLoadedEventEnd, + load_event_ms: navigation.loadEventEnd, + transfer_size: navigation.transferSize, + }; + }); + + const combinedScan = { + candle_arrays: 0, + candles_seen: 0, + quote_objects: 0, + violations: [], + }; + for (const body of parsedBodies) { + if (!body.json_scan) continue; + combinedScan.candle_arrays += body.json_scan.candle_arrays; + combinedScan.candles_seen += body.json_scan.candles_seen; + combinedScan.quote_objects += body.json_scan.quote_objects; + for (const violation of body.json_scan.violations) { + if (combinedScan.violations.length < 50) { + combinedScan.violations.push({ url: body.url, ...violation }); + } + } + } + + const day = new Date().getUTCDay(); + const weekend = day === 0 || day === 6; + const liveQuoteAssessment = weekend + ? "NOT_ASSESSED_WEEKEND" + : websocket.ticker_matching_frames > 0 + ? "OBSERVED" + : "INCONCLUSIVE_NO_TICKER_FRAME"; + const historicalAssessment = combinedScan.candles_seen > 0 + ? "PARSED_AND_CHECKED" + : before.visible_chart_surface || after.visible_chart_surface + ? "VISUAL_SURFACE_ONLY" + : "NOT_OBSERVED"; + + const warningSignals = [ + navigationError, + !before.visible_chart_surface && !after.visible_chart_surface ? "NO_VISIBLE_CHART_SURFACE" : null, + pageErrors.length > 0 ? "PAGE_ERRORS" : null, + failedRequests.some((item) => ["xhr", "fetch", "websocket"].includes(item.resource_type)) + ? "MARKET_DATA_REQUEST_FAILURE" + : null, + combinedScan.violations.length > 0 ? "DATA_INVARIANT_VIOLATIONS" : null, + !timeframeAction.clicked ? "TIMEFRAME_CONTROL_NOT_FOUND" : null, + ].filter(Boolean); + + const result = { + schema_version: "liminalqa-public-market-data-profile-result-v1", + profile: profile.id, + started_at: startedAt, + completed_at: new Date().toISOString(), + target_url: config.target_url, + final_url: page.url(), + ticker: config.ticker, + verdict: warningSignals.length === 0 ? "OBSERVED" : "WARN", + warning_signals: warningSignals, + navigation_error: navigationError, + chart_visible_at_ms: chartVisibleAtMs, + navigation_timing: navigationTiming, + timeframe_action: timeframeAction, + dom_before: before, + dom_after: after, + console_messages: consoleMessages, + page_errors: pageErrors, + failed_requests: failedRequests, + responses, + parsed_public_response_bodies: parsedBodies, + data_invariant_summary: combinedScan, + websocket, + assessments: { + historical_chart_loading: historicalAssessment, + live_quote_liveness: liveQuoteAssessment, + market_window_note: weekend + ? "Run occurred during a UTC weekend; absent live updates are not classified as a bug." + : "Trading-session status was not independently verified.", + }, + }; + + await fs.writeFile( + path.join(outputDir, `${profile.id}-result.json`), + `${JSON.stringify(result, null, 2)}\n`, + "utf8" + ); + await context.close(); + return result; +} + +function renderMarkdown(packet) { + const lines = [ + "# LiminalQA · Tradernet public charts and quotes", + "", + `**Verdict:** ${packet.verdict} `, + `**Target:** ${packet.config.target_url} `, + `**Ticker:** ${packet.config.ticker}`, + "", + "## Profile results", + "", + "| Profile | Chart visible | Visible at | Historical data | Live quotes | Page errors | Failed requests | Invariant findings |", + "|---|---:|---:|---|---|---:|---:|---:|", + ]; + for (const result of packet.results) { + lines.push( + `| ${result.profile} | ${result.dom_before.visible_chart_surface || result.dom_after.visible_chart_surface ? "yes" : "no"} | ` + + `${result.chart_visible_at_ms ?? "n/a"} ms | ${result.assessments.historical_chart_loading} | ` + + `${result.assessments.live_quote_liveness} | ${result.page_errors.length} | ` + + `${result.failed_requests.length} | ${result.data_invariant_summary.violations.length} |` + ); + } + lines.push("", "## Causal test map", "", "```text"); + lines.push("Public chart navigation"); + lines.push(" → document and shared runtime"); + lines.push(" → chart surface creation"); + lines.push(" → public historical-data responses"); + lines.push(" → candle/quote invariant checks"); + lines.push(" → one bounded timeframe switch"); + lines.push(" → WebSocket observation without direct subscription"); + lines.push(" → evidence-backed next experiments"); + lines.push("```", ""); + lines.push( + "> The run is passive and public-page-only. It does not authenticate, call application APIs directly, subscribe to market depth, place orders, fuzz symbols, or classify absent weekend quotes as a defect.", + "" + ); + return lines.join("\n"); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + for (const key of ["config", "chrome", "output-dir"]) { + if (!args[key]) throw new Error(`--${key} is required`); + } + const config = JSON.parse(await fs.readFile(args.config, "utf8")); + if (config.target_url !== "https://tradernet.ru/charts/MICEXINDEXCF") { + throw new Error("Unexpected target URL"); + } + if (config.profiles.length !== 2 || config.observation_ms > 30_000) { + throw new Error("Audit boundary exceeded"); + } + + await fs.mkdir(args["output-dir"], { recursive: true }); + const browser = await puppeteer.launch({ + executablePath: args.chrome, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"], + }); + + const results = []; + try { + for (const profile of config.profiles) { + results.push(await runProfile(browser, config, profile, args["output-dir"])); + } + } finally { + await browser.close(); + } + + const warnings = results.flatMap((result) => result.warning_signals.map((signal) => ({ + profile: result.profile, + signal, + }))); + const packet = { + schema_version: "liminalqa-public-market-data-audit-result-v1", + verdict: warnings.length === 0 ? "OBSERVED" : "WARN", + config, + results, + warnings, + generated_at: new Date().toISOString(), + }; + const resultDir = path.join(args["output-dir"], "result"); + await fs.mkdir(resultDir, { recursive: true }); + await fs.writeFile( + path.join(resultDir, "chart-quote-result.json"), + `${JSON.stringify(packet, null, 2)}\n`, + "utf8" + ); + await fs.writeFile( + path.join(resultDir, "chart-quote-summary.md"), + renderMarkdown(packet), + "utf8" + ); + console.log(JSON.stringify({ verdict: packet.verdict, warnings, results: results.map((result) => ({ + profile: result.profile, + chart_visible_at_ms: result.chart_visible_at_ms, + historical: result.assessments.historical_chart_loading, + live_quotes: result.assessments.live_quote_liveness, + page_errors: result.page_errors.length, + failed_requests: result.failed_requests.length, + invariants: result.data_invariant_summary.violations.length, + websocket_frames: result.websocket.received_frames, + })) }, null, 2)); +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/tradernet_chart_route_matrix.mjs b/scripts/tradernet_chart_route_matrix.mjs new file mode 100644 index 00000000..0cf20106 --- /dev/null +++ b/scripts/tradernet_chart_route_matrix.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import puppeteer from "puppeteer-core"; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) throw new Error(`Invalid argument near ${key}`); + args[key.slice(2)] = value; + } + return args; +} + +async function runVariant(browser, targetUrl, variant, outputDir) { + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setUserAgent(variant.user_agent); + await page.setViewport(variant.viewport); + await page.setCacheEnabled(false); + + let documentStatus = null; + let navigationError = null; + const response = await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 90_000 }).catch((error) => { + navigationError = String(error?.stack || error); + return null; + }); + documentStatus = response?.status() ?? null; + await new Promise((resolve) => setTimeout(resolve, 12_000)); + + const dom = await page.evaluate(() => { + const bodyText = document.body?.innerText || ""; + const surfaces = [...document.querySelectorAll('canvas, svg, [id*="chart" i], [class*="chart" i]')] + .map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + tag: element.tagName, + width: Math.round(rect.width), + height: Math.round(rect.height), + visible: rect.width >= 120 && rect.height >= 80 && style.display !== "none" && style.visibility !== "hidden", + }; + }); + return { + title: document.title, + final_url: location.href, + has_404_text: /404\s*ERROR|Страница не найдена|page not found/i.test(bodyText), + has_chart_surface: surfaces.some((surface) => surface.visible), + surface_count: surfaces.length, + body_excerpt: bodyText.replace(/\s+/g, " ").trim().slice(0, 500), + }; + }); + + await page.screenshot({ path: path.join(outputDir, `${variant.id}.png`), fullPage: true }); + const result = { + id: variant.id, + user_agent_family: variant.user_agent_family, + viewport_family: variant.viewport_family, + document_status: documentStatus, + navigation_error: navigationError, + ...dom, + }; + await fs.writeFile(path.join(outputDir, `${variant.id}.json`), `${JSON.stringify(result, null, 2)}\n`); + await context.close(); + return result; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + for (const key of ["config", "chrome", "output-dir"]) { + if (!args[key]) throw new Error(`--${key} is required`); + } + const config = JSON.parse(await fs.readFile(args.config, "utf8")); + const desktop = config.profiles.find((profile) => profile.id === "desktop_broadband"); + const mobile = config.profiles.find((profile) => profile.id === "mobile_4g"); + if (!desktop || !mobile) throw new Error("Required profiles are missing"); + + const variants = [ + { + id: "desktop_ua_desktop_viewport", + user_agent_family: "desktop", + viewport_family: "desktop", + user_agent: desktop.user_agent, + viewport: desktop.viewport, + }, + { + id: "desktop_ua_mobile_viewport", + user_agent_family: "desktop", + viewport_family: "mobile", + user_agent: desktop.user_agent, + viewport: mobile.viewport, + }, + { + id: "mobile_ua_desktop_viewport", + user_agent_family: "mobile", + viewport_family: "desktop", + user_agent: mobile.user_agent, + viewport: desktop.viewport, + }, + { + id: "mobile_ua_mobile_viewport", + user_agent_family: "mobile", + viewport_family: "mobile", + user_agent: mobile.user_agent, + viewport: mobile.viewport, + }, + ]; + + await fs.mkdir(args["output-dir"], { recursive: true }); + const browser = await puppeteer.launch({ + executablePath: args.chrome, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"], + }); + const results = []; + try { + for (const variant of variants) { + results.push(await runVariant(browser, config.target_url, variant, args["output-dir"])); + } + } finally { + await browser.close(); + } + + const mobileUaResults = results.filter((result) => result.user_agent_family === "mobile"); + const desktopUaResults = results.filter((result) => result.user_agent_family === "desktop"); + const mobileUaAlways404 = mobileUaResults.every( + (result) => result.document_status === 404 || result.has_404_text + ); + const desktopUaAlwaysWorks = desktopUaResults.every( + (result) => result.document_status === 200 && result.has_chart_surface + ); + const verdict = mobileUaAlways404 && desktopUaAlwaysWorks + ? "USER_AGENT_ROUTING_CONFIRMED" + : results.some((result) => result.document_status === 404 || result.has_404_text) + ? "MIXED_ROUTE_FAILURE" + : "NOT_REPRODUCED"; + + const packet = { + schema_version: "liminalqa-public-route-matrix-result-v1", + target_url: config.target_url, + verdict, + results, + interpretation: + verdict === "USER_AGENT_ROUTING_CONFIRMED" + ? "The public chart route succeeds for the desktop user-agent at both viewport sizes and returns the 404 experience for the mobile user-agent at both viewport sizes. The dominant cause is server/client routing by user-agent rather than responsive viewport width." + : "The four-way matrix did not isolate a pure user-agent branch; inspect the individual results.", + generated_at: new Date().toISOString(), + }; + await fs.writeFile( + path.join(args["output-dir"], "route-matrix-result.json"), + `${JSON.stringify(packet, null, 2)}\n`, + "utf8" + ); + const lines = [ + "# Tradernet public chart route matrix", + "", + `**Verdict:** ${verdict}`, + "", + "| Variant | Document status | 404 experience | Chart surface |", + "|---|---:|---:|---:|", + ...results.map( + (result) => + `| ${result.id} | ${result.document_status ?? "n/a"} | ${result.has_404_text ? "yes" : "no"} | ${result.has_chart_surface ? "yes" : "no"} |` + ), + "", + packet.interpretation, + "", + ]; + await fs.writeFile(path.join(args["output-dir"], "route-matrix-summary.md"), lines.join("\n")); + console.log(JSON.stringify(packet, null, 2)); +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/tradernet_chart_timeframe_probe.mjs b/scripts/tradernet_chart_timeframe_probe.mjs new file mode 100644 index 00000000..65bec301 --- /dev/null +++ b/scripts/tradernet_chart_timeframe_probe.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import puppeteer from "puppeteer-core"; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument near ${key ?? ""}`); + } + args[key.slice(2)] = value; + } + return args; +} + +function requestShape(url) { + try { + const parsed = new URL(url); + const rawQuery = parsed.searchParams.get("q"); + if (!rawQuery) return null; + const q = JSON.parse(rawQuery); + return { + id: q.params?.id ?? null, + timeframe: q.params?.timeframe ?? null, + interval: q.params?.interval ?? null, + interval_mode: q.params?.intervalMode ?? null, + date_from: q.params?.date_from ?? null, + date_to: q.params?.date_to ?? null, + count: q.params?.count ?? null, + demo: q.params?.demo ?? null, + }; + } catch { + return null; + } +} + +function analyzeHloc(json, ticker) { + const candles = json?.hloc?.[ticker]; + const timestamps = json?.xSeries?.[ticker]; + const volumes = json?.vl?.[ticker]; + const violations = []; + + if (!Array.isArray(candles)) violations.push("HLOC_MISSING"); + if (!Array.isArray(timestamps)) violations.push("TIMESTAMPS_MISSING"); + if (!Array.isArray(volumes)) violations.push("VOLUMES_MISSING"); + if (!Array.isArray(candles) || !Array.isArray(timestamps) || !Array.isArray(volumes)) { + return { candle_count: 0, timestamp_count: 0, volume_count: 0, violations }; + } + + if (candles.length !== timestamps.length) violations.push("HLOC_TIMESTAMP_LENGTH_MISMATCH"); + if (candles.length !== volumes.length) violations.push("HLOC_VOLUME_LENGTH_MISMATCH"); + + let previousTimestamp = null; + const seen = new Set(); + candles.forEach((row, index) => { + if (!Array.isArray(row) || row.length < 4) { + violations.push(`CANDLE_SHAPE_INVALID:${index}`); + return; + } + const [high, low, open, close] = row.map(Number); + if (![high, low, open, close].every(Number.isFinite)) { + violations.push(`CANDLE_NON_NUMERIC:${index}`); + return; + } + if (high < low) violations.push(`HIGH_BELOW_LOW:${index}`); + if (open < low || open > high) violations.push(`OPEN_OUTSIDE_RANGE:${index}`); + if (close < low || close > high) violations.push(`CLOSE_OUTSIDE_RANGE:${index}`); + + const timestamp = Number(timestamps[index]); + if (!Number.isFinite(timestamp)) { + violations.push(`TIMESTAMP_NON_NUMERIC:${index}`); + } else { + if (seen.has(timestamp)) violations.push(`TIMESTAMP_DUPLICATE:${index}`); + if (Number.isFinite(previousTimestamp) && timestamp <= previousTimestamp) { + violations.push(`TIMESTAMP_NOT_INCREASING:${index}`); + } + seen.add(timestamp); + previousTimestamp = timestamp; + } + + const volume = Number(volumes[index]); + if (!Number.isFinite(volume)) violations.push(`VOLUME_NON_NUMERIC:${index}`); + if (volume < 0) violations.push(`VOLUME_NEGATIVE:${index}`); + }); + + return { + candle_count: candles.length, + timestamp_count: timestamps.length, + volume_count: volumes.length, + first_timestamp: timestamps.length ? Number(timestamps[0]) : null, + last_timestamp: timestamps.length ? Number(timestamps.at(-1)) : null, + violations: violations.slice(0, 100), + }; +} + +async function chartState(page) { + return page.evaluate(() => { + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + rect.width >= 120 && + rect.height >= 80 && + style.display !== "none" && + style.visibility !== "hidden" + ); + }; + const opener = document.querySelector(".js-intervalSelector"); + const selectedText = opener?.textContent?.replace(/\s+/g, " ").trim() ?? null; + const selectedValue = opener?.getAttribute("data-value") ?? null; + const chartSurfaces = [ + ...document.querySelectorAll( + 'canvas, svg, [id*="chart" i], [class*="chart" i], [id*="graph" i], [class*="graph" i]' + ), + ].filter(visible); + return { + selected_text: selectedText, + selected_value: selectedValue, + chart_visible: chartSurfaces.length > 0, + visible_chart_surface_count: chartSurfaces.length, + title: document.title, + final_url: location.href, + }; + }); +} + +async function clickExactInterval(page, value) { + return page.evaluate((targetValue) => { + const opener = document.querySelector(".js-intervalSelector"); + if (!opener) return { opened: false, selected: false, reason: "opener_not_found" }; + opener.click(); + const option = document.querySelector(`.js-selectInterval .js-chart-click[data-value="${targetValue}"]`); + if (!option) return { opened: true, selected: false, reason: "option_not_found" }; + const text = option.textContent?.replace(/\s+/g, " ").trim() ?? null; + option.click(); + return { + opened: true, + selected: true, + target_value: targetValue, + target_text: text, + option_tag: option.tagName, + option_class: typeof option.className === "string" ? option.className : null, + }; + }, value); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + for (const key of ["config", "chrome", "output-dir"]) { + if (!args[key]) throw new Error(`--${key} is required`); + } + + const config = JSON.parse(await fs.readFile(args.config, "utf8")); + const profile = config.profiles.find((item) => item.id === "desktop_broadband"); + if (!profile || config.target_url !== "https://tradernet.ru/charts/MICEXINDEXCF") { + throw new Error("Unexpected audit configuration"); + } + + await fs.mkdir(args["output-dir"], { recursive: true }); + const browser = await puppeteer.launch({ + executablePath: args.chrome, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"], + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setUserAgent(profile.user_agent); + await page.setViewport(profile.viewport); + await page.setCacheEnabled(false); + + const observations = []; + const pendingBodies = []; + page.on("response", (response) => { + if (!response.url().includes("getHloc")) return; + const observation = { + status: response.status(), + url: response.url(), + shape: requestShape(response.url()), + observed_at: new Date().toISOString(), + body_analysis: null, + body_error: null, + }; + observations.push(observation); + pendingBodies.push( + (async () => { + try { + const json = await response.json(); + observation.body_analysis = analyzeHloc(json, config.ticker); + } catch (error) { + observation.body_error = String(error?.message || error); + } + })() + ); + }); + + let navigationError = null; + try { + await page.goto(config.target_url, { waitUntil: "domcontentloaded", timeout: 90_000 }); + } catch (error) { + navigationError = String(error?.stack || error); + } + + const initialDeadline = Date.now() + 35_000; + while (!observations.some((item) => item.shape?.interval === "D1") && Date.now() < initialDeadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + await Promise.allSettled(pendingBodies); + const before = await chartState(page); + await page.screenshot({ path: path.join(args["output-dir"], "before-D1.png"), fullPage: true }); + + const action = await clickExactInterval(page, "H1"); + const transitionDeadline = Date.now() + 35_000; + while (!observations.some((item) => item.shape?.interval === "H1") && Date.now() < transitionDeadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.allSettled(pendingBodies); + + const after = await chartState(page); + await page.screenshot({ path: path.join(args["output-dir"], "after-H1.png"), fullPage: true }); + + const d1 = observations.find((item) => item.shape?.interval === "D1") ?? null; + const h1 = observations.find((item) => item.shape?.interval === "H1") ?? null; + const h1IntegrityPass = Boolean( + h1?.body_analysis && + h1.body_analysis.candle_count > 0 && + h1.body_analysis.violations.length === 0 + ); + + let verdict; + if (navigationError) verdict = "EVIDENCE_FAILURE"; + else if (!before.chart_visible) verdict = "INITIAL_CHART_NOT_VISIBLE"; + else if (!action.opened || !action.selected) verdict = "EXACT_INTERVAL_CONTROL_FAILED"; + else if (!h1) verdict = "UI_CHANGED_WITHOUT_H1_REQUEST"; + else if (h1.shape?.timeframe === d1?.shape?.timeframe) verdict = "TIMEFRAME_DID_NOT_CHANGE"; + else if (after.selected_value !== "H1" || !after.chart_visible) verdict = "UI_REQUEST_STATE_DIVERGENCE"; + else if (!h1IntegrityPass) verdict = "H1_DATA_INTEGRITY_WARN"; + else verdict = "TRANSITION_PASS"; + + const result = { + schema_version: "liminalqa-chart-timeframe-transition-v2", + target_url: config.target_url, + ticker: config.ticker, + verdict, + navigation_error: navigationError, + before, + action, + after, + d1_observation: d1, + h1_observation: h1, + all_get_hloc_observations: observations, + generated_at: new Date().toISOString(), + }; + + await fs.writeFile( + path.join(args["output-dir"], "timeframe-transition-result.json"), + `${JSON.stringify(result, null, 2)}\n`, + "utf8" + ); + const lines = [ + "# Tradernet chart interval transition", + "", + `**Verdict:** ${verdict} `, + `**UI:** ${before.selected_value ?? "n/a"} → ${after.selected_value ?? "n/a"} `, + `**Requests observed:** ${observations.length}`, + "", + "| Phase | Timeframe | Interval | Candles | Violations | Status |", + "|---|---:|---|---:|---:|---:|", + `| Initial | ${d1?.shape?.timeframe ?? "n/a"} | ${d1?.shape?.interval ?? "n/a"} | ${d1?.body_analysis?.candle_count ?? 0} | ${d1?.body_analysis?.violations.length ?? "n/a"} | ${d1?.status ?? "n/a"} |`, + `| After switch | ${h1?.shape?.timeframe ?? "n/a"} | ${h1?.shape?.interval ?? "n/a"} | ${h1?.body_analysis?.candle_count ?? 0} | ${h1?.body_analysis?.violations.length ?? "n/a"} | ${h1?.status ?? "n/a"} |`, + "", + "> The audit clicked exactly one public UI control and only observed the requests naturally initiated by that interaction.", + "", + ]; + await fs.writeFile( + path.join(args["output-dir"], "timeframe-transition-summary.md"), + lines.join("\n"), + "utf8" + ); + + await context.close(); + await browser.close(); + console.log(JSON.stringify(result, null, 2)); + if (verdict === "EVIDENCE_FAILURE") process.exitCode = 1; +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/tradernet_chart_timeframe_real_click.mjs b/scripts/tradernet_chart_timeframe_real_click.mjs new file mode 100644 index 00000000..e51f98d7 --- /dev/null +++ b/scripts/tradernet_chart_timeframe_real_click.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import puppeteer from "puppeteer-core"; + +function parseArgs(argv) { + const result = {}; + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i]; + const value = argv[i + 1]; + if (!key?.startsWith("--") || value === undefined) throw new Error(`Invalid argument near ${key}`); + result[key.slice(2)] = value; + } + return result; +} + +function parseRequest(url) { + try { + const parsed = new URL(url); + const payload = JSON.parse(parsed.searchParams.get("q")); + return { + id: payload.params?.id ?? null, + timeframe: payload.params?.timeframe ?? null, + interval: payload.params?.interval ?? null, + interval_mode: payload.params?.intervalMode ?? null, + date_from: payload.params?.date_from ?? null, + date_to: payload.params?.date_to ?? null, + count: payload.params?.count ?? null, + demo: payload.params?.demo ?? null, + }; + } catch { + return null; + } +} + +function inspectHloc(json, ticker) { + const candles = json?.hloc?.[ticker]; + const times = json?.xSeries?.[ticker]; + const volumes = json?.vl?.[ticker]; + const violations = []; + if (!Array.isArray(candles)) violations.push("HLOC_MISSING"); + if (!Array.isArray(times)) violations.push("TIMESTAMPS_MISSING"); + if (!Array.isArray(volumes)) violations.push("VOLUMES_MISSING"); + if (!Array.isArray(candles) || !Array.isArray(times) || !Array.isArray(volumes)) { + return { candle_count: 0, timestamp_count: 0, volume_count: 0, violations }; + } + if (candles.length !== times.length) violations.push("HLOC_TIMESTAMP_LENGTH_MISMATCH"); + if (candles.length !== volumes.length) violations.push("HLOC_VOLUME_LENGTH_MISMATCH"); + + let previous = null; + const seen = new Set(); + candles.forEach((row, index) => { + if (!Array.isArray(row) || row.length < 4) { + violations.push(`CANDLE_SHAPE_INVALID:${index}`); + return; + } + const [high, low, open, close] = row.map(Number); + if (![high, low, open, close].every(Number.isFinite)) violations.push(`CANDLE_NON_NUMERIC:${index}`); + if (high < low) violations.push(`HIGH_BELOW_LOW:${index}`); + if (open < low || open > high) violations.push(`OPEN_OUTSIDE_RANGE:${index}`); + if (close < low || close > high) violations.push(`CLOSE_OUTSIDE_RANGE:${index}`); + + const timestamp = Number(times[index]); + if (!Number.isFinite(timestamp)) violations.push(`TIMESTAMP_NON_NUMERIC:${index}`); + else { + if (seen.has(timestamp)) violations.push(`TIMESTAMP_DUPLICATE:${index}`); + if (Number.isFinite(previous) && timestamp <= previous) violations.push(`TIMESTAMP_NOT_INCREASING:${index}`); + seen.add(timestamp); + previous = timestamp; + } + + const volume = Number(volumes[index]); + if (!Number.isFinite(volume)) violations.push(`VOLUME_NON_NUMERIC:${index}`); + else if (volume < 0) violations.push(`VOLUME_NEGATIVE:${index}`); + }); + + return { + candle_count: candles.length, + timestamp_count: times.length, + volume_count: volumes.length, + first_timestamp: times.length ? Number(times[0]) : null, + last_timestamp: times.length ? Number(times.at(-1)) : null, + violations: violations.slice(0, 100), + }; +} + +async function state(page) { + return page.evaluate(() => { + const opener = document.querySelector(".js-intervalSelector"); + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width >= 120 && rect.height >= 80 && style.display !== "none" && style.visibility !== "hidden"; + }; + return { + selected_value: opener?.getAttribute("data-value") ?? null, + selected_text: opener?.textContent?.replace(/\s+/g, " ").trim() ?? null, + chart_visible: [...document.querySelectorAll('canvas, svg, [id*="chart" i], [class*="chart" i]')].some(visible), + final_url: location.href, + title: document.title, + }; + }); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + for (const key of ["config", "chrome", "output-dir"]) { + if (!args[key]) throw new Error(`--${key} is required`); + } + const config = JSON.parse(await fs.readFile(args.config, "utf8")); + const profile = config.profiles.find((item) => item.id === "desktop_broadband"); + if (!profile || config.target_url !== "https://tradernet.ru/charts/MICEXINDEXCF") { + throw new Error("Unexpected configuration"); + } + + await fs.mkdir(args["output-dir"], { recursive: true }); + const browser = await puppeteer.launch({ + executablePath: args.chrome, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"], + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setUserAgent(profile.user_agent); + await page.setViewport(profile.viewport); + await page.setCacheEnabled(false); + + const observations = []; + const bodyTasks = []; + page.on("response", (response) => { + if (!response.url().includes("getHloc")) return; + const observation = { + status: response.status(), + url: response.url(), + shape: parseRequest(response.url()), + observed_at: new Date().toISOString(), + body_analysis: null, + body_error: null, + }; + observations.push(observation); + bodyTasks.push( + (async () => { + try { + observation.body_analysis = inspectHloc(await response.json(), config.ticker); + } catch (error) { + observation.body_error = String(error?.message || error); + } + })() + ); + }); + + let navigationError = null; + try { + await page.goto(config.target_url, { waitUntil: "domcontentloaded", timeout: 90_000 }); + } catch (error) { + navigationError = String(error?.stack || error); + } + + const initialDeadline = Date.now() + 35_000; + while (!observations.some((item) => item.shape?.interval === "D1") && Date.now() < initialDeadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + await Promise.allSettled(bodyTasks); + const before = await state(page); + await page.screenshot({ path: path.join(args["output-dir"], "before-D1.png"), fullPage: true }); + + const openerSelector = ".js-intervalSelector[data-value=\"D1\"]"; + const optionSelector = ".js-selectInterval .js-chart-click[data-value=\"H1\"]"; + let action = { opened: false, option_visible: false, selected: false, error: null }; + try { + await page.waitForSelector(openerSelector, { visible: true, timeout: 10_000 }); + await page.click(openerSelector); + action.opened = true; + await page.waitForSelector(optionSelector, { visible: true, timeout: 10_000 }); + action.option_visible = true; + const optionText = await page.$eval(optionSelector, (element) => element.textContent?.replace(/\s+/g, " ").trim() ?? null); + await page.click(optionSelector); + action = { ...action, selected: true, target_value: "H1", target_text: optionText }; + } catch (error) { + action.error = String(error?.stack || error); + } + + const h1Deadline = Date.now() + 35_000; + while (!observations.some((item) => item.shape?.interval === "H1") && Date.now() < h1Deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.allSettled(bodyTasks); + const after = await state(page); + await page.screenshot({ path: path.join(args["output-dir"], "after-H1.png"), fullPage: true }); + + const d1 = observations.find((item) => item.shape?.interval === "D1") ?? null; + const h1 = observations.find((item) => item.shape?.interval === "H1") ?? null; + const h1Clean = Boolean(h1?.body_analysis?.candle_count > 0 && h1.body_analysis.violations.length === 0); + + let verdict; + if (navigationError) verdict = "EVIDENCE_FAILURE"; + else if (!before.chart_visible || before.selected_value !== "D1") verdict = "INVALID_INITIAL_STATE"; + else if (!action.opened || !action.option_visible || !action.selected) verdict = "TRUSTED_CLICK_FAILED"; + else if (!h1) verdict = "TRUSTED_CLICK_NO_H1_REQUEST"; + else if (after.selected_value !== "H1" || !after.chart_visible) verdict = "UI_REQUEST_STATE_DIVERGENCE"; + else if (h1.shape?.timeframe === d1?.shape?.timeframe) verdict = "TIMEFRAME_DID_NOT_CHANGE"; + else if (!h1Clean) verdict = "H1_DATA_INTEGRITY_WARN"; + else verdict = "TRANSITION_PASS"; + + const result = { + schema_version: "liminalqa-chart-timeframe-trusted-click-v1", + verdict, + target_url: config.target_url, + ticker: config.ticker, + navigation_error: navigationError, + before, + action, + after, + d1_observation: d1, + h1_observation: h1, + observations, + generated_at: new Date().toISOString(), + }; + await fs.writeFile(path.join(args["output-dir"], "timeframe-transition-result.json"), `${JSON.stringify(result, null, 2)}\n`); + await fs.writeFile( + path.join(args["output-dir"], "timeframe-transition-summary.md"), + [ + "# Tradernet D1 → H1 trusted-click transition", + "", + `**Verdict:** ${verdict} `, + `**UI:** ${before.selected_value ?? "n/a"} → ${after.selected_value ?? "n/a"} `, + `**Requests:** ${observations.length}`, + "", + "| Phase | Timeframe | Interval | Candles | Violations | Status |", + "|---|---:|---|---:|---:|---:|", + `| Initial | ${d1?.shape?.timeframe ?? "n/a"} | ${d1?.shape?.interval ?? "n/a"} | ${d1?.body_analysis?.candle_count ?? 0} | ${d1?.body_analysis?.violations.length ?? "n/a"} | ${d1?.status ?? "n/a"} |`, + `| After switch | ${h1?.shape?.timeframe ?? "n/a"} | ${h1?.shape?.interval ?? "n/a"} | ${h1?.body_analysis?.candle_count ?? 0} | ${h1?.body_analysis?.violations.length ?? "n/a"} | ${h1?.status ?? "n/a"} |`, + "", + "> Exactly one visible UI option was selected with Puppeteer's trusted mouse event. No direct API request was issued.", + "", + ].join("\n"), + "utf8" + ); + + await context.close(); + await browser.close(); + console.log(JSON.stringify(result, null, 2)); + if (verdict === "EVIDENCE_FAILURE") process.exitCode = 1; +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/tradernet_public_hloc_integrity.mjs b/scripts/tradernet_public_hloc_integrity.mjs new file mode 100644 index 00000000..fe48ff43 --- /dev/null +++ b/scripts/tradernet_public_hloc_integrity.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import puppeteer from "puppeteer-core"; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]; + const value = argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) throw new Error(`Invalid argument near ${key}`); + args[key.slice(2)] = value; + } + return args; +} + +function round(value, digits = 3) { + if (!Number.isFinite(value)) return null; + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function analyzeHloc(json, ticker) { + const hloc = json?.hloc?.[ticker]; + const timestamps = json?.xSeries?.[ticker]; + const volumes = json?.vl?.[ticker]; + const violations = []; + const add = (type, index = null, details = {}) => { + if (violations.length < 100) violations.push({ type, index, ...details }); + }; + + if (!Array.isArray(hloc)) add("HLOC_SERIES_MISSING"); + if (!Array.isArray(timestamps)) add("TIMESTAMP_SERIES_MISSING"); + if (!Array.isArray(volumes)) add("VOLUME_SERIES_MISSING"); + if (!Array.isArray(hloc) || !Array.isArray(timestamps) || !Array.isArray(volumes)) { + return { candle_count: 0, timestamp_count: 0, volume_count: 0, violations }; + } + + if (hloc.length !== timestamps.length) { + add("HLOC_TIMESTAMP_LENGTH_MISMATCH", null, { hloc: hloc.length, timestamps: timestamps.length }); + } + if (hloc.length !== volumes.length) { + add("HLOC_VOLUME_LENGTH_MISMATCH", null, { hloc: hloc.length, volumes: volumes.length }); + } + + const seen = new Set(); + let previous = null; + let firstTimestamp = null; + let lastTimestamp = null; + let nonZeroVolumeCount = 0; + + hloc.forEach((row, index) => { + if (!Array.isArray(row) || row.length < 4) { + add("CANDLE_SHAPE_INVALID", index, { length: Array.isArray(row) ? row.length : null }); + return; + } + const [high, low, open, close] = row.map(Number); + if (![high, low, open, close].every(Number.isFinite)) add("CANDLE_NON_NUMERIC", index); + if (high < low) add("CANDLE_HIGH_BELOW_LOW", index); + if (open < low || open > high) add("CANDLE_OPEN_OUTSIDE_RANGE", index); + if (close < low || close > high) add("CANDLE_CLOSE_OUTSIDE_RANGE", index); + if ([high, low, open, close].some((value) => value <= 0)) add("CANDLE_NON_POSITIVE_PRICE", index); + + const timestamp = Number(timestamps[index]); + if (!Number.isFinite(timestamp)) { + add("TIMESTAMP_NON_NUMERIC", index); + } else { + firstTimestamp ??= timestamp; + lastTimestamp = timestamp; + if (seen.has(timestamp)) add("TIMESTAMP_DUPLICATE", index); + seen.add(timestamp); + if (Number.isFinite(previous) && timestamp <= previous) add("TIMESTAMP_NOT_STRICTLY_INCREASING", index); + previous = timestamp; + } + + const volume = Number(volumes[index]); + if (!Number.isFinite(volume)) add("VOLUME_NON_NUMERIC", index); + if (volume < 0) add("VOLUME_NEGATIVE", index); + if (volume > 0) nonZeroVolumeCount += 1; + }); + + const maxSeries = Number(json?.maxSeries); + if (Number.isFinite(maxSeries) && Number.isFinite(lastTimestamp) && maxSeries !== lastTimestamp) { + add("MAX_SERIES_DIFFERS_FROM_LAST_TIMESTAMP", null, { + max_series: maxSeries, + last_timestamp: lastTimestamp, + delta_seconds: maxSeries - lastTimestamp, + }); + } + + return { + candle_count: hloc.length, + timestamp_count: timestamps.length, + volume_count: volumes.length, + non_zero_volume_count: nonZeroVolumeCount, + first_timestamp: firstTimestamp, + first_timestamp_iso: Number.isFinite(firstTimestamp) ? new Date(firstTimestamp * 1000).toISOString() : null, + last_timestamp: lastTimestamp, + last_timestamp_iso: Number.isFinite(lastTimestamp) ? new Date(lastTimestamp * 1000).toISOString() : null, + max_series: Number.isFinite(maxSeries) ? maxSeries : null, + server_took_ms: Number.isFinite(Number(json?.took)) ? Number(json.took) : null, + info_present: Boolean(json?.info?.[ticker]), + violations, + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + for (const key of ["config", "chrome", "output-dir"]) { + if (!args[key]) throw new Error(`--${key} is required`); + } + const config = JSON.parse(await fs.readFile(args.config, "utf8")); + const desktop = config.profiles.find((profile) => profile.id === "desktop_broadband"); + if (!desktop || config.target_url !== "https://tradernet.ru/charts/MICEXINDEXCF") { + throw new Error("Unexpected audit configuration"); + } + + const browser = await puppeteer.launch({ + executablePath: args.chrome, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-background-networking"], + }); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setUserAgent(desktop.user_agent); + await page.setViewport(desktop.viewport); + await page.setCacheEnabled(false); + + const client = await page.createCDPSession(); + await client.send("Network.enable"); + await client.send("Network.setCacheDisabled", { cacheDisabled: true }); + await client.send("Network.emulateNetworkConditions", { + offline: false, + latency: desktop.network.latency_ms, + downloadThroughput: desktop.network.download_bytes_per_second, + uploadThroughput: desktop.network.upload_bytes_per_second, + connectionType: desktop.network.connection_type, + }); + + let captured = null; + let captureError = null; + let requestShape = null; + page.on("response", (response) => { + if (captured || captureError || !response.url().includes("getHloc")) return; + void (async () => { + try { + const url = new URL(response.url()); + const rawQuery = url.searchParams.get("q"); + if (rawQuery) { + const requestJson = JSON.parse(rawQuery); + requestShape = { + cmd: requestJson.cmd, + id: requestJson.params?.id, + timeframe: requestJson.params?.timeframe, + interval: requestJson.params?.interval, + interval_mode: requestJson.params?.intervalMode, + date_from: requestJson.params?.date_from, + date_to: requestJson.params?.date_to, + count: requestJson.params?.count, + demo: requestJson.params?.demo, + }; + } + const json = await response.json(); + captured = { + url: response.url(), + status: response.status(), + analysis: analyzeHloc(json, config.ticker), + }; + } catch (error) { + captureError = String(error?.stack || error); + } + })(); + }); + + let navigationError = null; + const started = performance.now(); + try { + await page.goto(config.target_url, { waitUntil: "domcontentloaded", timeout: 90_000 }); + } catch (error) { + navigationError = String(error?.stack || error); + } + + const deadline = Date.now() + 35_000; + while (!captured && !captureError && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + const elapsedMs = round(performance.now() - started); + await fs.mkdir(args["output-dir"], { recursive: true }); + await page.screenshot({ path: path.join(args["output-dir"], "hloc-page.png"), fullPage: true }); + + const result = { + schema_version: "liminalqa-public-hloc-integrity-result-v1", + target_url: config.target_url, + final_url: page.url(), + ticker: config.ticker, + navigation_error: navigationError, + capture_error: captureError, + observed_within_ms: elapsedMs, + request_shape: requestShape, + response: captured, + verdict: + navigationError || captureError || !captured + ? "EVIDENCE_FAILURE" + : captured.analysis.violations.length > 0 + ? "WARN" + : "PASS", + generated_at: new Date().toISOString(), + }; + + const jsonPath = path.join(args["output-dir"], "hloc-integrity-result.json"); + await fs.writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + const analysis = captured?.analysis; + const markdown = [ + "# Tradernet public HLOC integrity", + "", + `**Verdict:** ${result.verdict} `, + `**Ticker:** ${config.ticker} `, + `**Observed within:** ${elapsedMs} ms`, + "", + "| Candles | Timestamps | Volumes | First candle | Last candle | Violations |", + "|---:|---:|---:|---|---|---:|", + `| ${analysis?.candle_count ?? 0} | ${analysis?.timestamp_count ?? 0} | ${analysis?.volume_count ?? 0} | ${analysis?.first_timestamp_iso ?? "n/a"} | ${analysis?.last_timestamp_iso ?? "n/a"} | ${analysis?.violations.length ?? 0} |`, + "", + "> The response was captured only because the public chart page naturally requested it. No direct API request was issued by the audit.", + "", + ].join("\n"); + await fs.writeFile(path.join(args["output-dir"], "hloc-integrity-summary.md"), markdown, "utf8"); + + await context.close(); + await browser.close(); + console.log(JSON.stringify(result, null, 2)); + if (result.verdict === "EVIDENCE_FAILURE") process.exitCode = 1; +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exitCode = 1; +});