<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mishan Raj Shah</title>
    <description>The latest articles on DEV Community by Mishan Raj Shah (@mishansavy).</description>
    <link>https://dev.to/mishansavy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1567094%2Ff45f8710-9ade-46a1-8fb4-75eac6402af5.jpg</url>
      <title>DEV Community: Mishan Raj Shah</title>
      <link>https://dev.to/mishansavy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mishansavy"/>
    <language>en</language>
    <item>
      <title>Why I still use NGINX over everything else</title>
      <dc:creator>Mishan Raj Shah</dc:creator>
      <pubDate>Sat, 27 Jun 2026 16:10:32 +0000</pubDate>
      <link>https://dev.to/mishansavy/why-i-still-use-nginx-over-everything-else-24hj</link>
      <guid>https://dev.to/mishansavy/why-i-still-use-nginx-over-everything-else-24hj</guid>
      <description>&lt;p&gt;People keep recommending Caddy. The config is cleaner, automatic HTTPS, three lines and you're done. They're not wrong. For a side project or a small team that doesn't want to think about infrastructure, Caddy is fine.&lt;/p&gt;

&lt;p&gt;I still use NGINX. Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that made it clear
&lt;/h2&gt;

&lt;p&gt;Earlier this year I deployed a production system on a single VPS. Hostinger KVM2, 2 vCPU, 8 GB RAM. The kind of system that sits quiet for months and then gets hit hard in a short window, hundreds of concurrent users at the same moment.&lt;/p&gt;

&lt;p&gt;The requirement was 1,000 requests per minute. I load tested to 2,500 requests per second. Zero errors. Zero timeouts. p99 on the result lookup was 308ms.&lt;/p&gt;

&lt;p&gt;No autoscaling. No managed load balancer. No cloud bill. One NGINX config that I wrote by hand.&lt;/p&gt;

&lt;p&gt;That's the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NGINX actually does in production
&lt;/h2&gt;

&lt;p&gt;Here's the architecture that handled it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
    | HTTPS
    v
Cloudflare  (DDoS protection, CDN, TLS to browser)
    | HTTPS
    v
NGINX (host)  (TLS termination, rate limiting, cache)
    |-- /api/*  →  Express + Prisma container (127.0.0.1:5001)
    └-- /*      →  Next.js container (127.0.0.1:3001)
                        |
                 PostgreSQL (internal Docker network only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Containers bind to localhost only. Nothing is reachable from the internet except through NGINX. That one decision eliminates an entire class of exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things NGINX handles that people underestimate
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Rate limiting before the app sees the request
&lt;/h3&gt;

&lt;p&gt;Rate limiting in Express middleware still consumes a Node.js worker thread per rejected request. NGINX rejects rate-limited requests before they reach Node.js. No event loop, no DB query, no memory allocation. A 429 from NGINX costs almost nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$real_ip&lt;/span&gt; &lt;span class="s"&gt;zone=api:10m&lt;/span&gt; &lt;span class="s"&gt;rate=10r/s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=api&lt;/span&gt; &lt;span class="s"&gt;burst=20&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req_status&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:5001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Cache stampede protection
&lt;/h3&gt;

&lt;p&gt;When the traffic spike hits, hundreds of users land on the same URL at the exact same moment. Without a cache, every request goes Next.js → Node.js → PostgreSQL. With &lt;code&gt;proxy_cache_lock on&lt;/code&gt;, when the cache expires only one upstream request goes through. Every other concurrent request waits for that single response, then gets served from cache. Without the lock, every request that arrives during the cache miss window hits upstream simultaneously. That's the thundering herd problem, and it's what kills servers on high-traffic moments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_cache_path&lt;/span&gt; &lt;span class="n"&gt;/var/cache/nginx&lt;/span&gt; &lt;span class="s"&gt;levels=1:2&lt;/span&gt; &lt;span class="s"&gt;keys_zone=pages:10m&lt;/span&gt;
                 &lt;span class="s"&gt;max_size=100m&lt;/span&gt; &lt;span class="s"&gt;inactive=1m&lt;/span&gt; &lt;span class="s"&gt;use_temp_path=off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache&lt;/span&gt; &lt;span class="s"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_lock&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_lock_timeout&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;10 seconds is short enough to feel live, long enough to absorb any realistic spike.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cloudflare real-IP restoration
&lt;/h3&gt;

&lt;p&gt;Cloudflare proxies all traffic, which means every request arrives at NGINX from a Cloudflare edge IP. Without fixing this, your rate limiting targets Cloudflare's shared IPs and blocks every user at once instead of individual abusers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;real_ip_header&lt;/span&gt; &lt;span class="s"&gt;CF-Connecting-IP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;173.245&lt;/span&gt;&lt;span class="s"&gt;.48.0/20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;103.21&lt;/span&gt;&lt;span class="s"&gt;.244.0/22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;103.22&lt;/span&gt;&lt;span class="s"&gt;.200.0/22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;103.31&lt;/span&gt;&lt;span class="s"&gt;.4.0/22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;141.101&lt;/span&gt;&lt;span class="s"&gt;.64.0/18&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;108.162&lt;/span&gt;&lt;span class="s"&gt;.192.0/18&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;190.93&lt;/span&gt;&lt;span class="s"&gt;.240.0/20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;188.114&lt;/span&gt;&lt;span class="s"&gt;.96.0/20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;197.234&lt;/span&gt;&lt;span class="s"&gt;.240.0/22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;198.41&lt;/span&gt;&lt;span class="s"&gt;.128.0/17&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;162.158&lt;/span&gt;&lt;span class="s"&gt;.0.0/15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;104.16&lt;/span&gt;&lt;span class="s"&gt;.0.0/13&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;104.24&lt;/span&gt;&lt;span class="s"&gt;.0.0/14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;172.64&lt;/span&gt;&lt;span class="s"&gt;.0.0/13&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;131.0&lt;/span&gt;&lt;span class="s"&gt;.72.0/22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_cf_connecting_ip&lt;/span&gt; &lt;span class="nv"&gt;$real_ip&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="nv"&gt;$http_cf_connecting_ip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;""&lt;/span&gt;      &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. One domain, multiple apps, zero complexity
&lt;/h3&gt;

&lt;p&gt;The entire system runs under one domain. Frontend on port 3001, backend on port 5001, both invisible to the internet, both served through the same NGINX vhost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;yourdomain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:5001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$real_ip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$real_ip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate subdomains. No DNS juggling. One cert. One vhost.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Static file serving and compression with no extra layer
&lt;/h3&gt;

&lt;p&gt;Gzip compression built in. Static assets served directly without hitting the app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_types&lt;/span&gt; &lt;span class="nc"&gt;text/plain&lt;/span&gt; &lt;span class="nc"&gt;text/css&lt;/span&gt; &lt;span class="nc"&gt;application/json&lt;/span&gt;
           &lt;span class="nc"&gt;application/javascript&lt;/span&gt; &lt;span class="nc"&gt;text/xml&lt;/span&gt; &lt;span class="nc"&gt;application/xml&lt;/span&gt;
           &lt;span class="nc"&gt;image/svg&lt;/span&gt;&lt;span class="s"&gt;+xml&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_min_length&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/_next/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache_valid&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="s"&gt;1y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=31536000,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js static assets are immutable by design (content-hashed filenames). Tell the browser and Cloudflare to cache them for a year. Zero repeat fetches on return visits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Caddy or Traefik
&lt;/h2&gt;

&lt;p&gt;Caddy's killer feature is automatic HTTPS with zero config. That's genuinely useful if cert management is your problem. But the same server was already running WordPress, MariaDB, and a PM2 process with existing certs. Caddy would mean either migrating all of that or running two proxies. NGINX was already there.&lt;/p&gt;

&lt;p&gt;Traefik is the right choice if your services come and go dynamically, containers spinning up and down, labels driving routing automatically. When you own a fixed VPS with a known set of services, that auto-discovery is solving a problem you don't have. You end up with more moving parts for no benefit.&lt;/p&gt;

&lt;p&gt;NGINX wins on control. When something goes wrong at 2 AM, I want to read a config file, not debug a label hierarchy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when none of this is in place
&lt;/h2&gt;

&lt;p&gt;Failure under load is not isolated. It cascades.&lt;/p&gt;

&lt;p&gt;The primary endpoint slows down. Users retry. The request queue builds. Database connections spike. Other parts of the app start timing out. Users see errors on pages that have nothing to do with the original problem. One block hits another, then another.&lt;/p&gt;

&lt;p&gt;Every one of those config blocks above cuts a link in that chain. Rate limiting stops the retry spiral before it reaches the app. Cache lock stops the thundering herd before it reaches the container. Real-IP restoration stops rate limiting from blocking the wrong people entirely. These are not optimizations. They are the difference between a system that survives a spike and one that takes everything else down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual numbers
&lt;/h2&gt;

&lt;p&gt;2,500 requests per second. 147 times the required throughput. p99 on the primary endpoint at 308ms. Zero errors in 30 seconds of sustained load.&lt;/p&gt;

&lt;p&gt;One VPS. One NGINX config. No managed services.&lt;/p&gt;

&lt;p&gt;A VPS is not a limitation. It is an exam. Pass it and you understand distributed systems better than most people who have only ever scaled horizontally.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you hit a case where NGINX was not enough on a single server? Curious where the ceiling actually is before you have to scale out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>discuss</category>
      <category>infrastructure</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building a Stock Trend Predictor for a Market That Has No API</title>
      <dc:creator>Mishan Raj Shah</dc:creator>
      <pubDate>Sat, 30 May 2026 04:28:17 +0000</pubDate>
      <link>https://dev.to/mishansavy/building-a-stock-trend-predictor-for-a-market-that-has-no-api-4a89</link>
      <guid>https://dev.to/mishansavy/building-a-stock-trend-predictor-for-a-market-that-has-no-api-4a89</guid>
      <description>&lt;p&gt;Nepal Stock Exchange has been running since 1994. It lists 359 companies. Thousands of traders use it daily. It has no public API.&lt;/p&gt;

&lt;p&gt;That is the first wall you hit when you try to build anything data-driven for NEPSE. No official endpoint, no rate-limited free tier, no developer documentation. Just a website called MeroLagani that aggregates market data for human eyes.&lt;/p&gt;

&lt;p&gt;This is the story of building around that constraint. The scraper, the data pipeline, the LSTM training runs, the Django REST backend, the Next.js frontend, and where the validation loss told me the truth I did not want to hear.&lt;/p&gt;

&lt;p&gt;The project ran from March 2025 through May 2025, roughly three months from first commit to a working full-stack system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scraper
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa90f6cum6iv61jm4k0a6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa90f6cum6iv61jm4k0a6.png" alt="A Python script running in a terminal, scraping a financial data table. &lt;br&gt;
Clock on the wall shows 11:00 AM. Window outside shows Kathmandu cityscape. &lt;br&gt;
Dark moody lighting, code visible on screen, realistic editorial style." width="800" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With no API available, I wrote a scraper that hits MeroLagani's live market table every 30 seconds during trading hours.&lt;/p&gt;

&lt;p&gt;NEPSE trades Sunday through Thursday, 11:00 AM to 3:00 PM Nepali time. The scraper respects that schedule. It calculates the wait time to the next market open and sleeps, rather than polling and checking continuously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;market_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;market_close&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;allowed_weekdays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# Sunday to Thursday
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 30 minutes it pauses for one minute to avoid triggering any server-side rate limiting. Each scrape fetches Symbol, LTP (last traded price), percent change, open, high, low, quantity, previous close, and diff, covering nine fields per stock across all listed symbols.&lt;/p&gt;

&lt;p&gt;The data goes into a daily JSON file. One file per trading day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_filename&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;date_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;market_data_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_str&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The JSON problem
&lt;/h2&gt;

&lt;p&gt;The daily files came out broken.&lt;/p&gt;

&lt;p&gt;The scraper writes incrementally throughout the day. If anything interrupts the process, whether from a network hiccup, a keyboard interrupt, or a power cut,the JSON file ends mid-write and fails to parse. This happened repeatedly.&lt;/p&gt;

&lt;p&gt;I ended up writing a repair script that runs &lt;code&gt;json_repair&lt;/code&gt; on every raw file before importing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;json_repair&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;repair_json&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./new-data/market_data_2025-04-29.json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;broken_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;fixed_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;repair_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broken_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;./new-fixed/repair_market_data_2025-04-29-data.json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixed_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This became a mandatory step in the pipeline. Scrape, repair, import. The repair step should have been unnecessary. The right fix is atomic writes or appending newline-delimited JSON instead of building one large JSON array throughout the day. I noted that and kept moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it into PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The import script uses &lt;code&gt;ijson&lt;/code&gt; for streaming. The daily files were large enough that loading them fully into memory caused problems on the local machine.&lt;/p&gt;

&lt;p&gt;The data also needed cleaning before insert. NEPSE formats numbers with commas (&lt;code&gt;1,234.56&lt;/code&gt;). Percentages come with a &lt;code&gt;%&lt;/code&gt; suffix. Quantity fields are integers stored as comma-formatted strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;numeric_cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;LTP&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Open&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;High&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Low&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PClose&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Diff.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;numeric_cols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;% Change&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;percent_change&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;% Change&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;coerce&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inserts go in batches of 1000 rows using SQLAlchemy's &lt;code&gt;multi&lt;/code&gt; method. The final dataset used for training had 1,109,081 rows across 359 symbols, covering around 20 trading days between February and April 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature engineering
&lt;/h2&gt;

&lt;p&gt;Before training, I added technical indicators on top of the raw OHLC data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10-day and 50-day SMA&lt;/li&gt;
&lt;li&gt;SMA crossover signal (1 when short SMA is above long SMA, 0 otherwise)&lt;/li&gt;
&lt;li&gt;Position differencing to detect actual crossover events&lt;/li&gt;
&lt;li&gt;EMA (10-period)&lt;/li&gt;
&lt;li&gt;RSI (14-period)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The training pipeline then used RandomForestRegressor to select the top 50 features from all engineered columns, including one-hot encoded symbol columns. The idea was to let the forest figure out which features actually predicted price movement rather than guessing manually.&lt;/p&gt;

&lt;p&gt;This decision created a problem I will get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Training
&lt;/h2&gt;

&lt;p&gt;Three stacked LSTM layers, dropout 0.2 after each, Adam at &lt;code&gt;lr=0.0005&lt;/code&gt;, MSE loss. Input windows of 60 timesteps, 50 features per timestep. Early stopping with &lt;code&gt;patience=5&lt;/code&gt; on validation loss. Training ran on Google Colab for GPU access since the local machine could not handle it.&lt;/p&gt;

&lt;p&gt;I ran multiple training iterations between March 9 and April 29, 2025 as I cleaned and expanded the dataset. The loss plots from those runs tell the same story every time.&lt;/p&gt;

&lt;p&gt;Train loss drops consistently across epochs. The model is learning. Validation loss oscillates. It spikes, drops, spikes again, and never converges with the training curve. In the worst runs it diverges completely after epoch 13 or 14, spiking to 3-4x the training loss before early stopping kicks in.&lt;/p&gt;

&lt;p&gt;This is overfitting. The model memorizes the training data and fails to generalize.&lt;/p&gt;

&lt;p&gt;The cause is straightforward: 20 trading days of intraday data is not enough to train an LSTM on time-series patterns across 359 symbols. The model sees each symbol only a handful of times in each window configuration. It has no real basis for generalization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature mismatch
&lt;/h2&gt;

&lt;p&gt;There is a second problem that compounds the first.&lt;/p&gt;

&lt;p&gt;During training, RandomForest selects the top 50 features including one-hot encoded symbol columns. Different symbols get selected because different stocks have different volatility patterns and sector behaviors. The model learns symbol-specific patterns as part of its 50-feature input.&lt;/p&gt;

&lt;p&gt;At inference time, I only have the 17 real market features for the stock being predicted. The symbol one-hot columns from the training dataset do not exist. So I pad the remaining 33 slots with zeros.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;dummy_cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;full_features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concatenate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;current_features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dummy_cols&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model runs, but it is partially blind. The symbol-specific patterns it learned during training contribute nothing at prediction time because those feature positions are always zero.&lt;/p&gt;

&lt;p&gt;The README documents this honestly. The fix is to use a fixed symbol embedding at both training and inference. A lookup table of learned vectors per symbol, reconstructed from just the symbol string, without needing the full training dataset present.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend
&lt;/h2&gt;

&lt;p&gt;The Django REST backend exposes prediction and market data through a clean set of endpoints under &lt;code&gt;/nepse/&lt;/code&gt;. The prediction endpoint takes a stock symbol, pulls the last 60 records from PostgreSQL, engineers the 17 features in real time, pads to 50, runs the LSTM model, and returns a normalized trend value between 0 and 1. Above 0.5 signals an upward trend, below 0.5 signals downward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@api_view&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;predict_stock_price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stock_symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symbol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stock_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Marketdata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;stock_symbol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stock_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Not enough historical data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentication uses JWT via &lt;code&gt;djangorestframework-simplejwt&lt;/code&gt;. Admin users can manage market data records directly through the API. Regular users get read access to stock lists and predictions. The 60-record minimum is a hard requirement. New or thinly traded symbols simply cannot be predicted without enough history in the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend
&lt;/h2&gt;

&lt;p&gt;The Next.js 15 frontend gives users a stock search interface where they select a symbol, enter the current NEPSE index, and hit predict. The result comes back as a normalized percentage with a Chart.js line chart showing the predicted price movement over time.&lt;/p&gt;

&lt;p&gt;There are two separate dashboards: one for regular users showing recent market data and trend indicators, and one for admins with full CRUD access to the market data records and a user management panel. Styling is Tailwind CSS 4. The whole frontend was built between April 5 and May 13, 2025, after the model training work was done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data wall actually looks like
&lt;/h2&gt;

&lt;p&gt;NYSE and NASDAQ have free API tiers through providers like Alpha Vantage and Polygon. Twenty years of daily OHLC for any listed stock, accessible with a single authenticated request.&lt;/p&gt;

&lt;p&gt;NEPSE has the official website with a data table, a downloads section with PDFs, and nothing else.&lt;/p&gt;

&lt;p&gt;The PDFs are worth mentioning. NEPSE publishes daily trading reports as downloadable PDFs. Parsing those is more work than scraping MeroLagani, but the data is authoritative and less likely to change HTML structure without notice. A more robust version of this scraper would target the PDFs as the primary source and use MeroLagani as a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Atomic file writes in the scraper.&lt;/strong&gt; Write each scrape snapshot to a separate file, or use newline-delimited JSON and append. Stop building one large JSON array that breaks on interruption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More data before any training.&lt;/strong&gt; I started training on weeks of data. That is not enough for an LSTM to learn market patterns across 359 symbols. The right approach is to scrape for at least 6 months before touching the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed feature contract between training and inference.&lt;/strong&gt; Encode each stock as a fixed embedding vector, not as one-hot columns selected by a forest that runs on the full training dataset. The symbol embedding approach means inference needs only the symbol string, not the entire historical dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate the scraper from everything else.&lt;/strong&gt; The scraper should run on a schedule, write to the database, and know nothing about the model. Mixing concerns early made the pipeline harder to maintain.&lt;/p&gt;

&lt;p&gt;The full codebase including the scraper, training pipeline, Django REST API, and Next.js frontend is at &lt;a href="https://github.com/Mishansavy/nepse-trend-analayis" rel="noopener noreferrer"&gt;github.com/Mishansavy/nepse-trend-analayis&lt;/a&gt;. The known limitations are in the README. Pull requests open.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>showdev</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
