← back to Commercialrealestate

scripts/analyze.js

84 lines

// analyze.js — deterministic leverage math (shared finance.js) + local Qwen qualitative read
// -> data/ranked.json. Qwen responses are cached by content hash so re-runs are instant.
// Run: node scripts/analyze.js   (Qwen3:14b on localhost:11434, free/local)
const fs = require('fs');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const CREFin = require('../public/finance.js');

const ROOT = path.join(__dirname, '..');
const { meta, listings } = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'listings.json'), 'utf8'));
const CACHE_PATH = path.join(ROOT, 'data', 'qwen-cache.json');
let cache = {};
try { cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); } catch (_) {}

// Reference assumptions for the stored finance block (viewer recomputes other scenarios live)
const A = { budget: 400000, downPct: 25, ratePct: 6.75, amortYears: 30, closingPct: 2.5 };

function qwen(prompt) {
  return new Promise((resolve) => {
    const body = JSON.stringify({ model: 'qwen3:14b', prompt: '/no_think ' + prompt, stream: false, think: false,
      options: { temperature: 0.3, num_predict: 600 } });
    const req = http.request({ host: 'localhost', port: 11434, path: '/api/generate', method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (res) => {
      let d = ''; res.on('data', c => d += c);
      res.on('end', () => { try { resolve(JSON.parse(d).response || ''); } catch { resolve(''); } });
    });
    req.on('error', () => resolve(''));
    req.setTimeout(60000, () => { req.destroy(); resolve(''); });
    req.write(body); req.end();
  });
}
const parseJSON = (txt) => { const m = txt.match(/\{[\s\S]*\}/); if (!m) return null; try { return JSON.parse(m[0]); } catch { return null; } };

const PROMPT = (L, f) => `You are a San Fernando Valley commercial real estate investment analyst.
An investor has $400,000 available as DOWN PAYMENT + closing (leveraged purchase). Evaluate THIS property.

FACTS:
- Address: ${L.address}, ${L.city} (${L.type})
- Asking price: $${L.price.toLocaleString()}
- Units: ${L.units} | SqFt: ${L.sqft || 'n/a'} | Year built: ${L.year_built || 'n/a'}
- Cap rate: ${L.cap_rate != null ? L.cap_rate + '% (in-place/verified)' : 'NOT DISCLOSED'}${L.cap_rate_projected ? ' | projected ' + L.cap_rate_projected + '% after value-add' : ''}
- Rent control: ${L.rent_control}
- Status: ${L.status}
- Notes: ${L.upside_note}
COMPUTED (25% down, 6.75%/30yr): cash needed $${f.cashNeeded.toLocaleString()} (${f.affordable ? 'WITHIN' : 'EXCEEDS'} the $400k budget); est NOI ${f.noi ? '$' + f.noi.toLocaleString() : 'unknown'}; cash-on-cash ${f.coc != null ? f.coc + '%' : 'n/a'}; DSCR ${f.dscr != null ? f.dscr : 'n/a'}.

Return ONLY a JSON object:
{"thesis":"2-3 sentence investment thesis for a $400k-down buyer","risks":["risk1","risk2","risk3"],"upside":["lever1","lever2"],"opportunity_score":<0-100 integer, risk-adjusted for THIS budget>,"recommendation":"Buy"|"Watch"|"Pass"}`;

(async () => {
  const out = [];
  let hits = 0, misses = 0;
  for (let i = 0; i < listings.length; i++) {
    const L = listings[i];
    const f = CREFin.finance(L, A);
    const fs2 = CREFin.financeScore(L, f);
    // cache key = hash of the facts that feed the prompt
    const key = crypto.createHash('sha1').update(JSON.stringify([L.id, L.price, L.cap_rate, L.cap_rate_projected, L.units, L.status, L.upside_note])).digest('hex').slice(0, 12);
    let q, qScore;
    if (cache[L.id] && cache[L.id].key === key) {
      q = cache[L.id].qwen; qScore = cache[L.id].qwenScore; hits++;
      process.stdout.write(`[${i + 1}/${listings.length}] ${L.address} … cached `);
    } else {
      process.stdout.write(`[${i + 1}/${listings.length}] ${L.address} … qwen `);
      const raw = await qwen(PROMPT(L, f));
      q = parseJSON(raw) || { thesis: '(local model unavailable — finance-only score)', risks: [], upside: [], opportunity_score: null, recommendation: null };
      qScore = (typeof q.opportunity_score === 'number') ? q.opportunity_score : null;
      cache[L.id] = { key, qwen: q, qwenScore: qScore };
      misses++;
    }
    const composite = CREFin.composite(L, f, fs2.score, qScore);
    console.log(`fin=${fs2.score} qwen=${qScore ?? '—'} -> ${composite} (${q.recommendation || 'n/a'})`);
    out.push({ ...L, finance: f, financeScore: fs2.score, financeConf: fs2.conf, qwen: q, qwenScore: qScore, composite });
  }
  out.sort((a, b) => b.composite - a.composite);
  out.forEach((o, i) => o.rank = i + 1);
  fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
  fs.writeFileSync(path.join(ROOT, 'data', 'ranked.json'),
    JSON.stringify({ meta, assumptions: A, generated: process.env.GEN_TS || null, ranked: out }, null, 2));
  console.log(`\n=== ranked.json written: ${out.length} properties (qwen ${hits} cached / ${misses} fresh) ===`);
  console.log('TOP 5:'); out.slice(0, 5).forEach(o => console.log(`  #${o.rank} ${o.composite} — ${o.address}, ${o.city} ($${o.price.toLocaleString()}, ${o.units}u, ${o.cap_rate != null ? o.cap_rate + '% cap' : 'cap n/d'})`));
})();