# DSE Circuit Breaker Cache — Porting Guide

Self-contained guide to migrate **PaperTreadingCacheController** (DSE circuit breaker scrape + store) into another Laravel project.

**Source project:** Fintra `fi_adm`  
**Source file:** `app/Http/Controllers/PaperTreadingCacheController.php`  
**Class name note:** spelled `PaperTreadingCache` (typo for “Trading”). Rename only if you update all references.

**Stack verified against:** Laravel `^11.31`, PHP `^8.2`

---

## 1. What this feature does

1. HTTP-GETs the DSE circuit breaker HTML page (`https://www.dse.com.bd/cbul.php` by default).
2. Parses the HTML table into structured rows + a `by_trade_code` lookup map.
3. Stores the payload (today: Laravel Cache, 8h TTL — key `paper_trading_circuit_breaker`).
4. Exposes:
   - **HTTP:** `GET /api/paper-trading/cache-circuit-breaker[?refresh=1]`
   - **PHP:** `ensureCircuitBreakerCache(bool $forceRefresh = false): ?array` for other controllers

Consumers use lower/upper price limits and tick size to validate paper-trading orders and market depth.

### Flow

```
GET /api/paper-trading/cache-circuit-breaker[?refresh=1]
        │
        ▼
cache_circuit_breaker()
        │
        ▼
ensureCircuitBreakerCache($forceRefresh)
        │
        ├─ hit & !forceRefresh → return stored payload
        │
        └─ miss or refresh → fetchAndStoreCircuitBreakerCache()
                │
                ├─ Http GET config('paper_trading.circuit_breaker_url')
                ├─ parseCircuitBreakerHtml()
                ├─ stamp fetched_at (Asia/Dhaka)
                └─ store (Cache today; DB table recommended for port)
```

---

## 2. Prerequisites (target Laravel project)

| Requirement | Why |
|-------------|-----|
| PHP **8.2+** | Typed syntax / nullsafe operators in source |
| Laravel **10/11+** (Http client + Cache) | `Http::`, `Cache::`, `config()` |
| PHP extension **`dom`** / **`libxml`** | `DOMDocument` + `DOMXPath` HTML parse |
| Outbound HTTPS to DSE | Scrape source |
| Cache driver configured (`CACHE_STORE`) | Current storage; optional if you use DB only |
| `ext-curl` (usual with Laravel) | HTTP client |

No Composer packages beyond Laravel itself are required for this controller.

---

## 3. Files to create / copy (checklist)

| # | Path in target project | Action |
|---|------------------------|--------|
| 1 | `config/paper_trading.php` | Create (full contents below) |
| 2 | `.env` (+ `.env.example`) | Add 3 env vars (below) |
| 3 | `app/Support/AppDateTime.php` | Create **or** replace `AppDateTime::now()` with `now('Asia/Dhaka')` |
| 4 | `app/Http/Controllers/PaperTreadingCacheController.php` | Create (full contents below) |
| 5 | `routes/api.php` | Register route (snippet below) |
| 6 | `database/migrations/xxxx_create_paper_circuit_breaker_table.php` | Create if using DB persistence (recommended) |
| 7 | `app/Models/PaperCircuitBreaker.php` | Create if using DB persistence |

**Optional consumer helpers** (only if you also port order place / modify / depth):

| Consumer (source) | How it uses this feature |
|-------------------|--------------------------|
| `PaperOrderPlacerController` | `app(PaperTreadingCacheController::class)->ensureCircuitBreakerCache(false)` then `by_trade_code[$code]` |
| `PaperOrderModifierController` | Same |
| `PaperMarketDepthOrderbookManagementController` | Warms via `ensureCircuitBreakerCache()`, then `Cache::get(CIRCUIT_BREAKER_CACHE_KEY)` |

Minimal lookup helper to reuse in any consumer is in **§9**.

---

## 4. Environment variables

Add to `.env` / `.env.example`:

```env
DSE_CIRCUIT_BREAKER_URL=https://www.dse.com.bd/cbul.php

# Local XAMPP often lacks CA bundle (cURL error 60) → false locally.
# Production: true (or set CA bundle path below).
DSE_CIRCUIT_BREAKER_VERIFY_SSL=false

# Optional absolute path to CA bundle, e.g. C:\xampp\apache\bin\curl-ca-bundle.crt
DSE_CIRCUIT_BREAKER_CA_BUNDLE=

# If keeping Laravel Cache layer (current Fintra behavior):
CACHE_STORE=database
# DB_CACHE_TABLE=cache
```

---

## 5. Config file — `config/paper_trading.php`

Laravel auto-loads any file in `config/` as `config('paper_trading.*')`. No provider registration needed.

```php
<?php

return [
    'circuit_breaker_url' => env(
        'DSE_CIRCUIT_BREAKER_URL',
        'https://www.dse.com.bd/cbul.php'
    ),

    /*
    | Local XAMPP often lacks a CA bundle (cURL error 60). Set true in production
    | when php.ini curl.cainfo or DSE_CIRCUIT_BREAKER_CA_BUNDLE is configured.
    */
    'circuit_breaker_verify_ssl' => filter_var(
        env('DSE_CIRCUIT_BREAKER_VERIFY_SSL', false),
        FILTER_VALIDATE_BOOLEAN
    ),

    'circuit_breaker_ca_bundle' => env('DSE_CIRCUIT_BREAKER_CA_BUNDLE'),
];
```

After adding env keys, run `php artisan config:clear` (or `config:cache` in production).

---

## 6. Support class — `app/Support/AppDateTime.php`

Used only to stamp `fetched_at` in **Asia/Dhaka**.

```php
<?php

namespace App\Support;

use Carbon\Carbon;

final class AppDateTime
{
    public const TIMEZONE = 'Asia/Dhaka';

    public static function now(): Carbon
    {
        return Carbon::now(self::TIMEZONE);
    }

    public static function parse(null|string|\DateTimeInterface $value): Carbon
    {
        return Carbon::parse($value, self::TIMEZONE);
    }
}
```

**Alternative (no helper file):** in the controller replace:

```php
$payload['fetched_at'] = AppDateTime::now()->toDateTimeString();
```

with:

```php
$payload['fetched_at'] = now('Asia/Dhaka')->toDateTimeString();
```

and drop the `use App\Support\AppDateTime;` import.

---

## 7. Route — `routes/api.php`

Fintra registers this **without JWT** (open route). Protect at gateway if needed.

```php
use App\Http\Controllers\PaperTreadingCacheController;

// ==================== PAPER TRADING CACHE (open / no auth) ====================
Route::match(['get'], 'paper-trading/cache-circuit-breaker', [PaperTreadingCacheController::class, 'cache_circuit_breaker'])
    ->name('paper-trading-cache-circuit-breaker');
```

With Laravel’s default `api` prefix this becomes:

| Item | Value |
|------|--------|
| Method | `GET` |
| URL | `/api/paper-trading/cache-circuit-breaker` |
| Query | `refresh=1` forces re-fetch |
| Route name | `paper-trading-cache-circuit-breaker` |

### Curl

```bash
curl --location --request GET '{{base_url}}/api/paper-trading/cache-circuit-breaker'
curl --location --request GET '{{base_url}}/api/paper-trading/cache-circuit-breaker?refresh=1'
```

---

## 8. Controller — full source

**Path:** `app/Http/Controllers/PaperTreadingCacheController.php`

```php
<?php

namespace App\Http\Controllers;

use App\Support\AppDateTime;
use DOMDocument;
use DOMXPath;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Client\PendingRequest;

class PaperTreadingCacheController extends Controller
{
    public const CIRCUIT_BREAKER_CACHE_KEY = 'paper_trading_circuit_breaker';

    private const CACHE_TTL_SECONDS = 28800; // 8 hours

    /**
     * Fetch DSE circuit breaker table and store in cache (8 hour TTL).
     */
    public function cache_circuit_breaker(Request $request)
    {
        $forceRefresh = $request->boolean('refresh');
        $servedFromExisting = false;
        $today = AppDateTime::now()->toDateString();

        $alreadyStoredInDb = DB::table('paper_circuit_breaker')
            ->whereDate('fetched_at', $today)
            ->exists();

        if (!$forceRefresh) {
            $check = Cache::get(self::CIRCUIT_BREAKER_CACHE_KEY);
            $servedFromExisting = is_array($check) && !empty($check['rows']);
        }

        $existing = $this->ensureCircuitBreakerCache($forceRefresh);

        if ($existing === null) {
            return response()->json([
                'status' => false,
                'message' => 'Failed to cache circuit breaker data',
            ], 500);
        }

        $wasInsertedNow = !empty($existing['db_inserted']);

        if ($alreadyStoredInDb && !$wasInsertedNow) {
            $message = "Today's data is already stored";
        } elseif ($servedFromExisting) {
            $message = 'Circuit breaker data served from cache';
        } else {
            $message = 'Circuit breaker data cached successfully';
        }

        return response()->json([
            'status' => true,
            'message' => $message,
            'cache_key' => self::CIRCUIT_BREAKER_CACHE_KEY,
            'ttl_hours' => 8,
            'cached' => $servedFromExisting,
            'already_stored' => $alreadyStoredInDb || $wasInsertedNow,
            'as_of' => $existing['as_of'] ?? null,
            'fetched_at' => $existing['fetched_at'] ?? null,
            'count' => count($existing['rows'] ?? []),
            'data' => $existing,
        ], 200);
    }

    /**
     * Return circuit breaker payload from cache; fetch and store when missing.
     *
     * @return array<string, mixed>|null
     */
    public function ensureCircuitBreakerCache(bool $forceRefresh = false): ?array
    {
        if (!$forceRefresh) {
            $existing = Cache::get(self::CIRCUIT_BREAKER_CACHE_KEY);
            if (is_array($existing) && !empty($existing['rows'])) {
                return $existing;
            }
        }

        return $this->fetchAndStoreCircuitBreakerCache();
    }

    /**
     * @return array<string, mixed>|null
     */
    private function fetchAndStoreCircuitBreakerCache(): ?array
    {
        $url = (string) config('paper_trading.circuit_breaker_url');

        try {
            $response = $this->circuitBreakerHttpClient()->get($url);

            if (!$response->successful()) {
                Log::warning('paper_trading.circuit_breaker_http_failed', [
                    'http_status' => $response->status(),
                    'source_url' => $url,
                ]);

                return null;
            }

            $payload = $this->parseCircuitBreakerHtml($response->body(), $url);
            $nowDhaka = AppDateTime::now();
            $payload['fetched_at'] = $nowDhaka->toDateTimeString();
            $today = $nowDhaka->toDateString();

            // Check if today's data is already stored in database (by fetched_at date)
            $alreadyExistsForToday = DB::table('paper_circuit_breaker')
                ->whereDate('fetched_at', $today)
                ->exists();

            if ($alreadyExistsForToday) {
                Log::info('paper_trading.circuit_breaker_db_skip', [
                    'reason' => 'Data for today (' . $today . ') is already stored in database.',
                ]);
                $payload['db_inserted'] = false;
                $payload['already_stored'] = true;
            } else {
                try {
                    DB::transaction(function () use ($payload) {
                        // Set all past records to inactive ('I') when storing new daily data
                        DB::table('paper_circuit_breaker')
                            ->where('activity_flag', 'A')
                            ->update(['activity_flag' => 'I']);

                        $now = now();
                        $insertData = [];

                        foreach ($payload['rows'] as $row) {
                            $insertData[] = [
                                'serial' => $row['serial'],
                                'trade_code' => $row['trade_code'],
                                'breaker_pct' => round((float) $row['breaker_pct'], 2),
                                'tick_size' => round((float) $row['tick_size'], 2),
                                'open_adj_price' => round((float) $row['open_adj_price'], 2),
                                'lower_limit' => round((float) $row['lower_limit'], 2),
                                'upper_limit' => round((float) $row['upper_limit'], 2),
                                'as_of' => $payload['as_of'],
                                'source_url' => $payload['source_url'],
                                'fetched_at' => $payload['fetched_at'],
                                'activity_flag' => 'A',
                                'created_at' => $now,
                                'updated_at' => $now,
                            ];
                        }

                        foreach (array_chunk($insertData, 100) as $chunk) {
                            DB::table('paper_circuit_breaker')->insert($chunk);
                        }
                    });

                    $payload['db_inserted'] = true;
                    $payload['already_stored'] = false;
                } catch (\Throwable $dbEx) {
                    Log::error('paper_trading.circuit_breaker_db_sync_failed', [
                        'error' => $dbEx->getMessage(),
                    ]);
                }
            }

            Cache::put(self::CIRCUIT_BREAKER_CACHE_KEY, $payload, self::CACHE_TTL_SECONDS);

            return $payload;
        } catch (\Throwable $e) {
            Log::error('paper_trading.circuit_breaker_cache_failed', [
                'error' => $e->getMessage(),
                'source_url' => $url,
            ]);

            return null;
        }
    }

    /**
     * @return array{
     *     source_url: string,
     *     as_of: ?string,
     *     rows: array<int, array<string, mixed>>,
     *     by_trade_code: array<string, array<string, mixed>>
     * }
     */
    private function parseCircuitBreakerHtml(string $html, string $sourceUrl): array
    {
        $asOf = null;
        if (preg_match('/Circuit Breaker on\s+([^<\n\r]+)/i', $html, $matches)) {
            $asOf = trim(html_entity_decode(strip_tags($matches[1])));
        }

        libxml_use_internal_errors(true);
        $dom = new DOMDocument();
        $dom->loadHTML($html);
        libxml_clear_errors();

        $xpath = new DOMXPath($dom);
        $rows = [];

        /** @var \DOMElement $tr */
        foreach ($xpath->query('//tr') as $tr) {
            $cells = $tr->getElementsByTagName('td');
            if ($cells->length !== 7) {
                continue;
            }

            $tradeCodeNode = $cells->item(1);
            $tradeCode = trim($tradeCodeNode?->textContent ?? '');
            if ($tradeCode === '' || !preg_match('/^[A-Z0-9]+$/i', $tradeCode)) {
                continue;
            }

            $row = [
                'serial' => (int) $this->parseNumber($cells->item(0)?->textContent ?? ''),
                'trade_code' => strtoupper($tradeCode),
                'breaker_pct' => $this->parseNumber($cells->item(2)?->textContent ?? ''),
                'tick_size' => $this->parseNumber($cells->item(3)?->textContent ?? ''),
                'open_adj_price' => $this->parseNumber($cells->item(4)?->textContent ?? ''),
                'lower_limit' => $this->parseNumber($cells->item(5)?->textContent ?? ''),
                'upper_limit' => $this->parseNumber($cells->item(6)?->textContent ?? ''),
            ];

            $rows[] = $row;
        }

        if ($rows === []) {
            throw new \RuntimeException('No circuit breaker rows parsed from source HTML.');
        }

        $byTradeCode = [];
        foreach ($rows as $row) {
            $byTradeCode[$row['trade_code']] = $row;
        }

        return [
            'source_url' => $sourceUrl,
            'as_of' => $asOf,
            'rows' => $rows,
            'by_trade_code' => $byTradeCode,
        ];
    }

    private function parseNumber(string $value): float
    {
        $normalized = str_replace(',', '', trim($value));

        return $normalized === '' || $normalized === '-'
            ? 0.0
            : (float) $normalized;
    }

    private function circuitBreakerHttpClient(): PendingRequest
    {
        $client = Http::connectTimeout(15)
            ->timeout(90)
            ->withHeaders([
                'User-Agent' => 'FintraPaperTrading/1.0',
                'Accept' => 'text/html,application/xhtml+xml',
            ]);

        $caBundle = config('paper_trading.circuit_breaker_ca_bundle');
        if (is_string($caBundle) && $caBundle !== '' && is_file($caBundle)) {
            return $client->withOptions(['verify' => $caBundle]);
        }

        $verifySsl = (bool) config('paper_trading.circuit_breaker_verify_ssl', false);

        return $client->withOptions(['verify' => $verifySsl]);
    }
}
```

### Methods summary

| Method | Visibility | Role |
|--------|------------|------|
| `cache_circuit_breaker(Request)` | public (HTTP) | Warm/serve + JSON response; `?refresh=1` |
| `ensureCircuitBreakerCache(bool)` | public | Shared warm/read for other controllers |
| `fetchAndStoreCircuitBreakerCache()` | private | HTTP → parse → `Cache::put` |
| `parseCircuitBreakerHtml(...)` | private | DOM parse; requires exactly **7** `<td>` cells per data row |
| `parseNumber(string)` | private | Strip commas / `-` → float |
| `circuitBreakerHttpClient()` | private | Timeouts 15/90s, UA, SSL options |

### Log channels / keys

| Level | Key | When |
|-------|-----|------|
| `warning` | `paper_trading.circuit_breaker_http_failed` | Non-2xx from DSE |
| `error` | `paper_trading.circuit_breaker_cache_failed` | Exception / empty parse |

---

## 9. Consumer integration (copy into order/depth controllers)

### Resolve one security’s band

```php
use App\Http\Controllers\PaperTreadingCacheController;

/**
 * @return array{
 *     breaker_pct: float,
 *     tick_size: float,
 *     open_adj_price: float,
 *     lower_limit: float,
 *     upper_limit: float
 * }|null
 */
private function resolveCircuitBreaker(string $securityCode): ?array
{
    $cacheController = app(PaperTreadingCacheController::class);
    $cached = $cacheController->ensureCircuitBreakerCache(false);
    if (!is_array($cached)) {
        return null;
    }

    $tradeCode = strtoupper(trim($securityCode));
    $row = $cached['by_trade_code'][$tradeCode] ?? null;
    if (!is_array($row)) {
        return null;
    }

    return [
        'breaker_pct' => (float) ($row['breaker_pct'] ?? 0),
        'tick_size' => (float) ($row['tick_size'] ?? 0),
        'open_adj_price' => (float) ($row['open_adj_price'] ?? 0),
        'lower_limit' => (float) ($row['lower_limit'] ?? 0),
        'upper_limit' => (float) ($row['upper_limit'] ?? 0),
    ];
}
```

### Typical validation

```php
$circuitBreaker = $this->resolveCircuitBreaker($securityCode);
if ($circuitBreaker === null) {
    return response()->json([
        'status' => false,
        'message' => 'Circuit breaker data is not available. Warm cache via paper-trading/cache-circuit-breaker.',
    ], 422);
}

if ($price < $circuitBreaker['lower_limit'] || $price > $circuitBreaker['upper_limit']) {
    // reject — outside band
}
```

### Where Fintra already calls this

```
PaperTreadingCacheController
        │
        ├── HTTP  GET /api/paper-trading/cache-circuit-breaker
        │
        ├── PaperOrderPlacerController::resolveCircuitBreaker()
        │       └── ensureCircuitBreakerCache(false) → by_trade_code
        │
        ├── PaperOrderModifierController::resolveCircuitBreaker()
        │       └── ensureCircuitBreakerCache(false) → by_trade_code
        │
        └── PaperMarketDepthOrderbookManagementController
                ├── ensureCircuitBreakerCache()          // warm
                └── Cache::get(CIRCUIT_BREAKER_CACHE_KEY) // read
```

---

## 10. Payload & API contract

### Cached / returned `data` shape

```json
{
  "source_url": "https://www.dse.com.bd/cbul.php",
  "as_of": "…date text from page…",
  "fetched_at": "2026-08-03 10:00:00",
  "rows": [
    {
      "serial": 1,
      "trade_code": "SQURPHARMA",
      "breaker_pct": 10,
      "tick_size": 0.1,
      "open_adj_price": 220.5,
      "lower_limit": 198.5,
      "upper_limit": 242.5
    }
  ],
  "by_trade_code": {
    "SQURPHARMA": { "...same as one row..." }
  }
}
```

### HTTP success (200)

| Field | Meaning |
|-------|---------|
| `status` | `true` |
| `message` | From cache vs freshly cached |
| `cache_key` | `paper_trading_circuit_breaker` |
| `ttl_hours` | `8` |
| `cached` | `true` if existing non-empty cache used without refresh |
| `as_of` / `fetched_at` / `count` | Metadata |
| `data` | Full payload above |

### HTTP failure (500)

```json
{ "status": false, "message": "Failed to cache circuit breaker data" }
```

### HTML parse rules (must match DSE page)

- Scan all `//tr`.
- Keep only rows with exactly **7** `<td>` cells.
- Column order: `serial`, `trade_code`, `breaker_pct`, `tick_size`, `open_adj_price`, `lower_limit`, `upper_limit`.
- `trade_code` must match `/^[A-Z0-9]+$/i`; stored uppercase.
- Page title/date via regex: `Circuit Breaker on …`.
- Zero rows → `RuntimeException` → cache write fails.

---

## 11. Storage today vs recommended DB table

### Current Fintra behavior

| Layer | Detail |
|-------|--------|
| API | `Cache::put('paper_trading_circuit_breaker', $payload, 28800)` |
| With `CACHE_STORE=database` | Laravel generic table **`cache`** (`key`, `value`, `expiration`) |
| Dedicated domain table | **None today** |

There is **no** `paper_circuit_breaker` migration in Fintra yet. For a new project, prefer a dedicated table (below).

### Recommended table: `paper_circuit_breaker`

One row per trade code; upsert on each scrape.

#### Columns

| Column | Type | Nullable | Default | Comment |
|--------|------|----------|---------|---------|
| `id` | `BIGINT UNSIGNED` PK AI | NO | — | Primary key |
| `serial` | `INT UNSIGNED` | YES | `NULL` | DSE serial |
| `trade_code` | `VARCHAR(64)` UNIQUE | NO | — | Security / trade code |
| `breaker_pct` | `DECIMAL(8,4)` | NO | `0` | Breaker % |
| `tick_size` | `DECIMAL(12,4)` | NO | `0` | Tick size |
| `open_adj_price` | `DECIMAL(12,4)` | NO | `0` | Open adj price |
| `lower_limit` | `DECIMAL(12,4)` | NO | `0` | Lower band |
| `upper_limit` | `DECIMAL(12,4)` | NO | `0` | Upper band |
| `as_of` | `VARCHAR(64)` | YES | `NULL` | Text from DSE page |
| `source_url` | `VARCHAR(255)` | YES | `NULL` | Scrape URL |
| `fetched_at` | `DATETIME` | NO | — | Last scrape (Asia/Dhaka) |
| `activity_flag` | `CHAR(1)` | NO | `A` | `A`=Active, `I`=Inactive |
| `created_at` | `TIMESTAMP` | YES | `NULL` | |
| `updated_at` | `TIMESTAMP` | YES | `NULL` | |

#### Indexes

- UNIQUE `trade_code`
- INDEX `activity_flag`
- INDEX `fetched_at`

#### MySQL DDL

```sql
CREATE TABLE `paper_circuit_breaker` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `serial` INT UNSIGNED NULL,
  `trade_code` VARCHAR(64) NOT NULL COMMENT 'DSE trade code / security_code',
  `breaker_pct` DECIMAL(8, 4) NOT NULL DEFAULT 0,
  `tick_size` DECIMAL(12, 4) NOT NULL DEFAULT 0,
  `open_adj_price` DECIMAL(12, 4) NOT NULL DEFAULT 0,
  `lower_limit` DECIMAL(12, 4) NOT NULL DEFAULT 0,
  `upper_limit` DECIMAL(12, 4) NOT NULL DEFAULT 0,
  `as_of` VARCHAR(64) NULL COMMENT 'Date text from DSE page',
  `source_url` VARCHAR(255) NULL,
  `fetched_at` DATETIME NOT NULL COMMENT 'Last scrape time Asia/Dhaka',
  `activity_flag` CHAR(1) NOT NULL DEFAULT 'A' COMMENT 'A = Active, I = Inactive',
  `created_at` TIMESTAMP NULL DEFAULT NULL,
  `updated_at` TIMESTAMP NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `paper_circuit_breaker_trade_code_unique` (`trade_code`),
  KEY `paper_circuit_breaker_activity_idx` (`activity_flag`),
  KEY `paper_circuit_breaker_fetched_at_idx` (`fetched_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

#### Laravel migration

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('paper_circuit_breaker', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('serial')->nullable();
            $table->string('trade_code', 64)->comment('DSE trade code / security_code');
            $table->decimal('breaker_pct', 8, 2)->default(0);
            $table->decimal('tick_size', 12, 2)->default(0);
            $table->decimal('open_adj_price', 12, 2)->default(0);
            $table->decimal('lower_limit', 12, 2)->default(0);
            $table->decimal('upper_limit', 12, 2)->default(0);
            $table->string('as_of', 64)->nullable()->comment('Date text from DSE page');
            $table->string('source_url', 255)->nullable();
            $table->dateTime('fetched_at')->comment('Last scrape time Asia/Dhaka');
            $table->char('activity_flag', 1)->default('A')->comment('A = Active, I = Inactive');
            $table->timestamps();

            $table->index('trade_code', 'paper_circuit_breaker_trade_code_idx');
            $table->index('activity_flag', 'paper_circuit_breaker_activity_idx');
            $table->index('fetched_at', 'paper_circuit_breaker_fetched_at_idx');
            $table->index(['trade_code', 'activity_flag'], 'paper_circuit_breaker_trade_activity_idx');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('paper_circuit_breaker');
    }
};
```

#### Model sketch — `app/Models/PaperCircuitBreaker.php`

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PaperCircuitBreaker extends Model
{
    protected $table = 'paper_circuit_breaker';

    protected $fillable = [
        'serial',
        'trade_code',
        'breaker_pct',
        'tick_size',
        'open_adj_price',
        'lower_limit',
        'upper_limit',
        'as_of',
        'source_url',
        'fetched_at',
        'activity_flag',
    ];

    protected $casts = [
        'breaker_pct' => 'float',
        'tick_size' => 'float',
        'open_adj_price' => 'float',
        'lower_limit' => 'float',
        'upper_limit' => 'float',
        'fetched_at' => 'datetime',
    ];
}
```

#### Suggested persist after scrape (add inside `fetchAndStoreCircuitBreakerCache`)

After building `$payload`, insert daily rows and set past runs inactive:

```php
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($payload) {
    // Set all past records to inactive ('I') when storing new daily data
    DB::table('paper_circuit_breaker')
        ->where('activity_flag', 'A')
        ->update(['activity_flag' => 'I']);

    $now = now();
    $insertData = [];

    foreach ($payload['rows'] as $row) {
        $insertData[] = [
            'serial' => $row['serial'],
            'trade_code' => $row['trade_code'],
            'breaker_pct' => round((float) $row['breaker_pct'], 2),
            'tick_size' => round((float) $row['tick_size'], 2),
            'open_adj_price' => round((float) $row['open_adj_price'], 2),
            'lower_limit' => round((float) $row['lower_limit'], 2),
            'upper_limit' => round((float) $row['upper_limit'], 2),
            'as_of' => $payload['as_of'],
            'source_url' => $payload['source_url'],
            'fetched_at' => $payload['fetched_at'],
            'activity_flag' => 'A',
            'created_at' => $now,
            'updated_at' => $now,
        ];
    }

    foreach (array_chunk($insertData, 100) as $chunk) {
        DB::table('paper_circuit_breaker')->insert($chunk);
    }
});
```

Lookup from DB instead of cache:

```php
$row = PaperCircuitBreaker::query()
    ->where('trade_code', strtoupper(trim($securityCode)))
    ->where('activity_flag', 'A')
    ->first();
```

| Layer | Role when both used |
|-------|---------------------|
| `paper_circuit_breaker` table | Durable source of truth |
| Cache key `paper_trading_circuit_breaker` | Optional 8h hot layer |

---

## 12. Dependencies map (no extra packages)

| Dependency | Type | Used for |
|------------|------|----------|
| `Illuminate\Http\Request` | Framework | `refresh` query flag |
| `Illuminate\Support\Facades\Cache` | Framework | Get/put payload (current) |
| `Illuminate\Support\Facades\Http` | Framework | GET DSE HTML |
| `Illuminate\Http\Client\PendingRequest` | Framework | Typed client |
| `Illuminate\Support\Facades\Log` | Framework | Failures |
| `DOMDocument` / `DOMXPath` | PHP ext-dom | HTML table parse |
| `App\Support\AppDateTime` | App | `fetched_at` Asia/Dhaka |
| `config/paper_trading.php` | App config | URL + SSL |
| DSE `cbul.php` | External | Source HTML |
| `paper_circuit_breaker` | DB (recommended) | Durable rows |

---

## 13. Step-by-step port checklist

1. [ ] Confirm PHP `dom` extension: `php -m | findstr /i dom` (Windows) or `php -m | grep -i dom`.
2. [ ] Copy/create `config/paper_trading.php`.
3. [ ] Add `.env` keys; `php artisan config:clear`.
4. [ ] Copy `AppDateTime` **or** inline `now('Asia/Dhaka')`.
5. [ ] Copy `PaperTreadingCacheController.php`.
6. [ ] Register route in `routes/api.php`.
7. [ ] (Recommended) Add migration + model for `paper_circuit_breaker`; `php artisan migrate`.
8. [ ] (Recommended) Wire upsert into `fetchAndStoreCircuitBreakerCache`.
9. [ ] Ensure cache driver works if keeping Cache layer (`CACHE_STORE`, and `cache` table if database driver).
10. [ ] Hit `GET /api/paper-trading/cache-circuit-breaker` — expect `status: true` and `count > 0`.
11. [ ] Hit again without `refresh` — expect `cached: true`.
12. [ ] Hit with `?refresh=1` — expect fresh `fetched_at`.
13. [ ] Wire `resolveCircuitBreaker()` into order/depth code if needed.
14. [ ] Production: set `DSE_CIRCUIT_BREAKER_VERIFY_SSL=true` and/or CA bundle.

---

## 14. Troubleshooting

| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| 500 / “Failed to cache…” | DSE unreachable or SSL error | Check URL; set `VERIFY_SSL=false` locally or set CA bundle |
| Empty parse exception | DSE HTML layout changed | Re-check 7-column table / regex |
| `cached: false` every time | Cache store misconfigured | Check `CACHE_STORE`; migrate `cache` table if database |
| Order API 422 “Warm cache…” | Cache empty and scrape failed | Call warm endpoint; check `storage/logs/laravel.log` for `paper_trading.circuit_breaker_*` |
| cURL error 60 | Missing CA on Windows/XAMPP | `VERIFY_SSL=false` or set `DSE_CIRCUIT_BREAKER_CA_BUNDLE` |

---

## 15. Quick file tree (target project)

```
app/
  Http/Controllers/PaperTreadingCacheController.php
  Models/PaperCircuitBreaker.php          # if using DB
  Support/AppDateTime.php                 # or skip and use now('Asia/Dhaka')
config/
  paper_trading.php
database/migrations/
  xxxx_xx_xx_xxxxxx_create_paper_circuit_breaker_table.php
routes/
  api.php                                 # + one Route::match line
.env                                      # + DSE_CIRCUIT_BREAKER_* keys
```
)
