← 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'})`));
})();